Skip to content

feat(seidroid-review): post the findings as one review - #97

Merged
bdchatham merged 5 commits into
feat/seidroid-reviewfrom
feat/post-findings-as-one-review
Sep 7, 2026
Merged

feat(seidroid-review): post the findings as one review#97
bdchatham merged 5 commits into
feat/seidroid-reviewfrom
feat/post-findings-as-one-review

Conversation

@bdchatham

@bdchatham bdchatham commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Findings the diff can hold now travel in one POST /pulls/{pr}/reviews call carrying the review body and every inline comment together. An author with ten findings gets one review and one notification instead of ten ungrouped threads, and the run spends one call where it spent ten.

What changed

Place findings on the code posted one POST /pulls/{pr}/comments per finding and let the API decide the rung by refusing. It now reads the pull request's own diff first, splits the findings against it, and posts the anchorable ones as a single review.

The three rungs hold, and the middle one still costs a call of its own: the reviews API carries path, line, side and body per comment and has no subject_type, so a file-level comment cannot ride in the batch.

Rung Before After
on the line POST .../comments, one per finding one POST .../reviews for all of them
on the file POST .../comments with subject_type=file unchanged
in the summary appended to $NOTE unchanged

How ai-review.yml does it, and what transferred

  • .github/workflows/ai-review.yml:860-880 walks each pulls.listFiles patch into a set of commentable lines per file and side. RIGHT takes an added or a context line, LEFT takes a removed or a context one. Transferred, as a jq walk over the same patches. It is what makes the batch safe to send.
  • ai-review.yml:897,901 anchors a finding whose line is in that set and orphans the rest into the body. Transferred in shape: an unanchorable finding here takes the file rung first and the summary second, which is this workflow's own ladder and is richer than orphaning.
  • ai-review.yml:960-967 builds the comments array and one createReview call carrying commit_id, event, body and comments. Transferred.
  • ai-review.yml:968-991 falls back inline → body-only → COMMENT. Not transferred as written. That chain protects a review body this step does not own: the verdict and the summary go out as an issue comment from Post the verdict, and the position goes out from State the review's position. The fallback here is different and is answer 4 below.

The four points

1. The finding marker. Every inline comment in the batch opens with FINDING_MARKER as its first bytes, built in jq as "\($marker)\n**\(.severity)** — \(.detail)". The review's own body carries no marker. The history read and the resolve step both key on (.comments.nodes[0].body) | startswith($ENV.FINDING_MARKER) over review threads. A comment sent in a createReview call opens a thread whose first comment is that comment, which is measured rather than assumed: the same GraphQL query those two steps run, against #90, returns five threads whose comments.nodes[0] is a github-actions comment belonging to an APPROVED review — ai-review.yml's own batch. A marker on the review body would be read by nothing and is left off. Case 1 in the table below asserts the marker is the first bytes of all four comment bodies; case 3 asserts the same for the seven bodies the per-finding fallback sends.

2. unplaced on the merge gate. What counts as placed is unchanged. on_line still counts findings the API accepted, and it is incremented only after the review call returns success. on_file and unplaced are untouched. Resolve the threads this review closed reads placed = on_line + on_file and requires unplaced == 0; the harness shows base and branch agreeing on all three counts in every case where the API cooperates.

One case moves, and it moves toward holding the gate: when the batch fails with a 5xx or with no status at all, all of its findings go to the summary, so unplaced > 0 and no superseded thread closes. Base would have placed those findings one at a time.

3. Riding on the position step's call. Rejected; this is a second review object. Four reasons.

The ticket says the position step is skipped on a no-verdict run while placement is not. That is not true on this base: both gate on inputs.mode == 'review' && !cancelled() && steps.drive.outputs.verdict_produced == 'true', character for character. The real asymmetry is the empty-event path above.

4. The batch is all-or-nothing. Two failures, two answers.

  • Any 4xx. The API refused the request and created nothing. Each finding is posted on its own down the full three-rung ladder, so the one comment that was refused costs only itself. This request carries every finding's whole detail, which is model prose under no length bound, so its size is refused as readily as its content and GitHub answers that with 413. Cases 3, 4 and 22 to 24 cover 422, 413, 403 and 400; case 4 refuses the batch and then one line individually, and that finding lands on its file while the other three land on their lines.
  • Anything else, including a call that reached no response. Those findings go to the summary under a heading that says so. A 5xx, a secondary rate limit or a dropped connection may be a write that landed, and repeating it posts the review twice. The reader still gets every finding, and unplaced > 0 holds the superseded threads open. Cases 5, 6 and 25.

The code is read from the response's own status line, which gh api -i puts first — not from a status field in the error body. GitHub's validation-error schema does not declare that field, and a refusal carrying none would have read as no refusal at all and sent every anchorable finding to the summary, which is worse than base. Measured on this endpoint: a 422 does carry status today, and the schema does not promise it.

A refusal is predicted rather than met: the commentable-line index is built before the call, from the diff at the reviewed commit. GET /pulls/{n}/files answers for the pull request's current head and takes no commit — measured, it accepts a sha parameter and ignores it, so a push mid-review would index one commit and comment on another. GET /compare/{base.sha}...{REVIEWED_SHA} does take one; measured on #90, it answers differently per commit and reproduces pulls/{n}/files byte for byte at the head. Its three-dot form is the diff the pull request shows, and a base branch that moves during the review does not move that merge base, because the reviewed commit is fixed.

The index is read as the diff only when its length matches the pull request's own changed_files, which the same GET /pulls/{n} call already answers. GET /compare sends at most 300 files and drops the rest in silence: no total, no Link header for them, no flag. Its pages are pages of commits, and a second page carries no files key at all, so --paginate cannot reach the ones it dropped; it is gone from the fetch, which now costs one call instead of one per hundred commits.

Measured against the live API. kubernetes/kubernetes#137092 reports changed_files: 398; GET /compare/{base}...{head} answers with exactly 300 on a single page, while GET /pulls/{n}/files --paginate returns all 398. Three-dot ranges of 309 and 321 files both answer with 300, and one of 251 answers with 251. changed_files and the compare length agree on every whole list measured, including 295 and 261 — just under the cap.

A short list read as the diff is the one thing the unknown bucket exists to stop: every file it dropped looks exactly like a file the pull request never touched, so each finding in one took a file comment whose body told the author their cited line was outside a diff that holds it. A short list now indexes nothing. Cases 18 to 21 cover a short list, a list at the cap the count confirms, a list at the cap with no count to confirm it, and a list under the cap with no count.

When the index cannot be built — the base commit or the diff cannot be read — the step falls back to posting each finding on its own, which is what it did before. Cases 10 and 14c.

A file the API sends without a patch is a third group, not a file with no lines. A binary file and a file whose diff was too large both arrive that way, so counting them as empty would drop every finding on one to the file rung under a body claiming the cited line is outside the diff. Those findings go to the API one at a time instead. Case 17.

Verification

Nothing here ran on a GitHub runner. The step's script was extracted from the shipped YAML with a YAML parser and run under bash with a gh stub on PATH that serves fixture JSON through the step's own jq and captures the request body. base is the same harness against the same step extracted from bc93b4f.

Columns: API calls made, then the three counts written to $GITHUB_OUTPUT, then lines in the summary note.

