# DMs a PR's author on Slack when its CI finishes, from ArgoCI's or Semaphore's
# `status`. Bot PRs with an owned label go to their assignees.
#
# An approval DM exists too, but only on the manual `workflow_dispatch` path.
# It cannot be driven by `pull_request_review`: that event is
# pull-request-scoped, so a fork PR's run gets neither secrets nor variables
# (measured: "Secret source: None"), and this repo holds any PR-scoped run
# whose author or triggering actor is not a member for maintainer approval —
# Copilot reviews nearly every PR, so every review left a held "approve this
# workflow" run on other contributors' PRs. A repository-level schedule can
# reach the secrets, but polling hourly for approvals is not worth the cron
# quota it spends.
#
# Opt-in is a repo *variable* (not a secret — Slack member IDs aren't
# sensitive): CI_NOTIFY_MAP, a comma-separated list of
#   <github-login>:<slack-member-id>
# pairs, e.g.
#   CI_NOTIFY_MAP = "tomastigera:U01ABCD2EFG, alice:U09ZYX8WVUT"
# Set it with:  gh variable set CI_NOTIFY_MAP --body 'login:Uxxxx,...'
#
# Delivery uses the gh-pr-monitor Slack bot. Its token is a repo secret
# GH_PR_MONITOR_BOT_TOKEN; the bot needs chat:write + im:write scopes — it
# opens a DM to each recipient's member ID with conversations.open, then
# posts to that channel. (im:write is required; without it conversations.open
# returns missing_scope and no DM is sent.)
#
# The workflow is a no-op (never fails noisily) if the variable/secret
# are unset, no recipient has opted in, or Slack is unreachable.

name: PR Slack notify

on:
  status: {}
  # Manual test path: simulate a finished-CI status (or an approval) for a
  # given PR, so the full chain (resolve -> opt-in lookup -> DM) can be
  # exercised on demand without waiting for real CI. Dispatchable only once
  # this file is on the default branch (a GitHub limitation), so it's for
  # post-merge sanity checks, not pre-merge testing.
  workflow_dispatch:
    inputs:
      pr_number:
        description: "PR number to notify about"
        required: true
        type: string
      state:
        description: "Simulated CI outcome, or 'approved' for the review path"
        required: false
        default: success
        type: choice
        options: [success, failure, error, approved]

# Semaphore posts each pipeline's result several times; ArgoCI posts each once.
# Only Semaphore statuses are deduped, by an atomic git ref per (pipeline, state).

permissions:
  pull-requests: read
  # The dedup marker is a git ref. actions/cache can't be saved from a
  # `status` event.
  contents: write

