Skip to content

Commit a59727b

Browse files
Pigbibicodex
andauthored
fix: separate advisory review from required gate (#92)
* fix: separate review advisory from required gate Co-Authored-By: Codex <noreply@openai.com> * fix: bind static review gate to PR head Co-Authored-By: Codex <noreply@openai.com> --------- Co-authored-by: Codex <noreply@openai.com>
1 parent 70d7538 commit a59727b

4 files changed

Lines changed: 215 additions & 142 deletions

File tree

.github/workflows/codex_review_gate.yml

Lines changed: 11 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,50 +1,37 @@
11
name: Codex Review Gate
22

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.
3+
# Trusted driver for the required deterministic static check. The connector's
4+
# native GitHub review remains separate, non-required advisory evidence.
145

156
on:
16-
pull_request:
7+
pull_request_target:
178
types: [opened, synchronize, reopened, ready_for_review]
18-
pull_request_review:
19-
types: [submitted]
20-
workflow_call:
219

2210
permissions:
11+
checks: write
2312
contents: read
2413
pull-requests: read
2514

2615
concurrency:
27-
group: codex-review-gate-${{ github.event_name }}-${{ github.event.pull_request.number || github.run_id }}
16+
group: codex-review-gate-${{ github.event.pull_request.number }}
2817
cancel-in-progress: true
2918

3019
jobs:
3120
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
21+
name: Publish head gate
22+
if: github.event.pull_request.draft == false
3723

3824
runs-on: ubuntu-latest
39-
timeout-minutes: 15
25+
timeout-minutes: 5
4026
env:
4127
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 || '5' }}
4428

4529
steps:
4630
- name: Checkout
4731
uses: actions/checkout@v6
32+
with:
33+
ref: ${{ github.event.pull_request.base.sha }}
34+
persist-credentials: false
4835

4936
- name: Set up Python
5037
uses: actions/setup-python@v6

docs/ai_autonomy_architecture.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -55,9 +55,11 @@ AIAuditBridge 是 QuantStrategyLab 的 AI 审计控制面,负责:
5555
- 上传诊断 artifact。
5656

5757
- `codex_review_gate.yml`
58-
- 把 Codex GitHub App review 变成 gate。
59-
- 具备 WAIT / REACT 两种模式。
60-
- 目标是让 review 结果真正影响 merge。
58+
- 只执行确定性的 secret / path / metadata 静态门禁。
59+
- 使用受信任 base 代码检查 PR diff,并通过 Checks API 把 `Codex Review Gate`
60+
明确发布到 current head SHA;API 失败时 fail closed。
61+
- Codex connector 的原生 GitHub review 与 unresolved threads 仅作为非 required
62+
advisory evidence,不再镜像成仓库自建 check。
6163

6264
- `monthly-orchestrator.yml`
6365
- 生成月度审计 issue。
@@ -103,7 +105,7 @@ AIAuditBridge 是 QuantStrategyLab 的 AI 审计控制面,负责:
103105
- service 失败时可按条件回退到 API review。
104106

105107
- `scripts/gate_codex_app_review.py`
106-
-review gate 的形式保护合并。
108+
-current-head 静态 check 的形式保护合并;不处理 AI review verdict
107109

108110
### 1.3 已经具备的自动化能力
109111

scripts/gate_codex_app_review.py