# Case reviews line-comments file-comments on_line on_file unplaced note
1 4 findings, all on covered lines 1 (base 4 comments) 0 0 4 0 0 0
2 + off-hunk line, untouched file, no line 1 (base 7) 0 3 4 2 1 1
3 batch refused with 422 1 4 3 4 2 1 1
4 batch 422, and one line refused on its own 1 4 4 3 3 1 1
5 batch fails 500 1 0 3 0 2 5 5
6 call reached no response 1 0 3 0 2 5 5
7 zero findings (empty file) 0 0 0 0 0 0 0
8 empty findings array 0 0 0 0 0 0 0
9 findings file will not parse 0 0 0 0 0 0 0
10 the diff cannot be read 0 7 3 4 2 1 1
11 no reviewed commit recorded 0 0 0 0 0 7 7
12 string line, junk line, missing side 1 0 1 2 1 0 0
13 empty severity 0 0 2 0 1 1 1
14 index read at REVIEWED_SHA 1 0 0 4 0 0 0
14b the same run against a pushed head's diff 0 0 4 0 4 0 0
14c base commit cannot be read 0 7 3 4 2 1 1
15 500: on-diff findings get their own heading 1 0 3 0 2 5 5
15b 422 ladder: what it believed on-diff, likewise 1 4 7 0 0 7 7
15c no on-diff group, so no on-diff heading 1 0 3 4 2 1 1
16 422 whose body carries no status 1 4 3 4 2 1 1
17 file the API sent with no patch 1 2 1 2 1 0 0
18 list short of changed_files 0 4 0 4 0 0 0
19 at the cap, the count confirms it 1 0 0 1 0 0 0
20 at the cap, no count to confirm it 0 1 0 1 0 0 0
21 under the cap, no count 1 0 0 4 0 0 0
22 batch refused with 413 1 4 3 4 2 1 1
23 batch refused with 403 1 4 3 4 2 1 1
24 batch refused with 400 1 4 3 4 2 1 1
25 batch fails 502 1 0 3 0 2 5 5

Cases 14 and 14b read pull and compare calls too; 14 asserts the compare range is {base.sha}...{REVIEWED_SHA}.

Case 18 is the regression this round fixes: pkg/b.go is in the pull request and missing from the compare response, which is what truncation looks like. Read as the diff it puts the finding on pkg/b.go under a body saying line 2 is outside a diff that adds line 2. Case 19 is the case that must not bail — a genuine 300-file pull request whose count agrees — so the guard cannot simply refuse any list of 300.

Every case exits 0. continue-on-error: true is unchanged on the step.

Base and branch produce identical counts in cases 1, 2, 3, 4, 7, 8, 9, 10, 11 and 14c. They differ in 5, 6, 15 and 15b by design (answer 4), in 14 and 14b because base has no index at all, and in 12, 13 and 17 for the reasons below.

165 assertions over 29 cases pass, including: the request carries event: "COMMENT", the recorded commit_id, a non-empty body, four comments in findings order with the right path/line/side, each opening with the marker as its first bytes; a multi-line detail and one carrying a backtick, a double quote and a $ survive intact; the review body starts with neither marker.

Also verified: actionlint finds the same four SC2102:info at the same in-script offsets before and after, and the whole-repo output is identical apart from line numbers. shellcheck -S style on the extracted script is clean. The file parses as YAML. The harness now lives at test/seidroid-review/ and runs in CI from .github/workflows/workflow-test-self.yml; it re-reads the step and the marker out of the YAML on every run, so it cannot pass against a stale copy.

Each guard was checked by removing it. Dropping the file-count test breaks 6 assertions, narrowing the retry back to 422 alone breaks 10, and widening it to 5xx breaks 13.

Not verified: nothing ran on a GitHub runner, and no call reached the GitHub write API. The atomicity of createReview, the claim that no 4xx can be a partial write, the 413 answer to an oversized body, and gh's placement of the error object on stdout are taken from the API documentation and from HTTP semantics, not measured. The read side — the 300-file cap, the absent files key on page two, and changed_files — is measured against the live API as above. Nothing here proves GitHub accepts this exact payload; the first real run does.

The harness also caught two defects in this branch before it shipped. The fixtures still modelled pulls/{n}/files, so every case fell to the ladder and 46 assertions failed loudly rather than passing quietly. And gh api --jq writes the error object itself on a failure, so a refused base read left a line of JSON in base_sha; it is now tested for the shape of a commit id, not merely for emptiness.

Two things the harness found in the base

Both are in the record the fallback rungs read, and this change rewrites that reader, so they are fixed here rather than left behind.

  • A field shift. @tsv writes an empty field as nothing between two tabs, and IFS=$'\t' read folds the pair into one delimiter because a tab is IFS whitespace. A finding with no side therefore read one field short: the severity arrived in the side, the base64 detail arrived in the severity, and the detail was lost. On base, a finding with an empty severity renders in the summary as - `pkg/untouched.go:3` () — with no text at all. The normalising jq now guarantees every field but the last is non-empty.
  • A line the model wrote as prose reaching gh as a field. Base passes -F line="$line" straight from the findings file, and gh reads a leading @ as a file to send. A finding whose line is @/etc/passwd produced exactly that call. Lines are now coerced to an integer, and a line that is not one reads as 0, which no diff covers, so the finding takes the file rung.

Scope

The 50-finding cap is not in this workflow; the driver writes findings.json and bounds it. Thread resolution, the cap, and the driver and caller repositories are untouched.

Residual: a diff so large the API sends no patch for a file a finding names. That finding costs one line call before the API answers, which is what base cost for every finding. A file present without a patch is handled as unknown, and a file list short of the pull request's own count now indexes nothing.

@cursor

cursor Bot commented Sep 6, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Changes how review comments are created and when unplaced is non-zero (notably on non-422 batch failures), which affects merge-gate thread resolution; mitigated by extensive local harness coverage but not verified against the real GitHub write API.

Overview
Place findings on the code no longer fires one POST .../comments per finding. It normalizes the findings JSON, builds a commentable-line index from compare/{base}...{REVIEWED_SHA} (with guards for truncated 300-file responses and missing patches), then posts all anchorable items in a single COMMENT review via POST .../reviews. File-level and summary fallbacks are unchanged in role; the summary now splits on-diff but not posted findings from off the changed lines.

Batch failure handling is explicit: 4xx (e.g. 422/413) retries each finding down the per-line → file → summary ladder; other errors (5xx, no status line) send batch findings to the summary to avoid duplicate reviews. Fixes include empty-field @TSV shift and coercing non-numeric line values before they hit gh.

Adds test/seidroid-review/ (YAML extract, gh stub, fixtures, run.sh with 25+ scenarios) and workflow-test-self.yml to run the harness on workflow changes—no live GitHub API.

Reviewed by Cursor Bugbot for commit b7e9f8d. Bugbot is set up for automated code reviews on this repo. Configure here.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Solid, well-reasoned consolidation of per-finding comments into one createReview call, with a sensible three-rung degradation ladder and two real base-branch bugs (the @tsv field shift and the unvalidated -F line=) fixed along the way. Three non-blocking issues: a summary header that misdescribes findings routed there by a batch failure, 422 detection resting on an undocumented status field, and findings in files whose patch GitHub omits getting silently demoted off their line.

Findings: 0 blocking | 3 non-blocking | 3 posted inline

Blockers

  • None at the file/PR level.

Non-blocking

  • None at the file/PR level.
  • 3 suggestion(s)/nit(s) flagged inline on specific lines.

Comment thread .github/workflows/seidroid-review.yml Outdated
place_each place_one < <(rows '.anchored[]' "$placement")
else
echo "::warning::the review carrying $anchored comment(s) could not be posted on $REPO#$PR; those findings are in the summary instead of on the diff"
place_each to_summary < <(rows '.anchored[]' "$placement")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] Findings routed here are on lines the diff does cover, but they land under the wrong header. Reaching this branch requires head_sha to be non-empty (it gates batched), so the block at 1764-1769 picks **Observations off the changed lines.** These are about code this pull request does not touch, so there is nowhere in the diff to attach them:. For a 500 or a dropped connection on the batch, every anchored finding is described to the author as being about untouched code — exactly the mismatch the two-header split at 1759-1763 was added to avoid.

Concrete case: 4 findings all on added lines, createReview returns 503. All four go to the note, and the author reads that all four are outside their diff.

