Skip to content

Commit 4f19ab4

Browse files
fix(scripts): pre-merge gate reads issue comments, not just review comments (#1549)
* fix(scripts): pre-merge gate reads issue comments, not just review comments Codex posts clean verdicts ("Didn't find any major issues") as ISSUE comments; pulls/{n}/comments returns only inline review comments, so clean-verdict PRs were blocked forever despite an explicit sign-off, contradicting the script's own documented contract (reviews, comments, check runs). Verified against PR #1543 and #1545: BLOCKED -> OK. * test(scripts): teach the pre-merge gh stub the issue-comments endpoint The fake gh fell through to 'unexpected gh invocation' on the new issues/{n}/comments lookup, failing all allow-merge scenarios (codex review on #1549). Adds the endpoint plus two scenarios: a codex clean-verdict issue comment satisfies the gate (the exact case the fix exists for), and an unreadable issue-comments feed blocks. * fix(scripts): require fresh clean-verdict codex issue comments Issue comments are PR-wide, not head-scoped: a clean Codex verdict posted before the latest push counted as reviewer activity for the new head. The gate now keeps only issue comments created after the head commit and, for Codex, requires the body to be a clean verdict — findings arrive as review comments, so only the sign-off is an issue comment. Adds stale and non-verdict scenarios to the gh stub. * fix(scripts): pin codex issue-comment verdicts to the head SHA Timestamp comparison against the head commit's committer date cannot prove a verdict reviewed this SHA (committer dates survive cherry-picks and rebases) and the unconditional commits API call blocked the gate on transient failures even when no issue comments existed. Codex embeds 'Reviewed commit: <short sha>' in its clean verdicts, so the gate now counts an issue comment only when it is a codex clean verdict whose embedded SHA is a prefix of the current head — no timestamps, no extra API call, and non-codex issue comments never count. * fix(scripts): extract the reviewed-commit SHA instead of substring search Searching for the head's first ten characters anywhere in the body could accept a verdict whose prose merely mentions the SHA and rejects verdicts where codex embeds a shorter short SHA. The gate now extracts the hex run following the 'Reviewed commit' label (>= 7 chars, git's short-SHA floor, markdown decoration tolerated) and requires it to be a prefix of the current head. Stub head is now a realistic 40-char SHA; adds short-SHA acceptance and SHA-in-prose rejection scenarios. * fix(scripts): skip unpinned codex verdicts instead of aborting the gate Under set -euo pipefail, the SHA-extraction grep pipeline exits nonzero when a clean verdict has no 'Reviewed commit' label, killing the whole gate. Tolerate the no-match case with || true so legacy unpinned verdicts are simply never counted while reviews and check runs still satisfy the reviewer requirement.
1 parent 3a3e03f commit 4f19ab4

2 files changed

Lines changed: 162 additions & 4 deletions

File tree