Lines changed: 77 additions & 114 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,11 @@
11
#!/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-
"""
2+
"""Required PR static gate; connector reviews are reported separately."""
133

144
from __future__ import annotations
155

166
import json
177
import os
188
import sys
19-
import time
209
import urllib.error
2110
import urllib.request
2211
from pathlib import Path
@@ -42,8 +31,8 @@
4231
)
4332

4433
API_BASE = "https://api.github.com"
45-
BOT_LOGIN = "chatgpt-codex-connector[bot]"
4634
POLICY_PATH = Path(".github/codex_auto_merge_policy.json")
35+
HEAD_CHECK_NAME = "Codex Review Gate"
4736

4837

4938
def load_policy(path: Path = POLICY_PATH) -> dict[str, Any]:
@@ -66,13 +55,6 @@ def env(name: str, default: str = "") -> str:
6655
return os.environ.get(name, default).strip()
6756

6857

69-
def env_int(name: str, default: int) -> int:
70-
try:
71-
return int(env(name, str(default)))
72-
except ValueError:
73-
return default
74-
75-
7658
def github_request(token: str, method: str, path: str,
7759
payload: dict[str, Any] | None = None) -> Any:
7860
url = f"{API_BASE}{path}" if not path.startswith("https://") else path
@@ -92,6 +74,8 @@ def github_request(token: str, method: str, path: str,
9274
except urllib.error.HTTPError as exc:
9375
detail = exc.read().decode("utf-8", errors="replace")
9476
raise RuntimeError(f"GitHub API {method} {url}: {exc.code} {detail[:500]}") from exc
77+
except urllib.error.URLError as exc:
78+
raise RuntimeError(f"GitHub API {method} {url} unavailable") from exc
9579
return json.loads(body) if body else {}
9680

9781

@@ -102,17 +86,54 @@ def step_summary(text: str) -> None:
10286
f.write(text + "\n")
10387

10488

89+
def create_head_check(token: str, repo: str, pr_number: int, head_sha: str) -> int:
90+
payload: dict[str, Any] = {
91+
"name": HEAD_CHECK_NAME,
92+
"head_sha": head_sha,
93+
"status": "in_progress",
94+
"external_id": f"codex-review-gate:{repo}:{pr_number}:{env('GITHUB_RUN_ID')}",
95+
}
96+
run_id = env("GITHUB_RUN_ID")
97+
if run_id:
98+
server = env("GITHUB_SERVER_URL", "https://github.com")
99+
payload["details_url"] = f"{server}/{repo}/actions/runs/{run_id}"
100+
result = github_request(token, "POST", f"/repos/{repo}/check-runs", payload)
101+
check_id = result.get("id") if isinstance(result, dict) else None
102+
if type(check_id) is not int or check_id <= 0:
103+
raise RuntimeError("GitHub Checks API did not return a valid check id")
104+
return check_id
105+
106+
107+
def complete_head_check(
108+
token: str,
109+
repo: str,
110+
check_id: int,
111+
conclusion: str,
112+
summary: str,
113+
) -> None:
114+
github_request(
115+
token,
116+
"PATCH",
117+
f"/repos/{repo}/check-runs/{check_id}",
118+
{
119+
"status": "completed",
120+
"conclusion": conclusion,
121+
"output": {
122+
"title": f"Static gate {conclusion}",
123+
"summary": summary,
124+
},
125+
},
126+
)
127+
128+
105129
def run_static_guard(token: str, repo: str, pr_number: int) -> int:
106130
"""Return 0 if clean, 1 if blocked."""
107131
policy = load_policy(POLICY_PATH)
108132
files: list[dict[str, Any]] = []
109133
page = 1
110134
while True:
111-
try:
112-
batch = github_request(token, "GET",
113-
f"/repos/{repo}/pulls/{pr_number}/files?per_page=100&page={page}")
114-
except RuntimeError:
115-
break
135+
batch = github_request(token, "GET",
136+
f"/repos/{repo}/pulls/{pr_number}/files?per_page=100&page={page}")
116137
if not isinstance(batch, list) or not batch:
117138
break
118139
files.extend(batch)
@@ -133,8 +154,8 @@ def run_static_guard(token: str, repo: str, pr_number: int) -> int:
133154
)
134155
with urllib.request.urlopen(req, timeout=30) as resp:
135156
diff_text = resp.read().decode("utf-8", errors="replace")
136-
except Exception:
137-
pass
157+
except (OSError, urllib.error.URLError) as exc:
158+
raise RuntimeError("Failed to fetch PR diff") from exc
138159

139160
issues = collect_static_gate_issues(files, diff_text, policy)
140161
if not issues:
@@ -148,38 +169,6 @@ def run_static_guard(token: str, repo: str, pr_number: int) -> int:
148169
return 1
149170

150171

151-
# ─── app review ──────────────────────────────────────────────────────────────
152-
153-
def get_codex_review(token: str, repo: str, pr_number: int) -> dict[str, Any] | None:
154-
reviews = github_request(token, "GET", f"/repos/{repo}/pulls/{pr_number}/reviews?per_page=100")
155-
if not isinstance(reviews, list):
156-
return None
157-
for r in reversed(reviews):
158-
if isinstance(r, dict) and (r.get("user") or {}).get("login") == BOT_LOGIN:
159-
return r
160-
return None
161-
162-
163-
def app_decision(review: dict[str, Any] | None) -> tuple[int, str, str]:
164-
"""(exit_code, title, summary)"""
165-
if review is None:
166-
return (0, "Codex: no review — passed through",
167-
"Codex did not respond in time. Merge allowed to avoid blocking development.")
168-
state = (review.get("state") or "").strip().upper()
169-
url = review.get("html_url", "")
170-
body = (review.get("body") or "").strip()
171-
at = review.get("submitted_at", "")
172-
173-
if state == "CHANGES_REQUESTED":
174-
snippet = (body[:500] + "...") if len(body) > 500 else body
175-
return (1, "Codex: changes requested — MERGE BLOCKED",
176-
f"Codex **requested changes** at {at}.\n\n{snippet}\n\n[View review]({url})")
177-
if state == "APPROVED":
178-
return (0, "Codex: approved", f"Codex approved at {at}. [View review]({url})")
179-
return (0, f"Codex: reviewed ({state.lower()})",
180-
f"Codex state `{state}` at {at}. Not blocking. [View review]({url})")
181-
182-
183172
# ─── main ────────────────────────────────────────────────────────────────────
184173

185174
def main() -> int:
@@ -195,69 +184,43 @@ def main() -> int:
195184
return 1
196185

197186
event = json.loads(event_path.read_text(encoding="utf-8"))
198-
event_name = env("GITHUB_EVENT_NAME", "")
199187
pr = event.get("pull_request") or {}
200188
pr_number = pr.get("number")
201189
head_sha = (pr.get("head") or {}).get("sha")
202190
if not pr_number or not head_sha:
203-
print("::warning::Cannot resolve PR context")
204-
return 0
205-
206-
print(f"PR #{pr_number} sha={head_sha[:12]} event={event_name}")
207-
208-
# ── Phase 1: Static guard (skip on review-only events) ────────────
209-
if event_name != "pull_request_review":
210-
try:
211-
rc = run_static_guard(token, repo, pr_number)
212-
except RuntimeError as exc:
213-
print(f"::warning::Static guard error: {exc}")
214-
rc = 0
215-
if rc != 0:
216-
return 1
217-
print("STATIC → clean")
191+
print("::error::Cannot resolve PR context", file=sys.stderr)
192+
return 1
218193

219-
# ── Phase 2: App review ───────────────────────────────────────────
220-
# REACT: Codex just submitted a review
221-
review_event = event.get("review") or {}
222-
if event_name == "pull_request_review" and (review_event.get("user") or {}).get("login") == BOT_LOGIN:
223-
rc, title, summary = app_decision(review_event)
224-
print(f"REACT → exit={rc}: {title}")
225-
step_summary(f"## {title}\n\n{summary}")
226-
return rc
194+
print(f"PR #{pr_number} sha={head_sha[:12]}")
195+
try:
196+
check_id = create_head_check(token, repo, pr_number, head_sha)
197+
except RuntimeError as exc:
198+
print(f"::error::Cannot publish head gate: {exc}", file=sys.stderr)
199+
return 1
227200

228-
# WAIT: poll for existing or upcoming review
229201
try:
230-
existing = get_codex_review(token, repo, pr_number)
231-
except RuntimeError:
232-
existing = None
233-
234-
if existing is not None:
235-
rc, title, summary = app_decision(existing)
236-
print(f"EXISTING → exit={rc}: {title}")
237-
step_summary(f"## {title}\n\n{summary}")
238-
return rc
239-
240-
poll_s = env_int("CODEX_GATE_POLL_SECONDS", 30)
241-
max_w = env_int("CODEX_GATE_MAX_WAIT_MINUTES", 5)
242-
deadline = time.time() + max_w * 60
243-
print(f"WAIT → polling every {poll_s}s for up to {max_w}min")
244-
245-
while time.time() < deadline:
246-
time.sleep(poll_s)
202+
rc = run_static_guard(token, repo, pr_number)
203+
except RuntimeError as exc:
204+
print(f"::error::Static guard unavailable: {exc}", file=sys.stderr)
247205
try:
248-
review = get_codex_review(token, repo, pr_number)
249-
except RuntimeError:
250-
continue
251-
if review is not None:
252-
rc, title, summary = app_decision(review)
253-
print(f"WAIT → found review → exit={rc}: {title}")
254-
step_summary(f"## {title}\n\n{summary}")
255-
return rc
256-
257-
# Timeout
258-
print(f"TIMEOUT → Codex did not respond in {max_w}min; passing through")
259-
step_summary(f"## Codex: timeout after {max_w}min\n\nPassed through to avoid blocking development.")
260-
return 0
206+
complete_head_check(token, repo, check_id, "failure", "Static guard unavailable.")
207+
except RuntimeError as update_exc:
208+
print(f"::error::Cannot complete head gate: {update_exc}", file=sys.stderr)
209+
return 1
210+
conclusion = "success" if rc == 0 else "failure"
211+
summary = (
212+
"Static policy checks passed."
213+
if rc == 0
214+
else "Static policy checks blocked this PR."
215+
)
216+
try:
217+
complete_head_check(token, repo, check_id, conclusion, summary)
218+
except RuntimeError as exc:
219+
print(f"::error::Cannot complete head gate: {exc}", file=sys.stderr)
220+
return 1
221+
if rc == 0:
222+
print("STATIC → clean")
223+
return rc
261224

262225

263226
if __name__ == "__main__":

0 commit comments

Comments
 (0)