Skip to content

feat(ci): fix what the review finds, in the same run - #199

Open
MaryWylde wants to merge 5 commits into
devfrom
feat/agent-review-handoff
Open

feat(ci): fix what the review finds, in the same run#199
MaryWylde wants to merge 5 commits into
devfrom
feat/agent-review-handoff

Conversation

@MaryWylde

Copy link
Copy Markdown
Contributor

Today the review→fix handoff is a person reading a comment and telling their agent to go. That's a notification, not automation — someone has to be at a desk. This does the fixing in CI instead.

claude-code-review.yml gains a second job:

  1. claude-review reviews the PR, leaves inline comments, and publishes how many landed on the current head commit.
  2. claude-fix runs only when that count is above zero — checks out the PR branch, addresses the comments, commits with [agent-fix] in the subject, and pushes.

Nobody starts either one. Open a PR and both run.

Proven, not assumed

Exercised end to end on a throwaway probe PR (#198, closed, not merged) carrying a component with deliberate defects. Second run, on Opus:

82d9f44  fix(library): [agent-fix] make AgentFixProbe keyboard-operable and token-driven
9313427  fix(library): [agent-fix] replace the any types on AgentFixProbeProps
3f1c0e2  fix(library): [agent-fix] default-export AgentFixProbe and its barrel

It caught all five planted defects, stayed inside the probe files, and applied the AGENTS.md barrel convention (index.tsxindex.ts, default export) rather than just patching the symptom.

The first run failed, and that's why two of these commits exist. The fixer triggered correctly and then did nothing: the action's injected PR context arrived with no review comments, so it reported "no inline review comments were present" and declined to guess. Right call by the fixer, broken plumbing around it. Findings are now collected in a workflow step and written to .review-findings.json, which the prompt names as the authoritative list — deterministic, and it keeps unscoped gh out of the allowlist of the one job that writes code unattended.

Why it can't run away

The fix job pushes with GITHUB_TOKEN. In practice those pushes do not run: GitHub parks them at action_required pending maintainer approval, because the pushing actor is a bot. Observed, not assumed — see the two gated runs on #198.

That is a weaker guarantee than "workflows are never triggered": approving those runs would start a review on the agent's commits, which could hand off to another fix pass. It still cannot loop unattended, since each pass's push is gated the same way. Behind that sits a round guard — two [agent-fix] commits on a PR and the job labels agent:needs-human and stops.

Failing loudly

A fix job that produces nothing exits 0 and shows the same green tick as one that fixed everything — exactly what happened on the first run. The last step compares head before and after; if it didn't move, it warns, labels agent:needs-human, and fails.

Judgement, deliberately constrained

The fixer is told to touch only what the review raised, and to skip a comment it judges wrong rather than comply — automated review is mistaken sometimes, and an unattended agent that "improves" adjacent code is how a small PR becomes unreviewable. Read its summary before merging; a fix pushed unattended still needs a human to agree with it.

Both jobs are pinned to claude-opus-5 (the action defaults to Sonnet). Opus draws down the Max subscription faster, and each PR can now run two Claude jobs.

Test plan

  • Open a PR with a real defect; confirm claude-review comments and claude-fix pushes an [agent-fix] commit
  • Open a clean PR; confirm claude-fix does not run at all
  • Confirm the action_required runs after a fix push stay parked

🤖 Generated with Claude Code

MaryWylde and others added 4 commits September 7, 2026 22:13
Two people work this repo with separate agent setups, and the review→fix
handoff was a person reading a comment and telling their agent to go. That is a
notification, not automation — someone still has to be at a desk.

So do the fixing in CI. claude-code-review.yml gains a second job: the review
job publishes how many inline comments landed on the current head commit, and
the fix job runs only when that is above zero, checks out the PR branch,
addresses the comments and pushes. Nobody starts either one.

It cannot loop, structurally: the fix job pushes with GITHUB_TOKEN, and GitHub
does not start workflows from GITHUB_TOKEN pushes, so the fix commit triggers no
second review and therefore no second fix. One review, one fix pass, then it
waits for a person. A round guard on [agent-fix] commits is the backstop if
anyone later swaps in a PAT to get re-review chaining, and trips
agent:needs-human rather than going again.

The fixer is told to address only what the review raised and to skip comments it
judges wrong rather than comply — automated review is wrong sometimes, and an
unattended agent widening its own scope is how a small PR becomes an
unreviewable one.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The action defaults to claude-sonnet-5 and neither job set a model, so both
were running on the default. Pin both to claude-opus-5.

Review quality is the entire point of the review job — a defect it misses costs
more than the tokens it saved. The fix job matters more still: it writes code
and pushes it with nobody watching, so its judgement about which review
comments are worth acting on, and which are wrong, is the thing standing
between an unattended agent and a bad commit.

Note for quota: Opus draws down the Max subscription faster than Sonnet, and
each PR can now run both jobs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
First live run: claude-review posted 5 inline comments, claude-fix triggered
correctly, and then did nothing — its injected PR context arrived with no
review comments at all, so it reported 'no inline review comments were present'
and declined to guess. That was the right call by the fixer and a real gap in
the pipeline: the trigger said there were findings, the payload did not carry
them.

Collect the comments for the head commit in a workflow step and write them to
.review-findings.json, which the prompt names as the authoritative list. Two
reasons to prefer that over letting the fixer query GitHub: it is deterministic,
and it keeps unscoped gh out of the allowlist of the one job that writes code
unattended. The file is added to .git/info/exclude so it cannot be committed.

The prompt now also says to stop if the file is empty, rather than going
looking for things to fix.

Turned on show_full_output for this job too — the 9 permission denials behind
the first failure were invisible without it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A fix job woken with findings that produces no commit exits 0 and shows the
same green tick as one that fixed everything — which is exactly what happened
on the first live run. Compare the head before and after; if it did not move,
warn, label agent:needs-human, and fail.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown

Claude finished @MaryWylde's task in 2m 44s —— View job


Review — PR #199

  • Read the diff against origin/dev
  • Review the workflow changes (correctness, injection safety, permissions, gating)
  • Review the AGENTS.md doc changes for accuracy against the workflow
  • Post inline comments and summary

No application code changed here — the diff is CI YAML plus docs — so the React/TypeScript/SCSS checks in my brief don't apply. I reviewed this as workflow security and control flow instead.

Overall

The design is sound and the writing around it is unusually honest: the reviewer's --allowedTools is scoped against prompt injection with the reasoning written down (lines 117-128), the round guard exists, the "did it change anything" step exists because a green tick on a no-op was already observed, and the PR body documents the first run's failure rather than quietly deleting it. That's the right instinct. Four things to fix before merge.

Blocking

1. The findings list isn't filtered by author.github/workflows/claude-code-review.yml:227

pulls/$PR/comments returns every inline comment from every account, and on a public repo anyone can leave one. It lands in .review-findings.json, which the prompt names as authoritative, and an unattended agent with contents: write acts on it. Reachable without the attacker triggering anything: comment, wait for the author's next push. The reviewer job is hardened against untrusted PR content; the job that writes code should be too. Fix this →

2. The loudness check reads the local HEAD, not the remote.github/workflows/claude-code-review.yml:296

git rev-parse HEAD moves on commit, so a commit that fails to push reports pushed 1 commit(s) and goes green — the exact silent-success this step was added to kill. Not hypothetical here: GITHUB_TOKEN can't push changes under .github/workflows/ without the workflow scope, so a fix job on this PR's own file would commit, get rejected, and pass. Compare origin/<branch> after a fetch. Fix this →

Worth calling out in the PR body as a known limit: the fixer can never repair .github/workflows/* — findings on workflow files always need a human.

3. AGENTS.md asserts a guarantee the PR body says isn't trueAGENTS.md:509

Docs: "GitHub does not start workflows from GITHUB_TOKEN pushes." PR body: the runs are created and parked at action_required, which it correctly flags as weaker. The docs are the version a future maintainer reads before approving a queued run. The workflow comment at lines 165-169 needs the same correction. Fix this →

Worth checking before merge

4. commit_id may re-map across pushes.github/workflows/claude-code-review.yml:151. GitHub re-anchors still-valid review comments onto the new head, so round two may re-collect round one's findings. I haven't verified it on this repo — it's one probe PR to settle, and if it holds, filter by the review ID this run produced or by created_at. Details inline.

Smaller notes, not blocking

  • --jq interpolates $HEAD_SHA into the filter string. Safe for a SHA, but --jq '... $ENV.HEAD_SHA ...' removes the whole question.
  • git rev-list --count "$BEFORE..$after" is the step's last command, so a non-ancestor $BEFORE (force-push mid-run) fails the job at the point it's reporting success. Loud, so arguably fine — just not for the reason the log will say.
  • Workflow-level cancel-in-progress can kill claude-fix mid-pass, after commits and before push. Item 2's fix makes that visible rather than silent.
  • echo ".review-findings.json" >> .git/info/exclude instead of touching .gitignore is the right call — nice.

What I did not check

I couldn't fetch origin/dev (command denied), so I reviewed the files at their current state on the branch rather than a computed diff. Both changed files are additive per the PR metadata (+189/-0 and +36/-0), so this should be equivalent, but flagging the gap.
· branch feat/agent-review-handoff

Comment on lines +227 to +230
gh api "repos/$REPO/pulls/$PR/comments" --paginate \
--jq "[.[] | select(.commit_id == \"$HEAD_SHA\")
| {path, line: (.line // .original_line), body}]" \
> .review-findings.json

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Security: the findings list is not filtered by author.

repos/$REPO/pulls/$PR/comments returns every inline review comment on the PR, from any account. On a public repo, any GitHub user can leave one. The only filter here is commit_id, so a third party's comment lands in .review-findings.json as an authoritative "finding" — and the prompt at line 250-252 tells the fixer that file is the authoritative list.

The chain is reachable without the attacker triggering anything: comment on an open PR, wait for the author's next push. The review runs, the count at line 151 (same missing filter) includes the planted comment so claude-fix fires, and the fixer applies it with contents: write and Edit/Write in its allowlist. The reviewer's own allowlist is carefully scoped against exactly this class of problem (lines 117-121); the job that actually writes code should get the same treatment.

Filtering on the bot author closes it in both places:

Suggested change
gh api "repos/$REPO/pulls/$PR/comments" --paginate \
--jq "[.[] | select(.commit_id == \"$HEAD_SHA\")
| {path, line: (.line // .original_line), body}]" \
> .review-findings.json
gh api "repos/$REPO/pulls/$PR/comments" --paginate \
--jq "[.[] | select(.commit_id == \"$HEAD_SHA\"
and .user.login == \"github-actions[bot]\")
| {path, line: (.line // .original_line), body}]" \
> .review-findings.json

If human findings should be actionable, that's a reasonable product call — but then it wants to be a deliberate allowlist (collaborators with write access), not "anyone who can type in a comment box".

Comment on lines +296 to +304
after=$(git rev-parse HEAD)
echo "head before: $BEFORE"
echo "head after: $after"
if [ "$after" = "$BEFORE" ]; then
echo "::warning::claude-fix was woken with findings but pushed nothing."
gh pr edit "$PR" --repo "$REPO" --add-label "agent:needs-human" 2>/dev/null || true
exit 1
fi
echo "pushed $(git rev-list --count "$BEFORE..$after") commit(s)"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This checks the local HEAD, so it can't see a failed push.

git rev-parse HEAD moves as soon as the fixer commits. Whether the push landed is never consulted, so the one failure this step exists to catch — the fixer produced a commit that never reached the branch — reports pushed 1 commit(s) and a green tick.

That isn't hypothetical on this very PR: GITHUB_TOKEN cannot push changes under .github/workflows/ (no workflow scope), so a fix job triggered by a review of this file commits locally, gets rejected at push, and this step congratulates it. Comparing the remote ref catches both that and any other push rejection:

Suggested change
after=$(git rev-parse HEAD)
echo "head before: $BEFORE"
echo "head after: $after"
if [ "$after" = "$BEFORE" ]; then
echo "::warning::claude-fix was woken with findings but pushed nothing."
gh pr edit "$PR" --repo "$REPO" --add-label "agent:needs-human" 2>/dev/null || true
exit 1
fi
echo "pushed $(git rev-list --count "$BEFORE..$after") commit(s)"
branch="${{ github.event.pull_request.head.ref }}"
git fetch origin "$branch" --quiet
after=$(git rev-parse "origin/$branch")
echo "head before: $BEFORE"
echo "head after: $after"
if [ "$after" = "$BEFORE" ]; then
echo "::warning::claude-fix was woken with findings but pushed nothing."
gh pr edit "$PR" --repo "$REPO" --add-label "agent:needs-human" 2>/dev/null || true
exit 1
fi
echo "pushed $(git rev-list --count "$BEFORE..$after") commit(s)"

Worth noting separately in the PR description too: this workflow is self-modifying-proof — the fixer can never repair .github/workflows/*, so findings on workflow files always need a human.

Comment on lines +151 to +154
findings=$(gh api "repos/$REPO/pulls/$PR/comments" --paginate \
--jq "[.[] | select(.commit_id == \"$HEAD_SHA\")] | length")
echo "inline findings on $HEAD_SHA: $findings"
echo "findings=${findings:-0}" >> "$GITHUB_OUTPUT"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two things about this count, beyond the author filter noted on the collection step:

  1. commit_id is not stable per review round. GitHub re-maps a still-valid review comment onto the new head after a push, so comments from an earlier round can keep matching $HEAD_SHA. If that holds, a PR whose second review is clean can still wake claude-fix with findings the previous round already fixed, and the fixer either re-applies no-ops or gets told to fix already-fixed code. Cheap to confirm on a probe PR: comment on round one, push, and print commit_id here on round two. If it does re-map, filtering on created_at > <run start> or on the review ID this run produced is the sturdier signal.

  2. The count is a proxy for "the review found something". A comment the reviewer left as praise or a question counts as a finding and starts a code-writing job. Not a blocker — the fixer is told to skip what it judges wrong — but it means the trigger is noisier than the prose above it claims.

Comment thread AGENTS.md Outdated
Comment on lines +509 to +512
**Why this cannot loop.** The fix job pushes with `GITHUB_TOKEN`, and GitHub
does not start workflows from `GITHUB_TOKEN` pushes. So the fix commit does not
trigger a second review, which means it cannot trigger a second fix. One review,
one fix pass, then it stops. A re-review happens when a person next pushes.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This states the strong guarantee, but the PR description states the observed one — and they disagree:

That is a weaker guarantee than "workflows are never triggered": approving those runs would start a review on the agent's commits, which could hand off to another fix pass.

What you actually saw on #198 is GitHub parking the runs at action_required pending maintainer approval, not declining to create them. The distinction matters precisely for the person this section is written for: someone who reads "does not trigger a second review", then sees a queued run awaiting approval and clicks it, believing the docs rule out a chain. The round guard still holds, but they'd be approving something the docs told them couldn't exist.

The workflow comment at lines 165-169 carries the same claim and wants the same correction. Suggest matching the PR body's framing:

Suggested change
**Why this cannot loop.** The fix job pushes with `GITHUB_TOKEN`, and GitHub
does not start workflows from `GITHUB_TOKEN` pushes. So the fix commit does not
trigger a second review, which means it cannot trigger a second fix. One review,
one fix pass, then it stops. A re-review happens when a person next pushes.
**Why this cannot run away.** The fix job pushes with `GITHUB_TOKEN`. GitHub
parks the resulting workflow runs at `action_required`, pending maintainer
approval, because the pushing actor is a bot — so no fix commit starts a review
on its own. Approving one *would* start a review on the agent's commits, which
can hand off to another fix pass; each pass's push is gated the same way, so it
cannot run unattended. A re-review otherwise happens when a person next pushes.

@github-actions github-actions Bot added the agent:review-ready Claude's PR review finished and left findings — ready for a fixer agent label Sep 7, 2026
@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown

Claude finished @MaryWylde's task in 2m 57s —— View job


Fix pass — 1 pushed, 2 blocked, 1 skipped

Read .review-findings.json: 4 findings. One landed; two are correct, are fixed, and cannot be pushed by this job; one I judged not actionable as stated.

  • AGENTS.md overstates the loop guarantee — fixed & pushed (5507e33)
  • Author filter on findings collection + count — fixed, push rejected
  • Push verification against the remote ref — fixed, push rejected
  • commit_id re-mapping / praise comments — skipped, reasoning below

The blocker, which the review itself predicted

Finding #2 said this out loud:

GITHUB_TOKEN cannot push changes under .github/workflows/ (no workflow scope), so a fix job triggered by a review of this file commits locally, gets rejected at push, and this step congratulates it.

That is not a hypothesis any more:

! [remote rejected] HEAD -> feat/agent-review-handoff (refusing to allow a
  GitHub App to create or update workflow `.github/workflows/claude-code-review.yml`
  without `workflows` permission)

So the fixer can never repair its own workflow file. Findings on .github/workflows/* always need a human, and no amount of prompt tuning changes that — it's a token scope. Worth putting in the PR description alongside the loop discussion.

There's a second-order irony worth catching before merge: this job's own "Did it actually change anything" step will show green, because it reads local HEAD and my rejected commit moved it. Something genuinely did land (the AGENTS.md commit), so the verdict happens to be right — but it's right by luck, arriving through exactly the bug finding #2 identified.


1. Author filter — correct, fixed, unpushable

Real and reachable: repos/$REPO/pulls/$PR/comments returns every inline comment from any account, and on a public repo anyone can leave one. commit_id was the only filter, so a stranger's comment became an authoritative "finding" feeding a job holding contents: write and Edit/Write. Filtered on the reviewer bot in both places — the count that wakes the job, and the file the fixer is told to trust.

I could not verify the bot login empirically (gh is deliberately not in this job's allowlist — the same scoping decision the finding praises). The review job passes github_token: secrets.GITHUB_TOKEN, so its comments author as github-actions[bot]. Confirm that string against a real comment before merging; if it's wrong the filter silently matches nothing and the fixer never fires again.

I did not act on the finding's alternative — allowlisting write-access collaborators — since that's a product call, not a defect fix.

2. Push verification — correct, fixed, unpushable

git rev-parse HEAD moves the moment the fixer commits, so push rejection reported pushed 1 commit(s) and a green tick. Now compares the remote ref.

One deviation from the suggested patch: I used an explicit refspec rather than a bare git fetch origin "$branch", because actions/checkout was given a single ref: and refs/remotes/origin/<branch> isn't guaranteed to be configured for update.

--- a/.github/workflows/claude-code-review.yml
+++ b/.github/workflows/claude-code-review.yml
@@ -148,8 +148,13 @@ jobs:
           HEAD_SHA: ${{ github.event.pull_request.head.sha }}
           REPO: ${{ github.repository }}
         run: |
+          # Filter on the bot author as well as the commit. This endpoint returns
+          # every inline comment on the PR from any account, and on a public repo
+          # anyone can leave one — without that filter a stranger's comment counts
+          # as a finding and wakes the job that writes code unattended.
           findings=$(gh api "repos/$REPO/pulls/$PR/comments" --paginate \
-            --jq "[.[] | select(.commit_id == \"$HEAD_SHA\")] | length")
+            --jq "[.[] | select(.commit_id == \"$HEAD_SHA\"
+                        and .user.login == \"github-actions[bot]\")] | length")
           echo "inline findings on $HEAD_SHA: $findings"
@@ -224,8 +229,12 @@ jobs:
           REPO: ${{ github.repository }}
         run: |
+          # Same author filter as the count step, and for the same reason: this is
+          # the list the fixer is told to treat as authoritative, so anything that
+          # is not the reviewer's own comment must not reach it.
           gh api "repos/$REPO/pulls/$PR/comments" --paginate \
-            --jq "[.[] | select(.commit_id == \"$HEAD_SHA\")
+            --jq "[.[] | select(.commit_id == \"$HEAD_SHA\"
+                        and .user.login == \"github-actions[bot]\")
                    | {path, line: (.line // .original_line), body}]" \
             > .review-findings.json
@@ -293,7 +302,12 @@ jobs:
           BEFORE: ${{ github.event.pull_request.head.sha }}
         run: |
-          after=$(git rev-parse HEAD)
+          # Compare the remote ref, not local HEAD: HEAD moves as soon as the
+          # fixer commits, so a commit that was rejected at push would report
+          # success here — the one failure this step exists to catch.
+          branch="${{ github.event.pull_request.head.ref }}"
+          git fetch --quiet origin "+refs/heads/$branch:refs/remotes/origin/$branch"
+          after=$(git rev-parse "refs/remotes/origin/$branch")
           echo "head before: $BEFORE"
           echo "head after:  $after"
           if [ "$after" = "$BEFORE" ]; then

3. commit_id stability + praise comments — skipped

Both halves, deliberately.

The re-mapping concern is stated as a hypothesis the finding itself asks someone to confirm ("cheap to confirm on a probe PR… If it does re-map"). I can't run that probe from here, and its proposed remedy — keying off created_at > run start or the review ID this run produced — is a design change to the trigger, not a repair. Guessing at it unattended risks making the trigger miss real findings, which is a worse failure than the one it's guarding against. It deserves the probe and a human's call.

The praise-comments half the finding calls "not a blocker" and offers no fix for; the fixer is already told to skip what it judges wrong.

4. AGENTS.md — fixed and pushed

The doc claimed GitHub doesn't start workflows from GITHUB_TOKEN pushes; what was observed on #198 is GitHub creating those runs and parking them at action_required. Took the suggested framing, and adjusted the paragraph after it so the PAT point still follows — under the corrected framing a PAT removes the gate entirely rather than enabling a chain that was impossible.

The finding also flags the same claim in the workflow comment at lines 165-169. Correct, and blocked by the same token scope. Whoever applies the diff above should fix that comment in the same pass.


For a human to do: apply the diff to .github/workflows/claude-code-review.yml, correct the loop comment at lines 165-169 to match AGENTS.md, verify the github-actions[bot] login string, and decide on finding #3.
· branch feat/agent-review-handoff

…rantee

The review section claimed GitHub does not start workflows from GITHUB_TOKEN
pushes. What was actually observed on the probe PR is GitHub parking those runs
at action_required pending maintainer approval — it creates them, it just will
not start them unattended.

The difference matters for the reader this section is written for: someone who
takes 'cannot loop' literally, then sees a queued run awaiting approval and
clicks it, believing the docs rule out a chain.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agent:review-ready Claude's PR review finished and left findings — ready for a fixer agent

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant