diff --git a/.github/workflows/copilot-response.yml b/.github/workflows/copilot-response.yml index 3f6cd61..16df316 100644 --- a/.github/workflows/copilot-response.yml +++ b/.github/workflows/copilot-response.yml @@ -1,6 +1,9 @@ name: Copilot review response -# Fires when Copilot submits a review on a Dependabot PR the AI upgrade -# pipeline has already finished (label ai-complete). One Claude round +# Responds to a Copilot review on a Dependabot PR the AI upgrade pipeline +# has already finished (label ai-complete): started by the org dispatcher +# (v6 callers, pr-number/review-id inputs) or, for v5 callers, by the +# held-then-released pull_request_review run. Each review is answered at +# most once (review= in the ai-meta marker). One Claude round # verifies each Copilot finding as an UNTRUSTED CLAIM — fixing what is real, # rejecting what is not, with evidence — then a deterministic job pushes via # the validated-push action, replies to every thread with its verdict, @@ -27,6 +30,19 @@ on: description: "Bot actors allowed to trigger the Claude step" type: string default: talieisin-ai-upgrade-trigger + # Dispatch mode (bootstrap v6 callers): the org dispatcher starts the + # round via workflow_dispatch instead of the caller subscribing to + # pull_request_review, which GitHub holds at action_required on every + # PR Copilot reviews. Identifiers only — the gate re-reads the rest. + # Both empty = review-event mode (v5 callers). + pr-number: + description: "PR to respond on (dispatch mode); empty = take it from the pull_request_review event" + type: string + default: "" + review-id: + description: "Copilot review to respond to (dispatch mode); empty = take it from the pull_request_review event" + type: string + default: "" secrets: CLAUDE_CODE_OAUTH_TOKEN: required: true @@ -36,11 +52,14 @@ on: required: true concurrency: - group: copilot-response-${{ github.repository }}-${{ github.event.pull_request.number }} + group: copilot-response-${{ github.repository }}-${{ inputs.pr-number || github.event.pull_request.number }} cancel-in-progress: false env: STICKY_MARKER: "" + # The automation app's login: dispatch-mode actor, and the only author + # whose ai-meta review= markers count as "answered". + AUTOMATION_BOT: "talieisin-org-automation[bot]" jobs: gate: @@ -49,27 +68,50 @@ jobs: permissions: contents: read pull-requests: read + issues: read # answered-marker lookup on the PR's comments outputs: verdict: ${{ steps.checks.outputs.verdict }} + pr-number: ${{ steps.checks.outputs.pr-number }} + review-id: ${{ steps.checks.outputs.review-id }} head-sha: ${{ steps.checks.outputs.head-sha }} - head-ref: ${{ github.event.pull_request.head.ref }} + head-ref: ${{ steps.checks.outputs.head-ref }} + base-ref: ${{ steps.checks.outputs.base-ref }} findings: ${{ steps.checks.outputs.findings }} steps: - name: Provenance checks and findings extraction id: checks env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - REVIEWER: ${{ github.event.review.user.login }} - REVIEW_ID: ${{ github.event.review.id }} - PR_AUTHOR: ${{ github.event.pull_request.user.login }} - PR_STATE: ${{ github.event.pull_request.state }} - HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} BASE_REPO: ${{ github.repository }} - PR_NUMBER: ${{ github.event.pull_request.number }} - LABELS: ${{ join(github.event.pull_request.labels.*.name, ',') }} + IN_PR: ${{ inputs.pr-number }} + IN_REVIEW: ${{ inputs.review-id }} + EVENT_PR: ${{ github.event.pull_request.number }} + EVENT_REVIEW: ${{ github.event.review.id }} run: | set -euo pipefail skip() { echo "verdict=skip" >> "$GITHUB_OUTPUT"; echo "findings=0" >> "$GITHUB_OUTPUT"; echo "head-sha=" >> "$GITHUB_OUTPUT"; echo "note: $1"; exit 0; } + # Dispatch mode (inputs set) or review-event mode (v5 callers). + # Either way the inputs are identifiers only: everything the + # checks below judge is read back from the API, never trusted + # from the dispatch or the event payload. + if [ -n "$IN_PR$IN_REVIEW" ]; then + PR_NUMBER=$IN_PR REVIEW_ID=$IN_REVIEW + else + PR_NUMBER=$EVENT_PR REVIEW_ID=$EVENT_REVIEW + fi + [[ "$PR_NUMBER" =~ ^[1-9][0-9]*$ && "$REVIEW_ID" =~ ^[1-9][0-9]*$ ]] \ + || skip "no valid PR number and review id (pr='$PR_NUMBER' review='$REVIEW_ID')" + echo "pr-number=$PR_NUMBER" >> "$GITHUB_OUTPUT" + echo "review-id=$REVIEW_ID" >> "$GITHUB_OUTPUT" + # Scoped to the PR, so a review from another PR is a 404 (fails + # the job — the dispatcher's attempt cap bounds any retry). + gh api "repos/$BASE_REPO/pulls/$PR_NUMBER" > pr.json + gh api "repos/$BASE_REPO/pulls/$PR_NUMBER/reviews/$REVIEW_ID" > review.json + REVIEWER=$(jq -r '.user.login' review.json) + PR_AUTHOR=$(jq -r '.user.login' pr.json) + PR_STATE=$(jq -r '.state' pr.json) + HEAD_REPO=$(jq -r '.head.repo.full_name // ""' pr.json) + LABELS=$(jq -r '[.labels[].name] | join(",")' pr.json) case "$REVIEWER" in copilot-pull-request-reviewer*|github-copilot*|Copilot) : ;; *) skip "review author '$REVIEWER' is not Copilot" ;; @@ -81,6 +123,26 @@ jobs: *,ai-complete,*) : ;; *) skip "PR is not in ai-complete state (labels: $LABELS) — the pipeline responds only on finished PRs" ;; esac + # Findings are line-anchored to the reviewed commit; a round that + # starts hours later (dispatcher cadence, stale-green refreshes) + # must not apply them to a head that has since moved. The + # dispatcher selects on the same predicate, so this never loops. + REVIEW_SHA=$(jq -r '.commit_id' review.json) + HEAD_SHA=$(jq -r '.head.sha' pr.json) + [ "$REVIEW_SHA" = "$HEAD_SHA" ] \ + || skip "review $REVIEW_ID is on $REVIEW_SHA but the head is now $HEAD_SHA — request a new Copilot review" + # Answered at most once. push-response stamps review= into + # the ai-meta marker of every comment it writes; only the + # automation app's own comments count, so nobody can suppress a + # round by pasting a marker. Retry = request a new Copilot review. + # Read to a file first: a failed read must fail the job, not + # pass as "unanswered" (and grep -q in a pipefail pipeline would + # SIGPIPE gh and turn a match into a miss). + gh api "repos/$BASE_REPO/issues/$PR_NUMBER/comments?per_page=100" --paginate \ + --jq ".[] | select(.user.login == \"$AUTOMATION_BOT\") | .body" > own-comments.txt + if grep -Eq "^" + # review= is also the answered marker the gate checks: every + # comment written below carries it, whatever the outcome. + META="" marks() { # $1 = outcome, or empty for the model line only printf '%s\n' "$META" [ -z "$1" ] || printf '\n' "$GITHUB_RUN_ID" "$1" @@ -466,7 +543,7 @@ jobs: env: GH_TOKEN: ${{ steps.app-token.outputs.token }} REPO: ${{ github.repository }} - PR_NUMBER: ${{ github.event.pull_request.number }} + PR_NUMBER: ${{ needs.gate.outputs.pr-number }} OUTCOME: ${{ steps.watch.outputs.outcome }} SHA: ${{ needs.push-response.outputs.new-sha }} run: |