|
| 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