|
1 | 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. |
| 2 | +"""PR merge gate: static checks + Codex App review → check run for branch protection. |
| 3 | +
|
| 4 | +Two-phase guard (no API keys needed): |
| 5 | + 1. STATIC — scan diff for secrets, blocked files, metadata issues (<30s). |
| 6 | + Fail immediately on hard violations. |
| 7 | + 2. WAIT — poll for Codex GitHub App review up to N min. |
| 8 | + Fail on CHANGES_REQUESTED, pass on APPROVED/timeout. |
| 9 | + 3. REACT — on Codex bot review submitted: update check instantly. |
8 | 10 | """ |
9 | 11 |
|
10 | 12 | from __future__ import annotations |
11 | 13 |
|
12 | 14 | import json |
13 | 15 | import os |
| 16 | +import re |
14 | 17 | import sys |
15 | 18 | import time |
16 | 19 | import urllib.error |
|
22 | 25 | BOT_LOGIN = "chatgpt-codex-connector[bot]" |
23 | 26 | CHECK_NAME = "Codex Review Gate" |
24 | 27 | DETAIL_URL = "https://github.com/apps/chatgpt-codex-connector" |
| 28 | +POLICY_PATH = Path(".github/codex_auto_merge_policy.json") |
25 | 29 |
|
26 | 30 | # ── helpers ────────────────────────────────────────────────────────────────── |
27 | 31 |
|
@@ -164,6 +168,160 @@ def review_decision(review: dict[str, Any] | None) -> tuple[str, str, str]: |
164 | 168 | ) |
165 | 169 |
|
166 | 170 |
|
| 171 | +# ── static guard ───────────────────────────────────────────────────────────── |
| 172 | + |
| 173 | + |
| 174 | +def load_policy() -> dict[str, Any]: |
| 175 | + if POLICY_PATH.exists(): |
| 176 | + try: |
| 177 | + return json.loads(POLICY_PATH.read_text(encoding="utf-8")) |
| 178 | + except (OSError, json.JSONDecodeError): |
| 179 | + pass |
| 180 | + return { |
| 181 | + "version": 1, |
| 182 | + "blocked_path_patterns": [ |
| 183 | + r"(^|/)(\.env|.*secret.*|.*credential.*|.*token.*|.*private.*|.*\.pem|.*\.key)$", |
| 184 | + ], |
| 185 | + "max_changed_files": 50, |
| 186 | + "max_changed_lines": 5000, |
| 187 | + } |
| 188 | + |
| 189 | + |
| 190 | +def compile_blocked_patterns(policy: dict[str, Any]) -> list[re.Pattern[str]]: |
| 191 | + raw = policy.get("blocked_path_patterns", []) |
| 192 | + patterns: list[re.Pattern[str]] = [] |
| 193 | + for p in raw: |
| 194 | + if isinstance(p, str) and p.strip(): |
| 195 | + try: |
| 196 | + patterns.append(re.compile(p, re.IGNORECASE)) |
| 197 | + except re.error: |
| 198 | + pass |
| 199 | + return patterns |
| 200 | + |
| 201 | + |
| 202 | +_SENSITIVE_ASSIGN = re.compile( |
| 203 | + r'(?:api[_\s]?key|secret|password|token|credential|private[_\s]?key)\s*[:=]\s*["\']' |
| 204 | + r'(?!\$\{\{|{{|example|placeholder|test|your[-_\s]|xxx|TODO|CHANGEME|replace)[^"\']{12,}["\']', |
| 205 | + re.IGNORECASE, |
| 206 | +) |
| 207 | + |
| 208 | + |
| 209 | +def scan_diff_for_secrets(diff_text: str, path_patterns: list[re.Pattern[str]]) -> list[str]: |
| 210 | + """Scan PR diff for blocked files and hardcoded secret assignments.""" |
| 211 | + violations: list[str] = [] |
| 212 | + current_file = "" |
| 213 | + |
| 214 | + for line in diff_text.splitlines(): |
| 215 | + if line.startswith("diff --git "): |
| 216 | + parts = line.split(" ") |
| 217 | + current_file = parts[3][2:] if len(parts) >= 4 and parts[3].startswith("b/") else "" |
| 218 | + # Check path-level blocks once per file header |
| 219 | + if current_file: |
| 220 | + for pat in path_patterns: |
| 221 | + if pat.search(current_file): |
| 222 | + violations.append( |
| 223 | + f"**Blocked file**: `{current_file}` matches `{pat.pattern}`" |
| 224 | + ) |
| 225 | + break |
| 226 | + continue |
| 227 | + |
| 228 | + if line.startswith("+++ b/"): |
| 229 | + current_file = line[6:] |
| 230 | + continue |
| 231 | + |
| 232 | + # Only inspect added lines |
| 233 | + if not line.startswith("+") or line.startswith("+++"): |
| 234 | + continue |
| 235 | + |
| 236 | + content = line[1:] |
| 237 | + m = _SENSITIVE_ASSIGN.search(content) |
| 238 | + if m: |
| 239 | + violations.append( |
| 240 | + f"**Hardcoded secret** in `{current_file}`: `{m.group(0)[:100]}`" |
| 241 | + ) |
| 242 | + |
| 243 | + return list(dict.fromkeys(violations)) # dedup preserving order |
| 244 | + |
| 245 | + |
| 246 | +def check_pr_metadata(files: list[dict[str, Any]], policy: dict[str, Any]) -> list[str]: |
| 247 | + issues: list[str] = [] |
| 248 | + max_files = policy.get("max_changed_files", 50) |
| 249 | + max_lines = policy.get("max_changed_lines", 5000) |
| 250 | + ta = sum(f.get("additions", 0) or 0 for f in files) |
| 251 | + td = sum(f.get("deletions", 0) or 0 for f in files) |
| 252 | + |
| 253 | + for f in files: |
| 254 | + fn = f.get("filename", "?") |
| 255 | + st = (f.get("status") or "").lower().strip() |
| 256 | + if st == "removed": |
| 257 | + issues.append(f"**File deleted**: `{fn}` — verify intentional") |
| 258 | + elif st == "renamed": |
| 259 | + issues.append(f"**File renamed**: `{f.get('previous_filename', '?')}` → `{fn}`") |
| 260 | + |
| 261 | + if len(files) > max_files: |
| 262 | + issues.append(f"**Too many files**: {len(files)} changed (limit {max_files})") |
| 263 | + if ta + td > max_lines: |
| 264 | + issues.append(f"**Too many lines**: {ta + td} changed (limit {max_lines})") |
| 265 | + |
| 266 | + return issues |
| 267 | + |
| 268 | + |
| 269 | +def run_static_guard( |
| 270 | + token: str, repo: str, pr_number: int, head_sha: str |
| 271 | +) -> tuple[list[str], str, str] | None: |
| 272 | + """Run static checks. Returns (issues, title, summary) or None if clean.""" |
| 273 | + policy = load_policy() |
| 274 | + |
| 275 | + # Fetch PR files |
| 276 | + all_files: list[dict[str, Any]] = [] |
| 277 | + page = 1 |
| 278 | + while True: |
| 279 | + try: |
| 280 | + batch = github_request( |
| 281 | + token, "GET", f"/repos/{repo}/pulls/{pr_number}/files?per_page=100&page={page}" |
| 282 | + ) |
| 283 | + except RuntimeError: |
| 284 | + break |
| 285 | + if not isinstance(batch, list) or not batch: |
| 286 | + break |
| 287 | + all_files.extend(batch) |
| 288 | + if len(batch) < 100: |
| 289 | + break |
| 290 | + page += 1 |
| 291 | + |
| 292 | + # Fetch diff |
| 293 | + diff_text = "" |
| 294 | + try: |
| 295 | + req = urllib.request.Request( |
| 296 | + f"{API_BASE}/repos/{repo}/pulls/{pr_number}", |
| 297 | + method="GET", |
| 298 | + headers={ |
| 299 | + "Authorization": f"Bearer {token}", |
| 300 | + "Accept": "application/vnd.github.v3.diff", |
| 301 | + "X-GitHub-Api-Version": "2022-11-28", |
| 302 | + "User-Agent": "codex-review-gate", |
| 303 | + }, |
| 304 | + ) |
| 305 | + with urllib.request.urlopen(req, timeout=30) as resp: |
| 306 | + diff_text = resp.read().decode("utf-8", errors="replace") |
| 307 | + except Exception: |
| 308 | + pass |
| 309 | + |
| 310 | + # Scan |
| 311 | + issues: list[str] = [] |
| 312 | + issues.extend(check_pr_metadata(all_files, policy)) |
| 313 | + patterns = compile_blocked_patterns(policy) |
| 314 | + issues.extend(scan_diff_for_secrets(diff_text, patterns)) |
| 315 | + |
| 316 | + if not issues: |
| 317 | + return None |
| 318 | + |
| 319 | + title = f"Merge blocked: {len(issues)} static issue(s)" |
| 320 | + summary = "\n".join(f"- {i}" for i in issues) |
| 321 | + summary += "\n\n---\nPush a fix commit to clear this block." |
| 322 | + return issues, title, summary |
| 323 | + |
| 324 | + |
167 | 325 | # ── main logic ─────────────────────────────────────────────────────────────── |
168 | 326 |
|
169 | 327 |
|
@@ -197,6 +355,29 @@ def main() -> int: |
197 | 355 |
|
198 | 356 | print(f"PR #{pr_number} sha={head_sha[:12]} event={event_name}") |
199 | 357 |
|
| 358 | + # ═══════════════════════════════════════════════════════════════════ |
| 359 | + # PHASE 1: Static guard — runs on every PR event, <30s, no API keys |
| 360 | + # ═══════════════════════════════════════════════════════════════════ |
| 361 | + if event_name != "pull_request_review": # skip on review-only events |
| 362 | + try: |
| 363 | + static_result = run_static_guard(token, repo, pr_number, head_sha) |
| 364 | + except RuntimeError as exc: |
| 365 | + print(f"::warning::Static guard failed: {exc}") |
| 366 | + static_result = None |
| 367 | + |
| 368 | + if static_result is not None: |
| 369 | + issues, title, summary = static_result |
| 370 | + upsert_check_run(token, repo, head_sha, status="completed", |
| 371 | + conclusion="failure", title=title, summary=summary) |
| 372 | + print(f"STATIC → FAIL: {len(issues)} issue(s)") |
| 373 | + return 1 |
| 374 | + else: |
| 375 | + print("STATIC → clean") |
| 376 | + |
| 377 | + # ═══════════════════════════════════════════════════════════════════ |
| 378 | + # PHASE 2: App review gate |
| 379 | + # ═══════════════════════════════════════════════════════════════════ |
| 380 | + |
200 | 381 | # ── REACT mode: Codex just submitted a review ────────────────────── |
201 | 382 | review_event = event.get("review") or {} |
202 | 383 | review_user = (review_event.get("user") or {}).get("login", "") |
|
0 commit comments