3232# --verdict <v> override the Paperclip verdict lookup (testing).
3333# --dry-run do everything except POST the review (and print the plan).
3434#
35- # Exit codes: 0 ok/approved/dry-run; 2 not configured; 3 verdict not accept
36- # (no-op); 4 author==approver guard; 5 GitHub/API error.
35+ # Safe to run repeatedly (idempotent): no-ops if the PR is closed/merged, if the
36+ # verdict isn't ACCEPT, if the Gatekeeper already approved the current head, or
37+ # if Codex's reviewed SHA != the current head (stale → needs re-review).
38+ #
39+ # Exit codes: 0 ok/approved/dry-run/no-op; 2 not configured; 3 verdict not accept;
40+ # 5 GitHub/API error; 6 stale verdict (head moved past reviewed SHA).
3741# ─────────────────────────────────────────────────────────────────────────────
3842set -euo pipefail
3943
5963
6064log () { echo " [gatekeeper] $* " >&2 ; }
6165
62- # ── 1. Resolve the Codex review verdict for this PR from Paperclip ───────────
66+ # ── 1. Resolve the Codex review verdict (+reviewed SHA) from Paperclip ───────
67+ # Emits "<verdict> <reviewed_sha_or_empty>".
6368resolve_verdict () {
64- if [[ -n " $VERDICT_OVERRIDE " ]]; then echo " $VERDICT_OVERRIDE " ; return ; fi
69+ if [[ -n " $VERDICT_OVERRIDE " ]]; then echo " $VERDICT_OVERRIDE " ; return ; fi
6570 # Reads are fine unauthenticated against the local instance.
6671 curl -fsS -m 15 " ${PAPERCLIP_API_URL} /api/companies/${PAPERCLIP_COMPANY_ID} /issues?q=Review%20PR%20%23${PR} " 2> /dev/null \
6772 | PR=" $PR " CODEX=" $CODEX_REVIEWER_AGENT_ID " BASE=" $PAPERCLIP_API_URL " python3 -c '
68- import sys, json, os, urllib.request
73+ import sys, json, os, re, urllib.request
6974pr, codex, base = os.environ["PR"], os.environ["CODEX"], os.environ["BASE"]
7075iss = json.load(sys.stdin)
7176iss = iss if isinstance(iss, list) else iss.get("issues", iss.get("data", []))
72- # Codex review tasks for this PR (any title that references "PR #<n>"),
73- # assigned to the Codex reviewer, newest first (round-2 supersedes round-1).
77+ # Codex review tasks for this PR (title references "PR #<n>"), assigned to the
78+ # Codex reviewer, newest first (round-2 supersedes round-1).
7479needle = "PR #%s" % pr
7580revs = [i for i in iss
7681 if needle in (i.get("title") or "") and i.get("assigneeAgentId") == codex]
7782revs.sort(key=lambda x: x.get("createdAt", ""), reverse=True)
78- verdict = "unknown"
83+ verdict, sha = "unknown", " "
7984for i in revs:
8085 url = "%s/api/issues/%s/comments" % (base, i.get("id"))
8186 try:
@@ -84,17 +89,20 @@ for i in revs:
8489 continue
8590 c = c if isinstance(c, list) else c.get("comments", c.get("data", []))
8691 for cm in reversed(c):
87- b = (cm.get("body") or "").lower()
92+ body = cm.get("body") or ""
93+ b = body.lower()
8894 if "verdict:" not in b:
8995 continue
9096 tail = b.split("verdict:", 1)[1][:40]
97+ shas = re.findall(r"\b[0-9a-f]{40}\b", body) # SHA the reviewer pinned
98+ sha = shas[-1] if shas else ""
9199 if "accept" in tail:
92100 verdict = "accept"; break
93101 if "request_changes" in tail or "request changes" in tail:
94102 verdict = "request_changes"; break
95103 if verdict != "unknown":
96104 break
97- print(verdict)
105+ print(verdict, sha )
98106'
99107}
100108
@@ -117,18 +125,49 @@ mint_installation_token() {
117125}
118126
119127# ── main ────────────────────────────────────────────────────────────────────
120- VERDICT= " $( resolve_verdict || echo unknown) "
121- log " PR #${PR} Codex verdict = ${VERDICT} "
128+ read -r VERDICT REVIEWED_SHA <<< " $(resolve_verdict || echo ' unknown ' )"
129+ log " PR #${PR} Codex verdict = ${VERDICT}${REVIEWED_SHA : + (reviewed ${REVIEWED_SHA : 0 : 9} )} "
122130
123131if [[ " $VERDICT " != " accept" ]]; then
124132 log " verdict is not 'accept' — NOT approving (no-op). request_changes/unknown must not auto-approve."
125133 exit 3
126134fi
127135
128- # Author != approver guard. The App identity is inherently != the PR author,
129- # but verify the PR author is not somehow the bot (defense in depth).
130- AUTHOR=" $( gh pr view " $PR " --repo " $REPO " --json author -q .author.login 2> /dev/null || echo ' ?' ) "
131- log " PR #${PR} author = ${AUTHOR} (App approves as a distinct identity)"
136+ # Current PR head + author (one API read).
137+ read -r HEAD AUTHOR PR_STATE <<< " $(gh pr view " $PR " --repo " $REPO " --json headRefOid,author,state \
138+ -q '" \( .headRefOid) \( .author.login) \( .state)" ' 2>/dev/null || echo '? ? ?')"
139+ log " PR #${PR} state=${PR_STATE} head=${HEAD: 0: 9} author=${AUTHOR} (App approves as a distinct identity)"
140+
141+ # Don't act on already-closed/merged PRs.
142+ if [[ " $PR_STATE " != " OPEN" ]]; then
143+ log " PR is ${PR_STATE} — nothing to do."
144+ exit 0
145+ fi
146+
147+ # Stale-verdict guard: only approve the exact SHA Codex reviewed. If the head
148+ # moved past the reviewed commit, the verdict is stale → needs re-review.
149+ if [[ -n " $REVIEWED_SHA " && -n " $HEAD " && " $REVIEWED_SHA " != " $HEAD " ]]; then
150+ log " STALE: Codex reviewed ${REVIEWED_SHA: 0: 9} but head is ${HEAD: 0: 9} — NOT approving (re-review needed)."
151+ exit 6
152+ fi
153+
154+ # Idempotency: skip if the Gatekeeper already has an APPROVED review at this head.
155+ ALREADY=" $( gh api " repos/${REPO} /pulls/${PR} /reviews" 2> /dev/null \
156+ | HEAD=" $HEAD " python3 -c '
157+ import sys, json, os
158+ head = os.environ.get("HEAD","")
159+ try: revs = json.load(sys.stdin)
160+ except Exception: revs = []
161+ for r in revs:
162+ u = (r.get("user") or {})
163+ if u.get("type") == "Bot" and "gatekeeper" in (u.get("login","").lower()) \
164+ and r.get("state") == "APPROVED" and (not head or r.get("commit_id") == head):
165+ print("yes"); break
166+ ' 2> /dev/null) "
167+ if [[ " $ALREADY " == " yes" ]]; then
168+ log " already approved by Gatekeeper at head ${HEAD: 0: 9} — no-op (idempotent)."
169+ exit 0
170+ fi
132171
133172if [[ -z " ${GATEKEEPER_APP_ID:- } " || -z " ${GATEKEEPER_INSTALLATION_ID:- } " || -z " ${GATEKEEPER_PRIVATE_KEY:- } " ]]; then
134173 log " NOT CONFIGURED: set GATEKEEPER_APP_ID, GATEKEEPER_INSTALLATION_ID, GATEKEEPER_PRIVATE_KEY"
0 commit comments