Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 101 additions & 24 deletions .github/workflows/copilot-response.yml
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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
Expand All @@ -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: "<!-- talieisin-ai-upgrade -->"
# 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:
Expand All @@ -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" ;;
Expand All @@ -81,22 +123,53 @@ 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=<id> 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 "^<!-- ai-meta v=1 [^>]* review=$REVIEW_ID " own-comments.txt; then
skip "review $REVIEW_ID has already been answered"
fi
# Deterministic findings from the review's inline comments (a
# quota-failure or comment-free review yields zero and we skip).
gh api "repos/$BASE_REPO/pulls/$PR_NUMBER/reviews/$REVIEW_ID/comments?per_page=100" \
--jq '[to_entries[] | {id: ("C" + ((.key + 1) | tostring)), comment_id: .value.id, path: .value.path, line: (.value.line // .value.original_line), body: .value.body}]' \
> 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"
echo "head-sha=$(gh api "repos/$BASE_REPO/pulls/$PR_NUMBER" --jq .head.sha)" >> "$GITHUB_OUTPUT"
echo "head-sha=$HEAD_SHA" >> "$GITHUB_OUTPUT"
echo "head-ref=$(jq -r .head.ref pr.json)" >> "$GITHUB_OUTPUT"
echo "base-ref=$(jq -r .base.ref pr.json)" >> "$GITHUB_OUTPUT"
- name: Upload findings
if: steps.checks.outputs.verdict == 'proceed'
uses: actions/upload-artifact@v7
with:
name: copilot-findings-${{ github.event.pull_request.number }}-${{ github.event.review.id }}
name: copilot-findings-${{ steps.checks.outputs.pr-number }}-${{ steps.checks.outputs.review-id }}
path: out/
retention-days: 14

Expand All @@ -119,7 +192,7 @@ jobs:
- name: Download findings
uses: actions/download-artifact@v8
with:
name: copilot-findings-${{ github.event.pull_request.number }}-${{ github.event.review.id }}
name: copilot-findings-${{ needs.gate.outputs.pr-number }}-${{ needs.gate.outputs.review-id }}
path: ${{ runner.temp }}/copilot
- name: Set up Node (npm)
if: inputs.setup == 'npm'
Expand Down Expand Up @@ -191,12 +264,13 @@ jobs:
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
github_token: ${{ github.token }}
# The triggering actor of a pull_request_review event IS Copilot.
allowed_bots: "${{ inputs.allowed-bots }},copilot-pull-request-reviewer,Copilot,github-copilot"
# The actor of a pull_request_review event IS Copilot; a dispatched
# run's actor is the automation app (the org dispatcher).
allowed_bots: "${{ inputs.allowed-bots }},copilot-pull-request-reviewer,Copilot,github-copilot${{ inputs.review-id != '' && ',talieisin-org-automation' || '' }}"
prompt: |
This working tree is a Dependabot dependency-upgrade PR branch
(base ${{ github.event.pull_request.base.ref }} in
${{ github.repository }}, PR #${{ github.event.pull_request.number }})
(base ${{ needs.gate.outputs.base-ref }} in
${{ github.repository }}, PR #${{ needs.gate.outputs.pr-number }})
that an automated pipeline has already upgraded, reviewed, and
taken to green CI. The commit messages on this branch record the
earlier rounds' intent — read them (git log) before judging.
Expand Down Expand Up @@ -274,7 +348,7 @@ jobs:
- name: Upload response artifact
uses: actions/upload-artifact@v7
with:
name: copilot-response-${{ github.event.pull_request.number }}-${{ github.event.review.id }}
name: copilot-response-${{ needs.gate.outputs.pr-number }}-${{ needs.gate.outputs.review-id }}
path: ${{ runner.temp }}/response/
retention-days: 14

Expand Down Expand Up @@ -302,7 +376,7 @@ jobs:
continue-on-error: true
uses: actions/download-artifact@v8
with:
pattern: copilot-*-${{ github.event.pull_request.number }}-${{ github.event.review.id }}
pattern: copilot-*-${{ needs.gate.outputs.pr-number }}-${{ needs.gate.outputs.review-id }}
path: ${{ runner.temp }}/in
merge-multiple: true
# hashFiles() only sees the workspace, so a runner.temp path always
Expand All @@ -325,7 +399,7 @@ jobs:
token: ${{ steps.app-token.outputs.token }}
app-slug: ${{ steps.app-token.outputs.app-slug }}
repo: ${{ github.repository }}
pr-number: ${{ github.event.pull_request.number }}
pr-number: ${{ needs.gate.outputs.pr-number }}
head-ref: ${{ needs.gate.outputs.head-ref }}
expected-sha: ${{ needs.gate.outputs.head-sha }}
patch-path: ${{ runner.temp }}/in/response.patch
Expand All @@ -336,7 +410,8 @@ 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 }}
REVIEW_ID: ${{ needs.gate.outputs.review-id }}
VP_OUTCOME: ${{ steps.vpush.outputs.outcome }}
VP_DETAIL: ${{ steps.vpush.outputs.detail }}
VP_SHA: ${{ steps.vpush.outputs.new-sha }}
Expand All @@ -349,7 +424,9 @@ jobs:
# ai-meta / ai-outcome markers for the model-tier report (see
# dependabot-upgrade.yml); a pushed round's outcome is recorded
# by ci-watch once CI has judged the response commit.
META="<!-- ai-meta v=1 run=$GITHUB_RUN_ID round=copilot mode=copilot model=$MODEL -->"
# review=<id> is also the answered marker the gate checks: every
# comment written below carries it, whatever the outcome.
META="<!-- ai-meta v=1 run=$GITHUB_RUN_ID round=copilot mode=copilot model=$MODEL review=$REVIEW_ID -->"
Comment thread
george-elphick-talieisin marked this conversation as resolved.
marks() { # $1 = outcome, or empty for the model line only
printf '%s\n' "$META"
[ -z "$1" ] || printf '<!-- ai-outcome v=1 run=%s round=copilot outcome=%s -->\n' "$GITHUB_RUN_ID" "$1"
Expand Down Expand Up @@ -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: |
Expand Down
Loading