A flag set in this branch selecting a third header (e.g. "the review carrying these could not be posted on the diff") would keep the note saying something true about the reader's own change.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in cabbc50. Those findings now go to a group of their own, headed **On the changed lines, and not posted.** GitHub would not take the review carrying these, so they are here rather than on the lines they name:. Both groups still count as unplaced, so the resolve gate is unchanged.
Harness cases 15, 15b and 15c assert the two headings, their order, the count under each, and that no on-diff group means no on-diff heading.

Comment thread .github/workflows/seidroid-review.yml Outdated
on_line=$((on_line + anchored))
echo "posted one review carrying $anchored comment(s) on $REPO#$PR"
else
status="$(jq -r '.status // empty' "$response" 2>/dev/null || true)"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] status is not part of GitHub's documented validation-error (422) schema — the comment at 2008-2012 says as much for the position step — and the PR body lists the 422 shape as unverified. The two steps make opposite bets on an absent code, though: there, absent means "no downgrade, keep the vote", which is safe. Here, absent means every anchorable finding drops two rungs straight to the summary, which is strictly worse than base (base would have placed them one by one on their lines), and unplaced > 0 then holds every superseded thread open at 2644, so the author sees each restated finding twice.

Concrete case: createReview 422s on one bad line with body {"message":"Validation Failed","errors":[...]} and no status. Ten findings that base would have placed on nine lines and one file all land in the summary, and no superseded thread closes.

Reading the code off the response rather than the body would be robust without depending on gh's human-facing stderr line: gh api -i -X POST ... > "$response" and take the code from the status line, or fall back to treating a body carrying an errors array as a refusal.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 37b2edf, and you are right that the failure direction was the wrong one. The code now comes from the response's own status line via gh api -i, not from a body field. Measured on this endpoint, a 422 does carry status today — and the schema does not promise it, which is your point.
An absent code still takes the summary, because that is the case where the write may have landed. Case 16 sends a 422 whose body has no status and asserts the ladder runs: 4 line retries, on_line 4, unplaced 1.

# rather than met there.
# shellcheck disable=SC2016 # $findings and friends are jq's, and single quotes are what keep them jq's
partition='def hunks:
reduce ((.patch // "") | split("\n"))[] as $l

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] (.patch // "") silently turns a file with no patch into a file with no commentable lines. pulls.listFiles omits patch when a file's diff is too large (and for binary files), and caps the response at 3000 files — so for those files every finding becomes loose regardless of whether its line is genuinely in the diff.

That costs twice: the finding drops from the line rung to the file rung, and the file-level body at 1600 then tells the author _Cited at `path:120`, outside this diff's changed lines._, which is false — the line is in the diff, the index just could not see it. Base would have posted it on the line, because it let the API decide.

Since the batch only needs to avoid lines the API will refuse, a file whose patch is unknown is better treated as unknown than as empty: keep those findings out of the batch but send them through place_one rather than on_file_or_summary, so the API still gets the chance to accept the line.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 37b2edf. A file the API sends without a patch is now a third group rather than a file with no lines: those findings stay out of the batch and go through place_one, so the API still gets to accept the line.
Case 17 puts a finding at pkg/huge.go:120 behind an absent patch and asserts it lands on its line — one line call, no file-level comment, and no outside this diff's changed lines note that would have been false.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Stale Bugbot comment from a previous run.

Comment thread .github/workflows/seidroid-review.yml
Comment thread .github/workflows/seidroid-review.yml Outdated
bdchatham and others added 2 commits September 6, 2026 15:51
Placement posted one `POST /pulls/{pr}/comments` per finding, so an author
with ten findings read ten ungrouped threads and got ten notifications. The
findings the diff can hold now ride in a single `pulls/{pr}/reviews` call
carrying the review body and every inline comment together, which is how
`ai-review.yml` posts.

The three rungs hold. A line the diff covers goes on the line, in the batch.
A line the diff does not cover goes on its file, which the reviews API has no
field for and which therefore keeps a call of its own. A finding that reaches
neither travels in the summary. `on_line`, `on_file` and `unplaced` count the
same events and reach the verdict comment and the resolve gate unchanged.

The batch is all-or-nothing, so the set of commentable lines is read from the
pull request's own diff before the call rather than discovered by refusal. A
422 retries each finding on its own; any other failure sends those findings to
the summary, because a write that may have landed must not be repeated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… the summary groups

Two claims the placement step made that were not true.

The index and the comments named different commits. The index came from
`GET /pulls/{n}/files`, which answers for the pull request's current head and
takes no commit — it accepts a `sha` parameter and ignores it, measured. Every
comment names the recorded `REVIEWED_SHA`. A push during the review therefore
marked lines from one diff and commented on another: a line the older commit
cannot carry costs the whole batch a 422, and a line it can carry is hidden and
falls to the file rung. The index now comes from
`GET /compare/{base.sha}...{REVIEWED_SHA}`, which does take a commit and whose
three-dot form is the diff the pull request shows. The check run is published
against the commit the review read for the same reason.

A failure that was not a 422 filed on-diff findings under a heading that denied
them. They went to the summary under "Observations off the changed lines. These
are about code this pull request does not touch", and they were on changed
lines. They now carry their own heading, which says GitHub would not take the
review that would have posted them. Both groups still count as unplaced, so the
resolve gate is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@bdchatham
bdchatham force-pushed the feat/post-findings-as-one-review branch from f6eb66f to cabbc50 Compare September 6, 2026 22:59
…t an absent patch as unknown

Two more claims the placement step made that were not true.

It read the refusal code from a `status` field in the error body. GitHub's
validation-error schema does not declare that field, so a 422 that carries none
read as "not 422" and sent every anchorable finding to the summary — worse than
the per-finding ladder this rung exists to reach, and `unplaced > 0` then holds
every superseded thread open. The code now comes from the response's own status
line, which `-i` puts first. A call that reached no response leaves no status
line, and that empty code still takes the summary, because that is the case
where the write may have landed.

It counted a file the API sends without a patch as a file with no commentable
lines. A binary file and a file whose diff was too large to send both arrive
that way, so every finding on one dropped to the file rung under a body reading
`Cited at path:120, outside this diff's changed lines` — which was false. Those
findings now form a third group and go to the API one at a time, which is the
only thing that can still say whether the line is in the diff.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 37b2edf. Configure here.

Comment thread .github/workflows/seidroid-review.yml

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Solid, carefully reasoned change that collapses N per-finding comment calls into one createReview batch, with a correct hunk walk, correct fallback ladder, and counters that stay in the parent shell. Two non-blocking gaps: files missing from a truncated compare response are misclassified as off-diff, and only 422 takes the safe per-finding retry.

Findings: 0 blocking | 4 non-blocking | 2 posted inline

Blockers

  • None at the file/PR level.

Non-blocking

  • The new partition/batch logic is the most intricate shell+jq in this workflow, and the verification harness described in the PR body (extracted step script + gh stub) is not committed, so none of it is exercised in CI. Landing that harness — or even a jq-only unit check of partition against fixture patches — would make the hunk walk and the three-way bucketing regression-proof.
  • 2 suggestion(s)/nit(s) flagged inline on specific lines.
  • 1 non-blocking pre-existing issue(s) listed below under pre-existing issues.

Pre-existing issues

  • [suggestion] A finding with an empty file is dropped without being counted as unplaced (base: [ -z "$path" ] && continue in the placement loop; the PR preserves the behaviour via select(.file != "")). Because Resolve the threads this review closed gates superseded threads on unplaced == 0, such a finding can silently take a live thread off the pull request with nothing posted in its place.

| ($findings[0]
| map(. as $g | ($index[$g.file]) as $h
| . + { ok: (($h[$g.side][$g.line | tostring]) // false),
unknown: (($index | has($g.file)) and ($h == null)) }))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] The unknown bucket is keyed on $index | has($g.file), so a file that is in the pull request but missing from the compare response falls into loose instead. The compare endpoint truncates its files array on very large diffs — it does not page files the way GET /pulls/{n}/files does — so on a PR past that cap every finding in a truncated-away file skips the line rung entirely (loose goes straight to on_file_or_summary at line 1891) and gets a file comment whose body asserts _Cited at path:line, outside this diff's changed lines._. That is exactly the false statement the unknown bucket was added to avoid, and it is a regression from base, which always tried the line first and let the API decide.

A local guard would cover it: if $files | length is at the cap (or disagrees with the pull request's changed_files), treat the diff as unreadable and fall through to the per-finding path the elif at line 1836 already provides.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 3215d68. Your premise held on measurement, and the cap is 300 exactly.

I measured it rather than taking the value, because the guard rests on it. On kubernetes/kubernetes#137092, GET /pulls/{n} reports changed_files: 398 and GET /compare/{base}...{head} answers with exactly 300 — on a single page, so pagination cannot be the escape. GET /pulls/{n}/files --paginate returns all 398 on the same pull request, which is the asymmetry you named. Two more three-dot ranges of 309 and 321 files also answer with 300, and one of 251 answers with 251, so the list is whole below the cap.

--paginate does not help and is gone. Its pages are pages of commits: on a 2091-commit range the Link header offers 21 pages, page 1 carries 300 files, and pages 2, 3 and 21 carry no files key at allhas("files") == false. So the step now makes one call where it made up to one per hundred commits. Nothing signals the truncation either: no total, no Link for the files, no flag. per_page does not move the file list, only the commits array.

On the test: changed_files is the exact comparison and the cap test only carries the case where that count cannot be read.

if [ -n "$changed_files" ] && [ "$n" -ne "$changed_files" ]; then   # short
if [ -z "$changed_files" ] && [ "$n" -ge 300 ]; then                # at the cap, no count

I did not write it as "bail at 300 or on disagreement", because that costs a genuine 300-file pull request the batch for nothing. changed_files and the compare length agree on every whole list I measured, including 295 and 261 — just under the cap — so the equality is safe to rest on. Both fields come from the one GET /pulls/{n} call the step already made, so the guard costs no request.

Case 18 is the case that fails without the fix: pkg/b.go is in the pull request and missing from the compare response. Read as the diff it puts the finding on pkg/b.go under a body saying line 2 is outside a diff that adds line 2. Deleting the shortfall test breaks 6 assertions. Case 19 is the one that must not bail — 300 files with a count that confirms them — and making the cap test unconditional breaks 6 of its own.

Not verified: nothing ran on a GitHub runner. The read side above is measured against the live API; the write side is not.

Comment thread .github/workflows/seidroid-review.yml Outdated
echo "posted one review carrying $anchored comment(s) on $REPO#$PR"
else
status="$(sed -n '1s|^HTTP/[0-9.]* \([0-9][0-9][0-9]\).*|\1|p' "$response" || true)"
if [ "$status" = "422" ]; then

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] Only 422 takes the per-finding retry. The most likely refusal for this particular call is an oversized request — it carries every finding's full detail, which is unbounded model prose — and GitHub can answer that with 413 rather than 422. On 413 (and on 400/401/403/404, none of which can be a partial write) every anchorable finding goes straight to the summary under a heading saying GitHub would not take the review, when posting them one at a time would likely have worked.

Matching any 4?? instead keeps only the genuinely ambiguous outcomes — 5xx, and the empty status from a call that reached no response — on the summary path, which is where the double-post risk the comment above describes actually lives.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 3215d68. Any 4xx now takes the per-finding retry.

case "$status" in
  4??)
    echo "::warning::$REPO#$PR refused the review carrying $anchored comment(s) with $status; ..."
    place_each place_one to_summary_ondiff < <(rows '.anchored[]' "$placement") ;;
  *)
    ... summary ...
