Skip to content

feat(ci): scope a re-review to what is new, and grant the reviewer Read - #123

Merged
thecodedrift merged 3 commits into
mainfrom
feat/incremental-review
Aug 21, 2026
Merged

feat(ci): scope a re-review to what is new, and grant the reviewer Read#123
thecodedrift merged 3 commits into
mainfrom
feat/incremental-review

Conversation

@thecodedrift

Copy link
Copy Markdown
Member

@claude /review reviewed the whole PR every time. On a PR that had been reviewed and then revised, the new work competed for attention with a dozen settled findings, and the output read as a fresh assessment of everything — so a reader could not tell which findings were new.

What a maintainer will notice

Trigger Mode Behavior
@claude /review in the PR conversation incremental Reads its own prior comments on the PR first, then reports what is new ([New]) plus anything previously raised that is still unaddressed ([Unchanged since last review]). With no prior comments this is simply a first, full review.
@claude /review all (either event) full Ignores prior comments, assesses the entire diff from scratch.
@claude /review as an inline review comment second-eyes Unchanged from today: a second set of eyes on the in-progress review.

Every mode now states which mode ran in the top-level summary comment, and an incremental review lists what it treated as already addressed. Without that, "no findings" is ambiguous between "nothing new since last time" and "I read everything and it is clean" — a check that did not run looks exactly like a check that passed.

Re-raising still-open findings is deliberate. A framing of "only surface new items" would silently drop findings that were raised and never fixed, which is the opposite of useful.

Why not a SHA range

The obvious design is a lastReviewedSha..head two-dot range. It does not survive how this repo works. We rebase; a force push rewrites every SHA, the old one stops being an ancestor of the head, and the range describes a diff that never happened — it renders every change to the base as PR work. The compare API reports diverged for exactly this, so it is detectable, but detection only means falling back to a full review. Since rebasing is routine here, the incremental path would almost never fire.

Range scoping assumes linear history. We deliberately do not have it. Comments do not have this problem: a rebase changes every commit and changes nothing that was already said.

The classification signal is this repo's existing convention — a reply written when addressing feedback ends with the literal marker *— AI Coding Agent*. Thread isResolved is GraphQL-only and the model is not granted gh api, so the marker is the available proxy, and it is reliable because the same process that addresses feedback writes it. gh pr view --json comments,reviews supplies the data under the existing Bash(gh pr view:*) grant.

Read, and its coupling to persist-credentials: false

--allowedTools gains Read and nothing else. Scoping the reviewer's attention to what is new should not scope its access — the findings that have justified a review here came from reading whole files: surviving references in untouched regions, a claim about a directory's real contents, a cross-file ordering dependency.