scripts/pre-merge-check.sh

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,16 @@ if ! COMMENTS=$(gh api "repos/${REPO}/pulls/${PR_NUMBER}/comments" --paginate --
108108
exit 1
109109
fi
110110

111+
# `pulls/{n}/comments` returns only inline REVIEW comments. When Codex finds
112+
# no issues it posts its verdict ("Didn't find any major issues") as an ISSUE
113+
# comment, which lives on `issues/{n}/comments` — without this, clean-verdict
114+
# PRs are blocked forever even though the reviewer explicitly signed off.
115+
# Body newlines/tabs are flattened so each comment stays one TSV row.
116+
if ! ISSUE_COMMENTS_RAW=$(gh api "repos/${REPO}/issues/${PR_NUMBER}/comments" --paginate --jq '.[] | [.user.login, (.body // "" | gsub("[\r\n\t]"; " "))] | @tsv' 2>/dev/null); then
117+
echo "[pre-merge] BLOCKED: Failed to read PR issue comments from GitHub."
118+
exit 1
119+
fi
120+
111121
# Some bots (Cursor Bugbot, Kilo Code Review) post as check runs via GitHub
112122
# Apps rather than as PR comments. A completed check run counts as reviewer
113123
# activity. The app.slug field maps to reviewer aliases (e.g. "cursor" matches
@@ -126,7 +136,30 @@ if [[ -n "$HEAD_SHA" ]]; then
126136
fi
127137
fi
128138

129-
ALL_REVIEWERS=$(printf '%s\n%s\n' "$REVIEWS" "$COMMENTS" | sort -u)
139+
# Issue comments are PR-wide, not head-scoped, and this gate reads them for
140+
# exactly one reason: Codex posts its clean verdict ("Didn't find any major
141+
# issues … **Reviewed commit:** \`<short sha>\`") as an issue comment. Count
142+
# ONLY those, and only when the SHA embedded after the "Reviewed commit"
143+
# label is a prefix (>= 7 hex chars, git's short-SHA floor) of the CURRENT
144+
# head. Timestamps cannot prove a verdict reviewed this SHA (committer dates
145+
# survive cherry-picks and rebases); the extracted SHA pin can. Every other
146+
# issue comment is conversation, never reviewer activity.
147+
ISSUE_COMMENTERS=""
148+
while IFS=$'\t' read -r ic_login ic_body; do
149+
[[ "$ic_login" == "chatgpt-codex-connector[bot]" ]] || continue
150+
grep -qiE "find any major issues|no major issues" <<< "$ic_body" || continue
151+
# Extract the hex run following the "Reviewed commit" label, tolerating
152+
# markdown decoration (bold markers, backticks, colon) between the two.
153+
# `|| true` keeps errexit/pipefail from aborting the gate on comments with
154+
# no label (legacy unpinned verdicts) — those are simply never counted.
155+
ic_sha=$(grep -oiE "reviewed commit[^0-9a-fA-F]*[0-9a-fA-F]{7,40}" <<< "$ic_body" \
156+
| grep -oE "[0-9a-fA-F]{7,40}" | head -n 1 | tr '[:upper:]' '[:lower:]' || true)
157+
if [[ -n "$ic_sha" && -n "$HEAD_SHA" && "$HEAD_SHA" == "$ic_sha"* ]]; then
158+
ISSUE_COMMENTERS+="${ic_login}"$'\n'
159+
fi
160+
done <<< "$ISSUE_COMMENTS_RAW"
161+
162+
ALL_REVIEWERS=$(printf '%s\n%s\n%s\n' "$REVIEWS" "$COMMENTS" "$ISSUE_COMMENTERS" | sort -u)
130163

131164
has_reviewer_check_run() {
132165
local reviewer="$1"

tests/pre-merge-check.test.mjs

Lines changed: 128 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ if [[ "$1" == "api" && "$2" == "graphql" ]]; then
4242
malformed_page:1)
4343
printf '5\\t0\\n'
4444
;;
45-
cursor_check_ok:1|unrelated_cursor_check:1|all_required_check_runs_ok:1)
45+
cursor_check_ok:1|unrelated_cursor_check:1|all_required_check_runs_ok:1|codex_issue_comment_ok:1|codex_issue_comment_short_sha:1|codex_issue_comment_stale:1|codex_issue_comment_not_verdict:1|codex_verdict_sha_in_prose:1|codex_verdict_unpinned:1|generic_issue_comment_ignored:1|issue_comments_fail:1)
4646
printf '3\\t0\\tfalse\\t\\n'
4747
;;
4848
repeated_cursor:1)
@@ -68,6 +68,10 @@ if [[ "$1" == "api" && "$2" == "repos/example/repo/pulls/7/reviews" ]]; then
6868
printf 'chatgpt-codex-connector[bot]\\n'
6969
exit 0
7070
fi
71+
if [[ "$GH_STUB_SCENARIO" == codex_issue_comment_* || "$GH_STUB_SCENARIO" == "codex_verdict_sha_in_prose" || "$GH_STUB_SCENARIO" == "generic_issue_comment_ignored" ]]; then
72+
printf 'cursor[bot]\\n'
73+
exit 0
74+
fi
7175
if [[ "$GH_STUB_SCENARIO" == "all_required_check_runs_ok" ]]; then
7276
exit 0
7377
fi
@@ -79,16 +83,52 @@ if [[ "$1" == "api" && "$2" == "repos/example/repo/pulls/7/comments" ]]; then
7983
exit 0
8084
fi
8185
86+
if [[ "$1" == "api" && "$2" == "repos/example/repo/issues/7/comments" ]]; then
87+
if [[ "$GH_STUB_SCENARIO" == "issue_comments_fail" ]]; then
88+
echo "issue comments unavailable" >&2
89+
exit 3
90+
fi
91+
if [[ "$GH_STUB_SCENARIO" == "codex_issue_comment_ok" ]]; then
92+
printf "chatgpt-codex-connector[bot]\\tCodex Review: Didn't find any major issues. **Reviewed commit:** deadbeef12\\n"
93+
exit 0
94+
fi
95+
if [[ "$GH_STUB_SCENARIO" == "codex_issue_comment_short_sha" ]]; then
96+
printf "chatgpt-codex-connector[bot]\\tCodex Review: Didn't find any major issues. Reviewed commit: deadbee\\n"
97+
exit 0
98+
fi
99+
if [[ "$GH_STUB_SCENARIO" == "codex_issue_comment_stale" ]]; then
100+
printf "chatgpt-codex-connector[bot]\\tCodex Review: Didn't find any major issues. **Reviewed commit:** cafebabe12\\n"
101+
exit 0
102+
fi
103+
if [[ "$GH_STUB_SCENARIO" == "codex_issue_comment_not_verdict" ]]; then
104+
printf "chatgpt-codex-connector[bot]\\tFound 2 major issues in the diff. **Reviewed commit:** deadbeef12\\n"
105+
exit 0
106+
fi
107+
if [[ "$GH_STUB_SCENARIO" == "codex_verdict_sha_in_prose" ]]; then
108+
printf "chatgpt-codex-connector[bot]\\tCodex Review: Didn't find any major issues. Compare with deadbeef12 later. **Reviewed commit:** cafebabe12\\n"
109+
exit 0
110+
fi
111+
if [[ "$GH_STUB_SCENARIO" == "codex_verdict_unpinned" ]]; then
112+
printf "chatgpt-codex-connector[bot]\\tCodex Review: Didn't find any major issues. Hooray!\\n"
113+
exit 0
114+
fi
115+
if [[ "$GH_STUB_SCENARIO" == "generic_issue_comment_ignored" ]]; then
116+
printf "someuser\\tLooks good to me! Reviewed commit: deadbeef12\\n"
117+
exit 0
118+
fi
119+
exit 0
120+
fi
121+
82122
if [[ "$1" == "pr" && "$2" == "view" ]]; then
83123
if [[ "$*" != *"--repo example/repo"* ]]; then
84124
echo "gh pr view must pass --repo" >&2
85125
exit 5
86126
fi
87-
printf 'deadbeef\\n'
127+
printf 'deadbeef1234567890abcdef1234567890abcdef\\n'
88128
exit 0
89129
fi
90130
91-
if [[ "$1" == "api" && "$2" == "repos/example/repo/commits/deadbeef/check-runs" ]]; then
131+
if [[ "$1" == "api" && "$2" == "repos/example/repo/commits/deadbeef1234567890abcdef1234567890abcdef/check-runs" ]]; then
92132
if [[ "$GH_STUB_SCENARIO" == "cursor_check_ok" ]]; then
93133
printf 'cursor\\tCursor Bugbot\\n'
94134
exit 0
@@ -194,6 +234,91 @@ test("pre-merge check fails closed when GitHub pagination does not advance", asy
194234
});
195235
});
196236