esac

The status still comes from the response's own status line via gh api -i, read by the same sed on the first line — I kept that, and the code is now in the warning so a run says which refusal it met. An empty status matches no 4?? and falls to *, so a call that reached no response keeps the summary path, which is where the double-post risk lives. 5xx keeps it too.

Your reasoning about 413 is why this is worth doing rather than a widened match for its own sake: the request carries every finding's whole detail, so the size of it is refused as readily as the content, and the summary heading would have told the author GitHub refused their findings when one call each would probably have placed them.

The boundary is pinned from both sides, which matters more than pinning it from one:

  • Cases 22, 23 and 24 send 413, 403 and 400. Narrowing the match back to 422 alone breaks 10 assertions across them — that is the state before this commit.
  • Cases 5, 15 and 25 send 500 and 502. Widening the match to [45]?? breaks 13 assertions across them, so the retry cannot creep onto the ambiguous outcomes later without a red build.

Not verified: that no 4xx from createReview can be a partial write is HTTP semantics and the endpoint's documentation, not a measurement. No call reached the write API. 413 as GitHub's answer to an oversized body is likewise from the documentation — I did not provoke one.

bdchatham and others added 2 commits September 7, 2026 12:30
…ees, and retry on any 4xx

Two answers from the review round on the batched placement call.

A file list from GET /compare is trusted as the diff only when its length
matches the pull request's own changed_files. The endpoint sends at most 300
files and drops the rest in silence: no total, no Link header for them, no
flag. Its pages are pages of commits, and a second page carries no files key,
so --paginate cannot reach the ones it dropped and is gone. Measured against
the live API: a 398-file pull request answers with 300 on one page, while
GET /pulls/{n}/files pages to all 398; a 251-file diff answers with 251.

A short list read as the diff is the one thing the unknown bucket exists to
stop. Every file it dropped looks exactly like a file the pull request never
touched, so each finding in one took a file comment whose body told the author
their cited line was outside a diff that holds it. A short list now indexes
nothing and every finding goes to the API one at a time, which is what base
did for all of them.

The per-finding retry now takes any 4xx, not 422 alone. The request carries
every finding's whole detail, which is model prose under no length bound, so
the size of it is refused as readily as the content and GitHub answers that
with 413. None of 400, 401, 403, 404, 413 or 422 can be a partial write, and
sending them to the summary spent findings that one call each would have
placed. That leaves 5xx and the empty status from a call that reached no
response on the summary path, which is where the double-post risk lives.

The pull request is read once for both fields, so the guard costs no call.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
165 assertions over 29 cases against the shell and jq of `Place findings on
the code`, which is the most intricate script in this workflow and had no
check on it.

run.sh reads the step's run: block and the workflow's own FINDING_MARKER out
of the YAML on every run, so a run tests the file as it stands and cannot pass
against a stale copy. bin/gh goes on PATH ahead of the real gh: it logs every
call, serves fixture JSON through the step's own jq, keeps the request body
the step sent, and decides per case whether a call succeeds. No call leaves
the runner, so the job needs no token and takes contents: read.

The cases cover the hunk walk, the three-way partition, the batch request's
shape and marker, the per-finding ladder, the summary headings, the field
shapes a model writes, the index commit, the file-count guard, and the status
boundary at 4xx against 5xx.

Each guard was checked by removing it: dropping the file-count test breaks 6
assertions, narrowing the retry back to 422 alone breaks 10, and widening it
to 5xx breaks 13. The fixtures for the generated at-cap case are built by jq
at run time rather than committed, so 300 files cost no diff to review.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@bdchatham

bdchatham commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

The harness landed here, and it runs in CI

Replying to the non-blocking note in the review body — that one is a review-level bullet with no inline thread, so this is a PR comment rather than an in-thread reply.

Committed in b7e9f8d at test/seidroid-review/, and run by .github/workflows/workflow-test-self.yml on any pull request that touches the step, the harness or the workflow. 165 assertions over 29 cases, up from the 123 the body described.

I took the whole harness rather than the jq-only partition check you offered as the smaller option, for one reason: both findings this round fixes live in the shell, not in partition. The file-count guard is a case/test chain around the fetch, and the status boundary is a case on the status line. A jq-only check of the hunk walk would have left the two new fixes uncovered, so it would not have paid for itself here.