jobs:
  notify:
    # Fire once when the aggregate ArgoCI or Semaphore status reaches a
    # terminal state. Skip 'pending' — that's CI starting/running, not finishing.
    # Approvals arrive only as a manual workflow_dispatch (state comes from
    # the input either way).
    if: >-
      github.event_name == 'workflow_dispatch' ||
      ((github.event.context == 'continuous-integration/calico' ||
        github.event.context == 'ci/semaphoreci/pr: Calico') &&
       (github.event.state == 'success' ||
        github.event.state == 'failure' ||
        github.event.state == 'error'))
    runs-on: ubuntu-latest
    # ArgoCI's triage comment can take ~5 minutes; see the poll below.
    timeout-minutes: 8
    env:
      GH_TOKEN: ${{ github.token }}
      REPO: ${{ github.repository }}
      CI_NOTIFY_MAP: ${{ vars.CI_NOTIFY_MAP }}
      # A PR one of these bots opened with one of these labels is owned by
      # its assignees. Both comma-separated.
      BOT_AUTHORS: marvin-tigera
      ASSIGNEE_OWNED_LABELS: release-backport
      SLACK_BOT_TOKEN: ${{ secrets.GH_PR_MONITOR_BOT_TOKEN }}
      # STATE drives everything downstream: 'success'/'failure'/'error' mean
      # the CI path, 'approved' the review path. A missing property evaluates
      # to empty, so this picks the status event's state or the input.
      STATE: ${{ github.event.state || inputs.state }}
      # Status events only — the commit to resolve a PR from. Empty on a
      # dispatch, which takes the PR number from its input.
      SHA: ${{ github.event.sha }}
      # Carries Semaphore's pipeline_id, which names the dedup marker.
      TARGET_URL: ${{ github.event.target_url }}
      # Empty on a dispatch.
      CONTEXT: ${{ github.event.context }}
    steps:
      - name: Skip if not configured
        id: gate
        run: |
          if [[ -z "${CI_NOTIFY_MAP}" || -z "${SLACK_BOT_TOKEN}" ]]; then
            echo "::notice::CI_NOTIFY_MAP variable or GH_PR_MONITOR_BOT_TOKEN secret missing — skipping"
            echo "skip=true" >> "$GITHUB_OUTPUT"
          else
            echo "skip=false" >> "$GITHUB_OUTPUT"
          fi

      - name: Resolve PR
        id: resolve
        if: steps.gate.outputs.skip != 'true'
        env:
          INPUT_PR: ${{ inputs.pr_number }}
        run: |
          if [[ -n "$INPUT_PR" ]]; then
            # Manual run: trust only a numeric PR number (guards against
            # injection via the free-form dispatch input).
            if [[ ! "$INPUT_PR" =~ ^[0-9]+$ ]]; then
              echo "::error::pr_number must be numeric, got '$INPUT_PR'"
              exit 1
            fi
            PR="$INPUT_PR"
          else
            # Status events: resolve PR from SHA. Use the search API rather
            # than repos/$REPO/commits/$SHA/pulls — the latter does not return
            # PRs whose head commit lives on a fork, and projectcalico/calico
            # PRs all come from forks.
            # Retry once: the search index can lag a few seconds behind a very
            # fresh commit.
            # A transient API error counts as a miss and retries the same way
            # (`|| true` keeps it from killing the step under -e; gh already
            # put the error on stderr).
            # The hit is not re-checked against the PR's current head sha on
            # purpose — the head legitimately moves on after the event (CI
            # finishing for a commit that has since been pushed over, or a push
            # landing between an approval and this run), and the PR is still
            # the one to notify about.
            search_pr() {
              gh api "search/issues?q=sha:$SHA+is:pr+is:open+repo:$REPO" \
                --jq '.items[0].number // empty' || true
            }
            PR=$(search_pr)
            if [[ -z "$PR" ]]; then
              sleep 5
              PR=$(search_pr)
            fi
            if [[ -z "$PR" ]]; then
              echo "::notice::no open PR for sha=$SHA — skipping"
              echo "skip=true" >> "$GITHUB_OUTPUT"
              exit 0
            fi
          fi
          # pr.json holds the free-form title; it stays on the runner disk
          # and is read with jq in later steps. It is never routed through
          # GITHUB_OUTPUT or workflow expressions — those would be a script-injection
          # vector for an attacker-chosen PR title.
          gh api "repos/$REPO/pulls/$PR" \
            --jq '{login: .user.login, title: .title, url: .html_url,
                   labels: [.labels[].name], assignees: [.assignees[].login],
                   base: .base.ref}' > pr.json
          # Semaphore is a no-op on master and may finish before ArgoCI posts.
          if [[ "$CONTEXT" == "ci/semaphoreci/pr: Calico" ]]; then
            ARGO=$(gh api "repos/$REPO/commits/$SHA/statuses?per_page=100" \
              --jq 'any(.[]; .context == "continuous-integration/calico")' || echo false)
            if [[ "$(jq -r .base pr.json)" == master || "$ARGO" == true ]]; then
              echo "::notice::ArgoCI reports this commit — skipping Semaphore's status"
              echo "skip=true" >> "$GITHUB_OUTPUT"
              exit 0
            fi
          fi
          echo "pr=$PR" >> "$GITHUB_OUTPUT"

      # A failed checkout surfaces as a lookup warning, not a red job.
      - name: Check out recipient script
        if: steps.gate.outputs.skip != 'true' && steps.resolve.outputs.skip != 'true'
        continue-on-error: true
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false
          sparse-checkout: .github/workflows/scripts
          path: src

      - name: Look up recipients' Slack IDs
        id: lookup
        if: steps.gate.outputs.skip != 'true' && steps.resolve.outputs.skip != 'true'
        run: |
          # Logins stay in pr.json, never in a workflow expression.
          if ! IDS=$(bash src/.github/workflows/scripts/ci-notify-recipients.sh pr.json); then
            echo "::warning::recipient lookup failed — skipping"
            echo "skip=true" >> "$GITHUB_OUTPUT"
            exit 0
          fi
          SLACK_IDS=$(paste -sd' ' <<<"$IDS")
          if [[ -z "$SLACK_IDS" ]]; then
            echo "::notice::no recipient of PR is in CI_NOTIFY_MAP — skipping"
            echo "skip=true" >> "$GITHUB_OUTPUT"
          else
            # Slack IDs are [A-Z0-9], so a space-separated list is safe here.
            echo "slack_ids=$SLACK_IDS" >> "$GITHUB_OUTPUT"
          fi

      # Naming only — whether this approval deserves a DM was settled by
      # whoever ran the dispatch, which takes write access. An empty result
      # just means the DM will not name anyone, so nothing here fails the job.
      - name: Name the approving reviewer
        id: reviewer
        if: >-
          steps.gate.outputs.skip != 'true' &&
          steps.resolve.outputs.skip != 'true' &&
          steps.lookup.outputs.skip != 'true' &&
          env.STATE == 'approved'
        env:
          PR: ${{ steps.resolve.outputs.pr }}
        run: |
          # --paginate applies --jq per page and concatenates the results, so
          # an index-into-array filter would emit one login *per page*. Filter
          # flat and take the last line: the most recent qualifying approval.
          # stderr is discarded rather than merged into the list — mixed in, a
          # mere gh warning would read as a login.
          LOGIN=$(gh api "repos/$REPO/pulls/$PR/reviews?per_page=100" --paginate \
            --jq '.[] | select(.state == "APPROVED" and (.author_association
                    | IN("OWNER","MEMBER","COLLABORATOR"))) | .user.login' \
            2>/dev/null | tail -n1) || true
          # A GitHub login is [A-Za-z0-9-], so it is safe via GITHUB_OUTPUT.
          echo "login=$LOGIN" >> "$GITHUB_OUTPUT"

      # Dedup: claim a git ref named after (pipeline, state) before sending.
      # Ref creation is atomic on GitHub's side, so concurrent duplicate
      # status events race to create the same ref and exactly one wins —
      # no concurrency group or serialization required. A non-"already
      # exists" failure fails OPEN (proceeds to send) rather than silently
      # swallowing a real notification on an API hiccup. Semaphore statuses
      # only: a manual dispatch is meant to send unconditionally.
      - name: Claim dedup marker (atomic)
        id: claim
        if: >-
          steps.gate.outputs.skip != 'true' &&
          steps.resolve.outputs.skip != 'true' &&
          steps.lookup.outputs.skip != 'true' &&
          env.CONTEXT == 'ci/semaphoreci/pr: Calico'
        run: |
          # The marker name carries everything dedup and pruning need: the
          # claim date, then the (pipeline, state) this DM is for. Duplicates
          # that straddle midnight UTC compute different names and so both
          # send — one extra DM at most, once a day, in exchange for keeping
          # age in the name.
          # A rerun is a new pipeline, so it gets its own DM. An unparsable URL
          # falls back to the commit.
          PIPELINE=$SHA
          if [[ "$TARGET_URL" =~ pipeline_id=([0-9a-f-]{36}) ]]; then
            PIPELINE=${BASH_REMATCH[1]}
          fi
          REF="refs/ci-notify/$(date -u +%Y%m%d)-${PIPELINE}-${STATE}"
          echo "ref=$REF" >> "$GITHUB_OUTPUT"
          # The ref *target* is GITHUB_SHA — the default-branch tip this run
          # was resolved from — deliberately not the status sha. A status sha
          # is a PR head commit living in a fork, and creating a ref that
          # points at one is not reliably permitted here: freshly pushed fork
          # commits have been observed to 403 ("Resource not accessible by
          # integration") while a day-old one succeeded, and that failure
          # takes the fail-open branch below and re-sends the DM. Nothing
          # reads the target, so a commit that is certainly local is enough.
          set +e
          ERR=$(gh api "repos/$REPO/git/refs" --method POST \
            -f ref="$REF" -f sha="$GITHUB_SHA" 2>&1 >/dev/null)
          RC=$?
          set -e
          if [[ $RC -eq 0 ]]; then
            echo "claimed=true" >> "$GITHUB_OUTPUT"
          elif [[ "$ERR" == *"Reference already exists"* ]]; then
            echo "::notice::already notified for $PIPELINE ($STATE) — skipping duplicate status event"
            echo "claimed=false" >> "$GITHUB_OUTPUT"
          else
            echo "::warning::dedup ref-claim failed unexpectedly ($ERR) — proceeding without dedup for this run"
            echo "claimed=true" >> "$GITHUB_OUTPUT"
          fi

      # On failure/error, sem-ai-ci-triage posts a failure-analysis comment
      # — but it runs concurrently on the same status event and only after
      # its LLM backend responds, so the comment usually isn't there yet.
      # Poll briefly for it so the DM can deep-link to the analysis. A pass
      # and an approval never get a triage comment, so the poll is skipped
      # on every state but failure/error. sem-ai-triage comments on ArgoCI and
      # Semaphore failures alike.
      - name: Wait for sem-ai-triage comment
        id: triage
        if: >-
          steps.gate.outputs.skip != 'true' &&
          steps.resolve.outputs.skip != 'true' &&
          steps.lookup.outputs.skip != 'true' &&
          steps.claim.outputs.claimed != 'false' &&
          (env.CONTEXT == 'ci/semaphoreci/pr: Calico' ||
           env.CONTEXT == 'continuous-integration/calico') &&
          (env.STATE == 'failure' || env.STATE == 'error')
        env:
          PR: ${{ steps.resolve.outputs.pr }}
          # When CI finished, per GitHub (status events only; empty on a
          # manual workflow_dispatch). Used as the freshness reference below.
          EVENT_CREATED_AT: ${{ github.event.created_at }}
        run: |
          MARKER="<!-- sem-ai-ci-triage -->"
          COMMENT_URL=""

          # Freshness gate: triage upserts a single comment per PR, so a
          # comment from an *earlier* failure can already exist. Accept it
          # only if it was updated after this CI run finished — i.e. triage
          # (which is triggered by the same status event) has rewritten it
          # for the current run. Compare GitHub-server timestamps on both
          # sides (event created_at vs comment updated_at); ISO-8601 UTC
          # sorts lexicographically, so a string '>' is a time comparison.
          # If triage lags we keep polling; if it skips the run entirely the
          # comment never goes fresh and we correctly omit the link. On a
          # manual run there is no event time, so skip the gate.
          REF="$EVENT_CREATED_AT"
          if [[ -n "$REF" ]]; then
            FRESH="and (.updated_at > \"$REF\")"
          else
            FRESH=""
          fi

          # Bound the query with `since` (filters by updated_at): triage's
          # comment is always recent, so a 2h window keeps the result to
          # ~1 page instead of walking every comment 12 times — avoids the
          # API churn/rate-limit risk of an unbounded --paginate in a loop.
          SINCE=$(date -u -d '2 hours ago' +%Y-%m-%dT%H:%M:%SZ)
          # Semaphore's triage comments within ~120s; argo-triage reads a whole run, so ArgoCI waits ~5 min.
          POLLS=12
          [[ "$CONTEXT" == "continuous-integration/calico" ]] && POLLS=30
          # Filter flat and take the last line rather than indexing into an
          # array: --paginate applies --jq per page, so [-1] would emit one URL
          # per page and a busy PR would yield a multi-line value.
          for _ in $(seq 1 "$POLLS"); do
            COMMENT_URL=$(gh api "repos/$REPO/issues/$PR/comments?since=$SINCE&per_page=100" --paginate \
              --jq ".[] | select(.user.login == \"github-actions[bot]\" and (.body | startswith(\"$MARKER\")) $FRESH) | .html_url" \
              | tail -n1)
            [[ -n "$COMMENT_URL" ]] && break
            sleep 10
          done
          # html_url is a github.com URL (safe charset) — OK via GITHUB_OUTPUT.
          echo "comment_url=$COMMENT_URL" >> "$GITHUB_OUTPUT"

      - name: Send Slack DM
        id: send
        if: >-
          steps.gate.outputs.skip != 'true' &&
          steps.resolve.outputs.skip != 'true' &&
          steps.lookup.outputs.skip != 'true' &&
          steps.claim.outputs.claimed != 'false'
        env:
          SLACK_IDS: ${{ steps.lookup.outputs.slack_ids }}
          PR: ${{ steps.resolve.outputs.pr }}
          TRIAGE_COMMENT_URL: ${{ steps.triage.outputs.comment_url }}
          # A GitHub login, passed via env rather than interpolated into the
          # script below, so it cannot affect shell parsing.
          REVIEWER: ${{ steps.reviewer.outputs.login }}
        run: |
          # TITLE/URL come from pr.json via jq, never from a workflow expression.
          TITLE=$(jq -r .title pr.json)
          URL=$(jq -r .url pr.json)

          # Slack mrkdwn link: <url|text> renders "PR #N" as a hyperlink.
          if [[ "$STATE" == "approved" ]]; then
            # REVIEWER is empty on a manual run against a PR that has no
            # approval to name, so name the reviewer only when known.
            TEXT=":+1: Your <$URL|PR #$PR> was approved${REVIEWER:+ by $REVIEWER}: $TITLE"
          else
            case "$STATE" in
              success) EMOJI=":white_check_mark:" WORD="passed" ;;
              failure) EMOJI=":x:"                WORD="failed" ;;
              error)   EMOJI=":warning:"          WORD="errored" ;;
              # Unreachable via the job's `if`; keeps a state added later
              # from rendering as a blank word.
              *)       EMOJI=":grey_question:"     WORD="$STATE" ;;
            esac
            TEXT="$EMOJI CI $WORD on your <$URL|PR #$PR>: $TITLE"
            # Point at the sem-ai triage analysis on the PR (which itself
            # links the run) when triage actually posted one — the poll only
            # runs on failure/error, and triage skips on backend hiccups, so
            # a URL here is the whole condition. No raw CI-run link on any
            # outcome — the PR link above is the lead.
            if [[ -n "$TRIAGE_COMMENT_URL" ]]; then
              TEXT="$TEXT"$'\n'":mag: Failure analysis: $TRIAGE_COMMENT_URL"
            fi
          fi

          # Resolve the member ID to a DM channel with conversations.open
          # (requires im:write) and post to *that* channel. Passing the bare
          # member ID as chat.postMessage's `channel` instead returns
          # `channel_not_found` — the IM has to be opened first. Soft-fail
          # throughout: never break the build on a Slack hiccup.
          slack() {
            # $1 = method, $2 = json body. Echoes the response; never fails.
            set +e
            curl -sS --max-time 30 -X POST "https://slack.com/api/$1" \
              -H "Authorization: Bearer $SLACK_BOT_TOKEN" \
              -H 'Content-Type: application/json; charset=utf-8' \
              --data-binary "$2"
            set -e
          }
          # jq stderr suppressed: an empty/non-JSON response (Slack unreachable)
          # just yields ok=false/error=unknown and takes the warning branch.
          ok()  { printf '%s' "$1" | jq -r '.ok // false'   2>/dev/null; }
          err() { printf '%s' "$1" | jq -r '.error // "unknown"' 2>/dev/null; }

          # One delivered DM keeps the claim; a retry would re-send to the others.
          for SLACK_ID in $SLACK_IDS; do
            OPEN=$(slack conversations.open "$(jq -n --arg u "$SLACK_ID" '{users: $u}')")
            if [[ "$(ok "$OPEN")" != "true" ]]; then
              # missing_scope -> bot lacks im:write; user_not_found -> wrong
              # member ID or wrong workspace for this token. Both surface here
              # instead of as an opaque channel_not_found from postMessage.
              echo "::warning::Slack conversations.open failed for $SLACK_ID: $(err "$OPEN")"
              continue
            fi
            CHANNEL=$(printf '%s' "$OPEN" | jq -r '.channel.id')

            RESP=$(slack chat.postMessage \
              "$(jq -n --arg c "$CHANNEL" --arg t "$TEXT" '{channel: $c, text: $t, unfurl_links: false}')")
            if [[ "$(ok "$RESP")" != "true" ]]; then
              echo "::warning::Slack DM to $SLACK_ID failed: $(err "$RESP")"
            else
              echo "DM sent to $SLACK_ID for PR #$PR ($STATE)"
              SENT=true
            fi
          done
          if [[ "${SENT:-}" == "true" ]]; then
            echo "sent=true" >> "$GITHUB_OUTPUT"
          fi

      # A failed send must release the claim, or a transient Slack outage on
      # the run that won the race would permanently suppress the
      # notification for this (pipeline, state) — no other duplicate event would
      # retry, since the ref would already exist. Best-effort: a delete
      # hiccup must never fail the notifier — the DM attempt is already done.
      - name: Release dedup marker if send failed
        if: steps.claim.outputs.claimed == 'true' && steps.send.outputs.sent != 'true'
        continue-on-error: true
        env:
          MARKER: ${{ steps.claim.outputs.ref }}
        run: gh api "repos/$REPO/git/$MARKER" --method DELETE

      # Housekeeping: markers are dead weight once the duplicate-event burst
      # (minutes) has passed, so prune any older than 2 days here rather than
      # in a separate scheduled workflow — keeps the whole mechanism in this
      # file and refs/ci-notify/* from growing without bound. Age is the
      # yyyymmdd prefix of the marker name, so the whole sweep costs one list
      # call and no per-ref lookups. This run's own marker carries today's
      # date and so can never precede the cutoff. Best-effort throughout: a
      # failed prune must never fail the notifier, and a missed one just
      # waits for the next run. Duplicate runs may race to delete the same
      # ref — the loser's 422 is swallowed.
      - name: Prune stale dedup markers
        if: steps.claim.outcome == 'success'
        continue-on-error: true
        run: |
          CUTOFF=$(date -u -d '2 days ago' +%Y%m%d)
          gh api "repos/$REPO/git/matching-refs/ci-notify/" --paginate --jq '.[].ref' \
          | while read -r REF; do
              NAME=${REF#refs/ci-notify/}
              DATE=${NAME%%-*}
              if [[ ! "$DATE" =~ ^[0-9]{8}$ ]]; then
                # A <40-hex>-<state> name predates this naming scheme and
                # cannot be aged, so retire it on sight. Worst case it
                # belonged to a burst still in flight during the rollout and
                # that burst sends one extra DM. Anything else under this
                # namespace was put there by hand — leave it alone rather
                # than delete someone's debugging marker.
                if [[ "$NAME" =~ ^[0-9a-f]{40}- ]]; then
                  gh api "repos/$REPO/git/$REF" --method DELETE \
                    && echo "pruned legacy $REF" \
                    || echo "::notice::could not prune $REF"
                fi
                continue
              fi
              # yyyymmdd sorts lexicographically, so '<' compares dates.
              if [[ "$DATE" < "$CUTOFF" ]]; then
                gh api "repos/$REPO/git/$REF" --method DELETE \
                  && echo "pruned $REF (claimed $DATE)" \
                  || echo "::notice::could not prune $REF"
              fi
            done
