Skip to content

Commit 714d0bc

Browse files
authored
Merge pull request #38 from hotdata-dev/feat/pullfrog-parallel-review-trial
feat(review): exclude the parallel reviewer from the prompt
2 parents b9ebe14 + e5093df commit 714d0bc

9 files changed

Lines changed: 453 additions & 41 deletions

README.md

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,58 @@ block delimiters are neutralised by shape rather than by exact string: `</pr_con
7171
`</PR_CONTEXT>` and `< / pr_context foo="1">` all read as the same delimiter to a model, and any of
7272
them would otherwise end the data block early and land the rest where it reads as instructions.
7373

74+
### A second reviewer in parallel
75+
76+
A comparison trial runs [Pullfrog](https://pullfrog.com) on OpenAI beside this reviewer, so its
77+
output is excluded from the two blocks that would otherwise carry it: `/pulls/{n}/comments`
78+
becomes `<prior_review_comments>` and `/issues/{n}/comments` becomes the PR conversation, and
79+
neither read filters by author. Both reviewers fire on `opened`, so the exclusion is what keeps
80+
the two arms of the comparison independent — whichever reviewer posts first would otherwise set
81+
what the other reads as settled prior feedback, not to be re-raised. It also protects the budget
82+
this reviewer already competes for: comment threads may take half of it, and a Pullfrog review
83+
body is a PR summary plus its findings.
84+
85+
The login lives in one constant, `OTHER_REVIEW_BOT`, and reaches five programs as a jq `--arg`.
86+
One of them reads nothing out of the prompt at all: the review-cycle drift predicate asks whether
87+
a bot review exists that the `claude[bot]` filter did not count, which is true on the first review
88+
of every PR in a trial repo — so without the exclusion it would report a reviewer-identity change
89+
that has not happened, on every pull request, which is how the warning goes unread on the one
90+
where it is real. The predicate still has to fire when a drifted login sits *beside* the excluded
91+
one, and after the trial that is the only shape a real drift takes, so it is the case the fixtures
92+
pin.
93+
94+
The CI block is the third channel and the one with teeth, so `CHECKS_JQ` and `FAILING_JOBS_JQ`
95+
drop the same reviewer's checks through one shared owner test, `CHECK_OWNER_JQ`, composed into both
96+
the way the workflow composes `CMD_JQ` into `TOOL_USAGE_JQ` — two copies of that rule were free to
97+
disagree about what "theirs" means, and did. It matches the app slug against both the entry's own
98+
name and its `workflowName`, downcased, since a GitHub App's login is its slug plus `[bot]` (so
99+
`$skip` stays the single constant) while an Actions check run carries the *job* name in `.name`
100+
matching that alone would make the exclusion depend on a job key in another repository. Pullfrog's verdict is a check,
101+
`pullfrog-approval`, failing when it requested changes, and this prompt tells the reviewer that a
102+
failing check is a blocking issue to name and cite. Unfiltered, the rollup does not merely leak
103+
the other arm's conclusion; it converts it into a request-changes this reviewer cannot
104+
substantiate from the diff. Its run-status check is worse per byte: the check links to Pullfrog's
105+
own Actions job, and `FAILING_JOBS_JQ` would fetch that job's log into a `### Failing job` excerpt
106+
— the other reviewer's transcript, verbatim, at up to an eighth of the context. That the check
107+
gates no merge is true of merge and silent about the prompt.
108+
109+
Where the exclusion empties a block it says so and how much it withheld, because
110+
"No prior review comments." on a PR that has some is the same false claim as an empty CI block
111+
reading as a green one. Where other entries remain it stays silent: neither block claims to be
112+
every comment or every check on the PR. One rendering changes with it. A human reply to an
113+
excluded comment is kept — real feedback, and dropping it would hide more than the exclusion
114+
protects — but its parent is gone, and an id pointing at nothing in the block turns "fixed in the
115+
next push" into a settled finding whose subject the reviewer never sees. So an orphaned reply is
116+
relabelled rather than renumbered or dropped.
117+
118+
Pullfrog cannot submit an approving review and `pullfrog-approval` is not required in the org
119+
ruleset, so this reviewer remains the only automated approval in the org for the duration of the
120+
trial. The constant, the four exclusions, the drift predicate's exception and this section come out
121+
when the trial ends — the drift predicate by name, because it is the one place an incomplete
122+
removal is silent. Delete `OTHER_REVIEW_BOT` and leave `$skip` in `DRIFT_JQ` and its `jq` call
123+
fails to compile, which the `elif` guarding it reads as "no drift": the backstop against a
124+
reviewer-login change would be gone with no warning in either direction.
125+
74126
### Tool usage artifact
75127

