Skip to content

Commit b37e435

Browse files
johanzanderclaude
andauthored
fix: scope the review-run check to its own PR, and survive a transient 503 (#639)
Two failures in `request-pr-review.sh`, both observed live, both of which cost a paid review round. WRONG RUN. `review_run_state()` took the newest `PR Review` run created after the trigger, with no filter for which PR it belonged to. `pr-review.yml` triggers on `issue_comment`, which fires for comments on ISSUES too, and those runs are gated out and complete as `skipped` in about ten seconds. So any comment posted anywhere in the repo during a review produced a newer run that was `completed` and not `success` -- read as `failed`. Measured on #636: a routine PO comment on issue #441 at 21:13:07 made the script abandon the review, which went on to APPROVE at 21:15:45. The caller was told the run was broken while it was still thinking, and the natural response -- re-request -- would have spent a second review round on a review already in flight. `displayTitle` carries the PR title for a run triggered on that PR, so it is the discriminator. The title is read once before the loop and matched via `--arg`, not string interpolation: a title containing a quote would otherwise break the filter silently. TRANSIENT FAILURE. `set -e` turned a single 503 on the verdict read into a fatal exit 1 -- AFTER the trigger comment had posted, so a re-run again spent a second round on a live review. GitHub returned 503s for roughly ninety minutes on 2026-08-17 and this fired twice. Both review reads now tolerate a failed call and retry on the next poll, which is what `review_run_state` already did via its `unknown` state. Both bug tests were verified to fail against the pre-fix script -- the second reproducing the exact exit 1 with the 503 on stderr -- while `test_this_prs_own_failed_run_is_still_reported` passes both before and after, which is what proves the scoping does not swallow a genuine failure. No CHANGELOG entry: agent tooling, no user-visible effect. Claude-Session: https://claude.ai/code/session_012LExo6fcbup75vtc9NfoAR Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 8043155 commit b37e435

2 files changed

Lines changed: 159 additions & 9 deletions

File tree

backend/tests/test_request_pr_review.py

Lines changed: 114 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,16 @@ def env_file(tmp_path: Path) -> Path:
5757
return p
5858

5959

60-
def _gh(bin_dir: Path, reviews: list, run_state: str) -> None:
60+
PR_TITLE = "fix: the pr under review"
61+
62+
63+
def _gh(
64+
bin_dir: Path,
65+
reviews: list,
66+
run_state: str,
67+
extra_runs: list | None = None,
68+
title: str = PR_TITLE,
69+
) -> None:
6170
"""A `gh` answering the two queries the script makes.
6271
6372
`reviews` is returned for `pr view --json reviews`; `run_state` drives
@@ -78,14 +87,24 @@ def _gh(bin_dir: Path, reviews: list, run_state: str) -> None:
7887
}[run_state]
7988
)
8089
# createdAt must sort after the script's `since`, which it computes at start.
90+
# displayTitle is what scopes a run to THIS PR — a run carrying any other
91+
# title belongs to a different PR (or to an issue comment, which spawns a
92+
# gated-out `skipped` run) and must be ignored.
8193
for r in runs:
8294
r["createdAt"] = "2099-01-01T00:00:00Z"
95+
r.setdefault("displayTitle", title)
96+
97+
# `extra_runs` are NEWER runs belonging to something else. Real gh returns
98+
# newest-first, so they go in front — which is exactly how they used to be
99+
# mistaken for this PR's run.
100+
runs = (extra_runs or []) + runs
83101

84102
# The shim must APPLY --jq, like real gh does. An earlier version echoed the
85103
# raw JSON and the script happily reported it as a verdict — the shim has to
86104
# be faithful about the part under test, which here is the jq filter.
87105
(bin_dir / "reviews.json").write_text(json.dumps({"reviews": reviews}))
88106
(bin_dir / "runs.json").write_text(json.dumps(runs))
107+
(bin_dir / "title.json").write_text(json.dumps({"title": title}))
89108

90109
_write(
91110
bin_dir / "gh",
@@ -99,6 +118,7 @@ def _gh(bin_dir: Path, reviews: list, run_state: str) -> None:
99118
done
100119
101120
case "$*" in
121+
*'pr view'*'--json title'*) src='{bin_dir}/title.json' ;;
102122
*'pr view'*) src='{bin_dir}/reviews.json' ;;
103123
*'run list'*)
104124
if [ "{int(runs_fail)}" = "1" ]; then
@@ -286,3 +306,96 @@ def test_a_decisive_verdict_wins_over_an_earlier_commented(
286306

287307
assert proc.returncode == 0
288308
assert "VERDICT CHANGES_REQUESTED" in proc.stdout
309+
310+
311+
def _foreign_run(title: str, conclusion: str = "skipped") -> dict:
312+
"""A newer `PR Review` run belonging to something else.
313+
314+
`pr-review.yml` triggers on `issue_comment`, which fires for comments on
315+
ISSUES as well as PRs. Those runs are gated out and complete as `skipped`
316+
within about ten seconds, so any comment posted anywhere in the repo while a
317+
review is running produces one of these.
318+
"""
319+
return {
320+
"status": "completed",
321+
"conclusion": conclusion,
322+
"createdAt": "2099-01-01T00:00:30Z",
323+
"displayTitle": title,
324+
}
325+
326+
327+
def test_a_newer_run_for_a_different_pr_is_not_this_review(
328+
bin_dir: Path, env_file: Path
329+
) -> None:
330+
"""The bug that cost a real review round. A routine PO comment on issue
331+
#441 spawned a gated-out `skipped` run, which was newer than #636's and so
332+
was read as "this review failed" — while #636 went on to APPROVE two
333+
minutes later. The caller abandoned a review that was still thinking.
334+
"""
335+
_gh(
336+
bin_dir,
337+
[],
338+
"running",
339+
extra_runs=[_foreign_run("Question: Is it always counting with 15 minutes?")],
340+
)
341+
proc = _run(bin_dir, env_file, timeout=2)
342+
343+
# Times out waiting, which is correct: the review is still running.
344+
assert proc.returncode == 2
345+
assert "FAILED without submitting a verdict" not in proc.stderr
346+
347+
348+
def test_this_prs_own_failed_run_is_still_reported(
349+
bin_dir: Path, env_file: Path
350+
) -> None:
351+
"""The scoping must not swallow a genuine failure — that is the other half
352+
of the same rule, and the reason a fixed grace window was replaced by
353+
asking the run in the first place."""
354+
_gh(
355+
bin_dir,
356+
[],
357+
"failed",
358+
extra_runs=[_foreign_run("some unrelated issue")],
359+
)
360+
proc = _run(bin_dir, env_file, timeout=2)
361+
362+
assert proc.returncode == 2
363+
assert "FAILED without submitting a verdict" in proc.stderr
364+
365+
366+
def test_a_transient_api_failure_does_not_kill_the_poll(
367+
bin_dir: Path, env_file: Path
368+
) -> None:
369+
"""`set -e` used to turn one 503 on the verdict read into a fatal exit 1,
370+
AFTER the trigger comment had posted — so re-running spent a second paid
371+
review round on a review already in flight. GitHub returned 503s for about
372+
ninety minutes on 2026-08-17 and this fired twice.
373+
374+
The shim fails `pr view --json reviews` once, then serves normally.
375+
"""
376+
_gh(bin_dir, [_review("APPROVED")], "running")
377+
378+
# Wrap the shim: first reviews read fails, subsequent ones succeed.
379+
gh = bin_dir / "gh"
380+
real = gh.read_text()
381+
(bin_dir / "gh-real").write_text(real)
382+
(bin_dir / "gh-real").chmod(0o755)
383+
_write(
384+
gh,
385+
f"""#!/bin/sh
386+
case "$*" in
387+
*'pr view'*'--json reviews'*)
388+
if [ ! -f {bin_dir}/tripped ]; then
389+
touch {bin_dir}/tripped
390+
echo "HTTP 503: no server is currently available" >&2
391+
exit 1
392+
fi ;;
393+
esac
394+
exec {bin_dir}/gh-real "$@"
395+
""",
396+
)
397+
398+
proc = _run(bin_dir, env_file, timeout=6)
399+
400+
assert proc.returncode == 0, proc.stderr
401+
assert "VERDICT APPROVED" in proc.stdout

scripts/request-pr-review.sh

Lines changed: 45 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -87,19 +87,48 @@ interval="${REVIEW_POLL_INTERVAL:-60}"
8787
# reviews, so a crashed review burned the full timeout. On #623 that cost 16
8888
# minutes of waiting on a run that had already failed with "Reached maximum
8989
# number of turns (60)".
90+
#
91+
# THE RUN MUST BE THIS PR'S. Selecting the newest run by time alone was wrong,
92+
# and wrong in the direction that costs a review round: `pr-review.yml` triggers
93+
# on `issue_comment`, which fires for comments on ISSUES too, not only PRs.
94+
# Those runs are gated out and complete as `skipped` within about ten seconds.
95+
#
96+
# So any comment posted anywhere in the repo while a review is running produces
97+
# a newer `PR Review` run that is `completed` and not `success` -- which this
98+
# function read as `failed`. Measured on #636: a routine PO comment on issue
99+
# #441 at 21:13:07 made the script abandon a review that went on to APPROVE at
100+
# 21:15:45. The caller was told the run was broken while it was still thinking.
101+
#
102+
# `displayTitle` carries the PR title for a run triggered on that PR, so it is
103+
# the discriminator. The title is fetched once, before the loop, and matched
104+
# through `--arg` rather than string-interpolated -- a title containing a quote
105+
# would otherwise break the filter.
90106
review_run_state() {
91107
gh run list --workflow "PR Review" --limit 20 \
92-
--json status,conclusion,createdAt \
93-
--jq "[ .[] | select(.createdAt > \"${since}\") ] | first
94-
| if . == null then \"none\"
95-
elif .status != \"completed\" then \"running\"
96-
elif .conclusion == \"success\" then \"finished\"
97-
else \"failed\" end" 2>/dev/null || echo "unknown"
108+
--json status,conclusion,createdAt,displayTitle 2>/dev/null \
109+
| jq -r --arg since "$since" --arg title "$pr_title" '
110+
[ .[]
111+
| select(.createdAt > $since)
112+
| select(.displayTitle == $title) ] | first
113+
| if . == null then "none"
114+
elif .status != "completed" then "running"
115+
elif .conclusion == "success" then "finished"
116+
else "failed" end' 2>/dev/null || echo "unknown"
98117
}
99118

100119
repo_root=$(git rev-parse --show-toplevel)
101120
cd "$repo_root"
102121

122+
# The PR title, used to tell THIS PR's review run from any other `PR Review`
123+
# run that happens to be newer. Fetched before `since` so a slow call cannot
124+
# push the window past a review that lands immediately.
125+
pr_title=$(gh pr view "$pr" --json title --jq .title)
126+
if [ -z "$pr_title" ]; then
127+
echo "Could not read PR #${pr} title; refusing to poll without a way to" >&2
128+
echo "tell its review run from anyone elses." >&2
129+
exit 2
130+
fi
131+
103132
# Reviews strictly newer than this are the ones this run triggered.
104133
since=$(date -u +%Y-%m-%dT%H:%M:%SZ)
105134

@@ -118,12 +147,19 @@ while [ "$(date +%s)" -lt "$deadline" ]; do
118147
fi
119148

120149
# A decisive verdict wins immediately, whenever it appears.
150+
#
151+
# A FAILED READ IS NOT A RESULT, and `set -e` used to turn one into a fatal
152+
# error: a single transient 503 on this call killed the script with exit 1
153+
# AFTER the trigger comment had already posted, so re-running it spent a
154+
# second paid review round on a review already in flight. GitHub returned
155+
# 503s for roughly ninety minutes on 2026-08-17 and this fired twice.
156+
# Swallowing the failure costs one wasted poll; the next iteration retries.
121157
verdict=$(gh pr view "$pr" --json reviews \
122158
--jq "[.reviews[]
123159
| select(.submittedAt > \"${since}\")
124160
| select(.state == \"APPROVED\" or .state == \"CHANGES_REQUESTED\")]
125161
| last | select(. != null)
126-
| \"\(.state) \(.submittedAt) \(.author.login)\"")
162+
| \"\(.state) \(.submittedAt) \(.author.login)\"" 2>/dev/null) || verdict=""
127163

128164
if [ -n "$verdict" ]; then
129165
echo "VERDICT ${verdict}"
@@ -142,12 +178,13 @@ while [ "$(date +%s)" -lt "$deadline" ]; do
142178
exit 2
143179
fi
144180

181+
# Same transient-failure tolerance as the verdict read above.
145182
commented=$(gh pr view "$pr" --json reviews \
146183
--jq "[.reviews[]
147184
| select(.submittedAt > \"${since}\")
148185
| select(.state == \"COMMENTED\")]
149186
| last | select(. != null)
150-
| \"\(.state) \(.submittedAt) \(.author.login)\"")
187+
| \"\(.state) \(.submittedAt) \(.author.login)\"" 2>/dev/null) || commented=""
151188

152189
if [ -n "$commented" ]; then
153190
if [ "$state" = "running" ] || [ "$state" = "unknown" ]; then

0 commit comments

Comments
 (0)