How it runs. run.sh reads the step's run: block and the workflow's own FINDING_MARKER out of the YAML on every run, so a run tests the file as it stands and cannot pass against a stale extract. bin/gh goes on PATH ahead of the real gh: it logs each call, serves fixture JSON through the step's own jq, keeps the request body, and decides per case whether a call succeeds. No call leaves the runner, so the job takes contents: read and no token. It needs bash, jq and python3 with PyYAML — the workflow installs the last one rather than assuming the image carries it.

Size. 514 lines including fixtures. The 300-file at-cap fixture is built by jq at run time instead of committed, so the case that tests the cap costs no diff to read.

Each guard was checked by removing it, because a fixture that cannot fail the invariant does not test it:

Mutation Assertions broken
drop the file-count test 6 (case 18)
make the cap test ignore a confirmed count 6 (case 19)
drop the cap test 3 (case 20)
fire the cap test on any unknown count 3 (case 21)
narrow the retry back to 422 alone 10 (cases 22-24)
widen the retry to [45]?? 13 (cases 5, 15, 25)

The harness had already earned this once: after the rebase onto the compare endpoint, the fixtures still modelled pulls/{n}/files and 46 assertions failed loudly rather than passing quietly. This round it caught the same class again — the old fixtures modelled compare as paging its files array, which measurement disproves, so fx/files*.json are now one object each.

actionlint is unchanged against the base: the same four SC2102:info at the same in-script offsets, 44:14, 45:14, 207:16, 208:16. shellcheck -S style is clean on the extracted step and on the harness. The new workflow is actionlint-clean.

One thing this does not do: it is the first check that runs on this repository's own pull requests, so there was no existing job to hang it on. If a reviewer would rather it landed on its own, say so and I will split it — I kept it here because fx/files*.json and the stub's changed_files had to change with the fix, so splitting would create a merge-order dependency between the two.

It has now run. Run 34155941195 on b7e9f8d: Workflow tests / Place findings on the code, success, 165 passed, 0 failed on ubuntu-latest with Python 3.14.7. So the harness itself is verified on a runner.

Still not verified: the step's own write calls. No call reached the GitHub write API in any of this, and the reviews endpoint has not seen this payload.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Batching anchorable findings into a single POST /pulls/{n}/reviews is well-constructed: the commentable-line index is built at the reviewed commit, the short-list guard is a genuinely important safety check, and the 4xx-vs-other split correctly avoids double-posting. No blockers; a few accuracy and degradation issues in the fallback paths are worth tightening.

Findings: 0 blocking | 6 non-blocking | 4 posted inline

Blockers

  • None at the file/PR level.

Non-blocking

  • The new test/seidroid-review harness is a strong addition — extracting the step from the YAML on every run means it cannot pass against a stale copy, and the 25 cases cover the interesting failure modes. Two small gaps: no case exercises a finding whose severity/file key is absent (only empty-string), and no case covers a side: LEFT finding on a context line, which is the one place the index may be more permissive than the API.
  • run.sh prepends $HERE/bin to PATH inside run_case, so PATH grows by one entry per case. Harmless, but hoisting the export out of the function would keep the environment stable across cases.
  • 4 suggestion(s)/nit(s) flagged inline on specific lines.

# The caller knows whether the diff carries the line; this does not.
on_file_or_summary() {
if [ -n "$head_sha" ] && gh api -X POST "repos/$REPO/pulls/$PR/comments" \
-f body="$FINDING_MARKER"$'\n'"**$4** — $5"$'\n\n'"_Cited at \`$1:$2\`, outside this diff's changed lines._" \

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] When the batch is refused with a 4xx, place_each place_one to_summary_ondiff retries the anchored findings one at a time. If an individual line call then fails, place_one falls through to this file-level comment, whose body asserts _Cited at path:line, outside this diff's changed lines._ — but the index already determined that the line is on the changed lines. Case 4 in the harness exercises exactly this (on_file goes to 3), so an author can receive a file comment that tells them the wrong thing about their own diff.

Since the caller already knows which group the finding came from (it passes to_summary_ondiff vs to_summary as $6), that same signal could select the body: keep the current sentence for the loose group and use something like _Cited at path:line; GitHub would not take a comment on that line._ for the anchored-retry group.

else 0 end),
side: ((.side // "RIGHT") | tostring | ascii_upcase
| if . == "LEFT" then "LEFT" else "RIGHT" end),
severity: (.severity | tostring

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] .severity | tostring maps a missing or null severity to the literal string "null", not to "note" — the if . == "" then "note" guard only catches an empty string. A finding without a severity therefore reaches the pull request as **null** — <detail>. fx/shift.json uses "severity": "", so the harness does not cover the absent-key case.

(.severity // "" | tostring | if . == "" then "note" else . end) fixes it. The same applies two lines up: .file | tostring turns a missing file into the filename "null", which select(.file != "") does not drop — it survives to burn a file-comment call and then lands in the summary as `null:5`. (.file // "" | tostring) would let the existing select catch it.

if gh api "repos/$REPO/compare/$base_sha...$head_sha?per_page=100" > "$files.raw" \
&& jq '[ (.files // [])[] ]' "$files.raw" > "$files" \
&& n="$(jq 'length' "$files")" && is_count "$n"; then
if [ -n "$changed_files" ] && [ "$n" -ne "$changed_files" ]; then

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] This equality test will disagree for a common, healthy case: when the head branch has merged the base branch in. GET /pulls/{n} reports changed_files from the merge base of the current base tip and head, while compare/$base_sha...$head_sha resolves the merge base of base.sha and head — and base.sha is not kept in step with the base branch. If head merged base in, merge-base(base.sha, head) == base.sha, so compare returns a superset of the PR's files and n > changed_files.

The consequence is not incorrect placement (the fallback is safe), but such runs lose batching entirely and emit a ::warning:: annotation on an otherwise green job — the noise this step is otherwise careful to avoid. Two options: treat n > changed_files as a non-fatal log line and only refuse on n < changed_files (the truncation direction the guard actually exists for), or read the merge base from compare's own .merge_base_commit.sha and index against that.

elif ($l | startswith("+")) then .R[.n | tostring] = true | .n += 1
elif ($l | startswith("-")) then .L[.o | tostring] = true | .o += 1
elif ($l | startswith(" ")) then
.R[.n | tostring] = true | .L[.o | tostring] = true | .n += 1 | .o += 1

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] A context line is recorded as commentable on both sides. GitHub documents the opposite for side: "Use LEFT for deletions that appear in red. Use RIGHT for additions that appear in green or unchanged lines that appear in white and are shown for context." So a finding with side: LEFT on a context line is marked anchorable here and, if the API enforces that rule, takes the whole batch down with a 422 — the one failure mode this all-or-nothing call is most exposed to.

The fallback recovers it, so this is a cost rather than a loss, and I have not confirmed that the API actually rejects LEFT-on-context. But the harness only covers LEFT on a genuinely removed line (-gone11), so the case is untested either way. Dropping .L[.o | tostring] = true from this branch (keeping .o += 1) would match the documented rule and cost only the ability to anchor a LEFT comment on unchanged code, which renders identically on RIGHT.