76128
Each run attaches a `claude-tool-usage-pr-<number>` artifact (14-day retention): tool call counts,

scripts/gather-review-context.sh

Lines changed: 85 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -298,9 +298,31 @@ fetch_raw() {
298298
# Coupled to the reviewer's login: if that ever changes the count silently drops
299299
# to 0 and every round looks like the first, hence the warning below.
300300
CYCLE_JQ='[.[][] | select(.user.login == "claude[bot]") | .commit_id] | unique | length'
301+
# The login of the second automated reviewer running beside this one, and the one value
302+
# three programs below have to agree on. A comparison trial has Pullfrog reviewing the same
303+
# pull requests, and both reviewers fire on `opened`, so its output reaches this reviewer
304+
# through two reads that filter by nothing: `/pulls/{n}/comments` becomes
305+
# <prior_review_comments> and `/issues/{n}/comments` becomes the PR conversation. Left in,
306+
# it costs the trial its independence -- whichever reviewer posts first sets what the other
307+
# reads as settled prior feedback -- and it costs the diff blocks the bytes, since comment
308+
# threads may take half the escaped-byte budget and a cut context measured 0.92 findings a
309+
# run against 1.91 uncut. So the exclusion is not tidiness; it is what keeps the two arms
310+
# of the comparison, and the reviewer's own budget, intact.
311+
#
312+
# Handed to jq with --arg at each site rather than written into each program, because a
313+
# second copy of a constant is a copy free to drift from the first. tests/lib.sh reads this
314+
# assignment for the same reason it extracts the jq programs.
315+
#
316+
# It comes out when the trial ends. Nothing else in the org posts reviews.
317+
OTHER_REVIEW_BOT='pullfrog[bot]'
301318
# Only consulted when CYCLE is 0; see the warning below. Kept in its own variable
302319
# so tests/review-cycle-test.sh can assert it against the fixtures.
303-
DRIFT_JQ='any(.[][]; .user.type == "Bot")'
320+
#
321+
# $skip is excluded from the predicate, not merely from the count: the trial makes a bot
322+
# review on a genuine cycle 1 the ordinary case, so without it every first review in a
323+
# trial repo would report a reviewer-identity drift that has not happened -- and an alarm
324+
# that fires on every PR is an alarm nobody reads on the PR where it is real.
325+
DRIFT_JQ='any(.[][]; .user.type == "Bot" and .user.login != $skip)'
304326
# Never fail the review over the cycle number; degrade to 1, but say so. gh
305327
# writes its error body to stdout, so an unguarded pipe into jq aborts the step
306328
# under `bash -e` and skips the failure-notification step below.
@@ -326,9 +348,10 @@ if [ -z "$CYCLE" ]; then
326348
echo "::warning::Could not parse prior reviews; treating this as review cycle 1."
327349
CYCLE=0
328350
elif [ "$CYCLE" -eq 0 ] && printf '%s' "$REVIEWS" \
329-
| jq -e -s "$DRIFT_JQ" >/dev/null 2>&1; then
330-
# claude[bot] is the only bot that submits reviews across the org (598 of 598
331-
# sampled), so bot reviews that the login filter did not count mean the
351+
| jq --arg skip "$OTHER_REVIEW_BOT" -e -s "$DRIFT_JQ" >/dev/null 2>&1; then
352+
# claude[bot] was the only bot submitting reviews across the org when this was
353+
# written (598 of 598 sampled), and $OTHER_REVIEW_BOT is the stated exception,
354+
# so any *other* bot review that the login filter did not count means the
332355
# reviewer's identity moved and the counter has silently pinned at 1.
333356
echo "::warning::Bot reviews exist but none matched the reviewer login; the review cycle counter is stale."
334357
fi
@@ -352,22 +375,22 @@ fi
352375
# "No prior review comments." is only true when the fetch worked and returned
353376
# none. Saying it after a failed fetch is the same false claim as an empty CI block
354377
# reading as a green one, and it is the claim the cycle ladder acts on.
378+
#
379+
# $skip's comments are dropped here -- see OTHER_REVIEW_BOT above for why. The drop is
380+
# announced only when it empties the block, and that asymmetry is the point: with other
381+
# comments still present the block claims nothing about being every comment on the PR, but
382+
# "No prior review comments." on a PR that has some is the same false claim as an empty CI
383+
# block reading as a green one. So the empty case names what was withheld and how much.
384+
#
385+
# Single-line and single-quoted so tests/lib.sh can extract it; it was inline, and inline
386+
# meant the one program in this script that shapes the prompt's other output could not be
387+
# asserted against a fixture at all.
388+
THREADS_JQ='(add // []) as $all | ($all | map(select(.user.login != $skip))) as $kept | ($kept | map(.id)) as $ids | (($all | length) - ($kept | length)) as $dropped | if ($kept | length) == 0 then (if $dropped > 0 then "No prior review comments. (\($dropped) comment(s) from \($skip) are excluded from this block.)" else "No prior review comments." end) else ($kept | sort_by(.created_at) | .[] | "---", "Author: \(.user.login)", "File: \(.path)", (if .line then "Line: \(.line)" else empty end), (if .in_reply_to_id then (if (.in_reply_to_id | IN($ids[])) then "Reply to #\(.in_reply_to_id)" else "Reply to a comment excluded from this block" end) else "Thread #\(.id)" end), "", ((.body // "")[0:3000])) end'
355389
if [ "$COMMENTS_OK" -eq 0 ]; then
356390
THREADS='Unavailable: the prior inline review comments could not be read. This block is empty because the fetch failed, not because there were none.'
357391
else
358-
THREADS=$(printf '%s' "$COMMENTS" | jq -s -r '
359-
(add // []) | sort_by(.created_at) |
360-
if length == 0 then "No prior review comments."
361-
else .[] |
362-
"---",
363-
"Author: \(.user.login)",
364-
"File: \(.path)",
365-
(if .line then "Line: \(.line)" else empty end),
366-
(if .in_reply_to_id then "Reply to #\(.in_reply_to_id)" else "Thread #\(.id)" end),
367-
"",
368-
((.body // "")[0:3000])
369-
end
370-
') || THREADS='Unavailable: the prior inline review comments could not be parsed.'
392+
THREADS=$(printf '%s' "$COMMENTS" | jq --arg skip "$OTHER_REVIEW_BOT" -s -r "$THREADS_JQ") \
393+
|| THREADS='Unavailable: the prior inline review comments could not be parsed.'
371394
fi
372395

373396
# The prompt wraps both blocks below in <prior_review_comments> and <pr_context>
@@ -526,14 +549,49 @@ cap_file_escaped "$FILES_FILE" $((CTX_MAX_BYTES / 8)) \
526549
# The reviewer cannot run tests -- no dependencies are installed and the allowlist
527550
# would refuse anyway -- but CI already ran them. Whether they passed is the one
528551
# fact it was asserting without evidence.
529-
CHECKS_JQ='(.statusCheckRollup // []) | if length == 0 then "No checks reported." else map(if .__typename == "CheckRun" then "\(.conclusion // .status // "UNKNOWN") \(.workflowName // "") / \(.name // "(unnamed check)")" else "\(.state // "UNKNOWN") \(.context // "status")" end) | sort | join("\n") end'
552+
#
553+
# This block is the third channel $skip reaches, and the one where it does the most damage.
554+
# Pullfrog posts its verdict as a check -- `pullfrog-approval`, failing when it requested
555+
# changes -- and the prompt tells this reviewer that a failing check is a blocking issue to
556+
# name and cite. So an unfiltered rollup does not merely leak the other arm's conclusion; it
557+
# converts it into a request-changes this reviewer cannot substantiate from the diff.
558+
# `pullfrog` (the run-status check) rides along for the same reason, and its detailsUrl would
559+
# otherwise feed FAILING_JOBS_JQ below the *other reviewer's own job log* as a failing-job
560+
# excerpt, at up to an eighth of the context.
561+
#
562+
# Matched by name against the app slug rather than by a second constant: a GitHub App's bot
563+
# login is its slug plus "[bot]", and its checks are the slug and slug-prefixed names, so
564+
# $skip still carries the one value all five programs agree on. Both rollup shapes are
565+
# matched -- a CheckRun by .name, a StatusContext by .context -- because which of the two an
566+
# app posts is the app's choice, not ours.
567+
#
568+
# Silent while other checks remain, and stated when the exclusion empties the block: a list
569+
# claims nothing about being every check, but "No checks reported." on a PR that has some is
570+
# a false claim, and this is the block whose emptiness the README warns gets read as green.
571+
# One definition of "belongs to the other reviewer", composed into both programs below rather
572+
# than written into each: they are two programs, and a second copy of the rule is a copy free to
573+
# disagree with the first about what it matches -- which it already did, the failing-job scan
574+
# testing only .name while the list tested .context as well. Composed the way the workflow
575+
# composes CMD_JQ into TOOL_USAGE_JQ.
576+
#
577+
# It reads both names on the entry, not one. For a check the app posts itself, .name is the check
578+
# ("pullfrog", "pullfrog-approval") and there is no workflow behind it; for one that reached the
579+
# rollup from an Actions run, .name is the *job* name and .workflowName is the workflow's `name:`.
580+
# So a name-only test makes this exclusion depend on a job key in another repository staying
581+
# `pullfrog`: `name: Pullfrog` over a job called `review` arrives as
582+
# {name: "review", workflowName: "Pullfrog"} and slips through both programs whole. Dropping a
583+
# check belonging to a workflow named for the other reviewer is the intent in either shape.
584+
# Compared downcased for the same reason -- the slug is lowercase by construction, and a job name
585+
# is whatever someone typed.
586+
CHECK_OWNER_JQ='def slug: $skip | sub("\\[bot\\]$"; "") | ascii_downcase; def theirs: [(if .__typename == "CheckRun" then (.name // "") else (.context // "") end), (.workflowName // "")] | any(. != "" and (ascii_downcase | . == slug or startswith(slug + "-")));'
587+
CHECKS_JQ='def render: if .__typename == "CheckRun" then "\(.conclusion // .status // "UNKNOWN") \(.workflowName // "") / \(.name // "(unnamed check)")" else "\(.state // "UNKNOWN") \(.context // "status")" end; (.statusCheckRollup // []) as $all | ($all | map(select(theirs | not))) as $kept | (($all | length) - ($kept | length)) as $dropped | if ($kept | length) == 0 then (if $dropped > 0 then "No checks reported. (\($dropped) check(s) from \(slug) are excluded from this block; they carry a verdict from another reviewer, not a CI result.)" else "No checks reported." end) else ($kept | map(render) | sort | join("\n")) end'
530588
# Actions check runs carry the job id in detailsUrl; scan rather than capture so a
531589
# non-Actions check with no job id drops out instead of erroring.
532-
FAILING_JOBS_JQ='[(.statusCheckRollup // [])[] | select(.__typename == "CheckRun") | select((.conclusion // "") | test("FAILURE|TIMED_OUT|ACTION_REQUIRED")) | (.detailsUrl // "") | [scan("/job/([0-9]+)")] | flatten | .[0] // empty] | unique | .[0:3] | join(" ")'
590+
FAILING_JOBS_JQ='[(.statusCheckRollup // [])[] | select(.__typename == "CheckRun") | select(theirs | not) | select((.conclusion // "") | test("FAILURE|TIMED_OUT|ACTION_REQUIRED")) | (.detailsUrl // "") | [scan("/job/([0-9]+)")] | flatten | .[0] // empty] | unique | .[0:3] | join(" ")'
533591
if ROLLUP=$(gh pr view "$PR_NUMBER" --repo "$REPO" --json statusCheckRollup); then
534-
CHECKS=$(printf '%s' "$ROLLUP" | jq -r "$CHECKS_JQ" 2>/dev/null) \
592+
CHECKS=$(printf '%s' "$ROLLUP" | jq --arg skip "$OTHER_REVIEW_BOT" -r "$CHECK_OWNER_JQ $CHECKS_JQ" 2>/dev/null) \
535593
|| CHECKS="Could not parse checks."
536-
JOB_IDS=$(printf '%s' "$ROLLUP" | jq -r "$FAILING_JOBS_JQ" 2>/dev/null) || JOB_IDS=''
594+
JOB_IDS=$(printf '%s' "$ROLLUP" | jq --arg skip "$OTHER_REVIEW_BOT" -r "$CHECK_OWNER_JQ $FAILING_JOBS_JQ" 2>/dev/null) || JOB_IDS=''
537595
else
538596
echo "::warning::Could not read check status."
539597
CHECKS="Could not read check status."
@@ -706,9 +764,13 @@ if [ "$FULL_DIFF_MAX" -gt "$DIFF_MAX" ]; then FULL_DIFF_MAX=$DIFF_MAX; fi
706764
# prompt on www.hotdata.dev#332. Stripped before the cap for the reason strip_block_tags
707765
# always runs first -- the substitution grows the text, so a cap on the unstripped file
708766
# bounds a smaller string than the one emitted.
709-
ISSUE_COMMENTS_JQ='[.[][]] | if length == 0 then "No PR conversation comments." else sort_by(.created_at) | map("--- \(.user.login) at \(.created_at)\n\((.body // "")[0:3000])") | join("\n") end'
767+
#
768+
# $skip is excluded here as well, and for the same reason: the review body Pullfrog posts
769+
# is a PR summary plus its findings, and this endpoint is where it lands. The empty case
770+
# names the withholding on the same principle as the threads block above.
771+
ISSUE_COMMENTS_JQ='[.[][]] as $all | ($all | map(select(.user.login != $skip))) as $kept | (($all | length) - ($kept | length)) as $dropped | if ($kept | length) == 0 then (if $dropped > 0 then "No PR conversation comments. (\($dropped) comment(s) from \($skip) are excluded from this block.)" else "No PR conversation comments." end) else ($kept | sort_by(.created_at) | map("--- \(.user.login) at \(.created_at)\n\((.body // "")[0:3000])") | join("\n")) end'
710772
if CONVO_JSON=$(gh api "repos/${REPO}/issues/${PR_NUMBER}/comments" --paginate); then
711-
CONVO=$(printf '%s' "$CONVO_JSON" | jq -s -r "$ISSUE_COMMENTS_JQ" 2>/dev/null) \
773+
CONVO=$(printf '%s' "$CONVO_JSON" | jq --arg skip "$OTHER_REVIEW_BOT" -s -r "$ISSUE_COMMENTS_JQ" 2>/dev/null) \
712774
|| CONVO="Could not parse PR conversation comments."
713775
else
714776
echo "::warning::Could not read PR conversation comments."
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
[
2+
{
3+
"id": 2101,
4+
"user": { "login": "claude[bot]" },
5+
"path": "scripts/gather-review-context.sh",
6+
"line": 412,
7+
"created_at": "2026-08-27T09:00:00Z",
8+
"body": "The cap is measured on the unstripped file, so the emitted block can exceed it."
9+
},
10+
{
11+
"id": 2102,
12+
"user": { "login": "zfarrell" },
13+
"path": "scripts/gather-review-context.sh",
14+
"line": 412,
15+
"in_reply_to_id": 2101,
16+
"created_at": "2026-08-27T09:31:00Z",
17+
"body": "Fixed in the next push -- strip_block_tags runs first now."
18+
},
19+
{
20+
"id": 2103,
21+
"user": { "login": "pullfrog[bot]" },
22+
"path": "scripts/gather-review-context.sh",
23+
"line": 88,
24+
"created_at": "2026-08-27T09:04:00Z",
25+
"body": "## Summary\n\nThis PR reworks the context budget. One finding: the byte cap and the line cap disagree."
26+
},
27+
{
28+
"id": 2105,
29+
"user": { "login": "zfarrell" },
30+
"path": "scripts/gather-review-context.sh",
31+
"line": 88,
32+
"in_reply_to_id": 2103,
33+
"created_at": "2026-08-27T09:40:00Z",
34+
"body": "Good catch -- fixed in the next push."
35+
},
36+
{
37+
"id": 2104,
38+
"user": { "login": "claude[bot]" },
39+
"path": "tests/lib.sh",
40+
"line": 22,
41+
"created_at": "2026-08-27T09:05:00Z",
42+
"body": null
43+
}
44+
]
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
[
2+
{
3+
"id": 1,
4+
"commit_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
5+
"state": "COMMENTED",
6+
"submitted_at": "2026-08-28T00:00:00Z",
7+
"user": {
8+
"login": "pullfrog[bot]",
9+
"type": "Bot"
10+
}
11+
},
12+
{
13+
"id": 2,
14+
"commit_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
15+
"state": "CHANGES_REQUESTED",
16+
"submitted_at": "2026-08-28T00:01:00Z",
17+
"user": {
18+
"login": "claude-review[bot]",
19+
"type": "Bot"
20+
}
21+
},
22+
{
23+
"id": 3,
24+
"commit_id": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
25+
"state": "APPROVED",
26+
"submitted_at": "2026-08-28T01:00:00Z",
27+
"user": {
28+
"login": "claude-review[bot]",
29+
"type": "Bot"
30+
}
31+
}
32+
]
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
[
2+
{
3+
"id": 1,
4+
"commit_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
5+
"state": "COMMENTED",
6+
"submitted_at": "2026-08-28T00:00:00Z",
7+
"user": {
8+
"login": "pullfrog[bot]",
9+
"type": "Bot"
10+
}
11+
},
12+
{
13+
"id": 2,
14+
"commit_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
15+
"state": "CHANGES_REQUESTED",
16+
"submitted_at": "2026-08-28T00:00:05Z",
17+
"user": {
18+
"login": "pullfrog[bot]",
19+
"type": "Bot"
20+
}
21+
}
22+
]

0 commit comments

Comments
 (0)