Skip to content

Commit 1715a81

Browse files
fix(scripts): count a codex approving reaction on the PR body as sign-off (#1568)
* fix(scripts): count a codex approving reaction on the PR body as sign-off pre-merge-check.sh detected reviewer activity via reviews, PR comments, SHA-pinned codex issue-comment verdicts, and reviewer check runs — but not reactions. Codex (chatgpt-codex-connector[bot]) often signs off on a clean PR by leaving a thumbs-up (+1) reaction on the PR description instead of posting a review or comment, so such PRs blocked forever even though the required reviewer had approved (observed on #1565). Add PR-body reaction detection: a required reviewer counts as having weighed in if they left an explicitly POSITIVE reaction (+1/heart/hooray/rocket) on the PR body. Negative/ambiguous reactions (-1, confused, eyes) never count, and a positive reaction from a non-reviewer is not credited to the reviewer. The reactions read is non-fatal — a failure falls back to the other detection paths rather than crashing the gate. Extend tests/pre-merge-check.test.mjs: codex +1 signs off; negative reactions and other users' reactions still block; a reactions-endpoint failure degrades to a missing-reviewer verdict rather than an API-read crash. * fix(scripts): bind reaction sign-off to the current head commit Codex (P1 on #1568): a PR-body reaction carries no commit SHA and GitHub does not dismiss it on new pushes, so a +1 left on an earlier revision would keep satisfying the required reviewer for a newer diff it never saw — bypassing the current-head protection the issue-comment verdict path enforces via SHA pinning. Reactions can't be SHA-pinned, so bind by time: only count a reaction created at/after the head commit's committer date. A commit pushed after the +1 has a newer committer date and invalidates the stale reaction; a rebase rewrites committer dates and does the same — both fail closed and force a fresh sign-off. If the head commit date can't be read, no reaction counts. Timestamp compare is byte-wise under LC_ALL=C on GitHub's fixed-format ISO-8601 UTC values. Residual (documented): cherry-picking a commit with an OLD committer date as the new head could re-admit a reaction in that window; the SHA-pinned issue-comment path stays the strong signal. Tests: reject a reaction predating the head commit; fail closed when the head commit date is unknown. * fix(scripts): bind reaction sign-off to head push time, not committer date Codex (2nd P1 on #1568): committer date is not push-visibility time. A commit amended/created locally at 10:00, a +1 left at 10:05 on the still-old head, then the commit pushed at 10:10 — the reaction is not before the committer date (10:00) so the previous fix still credited a reaction placed before the new SHA was ever visible for review. Bind instead to when the head became visible on the PR: HEAD_VISIBLE_AT = earliest check-suite created_at for the head SHA (a push-time signal, stable across job re-runs, and — unlike author/committer date — not backdatable by a local amend or cherry-pick). A reaction counts only if created at/after that. GitHub's Commit.pushedDate was the obvious field but is null in practice, so the check-suite creation time is the reliable push marker. If it can't be read (no suite yet / API failure), no reaction is credited — fail closed. Tests updated: stale reaction now means "placed before the head's check-suite (push) time"; the fail-closed scenario keys off missing push time. * docs(scripts): document the accepted reaction-freshness residual Repo-owner decision on codex's 3rd P1: check-suite created_at is keyed to the SHA, not to when it became this PR's head, and GitHub exposes no clean per-PR "head advanced at" timestamp (Commit.pushedDate is null in practice). The remaining bypass needs the identical SHA pushed to two branches with an adversarially-timed +1 — impossible in this repo's one-branch-per-PR flow. The SHA-pinned issue-comment verdict stays the strong signal; reactions are a convenience sign-off, not the security boundary. Document the residual explicitly rather than drop reaction support (no reliable PR-scoped push signal exists).
1 parent 35b6f38 commit 1715a81

2 files changed

Lines changed: 188 additions & 5 deletions

File tree

scripts/pre-merge-check.sh

Lines changed: 83 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,12 @@ set -euo pipefail
99
# AI reviewers had time to post reviews. This script blocks merging until
1010
# reviewers have weighed in and all threads are resolved.
1111
#
12-
# Reviewer activity is detected via PR reviews, PR comments, and completed
13-
# check runs (Cursor Bugbot and Kilo Code Review run as GitHub App checks).
12+
# Reviewer activity is detected via PR reviews, PR comments, completed check
13+
# runs (Cursor Bugbot and Kilo Code Review run as GitHub App checks), and an
14+
# approving reaction on the PR body. Codex (chatgpt-codex-connector[bot]) often
15+
# signs off on a clean PR by leaving a thumbs-up (+1) reaction on the PR
16+
# description rather than posting a review or comment — without reaction
17+
# detection those PRs block forever even though the reviewer approved.
1418

1519
PR_NUMBER="${1:?Usage: scripts/pre-merge-check.sh <PR_NUMBER>}"
1620
REPO="${REMNIC_REPO:-joshuaswarren/remnic}"
@@ -127,13 +131,80 @@ if ! HEAD_SHA=$(gh pr view "$PR_NUMBER" --repo "$REPO" --json headRefOid --jq .h
127131
exit 1
128132
fi
129133
CHECK_RUNS=""
134+
HEAD_VISIBLE_AT=""
130135
if [[ -n "$HEAD_SHA" ]]; then
131136
if ! CHECK_RUNS=$(gh api "repos/${REPO}/commits/${HEAD_SHA}/check-runs" \
132137
--jq '.check_runs[] | select(.conclusion == "success" or .conclusion == "failure" or .conclusion == "neutral") | [.app.slug, .name] | @tsv' \
133138
2>/dev/null); then
134139
echo "[pre-merge] BLOCKED: Failed to read PR check runs from GitHub."
135140
exit 1
136141
fi
142+
# When did this exact SHA become visible on the PR? The earliest check-suite
143+
# created_at for the head commit marks when the push landed and CI kicked off
144+
# — a push-time signal (unlike the commit's committer/author date, which a
145+
# local amend or cherry-pick can backdate). It is also stable across job
146+
# re-runs (the suite is not recreated). Used to reject reaction sign-offs that
147+
# predate the current head being pushed (see the reactions block). Empty when
148+
# no check suite exists yet or the read fails → reactions fail closed.
149+
HEAD_VISIBLE_AT=$(gh api "repos/${REPO}/commits/${HEAD_SHA}/check-suites" \
150+
--jq '[.check_suites[].created_at] | min // empty' 2>/dev/null || true)
151+
fi
152+
153+
# Reviewer approval via a reaction on the PR body. Codex signs off on clean PRs
154+
# with a thumbs-up (+1) reaction on the PR description instead of a review or
155+
# comment. Only count explicitly POSITIVE reactions as a sign-off — a "-1" or
156+
# "confused" reaction is the opposite of approval, and "eyes" means "still
157+
# looking", so none of those count.
158+
#
159+
# CURRENT-HEAD BINDING: a reaction carries no commit SHA and GitHub does NOT
160+
# dismiss it when new commits are pushed, so a bare reaction would let a +1 left
161+
# on an earlier revision satisfy the reviewer for a newer diff it never saw.
162+
# Reactions cannot be SHA-pinned like issue-comment verdicts, so we bind to when
163+
# the current head became visible on the PR (HEAD_VISIBLE_AT = earliest
164+
# check-suite created_at for the head SHA — a push-time signal, not the commit's
165+
# author/committer date, which a local amend or cherry-pick can backdate). A
166+
# reaction only counts when it was created at/after the head was pushed; a +1
167+
# left before the current SHA landed no longer counts, forcing a fresh sign-off.
168+
# If head visibility cannot be proven (no check suite yet, or the read failed),
169+
# no reaction is credited — fail closed. A reactions read failure is likewise
170+
# non-fatal: we fall back to the other detection paths rather than block the gate.
171+
#
172+
# ACCEPTED RESIDUAL (deliberate, repo-owner decision): check-suite created_at is
173+
# keyed to the SHA, not to the moment that SHA became THIS PR's head, and GitHub
174+
# exposes no clean per-PR "head advanced at" timestamp (Commit.pushedDate is null
175+
# in practice). So if the identical SHA was already pushed to another branch —
176+
# creating its check suite earlier — a +1 placed on the old PR revision after
177+
# that suite but before the PR fast-forwards to the SHA could still be credited.
178+
# This requires an adversarial same-SHA-on-two-branches race that does not occur
179+
# in this repo's one-branch-per-PR flow; the SHA-pinned issue-comment verdict
180+
# remains the strong signal. Reactions are a convenience sign-off, not the
181+
# security boundary. Tightening this further would require dropping reaction
182+
# support entirely (no reliable PR-scoped push signal exists).
183+
#
184+
# Byte-wise "is $1 strictly before $2" for two GitHub ISO-8601 UTC (…Z)
185+
# timestamps. Runs in a subshell so LC_ALL=C stays scoped; the fixed-width
186+
# format makes a byte comparison chronological.
187+
iso_before() ( LC_ALL=C; [[ "$1" < "$2" ]] )
188+
189+
POSITIVE_REACTIONS_RE='^(\+1|heart|hooray|rocket)$'
190+
POSITIVE_REACTORS=""
191+
if [[ -z "$HEAD_VISIBLE_AT" ]]; then
192+
echo "[pre-merge] NOTE: Head push time unknown; PR-body reactions will not count as sign-off."
193+
elif BODY_REACTIONS=$(gh api "repos/${REPO}/issues/${PR_NUMBER}/reactions" --paginate \
194+
-H "Accept: application/vnd.github+json" \
195+
--jq '.[] | [.user.login, .content, .created_at] | @tsv' 2>/dev/null); then
196+
while IFS=$'\t' read -r rx_login rx_content rx_created; do
197+
[[ -n "$rx_login" ]] || continue
198+
[[ "$rx_content" =~ $POSITIVE_REACTIONS_RE ]] || continue
199+
# Reject reactions placed before the current head was pushed (stale sign-off).
200+
[[ -n "$rx_created" ]] || continue
201+
if iso_before "$rx_created" "$HEAD_VISIBLE_AT"; then
202+
continue
203+
fi
204+
POSITIVE_REACTORS+="${rx_login}"$'\n'
205+
done <<< "$BODY_REACTIONS"
206+
else
207+
echo "[pre-merge] NOTE: Could not read PR body reactions; relying on reviews/comments/checks."
137208
fi
138209

139210
# Issue comments are PR-wide, not head-scoped, and this gate reads them for
@@ -188,11 +259,20 @@ has_reviewer_check_run() {
188259
return 1
189260
}
190261

262+
# A required reviewer counts as having signed off if they left a positive
263+
# reaction on the PR body (exact, case-insensitive login match).
264+
has_reviewer_positive_reaction() {
265+
local reviewer="$1"
266+
[[ -n "$POSITIVE_REACTORS" ]] || return 1
267+
grep -qxiF "$reviewer" <<< "$POSITIVE_REACTORS"
268+
}
269+
191270
MISSING_REVIEWERS=()
192271
for reviewer in "${REQUIRED_REVIEWERS[@]}"; do
193272
# Use exact line match (-x) to avoid substring false positives.
194273
if ! echo "$ALL_REVIEWERS" | grep -qxiF "$reviewer" && \
195-
! has_reviewer_check_run "$reviewer"; then
274+
! has_reviewer_check_run "$reviewer" && \
275+
! has_reviewer_positive_reaction "$reviewer"; then
196276
MISSING_REVIEWERS+=("$reviewer")
197277
fi
198278
done

tests/pre-merge-check.test.mjs

Lines changed: 105 additions & 2 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|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)
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|codex_reaction_ok:1|codex_reaction_stale:1|codex_reaction_negative:1|reaction_wrong_user:1|reactions_read_fail:1|reaction_head_date_missing:1)
4646
printf '3\\t0\\tfalse\\t\\n'
4747
;;
4848
repeated_cursor:1)
@@ -68,7 +68,7 @@ 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
71+
if [[ "$GH_STUB_SCENARIO" == codex_issue_comment_* || "$GH_STUB_SCENARIO" == "codex_verdict_sha_in_prose" || "$GH_STUB_SCENARIO" == "generic_issue_comment_ignored" || "$GH_STUB_SCENARIO" == codex_reaction_* || "$GH_STUB_SCENARIO" == "reaction_wrong_user" || "$GH_STUB_SCENARIO" == "reactions_read_fail" || "$GH_STUB_SCENARIO" == "reaction_head_date_missing" ]]; then
7272
printf 'cursor[bot]\\n'
7373
exit 0
7474
fi
@@ -119,6 +119,42 @@ if [[ "$1" == "api" && "$2" == "repos/example/repo/issues/7/comments" ]]; then
119119
exit 0
120120
fi
121121
122+
# Head-visibility time = earliest check-suite created_at for the head SHA. Used
123+
# to reject reaction sign-offs placed before the current head was pushed.
124+
if [[ "$1" == "api" && "$2" == "repos/example/repo/commits/deadbeef1234567890abcdef1234567890abcdef/check-suites" ]]; then
125+
if [[ "$GH_STUB_SCENARIO" == "reaction_head_date_missing" ]]; then
126+
exit 0 # empty output -> HEAD_VISIBLE_AT unknown -> fail closed
127+
fi
128+
printf '2026-06-01T00:00:00Z\\n'
129+
exit 0
130+
fi
131+
132+
# PR-body reactions: login <TAB> content <TAB> created_at. Head-visibility time
133+
# in the stub is 2026-06-01T00:00:00Z, so "…06-02…" is fresh and "…05-01…" is
134+
# stale (placed before the head was pushed).
135+
if [[ "$1" == "api" && "$2" == "repos/example/repo/issues/7/reactions" ]]; then
136+
case "$GH_STUB_SCENARIO" in
137+
reactions_read_fail)
138+
echo "reactions unavailable" >&2
139+
exit 3
140+
;;
141+
codex_reaction_ok|reaction_head_date_missing)
142+
printf 'chatgpt-codex-connector[bot]\\t+1\\t2026-06-02T00:00:00Z\\n'
143+
;;
144+
codex_reaction_stale)
145+
printf 'chatgpt-codex-connector[bot]\\t+1\\t2026-05-01T00:00:00Z\\n'
146+
;;
147+
codex_reaction_negative)
148+
printf 'chatgpt-codex-connector[bot]\\tconfused\\t2026-06-02T00:00:00Z\\n'
149+
printf 'chatgpt-codex-connector[bot]\\t-1\\t2026-06-02T00:00:00Z\\n'
150+
;;
151+
reaction_wrong_user)
152+
printf 'someuser\\t+1\\t2026-06-02T00:00:00Z\\n'
153+
;;
154+
esac
155+
exit 0
156+
fi
157+
122158
if [[ "$1" == "pr" && "$2" == "view" ]]; then
123159
if [[ "$*" != *"--repo example/repo"* ]]; then
124160
echo "gh pr view must pass --repo" >&2
@@ -348,3 +384,70 @@ test("pre-merge check rejects unrelated checks from a matching app slug", async
348384
assert.equal((await readFile(countPath, "utf8")).trim(), "1");
349385
});
350386
});
387+
388+
test("pre-merge check accepts a codex thumbs-up reaction on the PR body as sign-off", async () => {
389+
// Codex often signs off on a clean PR with a +1 reaction on the PR
390+
// description rather than a review/comment/check run — the gap this fix
391+
// closes. cursor[bot] is satisfied via its review; codex only via the
392+
// reaction.
393+
await withGhStub("codex_reaction_ok", async (env) => {
394+
const result = runPreMergeCheck(env);
395+
396+
assert.equal(result.status, 0, result.stderr);
397+
assert.match(result.stdout, /OK: All reviewers posted/);
398+
});
399+
});
400+
401+
test("pre-merge check rejects a codex reaction placed before the head was pushed", async () => {
402+
// A +1 on an earlier revision must not satisfy the reviewer once a newer
403+
// commit is pushed — the reaction predates the head's check-suite (push) time.
404+
await withGhStub("codex_reaction_stale", async (env) => {
405+
const result = runPreMergeCheck(env);
406+
407+
assert.equal(result.status, 1);
408+
assert.match(result.stdout, /Missing reviews from: chatgpt-codex-connector\[bot\]/);
409+
});
410+
});
411+
412+
test("pre-merge check does not count reactions when the head push time is unknown", async () => {
413+
// Fail closed: without a push-visibility timestamp there is no way to prove a
414+
// reaction is fresh, so even a positive codex reaction must not sign off.
415+
await withGhStub("reaction_head_date_missing", async (env) => {
416+
const result = runPreMergeCheck(env);
417+
418+
assert.equal(result.status, 1);
419+
assert.match(result.stdout, /Head push time unknown/);
420+
assert.match(result.stdout, /Missing reviews from: chatgpt-codex-connector\[bot\]/);
421+
});
422+
});
423+
424+
test("pre-merge check ignores negative codex reactions (confused/-1) as sign-off", async () => {
425+
await withGhStub("codex_reaction_negative", async (env) => {
426+
const result = runPreMergeCheck(env);
427+
428+
assert.equal(result.status, 1);
429+
assert.match(result.stdout, /Missing reviews from: chatgpt-codex-connector\[bot\]/);
430+
});
431+
});
432+
433+
test("pre-merge check never credits a reviewer for another user's positive reaction", async () => {
434+
await withGhStub("reaction_wrong_user", async (env) => {
435+
const result = runPreMergeCheck(env);
436+
437+
assert.equal(result.status, 1);
438+
assert.match(result.stdout, /Missing reviews from: chatgpt-codex-connector\[bot\]/);
439+
});
440+
});
441+
442+
test("pre-merge check degrades gracefully when reactions cannot be read", async () => {
443+
// A reactions-endpoint failure is non-fatal: the gate falls back to the
444+
// other detection paths (here codex is otherwise absent, so it still blocks
445+
// — but on a missing-reviewer verdict, not an API-read crash).
446+
await withGhStub("reactions_read_fail", async (env) => {
447+
const result = runPreMergeCheck(env);
448+
449+
assert.equal(result.status, 1);
450+
assert.match(result.stdout, /Could not read PR body reactions/);
451+
assert.match(result.stdout, /Missing reviews from: chatgpt-codex-connector\[bot\]/);
452+
});
453+
});

0 commit comments

Comments
 (0)