From 8219f9528932ef1b21f478f39f74439259368441 Mon Sep 17 00:00:00 2001 From: George Elphick Date: Thu, 24 Sep 2026 08:33:49 +0100 Subject: [PATCH 1/2] feat: copilot-response accepts a dispatched PR and review Adds pr-number and review-id inputs so the org dispatcher can start a Copilot-response round via workflow_dispatch (bootstrap v6 callers) instead of callers subscribing to pull_request_review. GitHub holds that subscription at action_required on every PR Copilot reviews, which leaves held runs on ordinary PRs. Empty inputs keep review-event mode for v5 callers, so this is backward compatible and merges first. In both modes the gate now treats the PR number and review id as identifiers only and re-reads everything it judges from the API. It also skips a review whose commit is no longer the PR head, and one already answered: push-response stamps review= into the ai-meta marker of every comment it writes, and only the automation app's comments count. --- .github/workflows/copilot-response.yml | 116 ++++++++++++++++++++----- 1 file changed, 92 insertions(+), 24 deletions(-) diff --git a/.github/workflows/copilot-response.yml b/.github/workflows/copilot-response.yml index 3f6cd61..49172d7 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 +534,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: | From 9c8cf06e048e63a9c1c8073a2296a8c97b6b10f8 Mon Sep 17 00:00:00 2001 From: George Elphick Date: Fri, 25 Sep 2026 12:40:12 +0100 Subject: [PATCH 2/2] fix: an inline reply from the automation app also marks a review answered push-response replies to each thread before it writes the marked summary comment, and that write is best-effort. A verdicts-only round whose summary failed would leave no marker and no head move, so the review could be answered twice. --- .github/workflows/copilot-response.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/copilot-response.yml b/.github/workflows/copilot-response.yml index 49172d7..16df316 100644 --- a/.github/workflows/copilot-response.yml +++ b/.github/workflows/copilot-response.yml @@ -150,6 +150,15 @@ jobs: > findings.json n=$(jq length findings.json) [ "$n" -gt 0 ] || skip "review has no inline comments — nothing to respond to" + # Also answered if the automation app already replied to any of + # this review's inline comments: push-response posts those replies + # BEFORE the marked summary comment, whose write is best-effort. + gh api "repos/$BASE_REPO/pulls/$PR_NUMBER/comments?per_page=100" --paginate \ + --jq ".[] | select(.user.login == \"$AUTOMATION_BOT\") | .in_reply_to_id // empty" > own-replies.txt + jq -r '.[].comment_id' findings.json > finding-ids.txt + if grep -Fxqf finding-ids.txt own-replies.txt; then + skip "review $REVIEW_ID already has replies from $AUTOMATION_BOT (its summary comment is missing)" + fi mkdir -p out && cp findings.json out/findings.json echo "verdict=proceed" >> "$GITHUB_OUTPUT" echo "findings=$n" >> "$GITHUB_OUTPUT"