From 94f7260a2d4be4c68771bf2278327416401dd9c1 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Fri, 17 Jul 2026 02:20:39 +0800 Subject: [PATCH 1/2] fix: separate review advisory from required gate Co-Authored-By: Codex --- .github/workflows/codex_review_advisory.yml | 34 ++++++ .github/workflows/codex_review_gate.yml | 33 ++--- docs/ai_autonomy_architecture.md | 14 ++- scripts/gate_codex_app_review.py | 128 +++----------------- scripts/report_codex_app_review.py | 56 +++++++++ tests/test_review_authority_split.py | 91 ++++++++++++++ 6 files changed, 215 insertions(+), 141 deletions(-) create mode 100644 .github/workflows/codex_review_advisory.yml create mode 100644 scripts/report_codex_app_review.py create mode 100644 tests/test_review_authority_split.py diff --git a/.github/workflows/codex_review_advisory.yml b/.github/workflows/codex_review_advisory.yml new file mode 100644 index 00000000..d90aaa9c --- /dev/null +++ b/.github/workflows/codex_review_advisory.yml @@ -0,0 +1,34 @@ +name: Codex Review Advisory + +on: + pull_request_review: + types: [submitted] + +permissions: + contents: read + pull-requests: read + +concurrency: + group: codex-review-advisory-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + advisory: + if: github.event.review.user.login == 'chatgpt-codex-connector[bot]' + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - name: Checkout trusted base + uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.base.sha }} + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.11" + + - name: Report current-head Codex advisory + run: python scripts/report_codex_app_review.py diff --git a/.github/workflows/codex_review_gate.yml b/.github/workflows/codex_review_gate.yml index 673a5dec..faccab4e 100644 --- a/.github/workflows/codex_review_gate.yml +++ b/.github/workflows/codex_review_gate.yml @@ -1,50 +1,35 @@ name: Codex Review Gate -# Two-mode gate for the Codex GitHub App (chatgpt-codex-connector): -# -# 1. WAIT mode (PR opened/synchronized): -# Creates a pending check → polls for Codex review up to N minutes. -# If Codex responds → check reflects review state. -# If Codex times out → check passes (don't block when App is broken). -# -# 2. REACT mode (Codex submits review): -# Updates check instantly — CHANGES_REQUESTED → fail, APPROVED → pass. -# -# No API keys needed — reads the App's review via GitHub API. +# Required deterministic static gate. The connector review is reported by the +# separate non-required Codex Review Advisory workflow. on: - pull_request: + pull_request_target: types: [opened, synchronize, reopened, ready_for_review] - pull_request_review: - types: [submitted] - workflow_call: permissions: contents: read pull-requests: read concurrency: - group: codex-review-gate-${{ github.event_name }}-${{ github.event.pull_request.number || github.run_id }} + group: codex-review-gate-${{ github.event.pull_request.number }} cancel-in-progress: true jobs: gate: - if: > - github.event_name == 'pull_request_review' - && github.event.review.user.login == 'chatgpt-codex-connector[bot]' - || github.event_name == 'pull_request' - && github.event.pull_request.draft == false + if: github.event.pull_request.draft == false runs-on: ubuntu-latest - timeout-minutes: 15 + timeout-minutes: 5 env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" - CODEX_GATE_POLL_SECONDS: ${{ vars.CODEX_GATE_POLL_SECONDS || '30' }} - CODEX_GATE_MAX_WAIT_MINUTES: ${{ vars.CODEX_GATE_MAX_WAIT_MINUTES || '5' }} steps: - name: Checkout uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.base.sha }} + persist-credentials: false - name: Set up Python uses: actions/setup-python@v6 diff --git a/docs/ai_autonomy_architecture.md b/docs/ai_autonomy_architecture.md index d04509d6..faf152e5 100644 --- a/docs/ai_autonomy_architecture.md +++ b/docs/ai_autonomy_architecture.md @@ -55,9 +55,12 @@ AIAuditBridge 是 QuantStrategyLab 的 AI 审计控制面,负责: - 上传诊断 artifact。 - `codex_review_gate.yml` - - 把 Codex GitHub App review 变成 gate。 - - 具备 WAIT / REACT 两种模式。 - - 目标是让 review 结果真正影响 merge。 + - 只执行确定性的 secret / path / metadata 静态门禁。 + - 使用受信任 base 代码检查 PR diff;API 读取失败时 fail closed。 + +- `codex_review_advisory.yml` + - 事件驱动报告 current-head Codex GitHub App review。 + - 使用独立非 required check,不覆盖静态门禁结果,也不轮询。 - `monthly-orchestrator.yml` - 生成月度审计 issue。 @@ -103,7 +106,10 @@ AIAuditBridge 是 QuantStrategyLab 的 AI 审计控制面,负责: - service 失败时可按条件回退到 API review。 - `scripts/gate_codex_app_review.py` - - 以 review gate 的形式保护合并。 + - 以静态 gate 的形式保护合并;不处理 AI review verdict。 + +- `scripts/report_codex_app_review.py` + - 只报告 current-head connector review 的 advisory 状态。 ### 1.3 已经具备的自动化能力 diff --git a/scripts/gate_codex_app_review.py b/scripts/gate_codex_app_review.py index 5a813374..e99f51aa 100644 --- a/scripts/gate_codex_app_review.py +++ b/scripts/gate_codex_app_review.py @@ -1,22 +1,11 @@ #!/usr/bin/env python3 -"""PR merge gate: static scan + Codex App review → job exit code = check status. - -Two phases, zero API keys needed: - 1. STATIC — scan diff for secrets, blocked files, metadata issues (<30s). - Fail job immediately on hard violations. - 2. WAIT — poll for Codex GitHub App review up to N min. - Fail job on CHANGES_REQUESTED, pass on APPROVED/timeout. - 3. REACT — on Codex bot review submitted: update instantly. - -The workflow job IS the check — exit 0 = pass, exit 1 = fail. -""" +"""Required PR static gate; connector reviews are reported separately.""" from __future__ import annotations import json import os import sys -import time import urllib.error import urllib.request from pathlib import Path @@ -42,7 +31,6 @@ ) API_BASE = "https://api.github.com" -BOT_LOGIN = "chatgpt-codex-connector[bot]" POLICY_PATH = Path(".github/codex_auto_merge_policy.json") @@ -66,13 +54,6 @@ def env(name: str, default: str = "") -> str: return os.environ.get(name, default).strip() -def env_int(name: str, default: int) -> int: - try: - return int(env(name, str(default))) - except ValueError: - return default - - def github_request(token: str, method: str, path: str, payload: dict[str, Any] | None = None) -> Any: url = f"{API_BASE}{path}" if not path.startswith("https://") else path @@ -92,6 +73,8 @@ def github_request(token: str, method: str, path: str, except urllib.error.HTTPError as exc: detail = exc.read().decode("utf-8", errors="replace") raise RuntimeError(f"GitHub API {method} {url}: {exc.code} {detail[:500]}") from exc + except urllib.error.URLError as exc: + raise RuntimeError(f"GitHub API {method} {url} unavailable") from exc return json.loads(body) if body else {} @@ -108,11 +91,8 @@ def run_static_guard(token: str, repo: str, pr_number: int) -> int: files: list[dict[str, Any]] = [] page = 1 while True: - try: - batch = github_request(token, "GET", - f"/repos/{repo}/pulls/{pr_number}/files?per_page=100&page={page}") - except RuntimeError: - break + batch = github_request(token, "GET", + f"/repos/{repo}/pulls/{pr_number}/files?per_page=100&page={page}") if not isinstance(batch, list) or not batch: break files.extend(batch) @@ -133,8 +113,8 @@ def run_static_guard(token: str, repo: str, pr_number: int) -> int: ) with urllib.request.urlopen(req, timeout=30) as resp: diff_text = resp.read().decode("utf-8", errors="replace") - except Exception: - pass + except (OSError, urllib.error.URLError) as exc: + raise RuntimeError("Failed to fetch PR diff") from exc issues = collect_static_gate_issues(files, diff_text, policy) if not issues: @@ -148,38 +128,6 @@ def run_static_guard(token: str, repo: str, pr_number: int) -> int: return 1 -# ─── app review ────────────────────────────────────────────────────────────── - -def get_codex_review(token: str, repo: str, pr_number: int) -> dict[str, Any] | None: - reviews = github_request(token, "GET", f"/repos/{repo}/pulls/{pr_number}/reviews?per_page=100") - if not isinstance(reviews, list): - return None - for r in reversed(reviews): - if isinstance(r, dict) and (r.get("user") or {}).get("login") == BOT_LOGIN: - return r - return None - - -def app_decision(review: dict[str, Any] | None) -> tuple[int, str, str]: - """(exit_code, title, summary)""" - if review is None: - return (0, "Codex: no review — passed through", - "Codex did not respond in time. Merge allowed to avoid blocking development.") - state = (review.get("state") or "").strip().upper() - url = review.get("html_url", "") - body = (review.get("body") or "").strip() - at = review.get("submitted_at", "") - - if state == "CHANGES_REQUESTED": - snippet = (body[:500] + "...") if len(body) > 500 else body - return (1, "Codex: changes requested — MERGE BLOCKED", - f"Codex **requested changes** at {at}.\n\n{snippet}\n\n[View review]({url})") - if state == "APPROVED": - return (0, "Codex: approved", f"Codex approved at {at}. [View review]({url})") - return (0, f"Codex: reviewed ({state.lower()})", - f"Codex state `{state}` at {at}. Not blocking. [View review]({url})") - - # ─── main ──────────────────────────────────────────────────────────────────── def main() -> int: @@ -195,7 +143,6 @@ def main() -> int: return 1 event = json.loads(event_path.read_text(encoding="utf-8")) - event_name = env("GITHUB_EVENT_NAME", "") pr = event.get("pull_request") or {} pr_number = pr.get("number") head_sha = (pr.get("head") or {}).get("sha") @@ -203,60 +150,15 @@ def main() -> int: print("::warning::Cannot resolve PR context") return 0 - print(f"PR #{pr_number} sha={head_sha[:12]} event={event_name}") - - # ── Phase 1: Static guard (skip on review-only events) ──────────── - if event_name != "pull_request_review": - try: - rc = run_static_guard(token, repo, pr_number) - except RuntimeError as exc: - print(f"::warning::Static guard error: {exc}") - rc = 0 - if rc != 0: - return 1 - print("STATIC → clean") - - # ── Phase 2: App review ─────────────────────────────────────────── - # REACT: Codex just submitted a review - review_event = event.get("review") or {} - if event_name == "pull_request_review" and (review_event.get("user") or {}).get("login") == BOT_LOGIN: - rc, title, summary = app_decision(review_event) - print(f"REACT → exit={rc}: {title}") - step_summary(f"## {title}\n\n{summary}") - return rc - - # WAIT: poll for existing or upcoming review + print(f"PR #{pr_number} sha={head_sha[:12]}") try: - existing = get_codex_review(token, repo, pr_number) - except RuntimeError: - existing = None - - if existing is not None: - rc, title, summary = app_decision(existing) - print(f"EXISTING → exit={rc}: {title}") - step_summary(f"## {title}\n\n{summary}") - return rc - - poll_s = env_int("CODEX_GATE_POLL_SECONDS", 30) - max_w = env_int("CODEX_GATE_MAX_WAIT_MINUTES", 5) - deadline = time.time() + max_w * 60 - print(f"WAIT → polling every {poll_s}s for up to {max_w}min") - - while time.time() < deadline: - time.sleep(poll_s) - try: - review = get_codex_review(token, repo, pr_number) - except RuntimeError: - continue - if review is not None: - rc, title, summary = app_decision(review) - print(f"WAIT → found review → exit={rc}: {title}") - step_summary(f"## {title}\n\n{summary}") - return rc - - # Timeout - print(f"TIMEOUT → Codex did not respond in {max_w}min; passing through") - step_summary(f"## Codex: timeout after {max_w}min\n\nPassed through to avoid blocking development.") + rc = run_static_guard(token, repo, pr_number) + except RuntimeError as exc: + print(f"::error::Static guard unavailable: {exc}", file=sys.stderr) + return 1 + if rc != 0: + return 1 + print("STATIC → clean") return 0 diff --git a/scripts/report_codex_app_review.py b/scripts/report_codex_app_review.py new file mode 100644 index 00000000..2489dbe3 --- /dev/null +++ b/scripts/report_codex_app_review.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +"""Report the official Codex connector review as a current-head advisory.""" + +from __future__ import annotations + +import json +import os +import sys +from pathlib import Path +from typing import Any + + +BOT_LOGIN = "chatgpt-codex-connector[bot]" + + +def evaluate_event(event: dict[str, Any]) -> tuple[str, str]: + pr = event.get("pull_request") if isinstance(event.get("pull_request"), dict) else {} + review = event.get("review") if isinstance(event.get("review"), dict) else {} + actor = review.get("user") if isinstance(review.get("user"), dict) else {} + if actor.get("login") != BOT_LOGIN: + return ("ignored_actor", "Review was not submitted by the Codex connector.") + head_sha = (pr.get("head") or {}).get("sha") if isinstance(pr.get("head"), dict) else None + if type(head_sha) is not str or review.get("commit_id") != head_sha: + return ("ignored_stale", "Connector review does not match the current PR head.") + state = str(review.get("state") or "COMMENTED").strip().upper() + submitted_at = str(review.get("submitted_at") or "unknown time").strip() + return ( + "reported", + f"Current-head connector review state: `{state}` at {submitted_at}. Advisory only.", + ) + + +def main() -> int: + event_path = Path(os.environ.get("GITHUB_EVENT_PATH", "")) + if not event_path.is_file(): + print("::error::GITHUB_EVENT_PATH missing", file=sys.stderr) + return 1 + try: + event = json.loads(event_path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError) as exc: + print(f"::error::Invalid GitHub event: {type(exc).__name__}", file=sys.stderr) + return 1 + if not isinstance(event, dict): + print("::error::GitHub event must be an object", file=sys.stderr) + return 1 + outcome, summary = evaluate_event(event) + print(f"ADVISORY → {outcome}: {summary}") + step_summary = os.environ.get("GITHUB_STEP_SUMMARY", "") + if step_summary: + with open(step_summary, "a", encoding="utf-8") as handle: + handle.write(f"## Codex Review Advisory\n\n{summary}\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_review_authority_split.py b/tests/test_review_authority_split.py new file mode 100644 index 00000000..76974146 --- /dev/null +++ b/tests/test_review_authority_split.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +import importlib.util +import json +import os +import sys +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + + +ROOT = Path(__file__).resolve().parents[1] + + +def _load_module(name: str, relative_path: str): + path = ROOT / relative_path + spec = importlib.util.spec_from_file_location(name, path) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + sys.modules[name] = module + spec.loader.exec_module(module) + return module + + +class ReviewAuthoritySplitTest(unittest.TestCase): + def test_required_gate_has_no_review_event_or_polling(self) -> None: + workflow = (ROOT / ".github/workflows/codex_review_gate.yml").read_text( + encoding="utf-8" + ) + + self.assertIn("pull_request_target:", workflow) + self.assertNotIn("pull_request_review:", workflow) + self.assertNotIn("CODEX_GATE_POLL", workflow) + self.assertIn("github.event.pull_request.base.sha", workflow) + self.assertIn("persist-credentials: false", workflow) + + def test_connector_review_uses_a_distinct_advisory_check(self) -> None: + workflow = (ROOT / ".github/workflows/codex_review_advisory.yml").read_text( + encoding="utf-8" + ) + + self.assertIn("name: Codex Review Advisory", workflow) + self.assertIn("pull_request_review:", workflow) + self.assertIn("advisory:", workflow) + self.assertNotIn("gate:", workflow) + self.assertIn("report_codex_app_review.py", workflow) + + def test_static_gate_api_failure_is_blocking(self) -> None: + module = _load_module("gate_codex_app_review_split", "scripts/gate_codex_app_review.py") + with tempfile.TemporaryDirectory() as tmpdir: + event_path = Path(tmpdir) / "event.json" + event_path.write_text( + json.dumps( + { + "pull_request": { + "number": 7, + "head": {"sha": "abc123"}, + } + } + ), + encoding="utf-8", + ) + env = { + "GH_TOKEN": "token", + "GITHUB_REPOSITORY": "org/repo", + "GITHUB_EVENT_PATH": str(event_path), + "GITHUB_EVENT_NAME": "pull_request_target", + } + with ( + patch.dict(os.environ, env, clear=True), + patch.object(module, "run_static_guard", side_effect=RuntimeError("API unavailable")), + ): + self.assertEqual(module.main(), 1) + + def test_advisory_ignores_stale_review_commit(self) -> None: + module = _load_module("report_codex_app_review", "scripts/report_codex_app_review.py") + event = { + "pull_request": {"number": 7, "head": {"sha": "current-head"}}, + "review": { + "user": {"login": module.BOT_LOGIN}, + "commit_id": "old-head", + "state": "CHANGES_REQUESTED", + }, + } + + self.assertEqual(module.evaluate_event(event)[0], "ignored_stale") + + +if __name__ == "__main__": + unittest.main() From e4ae4e2b0b9f8fd087dfa4763510856f2234036c Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Fri, 17 Jul 2026 02:37:21 +0800 Subject: [PATCH 2/2] fix: bind static review gate to PR head Co-Authored-By: Codex --- .github/workflows/codex_review_advisory.yml | 34 ---------- .github/workflows/codex_review_gate.yml | 6 +- docs/ai_autonomy_architecture.md | 14 ++-- scripts/gate_codex_app_review.py | 71 ++++++++++++++++++-- scripts/report_codex_app_review.py | 56 ---------------- tests/test_review_authority_split.py | 74 +++++++++++++++------ 6 files changed, 127 insertions(+), 128 deletions(-) delete mode 100644 .github/workflows/codex_review_advisory.yml delete mode 100644 scripts/report_codex_app_review.py diff --git a/.github/workflows/codex_review_advisory.yml b/.github/workflows/codex_review_advisory.yml deleted file mode 100644 index d90aaa9c..00000000 --- a/.github/workflows/codex_review_advisory.yml +++ /dev/null @@ -1,34 +0,0 @@ -name: Codex Review Advisory - -on: - pull_request_review: - types: [submitted] - -permissions: - contents: read - pull-requests: read - -concurrency: - group: codex-review-advisory-${{ github.event.pull_request.number }} - cancel-in-progress: true - -jobs: - advisory: - if: github.event.review.user.login == 'chatgpt-codex-connector[bot]' - runs-on: ubuntu-latest - timeout-minutes: 5 - - steps: - - name: Checkout trusted base - uses: actions/checkout@v6 - with: - ref: ${{ github.event.pull_request.base.sha }} - persist-credentials: false - - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: "3.11" - - - name: Report current-head Codex advisory - run: python scripts/report_codex_app_review.py diff --git a/.github/workflows/codex_review_gate.yml b/.github/workflows/codex_review_gate.yml index faccab4e..d4faaa84 100644 --- a/.github/workflows/codex_review_gate.yml +++ b/.github/workflows/codex_review_gate.yml @@ -1,13 +1,14 @@ name: Codex Review Gate -# Required deterministic static gate. The connector review is reported by the -# separate non-required Codex Review Advisory workflow. +# Trusted driver for the required deterministic static check. The connector's +# native GitHub review remains separate, non-required advisory evidence. on: pull_request_target: types: [opened, synchronize, reopened, ready_for_review] permissions: + checks: write contents: read pull-requests: read @@ -17,6 +18,7 @@ concurrency: jobs: gate: + name: Publish head gate if: github.event.pull_request.draft == false runs-on: ubuntu-latest diff --git a/docs/ai_autonomy_architecture.md b/docs/ai_autonomy_architecture.md index faf152e5..769ce8c2 100644 --- a/docs/ai_autonomy_architecture.md +++ b/docs/ai_autonomy_architecture.md @@ -56,11 +56,10 @@ AIAuditBridge 是 QuantStrategyLab 的 AI 审计控制面,负责: - `codex_review_gate.yml` - 只执行确定性的 secret / path / metadata 静态门禁。 - - 使用受信任 base 代码检查 PR diff;API 读取失败时 fail closed。 - -- `codex_review_advisory.yml` - - 事件驱动报告 current-head Codex GitHub App review。 - - 使用独立非 required check,不覆盖静态门禁结果,也不轮询。 + - 使用受信任 base 代码检查 PR diff,并通过 Checks API 把 `Codex Review Gate` + 明确发布到 current head SHA;API 失败时 fail closed。 + - Codex connector 的原生 GitHub review 与 unresolved threads 仅作为非 required + advisory evidence,不再镜像成仓库自建 check。 - `monthly-orchestrator.yml` - 生成月度审计 issue。 @@ -106,10 +105,7 @@ AIAuditBridge 是 QuantStrategyLab 的 AI 审计控制面,负责: - service 失败时可按条件回退到 API review。 - `scripts/gate_codex_app_review.py` - - 以静态 gate 的形式保护合并;不处理 AI review verdict。 - -- `scripts/report_codex_app_review.py` - - 只报告 current-head connector review 的 advisory 状态。 + - 以 current-head 静态 check 的形式保护合并;不处理 AI review verdict。 ### 1.3 已经具备的自动化能力 diff --git a/scripts/gate_codex_app_review.py b/scripts/gate_codex_app_review.py index e99f51aa..80ad1392 100644 --- a/scripts/gate_codex_app_review.py +++ b/scripts/gate_codex_app_review.py @@ -32,6 +32,7 @@ API_BASE = "https://api.github.com" POLICY_PATH = Path(".github/codex_auto_merge_policy.json") +HEAD_CHECK_NAME = "Codex Review Gate" def load_policy(path: Path = POLICY_PATH) -> dict[str, Any]: @@ -85,6 +86,46 @@ def step_summary(text: str) -> None: f.write(text + "\n") +def create_head_check(token: str, repo: str, pr_number: int, head_sha: str) -> int: + payload: dict[str, Any] = { + "name": HEAD_CHECK_NAME, + "head_sha": head_sha, + "status": "in_progress", + "external_id": f"codex-review-gate:{repo}:{pr_number}:{env('GITHUB_RUN_ID')}", + } + run_id = env("GITHUB_RUN_ID") + if run_id: + server = env("GITHUB_SERVER_URL", "https://github.com") + payload["details_url"] = f"{server}/{repo}/actions/runs/{run_id}" + result = github_request(token, "POST", f"/repos/{repo}/check-runs", payload) + check_id = result.get("id") if isinstance(result, dict) else None + if type(check_id) is not int or check_id <= 0: + raise RuntimeError("GitHub Checks API did not return a valid check id") + return check_id + + +def complete_head_check( + token: str, + repo: str, + check_id: int, + conclusion: str, + summary: str, +) -> None: + github_request( + token, + "PATCH", + f"/repos/{repo}/check-runs/{check_id}", + { + "status": "completed", + "conclusion": conclusion, + "output": { + "title": f"Static gate {conclusion}", + "summary": summary, + }, + }, + ) + + def run_static_guard(token: str, repo: str, pr_number: int) -> int: """Return 0 if clean, 1 if blocked.""" policy = load_policy(POLICY_PATH) @@ -147,19 +188,39 @@ def main() -> int: pr_number = pr.get("number") head_sha = (pr.get("head") or {}).get("sha") if not pr_number or not head_sha: - print("::warning::Cannot resolve PR context") - return 0 + print("::error::Cannot resolve PR context", file=sys.stderr) + return 1 print(f"PR #{pr_number} sha={head_sha[:12]}") + try: + check_id = create_head_check(token, repo, pr_number, head_sha) + except RuntimeError as exc: + print(f"::error::Cannot publish head gate: {exc}", file=sys.stderr) + return 1 + try: rc = run_static_guard(token, repo, pr_number) except RuntimeError as exc: print(f"::error::Static guard unavailable: {exc}", file=sys.stderr) + try: + complete_head_check(token, repo, check_id, "failure", "Static guard unavailable.") + except RuntimeError as update_exc: + print(f"::error::Cannot complete head gate: {update_exc}", file=sys.stderr) return 1 - if rc != 0: + conclusion = "success" if rc == 0 else "failure" + summary = ( + "Static policy checks passed." + if rc == 0 + else "Static policy checks blocked this PR." + ) + try: + complete_head_check(token, repo, check_id, conclusion, summary) + except RuntimeError as exc: + print(f"::error::Cannot complete head gate: {exc}", file=sys.stderr) return 1 - print("STATIC → clean") - return 0 + if rc == 0: + print("STATIC → clean") + return rc if __name__ == "__main__": diff --git a/scripts/report_codex_app_review.py b/scripts/report_codex_app_review.py deleted file mode 100644 index 2489dbe3..00000000 --- a/scripts/report_codex_app_review.py +++ /dev/null @@ -1,56 +0,0 @@ -#!/usr/bin/env python3 -"""Report the official Codex connector review as a current-head advisory.""" - -from __future__ import annotations - -import json -import os -import sys -from pathlib import Path -from typing import Any - - -BOT_LOGIN = "chatgpt-codex-connector[bot]" - - -def evaluate_event(event: dict[str, Any]) -> tuple[str, str]: - pr = event.get("pull_request") if isinstance(event.get("pull_request"), dict) else {} - review = event.get("review") if isinstance(event.get("review"), dict) else {} - actor = review.get("user") if isinstance(review.get("user"), dict) else {} - if actor.get("login") != BOT_LOGIN: - return ("ignored_actor", "Review was not submitted by the Codex connector.") - head_sha = (pr.get("head") or {}).get("sha") if isinstance(pr.get("head"), dict) else None - if type(head_sha) is not str or review.get("commit_id") != head_sha: - return ("ignored_stale", "Connector review does not match the current PR head.") - state = str(review.get("state") or "COMMENTED").strip().upper() - submitted_at = str(review.get("submitted_at") or "unknown time").strip() - return ( - "reported", - f"Current-head connector review state: `{state}` at {submitted_at}. Advisory only.", - ) - - -def main() -> int: - event_path = Path(os.environ.get("GITHUB_EVENT_PATH", "")) - if not event_path.is_file(): - print("::error::GITHUB_EVENT_PATH missing", file=sys.stderr) - return 1 - try: - event = json.loads(event_path.read_text(encoding="utf-8")) - except (OSError, UnicodeError, json.JSONDecodeError) as exc: - print(f"::error::Invalid GitHub event: {type(exc).__name__}", file=sys.stderr) - return 1 - if not isinstance(event, dict): - print("::error::GitHub event must be an object", file=sys.stderr) - return 1 - outcome, summary = evaluate_event(event) - print(f"ADVISORY → {outcome}: {summary}") - step_summary = os.environ.get("GITHUB_STEP_SUMMARY", "") - if step_summary: - with open(step_summary, "a", encoding="utf-8") as handle: - handle.write(f"## Codex Review Advisory\n\n{summary}\n") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tests/test_review_authority_split.py b/tests/test_review_authority_split.py index 76974146..53cf175b 100644 --- a/tests/test_review_authority_split.py +++ b/tests/test_review_authority_split.py @@ -32,19 +32,13 @@ def test_required_gate_has_no_review_event_or_polling(self) -> None: self.assertIn("pull_request_target:", workflow) self.assertNotIn("pull_request_review:", workflow) self.assertNotIn("CODEX_GATE_POLL", workflow) + self.assertIn("checks: write", workflow) self.assertIn("github.event.pull_request.base.sha", workflow) self.assertIn("persist-credentials: false", workflow) - def test_connector_review_uses_a_distinct_advisory_check(self) -> None: - workflow = (ROOT / ".github/workflows/codex_review_advisory.yml").read_text( - encoding="utf-8" - ) - - self.assertIn("name: Codex Review Advisory", workflow) - self.assertIn("pull_request_review:", workflow) - self.assertIn("advisory:", workflow) - self.assertNotIn("gate:", workflow) - self.assertIn("report_codex_app_review.py", workflow) + def test_connector_review_remains_native_advisory_evidence(self) -> None: + self.assertFalse((ROOT / ".github/workflows/codex_review_advisory.yml").exists()) + self.assertFalse((ROOT / "scripts/report_codex_app_review.py").exists()) def test_static_gate_api_failure_is_blocking(self) -> None: module = _load_module("gate_codex_app_review_split", "scripts/gate_codex_app_review.py") @@ -69,22 +63,58 @@ def test_static_gate_api_failure_is_blocking(self) -> None: } with ( patch.dict(os.environ, env, clear=True), - patch.object(module, "run_static_guard", side_effect=RuntimeError("API unavailable")), + patch.object(module, "github_request", return_value={"id": 42}), + patch.object( + module, + "run_static_guard", + side_effect=RuntimeError("API unavailable"), + ), ): self.assertEqual(module.main(), 1) - def test_advisory_ignores_stale_review_commit(self) -> None: - module = _load_module("report_codex_app_review", "scripts/report_codex_app_review.py") - event = { - "pull_request": {"number": 7, "head": {"sha": "current-head"}}, - "review": { - "user": {"login": module.BOT_LOGIN}, - "commit_id": "old-head", - "state": "CHANGES_REQUESTED", - }, - } + def test_static_gate_publishes_success_on_exact_head(self) -> None: + module = _load_module("gate_codex_app_review_head", "scripts/gate_codex_app_review.py") + with tempfile.TemporaryDirectory() as tmpdir: + event_path = Path(tmpdir) / "event.json" + event_path.write_text( + json.dumps( + { + "pull_request": { + "number": 7, + "head": {"sha": "a" * 40}, + } + } + ), + encoding="utf-8", + ) + env = { + "GH_TOKEN": "token", + "GITHUB_REPOSITORY": "org/repo", + "GITHUB_EVENT_PATH": str(event_path), + "GITHUB_RUN_ID": "1234", + "GITHUB_SERVER_URL": "https://github.example", + } + with ( + patch.dict(os.environ, env, clear=True), + patch.object( + module, + "github_request", + side_effect=[{"id": 42}, {}], + ) as request, + patch.object(module, "run_static_guard", return_value=0), + ): + self.assertEqual(module.main(), 0) - self.assertEqual(module.evaluate_event(event)[0], "ignored_stale") + create = request.call_args_list[0] + self.assertEqual(create.args[:3], ("token", "POST", "/repos/org/repo/check-runs")) + self.assertEqual(create.args[3]["name"], module.HEAD_CHECK_NAME) + self.assertEqual(create.args[3]["head_sha"], "a" * 40) + complete = request.call_args_list[1] + self.assertEqual( + complete.args[:3], + ("token", "PATCH", "/repos/org/repo/check-runs/42"), + ) + self.assertEqual(complete.args[3]["conclusion"], "success") if __name__ == "__main__":