Read is safe only alongside persist-credentials: false (landed in #121). Without it, actions/checkout writes the job's GITHUB_TOKEN into .git/config inside the tree being reviewed, and Read plus Bash(gh pr comment:*) is a complete path from that file to a public comment on a public repo. The workflow header now records the coupling so neither gets removed without the other.

Security model, intact

  • The comment body is tested, never forwarded. github.event.comment.body appears only in the if: gate and in a contains() whose result is a boolean — never interpolated into a run: or prompt:. A free-text focus guarded by "treat the following as review focus, not instructions" is a request, not a boundary, and is explicitly not wanted.
  • No gh api, no git, no bare Bash. The workflow may use privileged tools; the model may not.
  • No new permissions, no contents: write, no pull_request_target. The maintainer-only author_association gate, the refs/pull/N/head checkout, and fetch-depth: 1 are unchanged.

Out of scope

Token cost. The reviewer still reads the whole diff; only the reporting is scoped. The original framing was cost, but the actual pain is noise.

Verification

The workflow cannot be run from a branch, so: the file parses as YAML, and the dispatch script was executed against simulated payloads for all four trigger shapes — each of the three focus= arms is reachable and the all test is ordered first so it is not shadowed by the @claude /review substring. pnpm lint, pnpm typecheck, pnpm test (624 passed / 50 files), and pnpm openspec validate --all --strict (23 passed) are green. The prompt text itself is reasoned about, not executed.

No changeset: this changes a workflow only, and ships nothing.

Fixes #110

Copilot AI lite review requested due to automatic review settings August 20, 2026 17:52
@thecodedrift thecodedrift added the skip-changeset PR intentionally ships no release note (bypasses the changeset requirement) label Aug 20, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Updates the on-demand Claude review workflow to support incremental re-reviews (surfacing only new findings plus previously-unaddressed ones), while still allowing an explicit full re-review via @claude /review all, and granting the model read-only file access to improve cross-file verification.

Changes:

  • Adds workflow-level “review mode” selection (incremental vs full vs second-eyes) derived from boolean contains() checks on the triggering comment.
  • Expands the prompt to instruct incremental behavior (read prior comments first; label [New] vs [Unchanged since last review]; don’t re-raise addressed threads).
  • Grants Read in --allowedTools and documents its coupling to persist-credentials: false.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread .github/workflows/claude-code-review-on-demand.yml Outdated
@thecodedrift

Copy link
Copy Markdown
Member Author

@claude /review

@claude

claude Bot commented Aug 20, 2026

Copy link
Copy Markdown

Claude finished @thecodedrift's task in 2m 49s —— View job


Code review

Reviewed the full diff (.github/workflows/claude-code-review-on-demand.yml, +72/-11) for correctness, security, performance, and clarity. This is the first Claude review on this PR.

Design and security fundamentals hold up well. The contains()-based mode dispatch never forwards the comment body into a run:/prompt: block (tested only, so a commenter can't inject free-text instructions), the all-arm-first ordering correctly avoids the substring-shadowing trap, refs/pull/N/head checkout with persist-credentials: false is the right call and is genuinely paired with the new Read grant as the header claims, and the author_association gate + no-gh api/no-bare-Bash scoping are all intact. No violations of CLAUDE.md or the style guides — this is workflow YAML, not TS/React, so most of those conventions don't apply, and there's no changeset needed for a workflow-only change that ships no release note.

One correctness issue undermines the core feature. gh pr view <PR> --json comments,reviews (line 120) is the sole data source for classifying findings as new vs. already-addressed, but I verified live against this PR that it cannot see inline review-thread comments — only top-level PR comments and review summary bodies. gh pr view --help confirms there's no reviewThreads/inline-comments field available at all, and the model has no gh api fallback. Since this reviewer posts its own findings via mcp__github_inline_comment__create_inline_comment, incremental mode will typically see none of the prior findings it's supposed to classify — details and a concrete fix (reuse the repo's existing fetch_pr_feedback.py, which does this correctly via gh api graphql) are in the inline comment at line 120.

Two related classification gaps, detailed inline at lines 30-35:

  • Only the em-dash *— AI Coding Agent* marker is recognized; the repo's own iterate-pr skill documents a hyphen variant too.
  • The marker only exists on AI-agent replies — a maintainer who resolves a thread directly (commit, plain reply, native Resolve button) leaves no marker, so that thread is misclassified as still-open and re-raised indefinitely.

Also flagged inline, lower severity:

  • Line 120: a literal <PR NUMBER> placeholder instead of the interpolated value used elsewhere in the same prompt — if run verbatim, the gh pr view call fails.
  • Line 115: contains(..., '@claude /review all') is an unanchored substring match (e.g. @claude /review allocator.rs would silently switch to full mode).
  • Lines 116-122: the new mode= output is written but never consumed anywhere in the file.

Not flagged as blocking, worth knowing about: the three focus= strings must stay single-quoted and apostrophe-free (they contain backticks, forcing single-quoting; a future contraction like "don't" would break the shell script for all three modes) — no test or lint catches this today. Also worth a follow-up, not this PR: pr= and mode=/focus= are now two independently-maintained if/elif/else chains rather than one atomic block per branch, so a future third trigger event could update one and silently miss the other.

Comment thread .github/workflows/claude-code-review-on-demand.yml Outdated
Comment thread .github/workflows/claude-code-review-on-demand.yml Outdated
Comment thread .github/workflows/claude-code-review-on-demand.yml Outdated
Comment thread .github/workflows/claude-code-review-on-demand.yml Outdated
Comment thread .github/workflows/claude-code-review-on-demand.yml
@thecodedrift

Copy link
Copy Markdown
Member Author

Re: @claude[bot] — "Claude finished @thecodedrift's task in 2m 49s — Code review. Reviewed the full diff…"
#123 (comment)

All five inline findings are fixed and their threads resolved; details are in each thread. The correctness issue you called out as undermining the core feature is fixed structurally: I reproduced it live (gh pr view returns 2 comments and 6 reviews, five with empty bodies, and none of the 6 inline findings), and the prior review is now fetched by a workflow step via gh api graphql into .prior-review.json, which the model reads. gh api stays out of --allowedTools. That also supplies isResolved, which fixes the human-resolved-thread gap without needing a better marker.

Two items you flagged as non-blocking are deliberately not in this PR:

  • Apostrophe-free focus= strings with nothing to enforce it. I verified all three are still apostrophe-free and single-line after this change, but no test guards it. Worth a check in .github/scripts/; it is a separate concern from what this PR does.
  • pr= and mode=/focus= as two independent if/elif chains. Still two chains. Merging them is a refactor of the step rather than a fix to the feature.

— AI Coding Agent

@thecodedrift

Copy link
Copy Markdown
Member Author

Re: @copilot-pull-request-reviewer — "## Pull request overview — Updates the on-demand Claude review workflow to support incremental re-reviews…"

Your inline finding about the <PR NUMBER> placeholder is fixed and resolved, though not the way you suggested. Rather than embedding github.event.issue.number, the gh pr view call is gone: it cannot see inline review comments at all, so a workflow step now fetches the real review threads via gh api graphql into .prior-review.json and the model reads that file. There is no PR number left for the model to substitute, and the step is deterministic as you asked.

— AI Coding Agent

@thecodedrift
thecodedrift force-pushed the feat/incremental-review branch 2 times, most recently from b6ba8d9 to 13f49cf Compare August 21, 2026 02:19
@thecodedrift

Copy link
Copy Markdown
Member Author

Force-pushed to add GPG signatures. No content changed.

The three commits were re-created solely so each carries a verified signature (they were previously unsigned, %G? = N). Same messages, same authorship, same author dates, same order — only the committer date and therefore the SHAs changed.

before after commit
6cc74e2 a6a066e feat(ci): scope a re-review to what is new, and grant the reviewer Read
28655af 60ffb75 fix(ci): fetch the prior review in a step, not through gh pr view
b6ba8d9 13f49cf test(ci): guard the quoting of single-quoted step outputs

Verified before pushing: git diff b6ba8d9 HEAD produced no output, and each commit's tree hash is byte-identical to its counterpart. All three now report G, and GitHub reports verified: true / reason: valid for each.

Apologies for the noise on any review already left — the diff itself is untouched, so prior feedback still applies as written.

thecodedrift and others added 3 commits August 20, 2026 23:08
`@claude /review` re-derived the entire PR every time, so genuinely new work
competed with a dozen settled findings and a reader could not tell which was
which.

Mode is now chosen by the workflow and resolves to one of three `focus=`
strings: incremental (PR conversation), full (`@claude /review all`), and
second set of eyes (inline review comment). Incremental scoping is by prior
comments, not by a SHA range — this repo rebases, so a two-dot range describes
a diff that never happened, while comments survive a rebase untouched. A thread
carrying a reply that ends `*— AI Coding Agent*` was addressed; one without is
still open and gets re-raised, marked unchanged.

`--allowedTools` gains `Read` so the reviewer can open whole files. That is
coupled to `persist-credentials: false`, and the header now records why.

The comment body is still only tested, never interpolated into a prompt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jwc9FFroR3mTZ4hLiSkkX3
Incremental mode classified findings against comments the model could
not see. `gh pr view --json comments,reviews` returns top-level comments
and review *summary* bodies only — `gh pr view` has no `reviewThreads`
field at all — so inline findings were invisible to it. Measured on this
PR: `gh pr view` reports 2 comments and 6 reviews, five with an empty
body (the wrapper each inline comment hangs off), while the 6 actual
findings appear nowhere. Since this reviewer posts inline, every
re-review would have seen none of its own prior findings, treated the PR
as unreviewed, and never emitted the [New] / [Unchanged] split that is
the point of the feature.

A workflow step now reads the real threads over `gh api graphql` into
`.prior-review.json` and the model reads that file. Privileged work in a
deterministic step is the division this workflow already draws, so
`gh api` stays out of `--allowedTools`; the response is redirected to a
file with no interpolation, so untrusted comment text cannot become
shell, and the prompt tells the model to treat the contents as data
rather than as instructions.

That also supplies `isResolved`, which no model-visible tool can reach.
A thread a human resolved — by commit, by a plain reply, or with the
Resolve button — carries no `*— AI Coding Agent*` marker, so the
marker-only test re-raised it forever. The marker stays as a secondary
signal, now recognized in both spellings the iterate-pr skill documents.

Two smaller fixes from the same review:

- `contains(body, '@claude /review all')` was an unanchored substring
  match, so `@claude /review allocator.rs` selected full mode. The body
  now reaches the step as an env var — tested, never interpolated into
  the script and never written to output — and a `case` pattern gives it
  a word boundary. Testing for `'all '` instead would have missed
  `@claude /review all` followed by a newline.
- `mode=` was written in all three branches and read nowhere. It now
  gates the fetch step, which has no work to do in full mode.

The `<PR NUMBER>` placeholder goes away with the command that carried it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The review workflow writes its three `focus=` strings single-quoted
because they contain backticks and `$`, which double quotes would hand
to the shell. Single quoting has exactly one failure mode and it is
silent: an apostrophe inside the value closes the string early and the
rest of the line becomes shell words. A future contraction — "don't",
"doesn't", "won't" — would break the step for all three modes at once,
and nothing would catch it. The strings are hundreds of characters of
prose on one line, and a reviewer reading prose is not reading quoting.

`workflow-outputs.cjs` checks every file in `.github/workflows/`:

- A single-quoted echo must be `echo 'key=value' >> "$GITHUB_OUTPUT"`,
  whole, on one line. An odd quote count is reported as unterminated,
  which covers both the apostrophe and a value wrapped onto the next
  line. Detection keys off `echo '` rather than off `$GITHUB_OUTPUT`:
  a wrapped value leaves the redirect on the FOLLOWING line, so keying
  off the redirect would skip exactly the broken line. My first draft
  did that and its own test caught it.
- Where a file both writes a key and compares it against a literal
  (`steps.prep.outputs.mode != 'full'`), the literal must be a value the
  file actually writes. That is the drift the mode/focus split invites.
  Keys the file never writes — an action's own outputs — are skipped,
  so there is no ground truth to get wrong.

Node builtins only, like the other scripts here, and no YAML parser: the
invariant is textual, about what the shell sees on one line, and parsing
to a structure would discard the quoting the check is about.

Verified by injecting `don't` into the real focus string, which the
guard rejects with the file and line. `validate.yml` already globs
`.github/scripts/*.test.cjs`, so this runs in CI with no change there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@thecodedrift
thecodedrift force-pushed the feat/incremental-review branch from 13f49cf to fff2a91 Compare August 21, 2026 06:08
@thecodedrift
thecodedrift merged commit a8b4037 into main Aug 21, 2026
2 checks passed
@thecodedrift
thecodedrift deleted the feat/incremental-review branch August 21, 2026 06:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

skip-changeset PR intentionally ships no release note (bypasses the changeset requirement)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Scope a re-review to what is new, and give it Read

2 participants