237+
test("pre-merge check accepts a codex clean-verdict ISSUE comment as reviewer activity", async () => {
238+
// Codex posts "no major issues" verdicts as issue comments, not reviews or
239+
// review comments — the endpoint gap this PR fixes.
240+
await withGhStub("codex_issue_comment_ok", async (env, countPath) => {
241+
const result = runPreMergeCheck(env);
242+
243+
assert.equal(result.status, 0, result.stderr);
244+
assert.match(result.stdout, /OK: All reviewers posted/);
245+
assert.equal((await readFile(countPath, "utf8")).trim(), "1");
246+
});
247+
});
248+
249+
test("pre-merge check accepts codex verdicts with a short (7-char) reviewed-commit SHA", async () => {
250+
// Codex controls its own short-SHA length; the gate must accept any git
251+
// short SHA (>= 7 hex chars) that is a prefix of the current head.
252+
await withGhStub("codex_issue_comment_short_sha", async (env) => {
253+
const result = runPreMergeCheck(env);
254+
255+
assert.equal(result.status, 0, result.stderr);
256+
assert.match(result.stdout, /OK: All reviewers posted/);
257+
});
258+
});
259+
260+
test("pre-merge check anchors the SHA pin to the Reviewed commit label, not prose", async () => {
261+
// The head SHA appearing elsewhere in the body must not satisfy the pin
262+
// when the verdict's own "Reviewed commit" points at a different SHA.
263+
await withGhStub("codex_verdict_sha_in_prose", async (env) => {
264+
const result = runPreMergeCheck(env);
265+
266+
assert.equal(result.status, 1);
267+
assert.match(result.stdout, /Missing reviews from: chatgpt-codex-connector\[bot\]/);
268+
});
269+
});
270+
271+
test("pre-merge check ignores unpinned codex verdicts without aborting the gate", async () => {
272+
// A legacy clean verdict with no "Reviewed commit" label must be skipped —
273+
// not crash the errexit/pipefail script — so reviewers satisfied via
274+
// reviews/check runs still pass.
275+
await withGhStub("codex_verdict_unpinned", async (env) => {
276+
const result = runPreMergeCheck(env);
277+
278+
assert.equal(result.status, 0, result.stderr);
279+
assert.match(result.stdout, /OK: All reviewers posted/);
280+
});
281+
});
282+
283+
test("pre-merge check rejects codex verdicts pinned to a different commit", async () => {
284+
// A clean verdict earned on a previous head must not carry over to new
285+
// commits — the comment feed is PR-wide, not SHA-scoped. The verdict's
286+
// embedded "Reviewed commit" SHA must be a prefix of the current head.
287+
await withGhStub("codex_issue_comment_stale", async (env) => {
288+
const result = runPreMergeCheck(env);
289+
290+
assert.equal(result.status, 1);
291+
assert.match(result.stdout, /Missing reviews from: chatgpt-codex-connector\[bot\]/);
292+
});
293+
});
294+
295+
test("pre-merge check ignores codex issue comments that are not clean verdicts", async () => {
296+
await withGhStub("codex_issue_comment_not_verdict", async (env) => {
297+
const result = runPreMergeCheck(env);
298+
299+
assert.equal(result.status, 1);
300+
assert.match(result.stdout, /Missing reviews from: chatgpt-codex-connector\[bot\]/);
301+
});
302+
});
303+
304+
test("pre-merge check never counts non-codex issue comments as reviewer activity", async () => {
305+
await withGhStub("generic_issue_comment_ignored", async (env) => {
306+
const result = runPreMergeCheck(env);
307+
308+
assert.equal(result.status, 1);
309+
assert.match(result.stdout, /Missing reviews from: chatgpt-codex-connector\[bot\]/);
310+
});
311+
});
312+
313+
test("pre-merge check blocks when issue comments cannot be read", async () => {
314+
await withGhStub("issue_comments_fail", async (env) => {
315+
const result = runPreMergeCheck(env);
316+
317+
assert.equal(result.status, 1);
318+
assert.match(result.stdout + result.stderr, /Failed to read PR issue comments/);
319+
});
320+
});
321+
197322
test("pre-merge check accepts the Cursor Bugbot check run as reviewer activity", async () => {
198323
await withGhStub("cursor_check_ok", async (env, countPath) => {
199324
const result = runPreMergeCheck(env);

0 commit comments

Comments
 (0)