Skip to content

Commit 2faa8b9

Browse files
Pigbibiclaude
andcommitted
feat: Codex Review Gate — block merge when App requests changes
- WAIT mode: polls for Codex review up to 5 min, passes through on timeout - REACT mode: instantly updates check when Codex submits review - No API keys needed — reads App review via GitHub API - Also deploys hide-codex-limit-comments to clean up rate-limit noise Co-Authored-By: Claude <noreply@anthropic.com>
1 parent b2f0e4d commit 2faa8b9

5 files changed

Lines changed: 398 additions & 6 deletions

File tree

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
name: Codex Review Gate
2+
3+
# Two-mode gate for the Codex GitHub App (chatgpt-codex-connector):
4+
#
5+
# 1. WAIT mode (PR opened/synchronized):
6+
# Creates a pending check → polls for Codex review up to N minutes.
7+
# If Codex responds → check reflects review state.
8+
# If Codex times out → check passes (don't block when App is broken).
9+
#
10+
# 2. REACT mode (Codex submits review):
11+
# Updates check instantly — CHANGES_REQUESTED → fail, APPROVED → pass.
12+
#
13+
# No API keys needed — reads the App's review via GitHub API.
14+
15+
on:
16+
pull_request:
17+
types: [opened, synchronize, reopened, ready_for_review]
18+
pull_request_review:
19+
types: [submitted]
20+
21+
permissions:
22+
checks: write
23+
contents: read
24+
pull-requests: read
25+
26+
concurrency:
27+
group: codex-review-gate-${{ github.event.pull_request.number }}
28+
cancel-in-progress: true
29+
30+
jobs:
31+
gate:
32+
if: >
33+
github.event_name == 'pull_request_review'
34+
&& github.event.review.user.login == 'chatgpt-codex-connector[bot]'
35+
|| github.event_name == 'pull_request'
36+
&& github.event.pull_request.draft == false
37+
38+
runs-on: ubuntu-latest
39+
timeout-minutes: 15
40+
env:
41+
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
42+
CODEX_GATE_POLL_SECONDS: ${{ vars.CODEX_GATE_POLL_SECONDS || '30' }}
43+
CODEX_GATE_MAX_WAIT_MINUTES: ${{ vars.CODEX_GATE_MAX_WAIT_MINUTES || '10' }}
44+
45+
steps:
46+
- name: Checkout
47+
uses: actions/checkout@v6
48+
49+
- name: Set up Python
50+
uses: actions/setup-python@v6
51+
with:
52+
python-version: "3.11"
53+
54+
- name: Evaluate Codex Review Gate
55+
id: gate
56+
env:
57+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
58+
run: python scripts/gate_codex_app_review.py
59+
60+
- name: Upload gate diagnostics
61+
if: always()
62+
uses: actions/upload-artifact@v7
63+
with:
64+
name: codex-review-gate-${{ github.event.pull_request.number || github.run_id }}
65+
path: data/output/codex_review_gate/
66+
if-no-files-found: warn
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
name: Hide Codex Rate Limit Comments
2+
3+
on:
4+
issue_comment:
5+
types: [created]
6+
pull_request_review_comment:
7+
types: [created]
8+
9+
jobs:
10+
hide-comment:
11+
runs-on: ubuntu-latest
12+
if: >
13+
github.event.comment.user.login == 'chatgpt-codex-connector[bot]' &&
14+
(
15+
contains(github.event.comment.body, 'usage limit') ||
16+
contains(github.event.comment.body, 'rate limit') ||
17+
contains(github.event.comment.body, 'quota') ||
18+
contains(github.event.comment.body, 'limit reached') ||
19+
contains(github.event.comment.body, 'try again later')
20+
)
21+
permissions:
22+
issues: write
23+
pull-requests: write
24+
steps:
25+
- name: Minimize comment
26+
uses: actions/github-script@v8
27+
with:
28+
script: |
29+
const mutation = `
30+
mutation($id: ID!) {
31+
minimizeComment(input: {subjectId: $id, classifier: OFF_TOPIC}) {
32+
minimizedComment { isMinimized }
33+
}
34+
}
35+
`;
36+
await github.graphql(mutation, { id: context.payload.comment.node_id });
37+
console.log('Minimized codex rate-limit comment:', context.payload.comment.id);

