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
144from __future__ import annotations
155
166import json
177import os
188import sys
19- import time
209import urllib .error
2110import urllib .request
2211from pathlib import Path
4231 )
4332
4433API_BASE = "https://api.github.com"
45- BOT_LOGIN = "chatgpt-codex-connector[bot]"
4634POLICY_PATH = Path (".github/codex_auto_merge_policy.json" )
35+ HEAD_CHECK_NAME = "Codex Review Gate"
4736
4837
4938def 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-
7658def 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+
105129def 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
185174def 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 \n Passed 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
263226if __name__ == "__main__" :
0 commit comments