Skip to content

Commit a7b086e

Browse files
committed
fix: decide a COMMENTED review by run state, and test the decision
Third round on the same finding, and the previous two "fixes" were asserted rather than demonstrated -- verification was quality-check.sh plus `bash -n`, neither of which executes the decision path. So this commit changes the mechanism AND adds the missing tests. The grace window was the wrong instrument, and the review named the reason: it competed against the ORIGINAL deadline instead of extending it, so a COMMENTED first seen in the last `grace` seconds of the window could never satisfy the `elapsed >= grace` branch -- the loop exited first and reported "no review landed", which was false. Observed live on #622: the stub landed 12:15:14, the real CHANGES_REQUESTED 12:16:39, and the script returned the stub. Sizing it differently would not have helped. The gap that matters is not placeholder-to-summary (16s on #622, 50s on #617) but placeholder-to-END-OF-RUN: the bot posts an early permission-check comment within a couple of minutes and works for five to eight more. No constant is both short enough to return a real COMMENT promptly and long enough never to pre-empt a summary. So ask the run instead. `review_run_state` reads the PR Review workflow run started since the trigger: running -> a COMMENTED decides nothing; keep waiting finished -> a COMMENTED last word IS the verdict, per pr-review.yml's own three-verdict contract (APPROVE / REQUEST_CHANGES / COMMENT) failed -> report at once; do not burn the timeout on a dead run none -> the trigger never reached the workflow, a different fault That last one matters as much as the first. A dead run and a thinking one are both silence if you only poll for reviews, which is how #623's run -- already failed on "Reached maximum number of turns (60)" -- was waited on for 16 minutes. Also from this review round: - pr-review.yml no longer REQUIRES `gh api` for inline comments. The reviewer reported that `gh api` is permission-gated and unavailable to it, and that it probed with `gh pr review` to find out -- which submits, and is where the stray "test permission check" reviews came from. The prompt now states the one hard rule (submit exactly ONE review, never probe with it), prefers `gh api` for inline notes, and says to fold findings into the summary with file:line when it is unavailable, rather than falling back to a second review. - SKILL.md Step 0 keyed a resume signal on `## Scope assessment`, which only CI mode writes into the PR body. An interactive-mode PR never carries it, so the row would not match for most PRs this skill opens. It now keys on the PR existing, which is what actually proves Step 9 was reached. 7 new tests, REVIEW_POLL_INTERVAL added as their seam. Two of them are the discriminating pair: identical reviews (COMMENTED only), opposite outcomes, differing only in run state -- so the decision is provably driven by the new signal and not by timing. The shim applies `--jq` like real gh does; an earlier version echoed raw JSON and the script reported it as a verdict.
1 parent a00ba4a commit a7b086e

4 files changed

Lines changed: 295 additions & 46 deletions

File tree

.claude/skills/implement-issue/SKILL.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ assume it:**
109109
|---|---|
110110
| branch or worktree exists | Step 4 |
111111
| commits ahead of `origin/main`, RED test in the diff | Step 5–7 |
112-
| open PR with `## Scope assessment` in the body | Step 9 |
112+
| an open PR exists for the branch | Step 9 |
113113
| `gh pr checks` green | Step 10 |
114114
| a terminal review verdict on the PR | Step 11, mid-loop |
115115

@@ -122,8 +122,11 @@ the session, and Step 11 depends on holding it. It is recoverable only because
122122
this skill already forces it to be written down:
123123

124124
- the Stage 2 `@claude-bot analyze` comment on the issue — the root cause
125-
- the PR body's `## Scope assessment` and `## Test plan` — the agreed approach
126-
and what the test was supposed to discriminate
125+
- the PR body's `## Test plan`, and its `## Scope assessment` **if the PR came
126+
from CI mode** — the agreed approach, and what the test was meant to
127+
discriminate. Interactive mode keeps its scope assessment conversational, so
128+
that heading is absent on most PRs this skill opens; the PR's existence is
129+
what proves Step 9 was reached, not any particular heading
127130
- the diff itself, and any inline review comments
128131

129132
**If those sources do not reconstruct a coherent diagnosis, STOP and report