scripts/gate_codex_app_review.py

Lines changed: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,289 @@
1+
#!/usr/bin/env python3
2+
"""Translate the Codex GitHub App's PR review into a check run for branch protection.
3+
4+
Two modes:
5+
WAIT — on PR opened/synchronize: create pending check, poll for Codex review,
6+
fall back to pass after timeout (Codex might be broken → don't block).
7+
REACT — on Codex bot review submitted: update check immediately.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import json
13+
import os
14+
import sys
15+
import time
16+
import urllib.error
17+
import urllib.request
18+
from pathlib import Path
19+
from typing import Any
20+
21+
API_BASE = "https://api.github.com"
22+
BOT_LOGIN = "chatgpt-codex-connector[bot]"
23+
CHECK_NAME = "Codex Review Gate"
24+
DETAIL_URL = "https://github.com/apps/chatgpt-codex-connector"
25+
26+
# ── helpers ──────────────────────────────────────────────────────────────────
27+
28+
29+
def env(name: str, default: str = "") -> str:
30+
return os.environ.get(name, default).strip()
31+
32+
33+
def env_int(name: str, default: int) -> int:
34+
try:
35+
return int(env(name, str(default)))
36+
except ValueError:
37+
return default
38+
39+
40+
def github_request(
41+
token: str, method: str, path: str, payload: dict[str, Any] | None = None
42+
) -> Any:
43+
url = f"{API_BASE}{path}" if not path.startswith("https://") else path
44+
data = json.dumps(payload).encode() if payload else None
45+
headers = {
46+
"Authorization": f"Bearer {token}",
47+
"Accept": "application/vnd.github+json",
48+
"X-GitHub-Api-Version": "2022-11-28",
49+
"User-Agent": "codex-review-gate",
50+
}
51+
if payload:
52+
headers["Content-Type"] = "application/json"
53+
54+
req = urllib.request.Request(url, data=data, method=method, headers=headers)
55+
try:
56+
with urllib.request.urlopen(req, timeout=30) as resp:
57+
body = resp.read().decode("utf-8")
58+
except urllib.error.HTTPError as exc:
59+
detail = exc.read().decode("utf-8", errors="replace")
60+
raise RuntimeError(f"GitHub API {method} {url}: {exc.code} {detail[:500]}") from exc
61+
return json.loads(body) if body else {}
62+
63+
64+
# ── review lookup ────────────────────────────────────────────────────────────
65+
66+
67+
def get_codex_review(token: str, repo: str, pr_number: int) -> dict[str, Any] | None:
68+
reviews = github_request(token, "GET", f"/repos/{repo}/pulls/{pr_number}/reviews?per_page=100")
69+
if not isinstance(reviews, list):
70+
return None
71+
for r in reversed(reviews):
72+
if isinstance(r, dict) and (r.get("user") or {}).get("login") == BOT_LOGIN:
73+
return r
74+
return None
75+
76+
77+
# ── check run management ─────────────────────────────────────────────────────
78+
79+
80+
def get_existing_check_run(token: str, repo: str, head_sha: str) -> dict[str, Any] | None:
81+
result = github_request(
82+
token, "GET", f"/repos/{repo}/commits/{head_sha}/check-runs?per_page=50&filter=latest"
83+
)
84+
runs = result.get("check_runs", []) if isinstance(result, dict) else []
85+
for run in runs:
86+
if isinstance(run, dict) and run.get("name") == CHECK_NAME:
87+
return run
88+
return None
89+
90+
91+
def upsert_check_run(
92+
token: str,
93+
repo: str,
94+
head_sha: str,
95+
*,
96+
status: str, # "queued" | "in_progress" | "completed"
97+
conclusion: str | None,
98+
title: str,
99+
summary: str,
100+
) -> dict[str, Any]:
101+
existing = get_existing_check_run(token, repo, head_sha)
102+
body: dict[str, Any] = {
103+
"name": CHECK_NAME,
104+
"head_sha": head_sha,
105+
"status": status,
106+
"details_url": DETAIL_URL,
107+
"output": {"title": title, "summary": summary},
108+
}
109+
if conclusion:
110+
body["conclusion"] = conclusion
111+
if status and status != "completed":
112+
body.pop("conclusion", None)
113+
114+
if existing and existing.get("id"):
115+
url = f"/repos/{repo}/check-runs/{existing['id']}"
116+
return github_request(token, "PATCH", url, body)
117+
else:
118+
return github_request(token, "POST", f"/repos/{repo}/check-runs", body)
119+
120+
121+
# ── state → decision ─────────────────────────────────────────────────────────
122+
123+
124+
def review_decision(review: dict[str, Any] | None) -> tuple[str, str, str]:
125+
"""Return (conclusion, title, summary) for a given review."""
126+
if review is None:
127+
return (
128+
"success",
129+
"Codex: no review — passed through",
130+
"The Codex GitHub App has not reviewed this PR.\n\n"
131+
"- Automatic reviews may be disabled in Codex settings.\n"
132+
"- Or mention `@codex review` in a comment to request one.\n"
133+
"- This check passes so development is not blocked.",
134+
)
135+
136+
state = (review.get("state") or "").strip().upper()
137+
submitted_at = review.get("submitted_at", "unknown time")
138+
review_url = review.get("html_url", "")
139+
140+
if state == "CHANGES_REQUESTED":
141+
body = (review.get("body") or "").strip()
142+
snippet = (body[:500] + "...") if len(body) > 500 else body
143+
return (
144+
"failure",
145+
"Codex: changes requested — MERGE BLOCKED",
146+
f"Codex **requested changes** at {submitted_at}.\n\n"
147+
+ (f"---\n\n{snippet}\n\n---\n\n" if snippet else "")
148+
+ "**Fix:** Push a new commit addressing the feedback.\n"
149+
+ f"[View full review]({review_url})",
150+
)
151+
if state == "APPROVED":
152+
return (
153+
"success",
154+
"Codex: approved",
155+
f"Codex **approved** this PR at {submitted_at}.\n\n[View review]({review_url})",
156+
)
157+
# COMMENTED / DISMISSED / PENDING
158+
return (
159+
"success",
160+
f"Codex: reviewed ({state.lower()})",
161+
f"Codex submitted a `{state}` review at {submitted_at}. "
162+
"Not a blocking review — merge is allowed.\n\n"
163+
f"[View review]({review_url})",
164+
)
165+
166+
167+
# ── main logic ───────────────────────────────────────────────────────────────
168+
169+
170+
def main() -> int:
171+
token = env("GH_TOKEN") or env("GITHUB_TOKEN")
172+
if not token:
173+
print("::error::GH_TOKEN required", file=sys.stderr)
174+
return 1
175+
176+
repo = env("GITHUB_REPOSITORY")
177+
if not repo:
178+
print("::error::GITHUB_REPOSITORY not set", file=sys.stderr)
179+
return 1
180+
181+
event_path = Path(os.environ.get("GITHUB_EVENT_PATH", ""))
182+
if not event_path.exists():
183+
print("::error::GITHUB_EVENT_PATH missing", file=sys.stderr)
184+
return 1
185+
186+
event = json.loads(event_path.read_text(encoding="utf-8"))
187+
event_name = env("GITHUB_EVENT_NAME", "")
188+
189+
# Resolve PR number + head SHA
190+
pr = event.get("pull_request") or {}
191+
pr_number = pr.get("number")
192+
head_sha = (pr.get("head") or {}).get("sha")
193+
194+
if not pr_number or not head_sha:
195+
print(f"::warning::Cannot resolve PR: number={pr_number} sha={head_sha}")
196+
return 0
197+
198+
print(f"PR #{pr_number} sha={head_sha[:12]} event={event_name}")
199+
200+
# ── REACT mode: Codex just submitted a review ──────────────────────
201+
review_event = event.get("review") or {}
202+
review_user = (review_event.get("user") or {}).get("login", "")
203+
204+
if event_name == "pull_request_review" and review_user == BOT_LOGIN:
205+
conclusion, title, summary = review_decision(review_event)
206+
upsert_check_run(token, repo, head_sha, status="completed",
207+
conclusion=conclusion, title=title, summary=summary)
208+
print(f"REACT → {conclusion}: {title}")
209+
return 1 if conclusion == "failure" else 0
210+
211+
# ── WAIT mode: PR opened/synchronized ─────────────────────────────
212+
# First check if Codex already reviewed
213+
try:
214+
existing_review = get_codex_review(token, repo, pr_number)
215+
except RuntimeError as exc:
216+
print(f"::warning::Cannot fetch reviews: {exc}")
217+
return 0
218+
219+
if existing_review is not None:
220+
conclusion, title, summary = review_decision(existing_review)
221+
upsert_check_run(token, repo, head_sha, status="completed",
222+
conclusion=conclusion, title=title, summary=summary)
223+
print(f"EXISTING → {conclusion}: {title}")
224+
return 1 if conclusion == "failure" else 0
225+
226+
# No review yet → set pending and poll
227+
poll_seconds = env_int("CODEX_GATE_POLL_SECONDS", 30)
228+
max_wait = env_int("CODEX_GATE_MAX_WAIT_MINUTES", 10)
229+
deadline = time.time() + max_wait * 60
230+
231+
upsert_check_run(
232+
token, repo, head_sha,
233+
status="in_progress",
234+
conclusion=None,
235+
title="Codex: waiting for review…",
236+
summary=(
237+
"Waiting for the Codex GitHub App to review this PR.\n\n"
238+
f"Polling every {poll_seconds}s for up to {max_wait} min.\n"
239+
"If Codex does not respond in time, the check passes through "
240+
"to avoid blocking development.\n\n"
241+
"Ensure automatic reviews are enabled in Codex settings, "
242+
"or mention `@codex review` in a comment."
243+
),
244+
)
245+
print(f"WAIT → polling every {poll_seconds}s for up to {max_wait}min")
246+
247+
while time.time() < deadline:
248+
time.sleep(poll_seconds)
249+
try:
250+
review = get_codex_review(token, repo, pr_number)
251+
except RuntimeError:
252+
continue # transient API error → retry
253+
254+
if review is not None:
255+
conclusion, title, summary = review_decision(review)
256+
upsert_check_run(token, repo, head_sha, status="completed",
257+
conclusion=conclusion, title=title, summary=summary)
258+
print(f"WAIT → found review → {conclusion}: {title}")
259+
return 1 if conclusion == "failure" else 0
260+
261+
# ── Timeout: Codex didn't respond ─────────────────────────────────
262+
title = "Codex: timeout — passed through"
263+
summary = (
264+
f"Codex did not submit a review within {max_wait} minutes.\n\n"
265+
"**Possible causes:**\n"
266+
"- Codex subscription may be paused or expired\n"
267+
"- Automatic reviews may be disabled for this repo\n"
268+
"- Codex service may be experiencing issues\n\n"
269+
"**This check passes** so development is not blocked.\n"
270+
"Request a manual review from a teammate before merging."
271+
)
272+
upsert_check_run(token, repo, head_sha, status="completed",
273+
conclusion="success", title=title, summary=summary)
274+
print(f"TIMEOUT → passed through (no Codex response in {max_wait}min)")
275+
276+
# Write decision artifact
277+
out_dir = Path("data/output/codex_review_gate")
278+
out_dir.mkdir(parents=True, exist_ok=True)
279+
(out_dir / "gate_decision.json").write_text(
280+
json.dumps({
281+
"repo": repo, "pr_number": pr_number, "head_sha": head_sha,
282+
"mode": "wait_timeout", "conclusion": "success", "title": title,
283+
}, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
284+
285+
return 0
286+
287+
288+
if __name__ == "__main__":
289+
raise SystemExit(main())

0 commit comments

Comments
 (0)