Both found by ChatGPT Codex on PR #133, both invisible on macOS, both arriving as exit 2 with no output -- jq's answer to an invalid --argjson, so a shell fault at the top of the script wears jq's exit code. `local n="$1" name="$2" f="$DIR/$n.$name.json"` creates every name before running the assignments on bash 5, so $n is unbound in the third clause and set -u kills the shell. The dead command substitution then hands --argjson an empty string. Works on macOS bash 3.2: the suite passed 19/19 locally and 2/19 in CI on the same commit. Each local now gets its own statement. `gh api --paginate` emits one JSON array PER PAGE, so a comment thread past the page size is several concatenated documents that --argjson rejects the same way. Both fetch paths now flatten through one pages_to_array (`jq -s 'add // []'`), which is a no-op on a single page. The fixture path shares that helper, which is the only reason a fixture can test it: a fixture holding a single array would exercise a shape the live fetch never produces. Three cases added, and removing the helper from the fixture path fails exactly those three, the wait case with the live exit-2 signature. Suite is 22 cases. No PR in this repo has yet had enough inline comments to reach a second page, so nothing but the test will see that one before a user does. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
6.8 KiB
Reading a PR's reviews
Twenty PRs merged in one campaign with their reviews unread. CI was green and an adversarial agent had been dispatched, which felt like the whole gate. Two ChatGPT Codex reviews were found afterwards on merged PRs; both were correct and one was a live P2. Three more were found on the next three PRs — one per PR, every one a P1.
scripts/pr-reviews <n> prints them. --wait <sec> blocks until one lands
against the current head.
Three surfaces, because a reviewer writes on whichever one is nearest
GitHub has three places to put a review, and they are three different API endpoints:
| printed as | endpoint | what it is |
|---|---|---|
[REVIEW] |
pulls/{n}/reviews |
the review's own body |
[INLINE] |
pulls/{n}/comments |
a comment on a line of the diff |
[COMMENT] |
issues/{n}/comments |
a comment on the PR thread |
pr-reviews read only the first for as long as it existed (improvise-xgra),
and the surface it could not see is the one a human is most likely to use. On
PR #122 the user reviewed with the body "Generally good, added comments
inline." and put five substantive rulings underneath it as inline comments.
pr-reviews printed the one-line body faithfully and nothing else, and an
agent reported the PR as awaiting review while the answers sat on it. The user
had to say so.
scripts/pr-watch does not cover the gap: that one watches checks. Nothing
in scripts/ surfaced the surface a human writes on.
The three are interleaved in time order, not printed in three blocks, because a review body and its inline comments are one act of reading and a block-per-endpoint layout hides which body the rulings belong under.
What satisfies --wait
An inline comment at the current head does, and that is the whole point of
the fix rather than an incidental widening. --wait's question is has anyone
read this tree yet, and a pulls/{n}/comments entry carries a commit_id: it
is a review comment, written by a reviewer, about a specific commit. The case
that failed on #122 is exactly one where the substance was inline — treating it
as not-a-review would leave --wait blind to the reviews it most needs to
catch. Note that a single "Add single comment" on GitHub creates a review whose
body is empty, so under the old rule that reviewer's exit code was right
and their words were invisible; the surfaces and the wait have to move together.
An issue-level comment does not, because it carries no commit. It makes no claim about which tree was read, and "I am rebasing this shortly" satisfying a wait for a review would be the widening that looks like the fix.
A comment on an earlier head does not satisfy the wait either — same rule
the review bodies already had, and the same reason: it may be fixed or still
live, and only reading it tells you which. An outdated comment has line: null
and only original_line left, which is what is printed.
A commit_id is per-comment, not per-review, and they differ inside one
submission. Measured on PR #122: one review submitted between 19:48 and 19:53
carries five inline comments, of which three name 246e8f4a and two name
6a4c7dcd, and the review body names 246e8f4a — GitHub pins each comment to
the file version it was written against, so a comment on an outdated diff
position keeps the older commit while its siblings move on. Two consequences.
The per-entry <- an EARLIER head flag is honest but can make a current
review look partly stale, so read the timestamps together before discounting
one. And a head comparison against the review body is not even a proxy for
its own comments: on that PR the body read as an earlier head while two of its
rulings sat on the current one.
$? must be this script's own
The usage text used to warn against piping into head/tail. That is narrower
than the real rule: nothing may follow the script in the same ; list
either, because a trailing echo "exit=$?" makes the status the echo's. An
agent nearly reported a review that did not exist that way. Redirect to a file
and read the file in a later command.
Two things the shell and gh do that this script got wrong first
Both were found by ChatGPT Codex on PR #133, and both are invisible on macOS.
A multi-assignment local is not left-to-right on bash 5. Written
local n="$1" name="$2" f="$DIR/$n.$name.json", bash creates every name
before running the assignments, so $n is unbound in the third one and
set -u kills the shell. It works on macOS's bash 3.2, so the whole suite
passed locally and 17 of 19 cases failed in CI. The signature to recognise is
exit 2 with no output at all: the dead command substitution handed
jq --argjson an empty string, and jq answers an invalid --argjson with a
usage error, which is 2. So a shell bug at the top of the script arrives
wearing jq's exit code. Give each local its own statement.
gh api --paginate emits one JSON array per page, not one array. A comment
thread longer than the page size therefore arrives as several concatenated
documents, and --argjson rejects it with the same exit 2. jq -s 'add // []'
flattens the pages and is a no-op on a single page. The fixture path runs
through the same helper, which is the only reason a fixture can test it at
all: a fixture that could hold just one array would be exercising a shape the
live fetch never produces. This was unreachable on every PR in this repo so far
— #122 has five inline comments — which is exactly why it needed a test rather
than a look.
Why a review is easy to miss here
It arrives as a review, not a comment, so reading the comment thread does not show it.
It posts under the repository owner's account — the same account this
repo's own agents post under — so authorship does not distinguish it. The
tell is placement: a Codex review ends with
_Automated review by ChatGPT Codex (…)_, where an agent's comment opens
with _Posted by Claude (…)_.
It may target an earlier head. pr-reviews says which, because that
changes what you do: a finding against an old commit may already be fixed or
may still be live, and only checking tells you which. Both cases have occurred
— one fixed by a later round, one live at the head after three more commits.
Absence is not approval
Reviews arrive as the owner's quota allows. Observed latency is ten to fifteen
minutes after a push. --wait exists so that a merge four minutes after a
push does not race the review that was about to arrive; when it times out,
that is a decision to merge unreviewed rather than evidence of a clean bill.
Answer them on the thread
Including after the PR has merged. That is where the next reader looks, and a finding that was declined or already-fixed is worth as much as one that was taken — the reasoning is the artefact, not the verdict.