@bdchatham
bdchatham merged commit b1b51f8 into feat/seidroid-review Sep 7, 2026
13 of 19 checks passed
@bdchatham
bdchatham deleted the feat/post-findings-as-one-review branch September 7, 2026 19:39
bdchatham added a commit that referenced this pull request Sep 7, 2026
… a step that cannot post (#100)

A run cancelled by a newer `@seidroid review` now clears the reactions
it left on its own
trigger comment, from a step that cannot post one. It left the 👀 there
for good.

Carries **PLT-1166**. **PLT-1159 comes out ruled out**, on the evidence
below. Nobody can
do it as written. The permission comment records the reason beside the
scope the ticket
asked to drop.

## PLT-1166 — the defect

`Answer the request` took `!cancelled()`. Two `@seidroid review`
comments in quick
succession put both runs in one concurrency group under
`cancel-in-progress`, so the newer
one cancels the older. By then the older comment already wears the 👀 —
the
acknowledgement is the first step of the job. The newer run answers its
**own** comment
id, so nothing ever reads the older one again.

The result: a comment that asked for a review, wears eyes, and never
gets an answer. That
is the defect PLT-1144 fixed on the no-verdict path, reached by the one
path that fix does
not cover.

## What ships

`Answer the request` keeps `!cancelled()` — unchanged from the base. The
withdrawal is a
new step, last in the job:

```yaml
- name: Withdraw the reactions on a cancelled run
  if: ${{ inputs.mode == 'review' && cancelled()
    && needs.guard.outputs.comment_id != ''
    && steps.verdict.outputs.posted != 'true' }}
```

`Post the verdict` gains `id: verdict` and a `posted` output so that
last term can read
it, and `Answer the request` gains `id: answer`. Nothing else in the
workflow changes.

### Why not `always()` on `Answer the request`

That was the first shape here and it was wrong. A step output persists
once its step
completes, so a cancellation landing any time after `drive` finishes
leaves `check_path`
and `verdict_produced` populated. `Answer the request` would read a real
conclusion and
post a thumb, while every publisher skips on `!cancelled()`. A thumb
reads as an answer.
That is worse than the stale eyes this PR set out to remove.

### Why the new step cannot state an outcome

**It contains no POST.** The script lists this bot's reactions and
deletes the three this
workflow posts. No code path in it adds one. Nothing the step receives
can therefore make
it state an outcome. That property holds whatever its inputs are, which
is what makes it
structural rather than a matter of what a cancellation happens to look
like.

It reads no check file, no `verdict_produced` and no conclusion.

### What the `steps.verdict.outputs.posted` term is, and why it does not
break that

A cancellation can arrive once the verdict is already on the pull
request — during thread
resolution, say — and the thumb `Answer the request` posted answers it
correctly.
Withdrawing it there leaves a published review with no reaction on the
request that asked
for it, which reads as never answered. That is this step's own defect,
one window later.

The posting step's **`posted` output** separates the two. It is one
boolean about another
step, written from the comment POST's own result. No conclusion is in
it. It says whether
an answer already stands, never which answer it would be, so reading it
gives the step
nothing to state. Handing it `verdict_produced` instead would have
restored the
conditional reasoning above: that flag is true whenever the driver
reached a verdict,
including when nothing published.

**Its outcome will not do, and that took a second pass to see.** `Post
the verdict` runs
under `continue-on-error` and tolerates a refused comment POST. Its
failure path ends on
a call whose failure it swallows. The step therefore exits 0, and its
outcome reads
`success` whether the verdict landed or not. The first version of this
gate read that
outcome. A refused POST followed by a cancellation then kept a thumb
standing for a
review nobody can see. That step already tracked the POST's result in a
shell variable.
It now writes it as an output.

Anything but a posted verdict withdraws. A value the step cannot read
therefore clears.
It does not leave a thumb standing for a verdict that may not be on the
pull request.

### The cross-run half of the same problem

A re-run replays the trigger comment id. The comment can therefore
already carry a thumb
from an **earlier** run whose verdict is on the pull request. A re-run
cancelled before it
answered took that thumb along with its own eyes. The comment then ended
bare while the
verdict it asked for still stood.

`Answer the request` gains `id: answer`, and the withdrawal reads its
outcome to decide
what this run may take:

| `steps.answer.outcome` | what it means | withdrawn |
|---|---|---|
| `skipped` | this run never touched the comment, so a thumb there is an
earlier run's | `eyes` only |
| `success` | this run withdrew the stale thumb and posted its own, and
published nothing | `+1 -1 eyes` |
| `failure`, `cancelled`, unreadable | the step ran partway and most
likely took the earlier thumb already | `+1 -1 eyes` |

The structural property is untouched: still no POST, and an outcome is
still four words
about another step with no conclusion among them.

**One case survives, and the comment states it rather than claiming it
away.** A run
answers, which withdraws an earlier thumb and posts its own. A
cancellation then arrives
before publishing, and the comment ends bare. The answer step already
took the earlier
thumb, so nothing at the end of the job can put it back. Knowing it
happened would need a
read of the pull request this step deliberately does not make. A
cancellation lands during
the driver far more often than in that gap. The step's comment records
the limit instead
of asserting the invariant outright.

**One tension with the stated acceptance criterion, deliberately.**
"Given a run cancelled
by a newer request, its trigger comment carries no reaction from this
bot" now fails on
one path. A cancelled re-run leaves an earlier run's thumb. The
criterion's intent holds.
The
comment does not wear 👀 with no answer coming, because the answer is on
the pull request.
Satisfying the literal wording would restore the defect above. I flag it
rather than read
the criterion loosely.

### Why last in the job, and what holds it there

The runner evaluates a step's condition when it reaches the step. **Any
step after the
withdrawal is a step during which a cancellation leaves the eyes
standing.** The runner
already evaluated the withdrawal and skipped it by then. Placed last it
also reads
`steps.verdict.outputs.posted` after that step has reported.

`conditions.py` checks the position rather than any one ordering, which
covers a step
appended later. Four mutations fail it. Move the withdrawal ahead of
`Post the verdict`,
ahead of the resolve step, or ahead of the no-verdict report. Or append
a step after it.
The id-ordering check caught only the first. In the other three `Post
the verdict` still
ran earlier.

### Why not the fix as named

Gating the conclusion read inside the script needs the job status in the
shell, and GitHub
does not offer it there. `cancelled()` is readable only in a step or job
`if`.
`PipelineTemplateEvaluator.EvaluateStepEnvironment` calls
`CreateContext(contextData, expressionFunctions)` with no
`expressionState`, where
`EvaluateStepIf` passes `step.ExecutionContext.ToExpressionState()`. And
`CancelledFunction.EvaluateCore` reads
`templateContext.State[nameof(IExecutionContext)]`
and `ArgUtil.NotNull`s it. `StepsRunner` turns that throw into
`CompleteStep(step, TaskResult.Failed)`, so `env: CANCELLED: ${{
cancelled() }}` fails the
step on every run, before the runner evaluates its condition. actionlint
refuses it too:
`calling function "cancelled" is not allowed here. "cancelled" is only
available in
"jobs.<job_id>.if", "jobs.<job_id>.steps.if"`. The same holds for
`run:`.

## The cancellation shapes, and which this covers

| when the cancellation lands | what runs | outcome | covered |
|---|---|---|---|
| while queued, job never starts | nothing | no eyes were ever posted |
n/a |
| before `drive` completes | withdrawal | all three withdrawn, no thumb
| yes |
| **after `drive` completes** | withdrawal | **it cannot read the
populated outputs** | yes |
| while `Answer the request` runs | its `!cancelled()` re-test fires,
the runner kills it, then the withdrawal | the withdrawal takes whatever
it left | yes |
| after the thumb, before the verdict published | answer, then
withdrawal | the withdrawal takes the thumb: it would stand for nothing
| yes |
| **after the verdict published** | answer only | **thumb survives
beside the published verdict** | yes |
| **the verdict POST refused, then cancelled** | answer, then withdrawal
| **thumb withdrawn: its outcome still reads success** | yes |
| during the withdrawal step | withdrawal | its own condition is
`cancelled()`, so the re-test keeps it alive | yes |
| once the runner reached every step | answer only | a thumb this run
earned stays | correct |
| a re-run, cancelled before it answered | withdrawal | an earlier run's
thumb stays, its eyes go | yes |
| a re-run, answered then cancelled before publishing | answer, then
withdrawal | bare comment, earlier verdict stands | **no** |
| **runner process shutdown** (`RunnerShutdownToken`) | nothing |
`StepsRunner` skips condition evaluation outright | **no** |

Two rows are gaps. In the answered-then-cancelled row the answer step
has already taken
the thumb, so no later step can restore it. A hard kill of the runner
leaves the eyes on
the comment, and nothing inside a workflow closes that.

## The reaction table

Each case declares two job states: the one the runner reached `Answer
the request` in, and
the one it reached the withdrawal step in. `success>cancelled` is a
cancellation that
arrived after the answer, so the answer step posts its own thumb and the
fixture places
nothing by hand.

| case | states | verdict outcome | ran | left on the comment |
|---|---|---|---|---|
| success | `success>success` | success | answer | `bot:+1` |
| failure | `success>success` | success | answer | `bot:-1` |
| no verdict | `success>success` | skipped | answer | *none* |
| neutral | `success>success` | skipped | answer | *none* |
| **cancelled after `drive`, outputs populated** | `cancelled>cancelled`
| skipped | withdraw | ***none*** |
| cancelled before `drive` finished | `cancelled>cancelled` | skipped |
withdraw | *none* |
| cancelled mid-publish | `success>cancelled` | cancelled |
answer+withdraw | *none* |
| the verdict failed to post | `success>cancelled` | failure |
answer+withdraw | *none* |
| the outcome went unreported | `success>cancelled` | *empty* |
answer+withdraw | *none* |
| **cancelled after the verdict published** | `success>cancelled` |
success | answer | ***`bot:+1`*** |
| the same, beside a human's | `success>cancelled` | success | answer |
`brandon:-1`, `bot:+1` |
| success, human `+1 -1 eyes` | `success>success` | success | answer |
human ×3, `bot:+1` |
| failure, human ×3 | `success>success` | success | answer | human ×3,
`bot:-1` |
| no verdict, human ×3 | `success>success` | skipped | answer | human ×3
|
| cancelled, human ×3 | `cancelled>cancelled` | skipped | withdraw |
human ×3 |
| stale `bot:-1` + `human:+1` | `success>success` | success | answer |
`human:+1`, `bot:+1` |
| the same, cancelled | `cancelled>cancelled` | skipped | withdraw |
`human:+1` |
| stale `bot:+1`, no verdict | `success>success` | skipped | answer |
*none* |
| `bot:rocket` from another workflow | `success>success` | success |
answer | `human:+1`, `bot:+1`, `bot:rocket` |
| the same, cancelled | `cancelled>cancelled` | skipped | withdraw |
`human:+1`, `bot:rocket` |
| the list call refused | `success>success` | success | answer |
`human:+1`, `bot:+1`, `bot:eyes` + warning |
| a delete refused | `success>success` | success | answer | `bot:+1`,
`bot:eyes` + warning |
| the add refused | `success>success` | success | answer | *none* +
warning |
| the list refused, cancelled | `cancelled>cancelled` | skipped |
withdraw | `bot:eyes` + warning |
| a delete refused, cancelled | `cancelled>cancelled` | skipped |
withdraw | `bot:eyes` + warning |
| the acknowledgement refused | `success>success` | success | answer |
`bot:+1` |

A human's reaction survives every path. A `bot:rocket` some other
workflow left survives
too, because each step deletes only the contents this workflow posts.
And a thumb that
answers a published verdict survives a later cancellation.

## PLT-1159 — ruled out, with the evidence

The ticket's premise is that `ai-review.yml` reaches the same reactions
through GraphQL
`addReaction` under `pull-requests: write`. **It does not, and the
workflow file does not
decide the question.**

**1. `ai-review.yml` does call both mutations, and its `permissions:`
blocks do lack
`issues`.** `preflight` is `contents: read` + `pull-requests: write` and
calls
`addReaction(content: EYES)`; `complete_review_reaction` is
`pull-requests: write` alone
and calls `addReaction(THUMBS_UP)` then `removeReaction(EYES)`.

**2. But neither call uses `GITHUB_TOKEN`.** Both steps pass
`github-token: ${{ steps.app-token.outputs.token || github.token }}`,
and in production
the App token wins. I checked live comments. Every reaction on an
`@seidroid review`
trigger in `sei-chain` belongs to `seidroid[bot]`, not to
`github-actions[bot]`:

```
comment 5536974191  +1 by seidroid[bot]   sei-chain#4088
comment 5544795940  +1 by seidroid[bot]   sei-chain#4101
comment 5531689281  +1 by seidroid[bot]   sei-chain#4095
comment 5493838638  +1 by seidroid[bot]   sei-chain#4063
```

The workflow's `permissions:` block does not bound an App installation
token. Its own
installation grant governs, and that App holds Issues: write —
`ai-assistant.yml` posts
`POST /repos/{o}/{r}/issues/comments/{id}/reactions` with the same
token. ai-review is
therefore **no evidence at all** about what `pull-requests: write` alone
can do. It is the
same class of wrong premise as the `enable-cursor: false` one.

**3. GitHub documents no permission for any GraphQL mutation.** Not a
gap in my reading —
checked at the data source. In `github/docs`,
`src/graphql/data/fpt/schema-reactions.json`
gives `addReaction` and `removeReaction` the keys `name, id, href,
description,
isDeprecated, inputFields, returnFields, category` and no permission
field. The public SDL
(`docs.github.com/public/fpt/schema.docs.graphql`) carries only
`@docsCategory(name: "reactions")`. The GraphQL guide's whole statement
on the subject is
that the API returns an error naming the permission it wanted. One route
therefore remains
to the requirement: make the call.

**4. GitHub documents what REST requires, and the alias stops at the
reaction.**
`github/docs`,
`src/github-apps/data/fpt-2026-03-10/server-to-server-permissions.json`:

| endpoint | permission |
|---|---|
| `GET/PATCH/DELETE /repos/{o}/{r}/issues/comments/{id}` | listed under
**both** `issues` and `pull_requests` |
| `POST /repos/{o}/{r}/issues/comments/{id}/reactions` | `issues: write`
**only** |
| `DELETE /repos/{o}/{r}/issues/comments/{id}/reactions/{rid}` |
`issues: write` **only** |
| `POST /repos/{o}/{r}/pulls/comments/{id}/reactions` | `pull_requests:
write` (a *review* comment — a different resource) |

That file expresses "either permission" by listing an endpoint twice. A
single listing on
the reactions endpoints is therefore a distinction, not an omission. It
confirms the claim
the guard job already makes in prose.

**Verdict.** The premise is void and the documentation says nothing.
Nothing here can mint
a fine-grained token scoped to `pull-requests` to test it. Shipping the
drop blind would
regress the defect this PR fixes. `continue-on-error` and a
`::warning::` swallow a 403 on
the reaction, so the eyes would stay on every comment and no run would
fail. Ruled out.

### Two things worth keeping for whoever re-files it

**`removeReaction` beats the REST loop, whatever the scope turns out to
be.** The ticket
assumed it takes a reaction node id. It does not. `RemoveReactionInput`
is
`{content: ReactionContent!, subjectId: ID!}`, and the subject is the
*comment*:
`IssueComment` sits in its `@possibleTypes`. It takes no actor input, so
it can only ever
remove the viewer's own reaction. That makes the human-scoping property
structural rather
than a `select(.user.login == $me)` filter. It also retires the
hardcoded
`me="github-actions[bot]"` login and the paginated list whose miss
leaves eyes behind.

**A third shape the ticket does not name looks likelier than either.**
This workflow
already mints an App token (`steps.identity.outputs.token`) and already
computes
`REVIEWER_LOGIN` from `app-slug`. Reacting under that identity needs no
caller scope at
all, and it is how production already posts these reactions. Two
obstacles stand in the
way. The acknowledgement runs before the mint, on purpose. And the mint
is optional, so a
`GITHUB_TOKEN` fallback keeps the scope required — unless a caller
without App credentials
may lose the reaction.

## The cost this change carries

The list-and-delete block is now duplicated between `Answer the request`
and the
withdrawal step. `me="github-actions[bot]"` and the set of contents each
step may delete
are two copies, and a reader has to keep them in step by hand. Edit one
and not the other
and a reaction stays behind on whichever path lost the edit.

That is the price of the separate step, and it buys the structural
property: the
withdrawing step has no POST. Sharing the block would mean one step
doing both jobs, which
is the shape that produced this PR's blocker. GitHub Actions offers no
way to share a
script between two steps without a checkout, and YAML anchors are not
supported.

Both harnesses cover both copies, so a drift fails rather than ships.
The GraphQL
`removeReaction` above is what would remove the duplication outright. It
needs no login
and no listing, so the whole block collapses to one mutation per
content.

## Every reaction site

Three steps, six calls, all in the `review` job, all on the ISSUE
comments endpoint:

| line | step | call |
|---|---|---|
| 1036 | `Acknowledge the trigger` | `POST
.../issues/comments/{id}/reactions` (`eyes`) |
| 2494 | `Answer the request` | `GET .../reactions --paginate` |
| 2504 | `Answer the request` | `DELETE .../reactions/{rid}` |
| 2519 | `Answer the request` | `POST .../reactions` (`+1` / `-1`) |
| 3292 | `Withdraw the reactions on a cancelled run` | `GET
.../reactions --paginate` |
| 3304 | `Withdraw the reactions on a cancelled run` | `DELETE
.../reactions/{rid}` |

The withdrawal step has no `POST`, and that is the fix. `guard` reacts
nowhere.
`ai-assistant.yml` and `ai-review.yml` have their own sites; neither is
in this workflow.

## Verification

**Committed, not kept locally.** An uncommitted harness is how the first
blocker survived
a reading and seven mutations. Two additions to `test/seidroid-review/`,
wired into
`workflow-test-self.yml` as their own job so the placement check keeps
its name.

**`reactions.sh` — 62 assertions over 32 cases.** It runs the reaction
steps under `bash`,
extracted from the workflow on every run. No case names the step it
runs.
`conditions.py --select` names it, from the job state and the posting
step's outcome, so
the two layers cannot drift. The `gh` stub keeps the reaction list a
comment carries and
serves it through the step's own `--jq`. It honours idempotence per
(user, content). It
can refuse the list, a delete or the add. The acknowledgement's calls go
to a separate
log, so every count belongs to the step under test. The stub reports any
call it cannot
serve.

**`conditions.py` — 76 assertions.** A step condition decides which
reaction step runs in
which job state, and a shell harness cannot see it. The model applies
the runner's own
rule: a condition naming none of
`always`/`cancelled`/`failure`/`success` becomes
`success() && (...)`. It treats a term it cannot decide as unknown
rather than false. Two
checks read the file rather than a table, so they cover a step added
later:

Both walk **every job's raw steps list** and search the **whole step**:

- No step that can run on a cancelled job may reach `check_path` or
`verdict_produced`.
- Every `steps.<id>` a step reads must be a real id on an earlier step.
- The withdrawal is the last step of the review job.

Keyed off a name they dropped an unnamed step. `- uses: ...` with no
`name:` is the usual
shape, so the step most likely to arrive later was the one they could
not see. The guard
job already carries one. The id check also reached only `if`, which left
the withdrawal's
new `env` read unchecked.

**Each fixture, mutation-tested.** A fixture that cannot fail the
invariant is not a test
of it:

| mutation | which case fails |
|---|---|
| **the gate reads `steps.verdict.outcome` again** | **`THUMB GOES` on a
refused POST, and the matching condition row** |
| **the `skipped` arm takes all three** | **`EARLIER THUMB SURVIVES`,
plus 3 more** |
| **`id: answer` deleted** | **`reads steps.answer, which is no step's
id`** |
| **an unnamed `always()` step reading `verdict_produced`** | **`step 17
runs on a cancelled run and reads verdict_produced`** |
| the `posted` term dropped | `THUMB SURVIVES` + 3 more + the condition
row |
| the same term inverted | 16 script cases, 5 condition cases |
| `id: verdict` deleted | `reads steps.verdict, which is no step's id` |
| the withdrawal moved ahead of `Post the verdict` | the position check,
and `reads steps.verdict, which runs later` |
| **the withdrawal moved ahead of the resolve step** | **the position
check alone — the id check passes it** |
| **the withdrawal moved ahead of the no-verdict report** | **the
position check alone** |
| **any step appended after the withdrawal** | **the position check** |
| `Answer the request` back to `always()` | its cancelled row, and the
sweep |
| `check_path` interpolated inline into `run:` | the sweep — an
`env`-only sweep passes it |
| the withdrawal step deleted | `no step named 'Withdraw the reactions
on a cancelled run'` |
| the withdrawal not scoped to this bot | the human and `rocket`
cancelled cases |
| the withdrawal ignoring which contents it may take |
`foreign-cancelled` loses a `rocket` |
| the missing-conclusion arm clearing only the eyes |
cancelled-with-stale-thumb, no-verdict-with-stale-thumb |
| the answer's list not scoped to this bot | every human case |
| the empty-reaction guard removed | every clear-only case posts a blank
reaction |
| the `VERDICT_PRODUCED` gate removed | no verdict thumbs the requester
down |
| the success arm no longer naming the eyes | eyes survive a green
review |

Only `conditions.py` catches `id: answer` deleted. `reactions.sh`
derives that outcome
itself, which is exactly why the id check has to exist.

`conditions.py` also models `steps.verdict.outcome` beside `posted`,
derived rather than
passed. It reads `success` whenever that step reported at all, which is
what the runner
sees. A gate that regresses to the outcome therefore fails an assertion
instead of
crashing the model.

**Five checks ran narrower than their own claim.** Mutating the thing
each claimed to
cover is what found them.

- The haystack read `env` only, so an inline interpolation passed.
- The model took an outcome as an argument and never checked the id
existed.
- The sweep keyed off a step name, so an unnamed step was invisible.
- `run_case` exported each per-case knob without clearing it. An
`ANSWERED_AS` override
  leaked forward and disarmed a later case.
- The gate read an exit code that cannot express whether the verdict
landed.

The first three now read the file. `run_case` clears the fourth at the
top of every case.
The fifth reads an output written from the POST's own result.

**actionlint**, base vs branch, both rule sets. `seidroid-review.yml`: 4
× `SC2102:info`,
unchanged. Whole `.github/workflows`: 37 findings, identical after
line-number
normalisation. `workflow-test-self.yml`: 0. `shellcheck -S warning`
clean on both new
harness files. The incumbent harness still passes, now 271 assertions
after #102.

**Base checked three times, and no move trusted.** It went to `b1b51f8`
(#97), then
`2f58b80` (#101), then `98c2619` (#102). #97 rewrote 341 lines of this
file and added the
harness. #102 rewrote the resolve step and placement, and renamed the
harness job. Each
time all three harnesses and actionlint ran again on the new history.
None of them carried
a result from before it. I checked each rebase by reading back four
things: the step
order, the step ids, the gate, and the withdrawal step's POST count. Not
by its exit
status.

The #102 rebase conflicted once, in the harness README, and I resolved
it keeping both
sides. `workflow-test-self.yml` merged cleanly: `place-findings` keeps
#102's renamed
display name `Place findings and resolve threads`, and the reaction job
keeps its own.

**One handoff for whoever lands second.** #103 replaces the hardcoded
`issues/comments`
with a `comment_api` output. Its author wrote it against **two**
reaction steps. This
branch leaves three, carrying six hardcoded paths rather than four: one
in
`Acknowledge the trigger`, three in `Answer the request`, two in the
withdrawal step.
Whichever of us rebases second has to reach all three steps.

## Not verified

**Nothing here ran on a GitHub runner.** Five things stay unverified.
That a real cancelled
run reaches the last step of the job. That `steps.verdict.outcome` reads
`success` on a run
cancelled after that step completed. That `steps.answer.outcome` reads
`skipped` rather
than empty on a run cancelled before that step. That the runner's
condition re-test kills
`Answer the request` mid-flight, as its source says. And every
permission claim above. The
cancellation semantics rest on `actions/runner` source and on
actionlint.
`conditions.py` models the expression engine; it is not the engine.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant