Skip to content

Commit fd3301c

Browse files
Pigbibiclaude
andcommitted
chore: add Codex PR review gate CI workflows
Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 0413999 commit fd3301c

3 files changed

Lines changed: 386 additions & 0 deletions

File tree

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
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+
contents: read
23+
pull-requests: read
24+
25+
concurrency:
26+
group: codex-review-gate-${{ github.event.pull_request.number }}
27+
cancel-in-progress: true
28+
29+
jobs:
30+
gate:
31+
if: >
32+
github.event_name == 'pull_request_review'
33+
&& github.event.review.user.login == 'chatgpt-codex-connector[bot]'
34+
|| github.event_name == 'pull_request'
35+
&& github.event.pull_request.draft == false
36+
37+
runs-on: ubuntu-latest
38+
timeout-minutes: 15
39+
env:
40+
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
41+
CODEX_GATE_POLL_SECONDS: ${{ vars.CODEX_GATE_POLL_SECONDS || '30' }}
42+
CODEX_GATE_MAX_WAIT_MINUTES: ${{ vars.CODEX_GATE_MAX_WAIT_MINUTES || '5' }}
43+
44+
steps:
45+
- name: Checkout
46+
uses: actions/checkout@v6
47+
48+
- name: Set up Python
49+
uses: actions/setup-python@v6
50+
with:
51+
python-version: "3.11"
52+
53+
- name: Evaluate Codex Review Gate
54+
id: gate
55+
env:
56+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
57+
run: python scripts/gate_codex_app_review.py
58+
59+
- name: Upload gate diagnostics
60+
if: always()
61+
uses: actions/upload-artifact@v7
62+
with:
63+
name: codex-review-gate-${{ github.event.pull_request.number || github.run_id }}
64+
path: data/output/codex_review_gate/
65+
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: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
1+
#!/usr/bin/env python3
2+
"""PR merge gate: static scan + Codex App review → job exit code = check status.
3+
4+
Two phases, zero API keys needed:
5+
1. STATIC — scan diff for secrets, blocked files, metadata issues (<30s).
6+
Fail job immediately on hard violations.
7+
2. WAIT — poll for Codex GitHub App review up to N min.
8+
Fail job on CHANGES_REQUESTED, pass on APPROVED/timeout.
9+
3. REACT — on Codex bot review submitted: update instantly.
10+
11+
The workflow job IS the check — exit 0 = pass, exit 1 = fail.
12+
"""
13+
14+
from __future__ import annotations
15+
16+
import json
17+
import os
18+
import re
19+
import sys
20+
import time
21+
import urllib.error
22+
import urllib.request
23+
from pathlib import Path
24+
from typing import Any
25+
26+
API_BASE = "https://api.github.com"
27+
BOT_LOGIN = "chatgpt-codex-connector[bot]"
28+
POLICY_PATH = Path(".github/codex_auto_merge_policy.json")
29+
30+
31+
def env(name: str, default: str = "") -> str:
32+
return os.environ.get(name, default).strip()
33+
34+
35+
def env_int(name: str, default: int) -> int:
36+
try: return int(env(name, str(default)))
37+
except ValueError: return default
38+
39+
40+
def github_request(token: str, method: str, path: str,
41+
payload: dict[str, Any] | None = None) -> Any:
42+
url = f"{API_BASE}{path}" if not path.startswith("https://") else path
43+
data = json.dumps(payload).encode() if payload else None
44+
headers = {
45+
"Authorization": f"Bearer {token}",
46+
"Accept": "application/vnd.github+json",
47+
"X-GitHub-Api-Version": "2022-11-28",
48+
"User-Agent": "codex-review-gate",
49+
}
50+
if payload: headers["Content-Type"] = "application/json"
51+
req = urllib.request.Request(url, data=data, method=method, headers=headers)
52+
try:
53+
with urllib.request.urlopen(req, timeout=30) as resp:
54+
body = resp.read().decode("utf-8")
55+
except urllib.error.HTTPError as exc:
56+
detail = exc.read().decode("utf-8", errors="replace")
57+
raise RuntimeError(f"GitHub API {method} {url}: {exc.code} {detail[:500]}") from exc
58+
return json.loads(body) if body else {}
59+
60+
61+
def step_summary(text: str) -> None:
62+
p = os.environ.get("GITHUB_STEP_SUMMARY", "")
63+
if p:
64+
with open(p, "a", encoding="utf-8") as f:
65+
f.write(text + "\n")
66+
67+
68+
# ─── policy ──────────────────────────────────────────────────────────────────
69+
70+
def load_policy() -> dict[str, Any]:
71+
if POLICY_PATH.exists():
72+
try: return json.loads(POLICY_PATH.read_text(encoding="utf-8"))
73+
except (OSError, json.JSONDecodeError): pass
74+
return {
75+
"version": 1,
76+
"blocked_path_patterns": [
77+
r"(^|/)(\.env|.*secret.*|.*credential.*|.*token.*|.*private.*|.*\.pem|.*\.key)$",
78+
],
79+
"max_changed_files": 50,
80+
"max_changed_lines": 5000,
81+
}
82+
83+
84+
def compile_patterns(policy: dict[str, Any]) -> list[re.Pattern[str]]:
85+
pp: list[re.Pattern[str]] = []
86+
for p in policy.get("blocked_path_patterns", []):
87+
if isinstance(p, str) and p.strip():
88+
try: pp.append(re.compile(p, re.IGNORECASE))
89+
except re.error: pass
90+
return pp
91+
92+
93+
# ─── static guard ────────────────────────────────────────────────────────────
94+
95+
_SENSITIVE = re.compile(
96+
r'(?:api[_\s]?key|secret|password|token|credential|private[_\s]?key)\s*[:=]\s*["\']'
97+
r'(?!\$\{\{|{{|example|placeholder|test|your[-_\s]|xxx|TODO|CHANGEME)[^"\']{12,}["\']',
98+
re.IGNORECASE,
99+
)
100+
101+
102+
def scan_diff(diff_text: str, path_patterns: list[re.Pattern[str]]) -> list[str]:
103+
violations: list[str] = []
104+
current = ""
105+
for line in diff_text.splitlines():
106+
if line.startswith("diff --git "):
107+
parts = line.split(" ")
108+
current = parts[3][2:] if len(parts) >= 4 and parts[3].startswith("b/") else ""
109+
for pat in path_patterns:
110+
if current and pat.search(current):
111+
violations.append(f"**Blocked file**: `{current}` matches `{pat.pattern}`")
112+
break
113+
continue
114+
if line.startswith("+++ b/"): current = line[6:]; continue
115+
if not line.startswith("+") or line.startswith("+++"): continue
116+
m = _SENSITIVE.search(line[1:])
117+
if m:
118+
violations.append(f"**Hardcoded secret** in `{current}`: `{m.group(0)[:100]}`")
119+
return list(dict.fromkeys(violations))
120+
121+
122+
def check_metadata(files: list[dict[str, Any]], policy: dict[str, Any]) -> list[str]:
123+
issues: list[str] = []
124+
mx_f = policy.get("max_changed_files", 50)
125+
mx_l = policy.get("max_changed_lines", 5000)
126+
ta = sum(f.get("additions", 0) or 0 for f in files)
127+
td = sum(f.get("deletions", 0) or 0 for f in files)
128+
for f in files:
129+
fn = f.get("filename", "?")
130+
st = (f.get("status") or "").lower().strip()
131+
if st == "removed": issues.append(f"**File deleted**: `{fn}` — verify intentional")
132+
elif st == "renamed": issues.append(f"**File renamed**: `{f.get('previous_filename', '?')}` → `{fn}`")
133+
if len(files) > mx_f:
134+
issues.append(f"**Too many files**: {len(files)} changed (limit {mx_f})")
135+
if ta + td > mx_l:
136+
issues.append(f"**Too many lines**: {ta + td} changed (limit {mx_l})")
137+
return issues
138+
139+
140+
def run_static_guard(token: str, repo: str, pr_number: int) -> int:
141+
"""Return 0 if clean, 1 if blocked."""
142+
policy = load_policy()
143+
files: list[dict[str, Any]] = []
144+
page = 1
145+
while True:
146+
try:
147+
batch = github_request(token, "GET",
148+
f"/repos/{repo}/pulls/{pr_number}/files?per_page=100&page={page}")
149+
except RuntimeError: break
150+
if not isinstance(batch, list) or not batch: break
151+
files.extend(batch)
152+
if len(batch) < 100: break
153+
page += 1
154+
155+
diff_text = ""
156+
try:
157+
req = urllib.request.Request(
158+
f"{API_BASE}/repos/{repo}/pulls/{pr_number}",
159+
headers={
160+
"Authorization": f"Bearer {token}",
161+
"Accept": "application/vnd.github.v3.diff",
162+
"X-GitHub-Api-Version": "2022-11-28",
163+
"User-Agent": "codex-review-gate",
164+
},
165+
)
166+
with urllib.request.urlopen(req, timeout=30) as resp:
167+
diff_text = resp.read().decode("utf-8", errors="replace")
168+
except Exception: pass
169+
170+
issues = check_metadata(files, policy) + scan_diff(diff_text, compile_patterns(policy))
171+
if not issues: return 0
172+
173+
print(f"STATIC → BLOCKED: {len(issues)} issue(s)")
174+
for i in issues: print(f" • {i}")
175+
step_summary(f"## Merge blocked: {len(issues)} static issue(s)\n\n" +
176+
"\n".join(f"- {i}" for i in issues))
177+
return 1
178+
179+
180+
# ─── app review ──────────────────────────────────────────────────────────────
181+
182+
def get_codex_review(token: str, repo: str, pr_number: int) -> dict[str, Any] | None:
183+
reviews = github_request(token, "GET", f"/repos/{repo}/pulls/{pr_number}/reviews?per_page=100")
184+
if not isinstance(reviews, list): return None
185+
for r in reversed(reviews):
186+
if isinstance(r, dict) and (r.get("user") or {}).get("login") == BOT_LOGIN:
187+
return r
188+
return None
189+
190+
191+
def app_decision(review: dict[str, Any] | None) -> tuple[int, str, str]:
192+
"""(exit_code, title, summary)"""
193+
if review is None:
194+
return (0, "Codex: no review — passed through",
195+
"Codex did not respond in time. Merge allowed to avoid blocking development.")
196+
state = (review.get("state") or "").strip().upper()
197+
url = review.get("html_url", "")
198+
body = (review.get("body") or "").strip()
199+
at = review.get("submitted_at", "")
200+
201+
if state == "CHANGES_REQUESTED":
202+
snippet = (body[:500] + "...") if len(body) > 500 else body
203+
return (1, "Codex: changes requested — MERGE BLOCKED",
204+
f"Codex **requested changes** at {at}.\n\n{snippet}\n\n[View review]({url})")
205+
if state == "APPROVED":
206+
return (0, "Codex: approved", f"Codex approved at {at}. [View review]({url})")
207+
return (0, f"Codex: reviewed ({state.lower()})",
208+
f"Codex state `{state}` at {at}. Not blocking. [View review]({url})")
209+
210+
211+
# ─── main ────────────────────────────────────────────────────────────────────
212+
213+
def main() -> int:
214+
token = env("GH_TOKEN") or env("GITHUB_TOKEN")
215+
repo = env("GITHUB_REPOSITORY")
216+
if not token or not repo:
217+
print("::error::GH_TOKEN + GITHUB_REPOSITORY required", file=sys.stderr)
218+
return 1
219+
220+
event_path = Path(os.environ.get("GITHUB_EVENT_PATH", ""))
221+
if not event_path.exists():
222+
print("::error::GITHUB_EVENT_PATH missing", file=sys.stderr)
223+
return 1
224+
225+
event = json.loads(event_path.read_text(encoding="utf-8"))
226+
event_name = env("GITHUB_EVENT_NAME", "")
227+
pr = event.get("pull_request") or {}
228+
pr_number = pr.get("number")
229+
head_sha = (pr.get("head") or {}).get("sha")
230+
if not pr_number or not head_sha:
231+
print(f"::warning::Cannot resolve PR context"); return 0
232+
233+
print(f"PR #{pr_number} sha={head_sha[:12]} event={event_name}")
234+
235+
# ── Phase 1: Static guard (skip on review-only events) ────────────
236+
if event_name != "pull_request_review":
237+
try: rc = run_static_guard(token, repo, pr_number)
238+
except RuntimeError as exc:
239+
print(f"::warning::Static guard error: {exc}"); rc = 0
240+
if rc != 0: return 1
241+
print("STATIC → clean")
242+
243+
# ── Phase 2: App review ───────────────────────────────────────────
244+
# REACT: Codex just submitted a review
245+
review_event = event.get("review") or {}
246+
if event_name == "pull_request_review" and (review_event.get("user") or {}).get("login") == BOT_LOGIN:
247+
rc, title, summary = app_decision(review_event)
248+
print(f"REACT → exit={rc}: {title}")
249+
step_summary(f"## {title}\n\n{summary}")
250+
return rc
251+
252+
# WAIT: poll for existing or upcoming review
253+
try: existing = get_codex_review(token, repo, pr_number)
254+
except RuntimeError: existing = None
255+
256+
if existing is not None:
257+
rc, title, summary = app_decision(existing)
258+
print(f"EXISTING → exit={rc}: {title}")
259+
step_summary(f"## {title}\n\n{summary}")
260+
return rc
261+
262+
poll_s = env_int("CODEX_GATE_POLL_SECONDS", 30)
263+
max_w = env_int("CODEX_GATE_MAX_WAIT_MINUTES", 5)
264+
deadline = time.time() + max_w * 60
265+
print(f"WAIT → polling every {poll_s}s for up to {max_w}min")
266+
267+
while time.time() < deadline:
268+
time.sleep(poll_s)
269+
try: review = get_codex_review(token, repo, pr_number)
270+
except RuntimeError: continue
271+
if review is not None:
272+
rc, title, summary = app_decision(review)
273+
print(f"WAIT → found review → exit={rc}: {title}")
274+
step_summary(f"## {title}\n\n{summary}")
275+
return rc
276+
277+
# Timeout
278+
print(f"TIMEOUT → Codex did not respond in {max_w}min; passing through")
279+
step_summary(f"## Codex: timeout after {max_w}min\n\nPassed through to avoid blocking development.")
280+
return 0
281+
282+
283+
if __name__ == "__main__":
284+
raise SystemExit(main())

0 commit comments

Comments
 (0)