.github/workflows/pr-review.yml

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -65,20 +65,29 @@ jobs:
6565
2. Identify the linked issue (if any) and read its analysis.
6666
6767
3. Walk through the review checklist in `.github/claude-bot.md`.
68-
For each finding, post an INLINE comment on the specific
69-
line. Do not bundle everything in one summary comment.
7068
71-
Post inline comments with `gh api`, NOT `gh pr review`:
69+
**Submit EXACTLY ONE review per run — the summary in step 4.**
70+
This is a hard constraint, not a style preference.
71+
`scripts/request-pr-review.sh` waits for your verdict, and any
72+
extra submitted review is indistinguishable by state from a
73+
real `COMMENT` verdict. That ambiguity left PR #615 approved
74+
but stuck in draft overnight, and a stray "test permission
75+
check" review was submitted to PR #622 while probing what was
76+
available.
77+
78+
So: never call `gh pr review` except once, in step 4. Never
79+
call it to probe permissions — it submits.
80+
81+
Prefer inline comments on the specific lines, via `gh api`:
7282
gh api repos/johanzander/bess-manager/pulls/${{ github.event.issue.number }}/comments \
7383
-f body='...' -f commit_id='<head sha>' -f path='<file>' -F line=<n>
7484
75-
This matters for automation. `gh pr review` SUBMITS a review,
76-
so using it for inline notes puts an extra `COMMENTED` review
77-
on the PR before your summary. `scripts/request-pr-review.sh`
78-
waits for a verdict, and a stray `COMMENTED` is
79-
indistinguishable by state from a real COMMENT verdict — that
80-
ambiguity left PR #615 approved but stuck in draft. Submit
81-
exactly ONE review per run: the summary in step 4.
85+
`gh api` may be unavailable to you — it is permission-gated in
86+
this repo, and past runs could not use it. If it fails, DO NOT
87+
fall back to `gh pr review` for notes. Fold the findings into
88+
the step 4 summary instead, each with an explicit `file:line`
89+
and the real code quoted. A complete summary review is a fine
90+
outcome; a second submitted review is not.
8291
8392
4. End with a single summary review using `gh pr review`:
8493
- APPROVE — no blockers, only nits or none
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
"""Tests for scripts/request-pr-review.sh — the Step 11 verdict wait.
2+
3+
This file exists because the same correctness bug survived THREE review rounds.
4+
Verification was `quality-check.sh` plus `bash -n`, neither of which exercises
5+
the decision path, so each "fix" was asserted rather than demonstrated. The
6+
decision is: given some reviews and a workflow-run state, what does this script
7+
report?
8+
9+
`gh` and `scripts/gh-agent.sh` are shimmed on PATH, so nothing here touches
10+
GitHub. `interval` is driven down via REVIEW_POLL_INTERVAL so a test costs
11+
milliseconds rather than a minute.
12+
"""
13+
14+
import json
15+
import os
16+
import stat
17+
import subprocess
18+
from pathlib import Path
19+
20+
import pytest
21+
22+
REPO_ROOT = Path(__file__).resolve().parents[2]
23+
SCRIPT = REPO_ROOT / "scripts" / "request-pr-review.sh"
24+
25+
26+
def _write(path: Path, body: str) -> None:
27+
path.write_text(body)
28+
path.chmod(path.stat().st_mode | stat.S_IEXEC)
29+
30+
31+
@pytest.fixture
32+
def bin_dir(tmp_path: Path) -> Path:
33+
d = tmp_path / "bin"
34+
d.mkdir()
35+
# The trigger comment goes through gh-agent.sh; make it a no-op.
36+
(d / "scripts").mkdir(parents=True, exist_ok=True)
37+
return d
38+
39+
40+
def _gh(bin_dir: Path, reviews: list, run_state: str) -> None:
41+
"""A `gh` answering the two queries the script makes.
42+
43+
`reviews` is returned for `pr view --json reviews`; `run_state` drives
44+
`run list`, which is how the script decides whether the reviewer is still
45+
working.
46+
"""
47+
runs = {
48+
"running": [{"status": "in_progress", "conclusion": None}],
49+
"finished": [{"status": "completed", "conclusion": "success"}],
50+
"failed": [{"status": "completed", "conclusion": "failure"}],
51+
"none": [],
52+
}[run_state]
53+
# createdAt must sort after the script's `since`, which it computes at start.
54+
for r in runs:
55+
r["createdAt"] = "2099-01-01T00:00:00Z"
56+
57+
# The shim must APPLY --jq, like real gh does. An earlier version echoed the
58+
# raw JSON and the script happily reported it as a verdict — the shim has to
59+
# be faithful about the part under test, which here is the jq filter.
60+
(bin_dir / "reviews.json").write_text(json.dumps({"reviews": reviews}))
61+
(bin_dir / "runs.json").write_text(json.dumps(runs))
62+
63+
_write(
64+
bin_dir / "gh",
65+
f"""#!/bin/sh
66+
# Pull the --jq filter out of the argument list, then apply it to the fixture.
67+
filter=''
68+
prev=''
69+
for a in "$@"; do
70+
if [ "$prev" = "--jq" ]; then filter="$a"; fi
71+
prev="$a"
72+
done
73+
74+
case "$*" in
75+
*'pr view'*) src='{bin_dir}/reviews.json' ;;
76+
*'run list'*) src='{bin_dir}/runs.json' ;;
77+
*'pr comment'*) exit 0 ;;
78+
*) echo "unexpected gh: $*" >&2; exit 1 ;;
79+
esac
80+
81+
if [ -n "$filter" ]; then
82+
jq -r "$filter" < "$src"
83+
else
84+
cat "$src"
85+
fi
86+
""",
87+
)
88+
89+
90+
def _review(state: str, at: str = "2099-01-01T00:00:01Z", body: str = "x") -> dict:
91+
return {"state": state, "submittedAt": at, "body": body, "author": {"login": "bot"}}
92+
93+
94+
def _run(bin_dir: Path, timeout: int = 2) -> subprocess.CompletedProcess:
95+
env = dict(
96+
os.environ,
97+
PATH=f"{bin_dir}:{os.environ['PATH']}",
98+
REVIEW_POLL_INTERVAL="1",
99+
)
100+
return subprocess.run(
101+
["bash", str(SCRIPT), "622", str(timeout)],
102+
capture_output=True,
103+
text=True,
104+
env=env,
105+
cwd=REPO_ROOT,
106+
)
107+
108+
109+
def test_approved_returns_immediately(bin_dir: Path) -> None:
110+
_gh(bin_dir, [_review("APPROVED")], "running")
111+
proc = _run(bin_dir)
112+
assert proc.returncode == 0
113+
assert "VERDICT APPROVED" in proc.stdout
114+
115+
116+
def test_changes_requested_returns_immediately(bin_dir: Path) -> None:
117+
_gh(bin_dir, [_review("CHANGES_REQUESTED")], "running")
118+
proc = _run(bin_dir)
119+
assert proc.returncode == 0
120+
assert "VERDICT CHANGES_REQUESTED" in proc.stdout
121+
122+
123+
def test_commented_while_running_is_not_a_verdict(bin_dir: Path) -> None:
124+
"""The bug, three rounds running.
125+
126+
The bot posts an early permission-check comment and keeps working for
127+
minutes. Returning that COMMENTED makes Step 11 see a non-APPROVED verdict
128+
and skip `gh pr ready` — how #615 sat approved-but-draft overnight. On #622
129+
the stub landed at 12:15:14 and the real CHANGES_REQUESTED at 12:16:39.
130+
"""
131+
_gh(
132+
bin_dir,
133+
[_review("COMMENTED", body="test permission check - ignore")],
134+
"running",
135+
)
136+
proc = _run(bin_dir)
137+
138+
assert proc.returncode == 2
139+
assert "VERDICT" not in proc.stdout
140+
assert "still running" in proc.stderr
141+
142+
143+
def test_commented_after_the_run_finished_is_the_verdict(bin_dir: Path) -> None:
144+
"""The opposite failure. `pr-review.yml` lists COMMENT as one of three final
145+
verdicts, so once the run is over a COMMENTED last word IS the answer —
146+
swallowing it made the script report "no summary" while findings sat on the
147+
PR."""
148+
_gh(bin_dir, [_review("COMMENTED")], "finished")
149+
proc = _run(bin_dir)
150+
151+
assert proc.returncode == 0
152+
assert "VERDICT COMMENTED" in proc.stdout
153+
154+
155+
def test_a_failed_run_reports_at_once_instead_of_waiting(bin_dir: Path) -> None:
156+
"""A dead run and a thinking one are both silence if you only poll reviews.
157+
#623's run died on `Reached maximum number of turns (60)` and the wait
158+
continued for 16 minutes."""
159+
_gh(bin_dir, [], "failed")
160+
proc = _run(bin_dir, timeout=60)
161+
162+
assert proc.returncode == 2
163+
assert "FAILED" in proc.stderr
164+
assert "not a slow one" in proc.stderr
165+
166+
167+
def test_no_run_at_all_is_reported_as_a_trigger_fault(bin_dir: Path) -> None:
168+
"""#619 failed this way twice: the trigger never reached the workflow, which
169+
needs a different response from a stalled review."""
170+
_gh(bin_dir, [], "none")
171+
proc = _run(bin_dir)
172+
173+
assert proc.returncode == 2
174+
assert "No PR Review run started at all" in proc.stderr
175+
assert "actor gate" in proc.stderr
176+
177+
178+
def test_a_decisive_verdict_wins_over_an_earlier_commented(bin_dir: Path) -> None:
179+
"""Ordering, not recency of any state: the stub is older, the verdict newer."""
180+
_gh(
181+
bin_dir,
182+
[
183+
_review("COMMENTED", at="2099-01-01T00:00:01Z"),
184+
_review("CHANGES_REQUESTED", at="2099-01-01T00:00:02Z"),
185+
],
186+
"running",
187+
)
188+
proc = _run(bin_dir)
189+
190+
assert proc.returncode == 0
191+
assert "VERDICT CHANGES_REQUESTED" in proc.stdout

0 commit comments

Comments
 (0)