Skip to content

Commit 55b4c8e

Browse files
BenWestgatecodex
authored andcommitted
ci: Add Codex review gate
Keep a commit-scoped required check pending until managed Codex posts a clean result or review findings for the pull request head. Leave quota-limited reviews pending and retry at most one pull request per hourly run after a cooldown. Rely on the existing strict up-to-date branch rule instead of duplicating base-branch race handling in the workflow. This keeps the coordinator small enough to audit and avoids duplicate review requests on every push. Validation: YAML parse, embedded JavaScript syntax, and git diff --check.
1 parent efece7d commit 55b4c8e

1 file changed

Lines changed: 296 additions & 0 deletions

File tree

Lines changed: 296 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,296 @@
1+
name: Codex review gate
2+
3+
on:
4+
pull_request_target:
5+
types: [opened, synchronize, reopened, ready_for_review]
6+
pull_request_review:
7+
types: [submitted]
8+
issue_comment:
9+
types: [created]
10+
schedule:
11+
- cron: "17 * * * *"
12+
workflow_dispatch:
13+
14+
concurrency:
15+
group: codex-review-gate-${{ github.repository }}
16+
cancel-in-progress: false
17+
18+
permissions:
19+
checks: write
20+
contents: read
21+
issues: write
22+
pull-requests: read
23+
24+
jobs:
25+
review:
26+
name: Codex review coordinator
27+
if: >-
28+
github.event_name != 'issue_comment' ||
29+
github.event.issue.pull_request
30+
runs-on: ubuntu-latest
31+
timeout-minutes: 5
32+
steps:
33+
- name: Reconcile Codex reviews
34+
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
35+
with:
36+
script: |
37+
const gateName = 'codex-review-gate';
38+
const gateVersion = 'simple-v1';
39+
const codexLogins = new Set([
40+
'chatgpt-codex-connector',
41+
'chatgpt-codex-connector[bot]',
42+
]);
43+
const trustedRequestAssociations = new Set([
44+
'OWNER',
45+
'MEMBER',
46+
'COLLABORATOR',
47+
]);
48+
const quotaText = 'reached your Codex usage limits for code reviews';
49+
const retryCooldownMs = 5.5 * 60 * 60 * 1000;
50+
const automaticReviewGraceMs = 90 * 60 * 1000;
51+
const { owner, repo } = context.repo;
52+
const now = Date.now();
53+
54+
const isCodex = (login) => codexLogins.has(login || '');
55+
const asTime = (value) => {
56+
const time = Date.parse(value || '');
57+
return Number.isNaN(time) ? 0 : time;
58+
};
59+
const latest = (values) =>
60+
values.reduce((best, value) => Math.max(best, value), 0);
61+
62+
function reviewedCommit(body) {
63+
const match = String(body || '').match(
64+
/\*\*Reviewed commit:\*\*\s*`([0-9a-f]{7,40})`/i,
65+
);
66+
return match ? match[1].toLowerCase() : null;
67+
}
68+
69+
function matchesHead(body, headSha) {
70+
const commit = reviewedCommit(body);
71+
return Boolean(commit && headSha.toLowerCase().startsWith(commit));
72+
}
73+
74+
async function latestGateRun(headSha, pullNumber) {
75+
const response = await github.rest.checks.listForRef({
76+
owner,
77+
repo,
78+
ref: headSha,
79+
check_name: gateName,
80+
filter: 'all',
81+
per_page: 100,
82+
});
83+
return response.data.check_runs
84+
.filter(
85+
(run) =>
86+
run.app?.slug === 'github-actions' &&
87+
run.external_id === `${gateVersion}:${pullNumber}`,
88+
)
89+
.sort((a, b) => b.id - a.id)[0];
90+
}
91+
92+
async function ensureGate(pull) {
93+
const existing = await latestGateRun(pull.head.sha, pull.number);
94+
if (existing) return existing;
95+
const created = await github.rest.checks.create({
96+
owner,
97+
repo,
98+
name: gateName,
99+
head_sha: pull.head.sha,
100+
external_id: `${gateVersion}:${pull.number}`,
101+
status: 'in_progress',
102+
started_at: new Date().toISOString(),
103+
details_url: `${context.serverUrl}/${owner}/${repo}/pull/${pull.number}`,
104+
output: {
105+
title: 'Codex review pending',
106+
summary: 'Waiting for a Codex result tied to this pull request head commit.',
107+
},
108+
});
109+
return created.data;
110+
}
111+
112+
async function setGate(run, conclusion, title, summary) {
113+
await github.rest.checks.update({
114+
owner,
115+
repo,
116+
check_run_id: run.id,
117+
status: 'completed',
118+
conclusion,
119+
completed_at: new Date().toISOString(),
120+
output: { title, summary },
121+
});
122+
}
123+
124+
async function inspectPull(pull) {
125+
const run = await ensureGate(pull);
126+
const [issueComments, reviews, reviewComments] = await Promise.all([
127+
github.paginate(github.rest.issues.listComments, {
128+
owner,
129+
repo,
130+
issue_number: pull.number,
131+
per_page: 100,
132+
}),
133+
github.paginate(github.rest.pulls.listReviews, {
134+
owner,
135+
repo,
136+
pull_number: pull.number,
137+
per_page: 100,
138+
}),
139+
github.paginate(github.rest.pulls.listReviewComments, {
140+
owner,
141+
repo,
142+
pull_number: pull.number,
143+
per_page: 100,
144+
}),
145+
]);
146+
147+
const commentsByReview = new Map();
148+
for (const comment of reviewComments) {
149+
if (!isCodex(comment.user?.login)) continue;
150+
const reviewId = Number(comment.pull_request_review_id);
151+
commentsByReview.set(
152+
reviewId,
153+
(commentsByReview.get(reviewId) || 0) + 1,
154+
);
155+
}
156+
157+
const terminal = [];
158+
for (const review of reviews) {
159+
if (!isCodex(review.user?.login)) continue;
160+
if (review.commit_id !== pull.head.sha) continue;
161+
const findingCount = commentsByReview.get(Number(review.id)) || 0;
162+
if (!findingCount) continue;
163+
terminal.push({
164+
at: asTime(review.submitted_at),
165+
kind: 'findings',
166+
findingCount,
167+
});
168+
}
169+
170+
for (const comment of issueComments) {
171+
if (!isCodex(comment.user?.login)) continue;
172+
const body = String(comment.body || '');
173+
if (!body.includes("Didn't find any major issues")) continue;
174+
if (!matchesHead(body, pull.head.sha)) continue;
175+
terminal.push({
176+
at: asTime(comment.created_at),
177+
kind: 'clean',
178+
findingCount: 0,
179+
});
180+
}
181+
182+
terminal.sort((a, b) => {
183+
const timeOrder = b.at - a.at;
184+
if (timeOrder !== 0) return timeOrder;
185+
return a.kind === 'findings' ? -1 : 1;
186+
});
187+
const verdict = terminal[0];
188+
if (verdict?.kind === 'findings') {
189+
await setGate(
190+
run,
191+
'failure',
192+
'Codex review found blocking issues',
193+
`Codex posted ${verdict.findingCount} finding(s) for this head commit.`,
194+
);
195+
} else if (verdict?.kind === 'clean') {
196+
await setGate(
197+
run,
198+
'success',
199+
'Codex review passed',
200+
'Codex completed review of this head commit without review findings.',
201+
);
202+
}
203+
204+
const quotaTimes = issueComments
205+
.filter(
206+
(comment) =>
207+
isCodex(comment.user?.login) &&
208+
String(comment.body || '').includes(quotaText),
209+
)
210+
.map((comment) => asTime(comment.created_at));
211+
const requestTimes = issueComments
212+
.filter((comment) => {
213+
if (String(comment.body || '').trim() !== '@codex review') return false;
214+
if (comment.user?.login === 'github-actions[bot]') return true;
215+
return trustedRequestAssociations.has(comment.author_association || '');
216+
})
217+
.map((comment) => asTime(comment.created_at));
218+
219+
return {
220+
pull,
221+
run,
222+
pending: !verdict,
223+
latestQuotaAt: latest(quotaTimes),
224+
latestRequestAt: latest(requestTimes),
225+
startedAt: asTime(run.started_at || run.created_at),
226+
};
227+
}
228+
229+
const repository = await github.rest.repos.get({ owner, repo });
230+
const defaultBranch = repository.data.default_branch;
231+
const openPulls = await github.paginate(github.rest.pulls.list, {
232+
owner,
233+
repo,
234+
state: 'open',
235+
base: defaultBranch,
236+
per_page: 100,
237+
});
238+
const reviewablePulls = openPulls.filter((pull) => !pull.draft);
239+
const inspected = [];
240+
for (const pull of reviewablePulls) {
241+
inspected.push(await inspectPull(pull));
242+
}
243+
244+
const pending = inspected.filter((item) => item.pending);
245+
const globalQuotaAt = latest(
246+
inspected.map((item) => item.latestQuotaAt),
247+
);
248+
const quotaCoolingDown =
249+
globalQuotaAt && now - globalQuotaAt < retryCooldownMs;
250+
if (!pending.length || quotaCoolingDown) return;
251+
252+
let candidate = null;
253+
if (context.eventName === 'schedule') {
254+
const due = pending.filter((item) => {
255+
const requestAfterGate = item.latestRequestAt > item.startedAt;
256+
if (requestAfterGate) {
257+
return now - item.latestRequestAt >= retryCooldownMs;
258+
}
259+
return now - item.startedAt >= automaticReviewGraceMs;
260+
});
261+
due.sort(
262+
(a, b) =>
263+
a.startedAt - b.startedAt || a.pull.number - b.pull.number,
264+
);
265+
candidate = due[0] || null;
266+
}
267+
268+
if (!candidate) return;
269+
const requestAfterGate = candidate.latestRequestAt > candidate.startedAt;
270+
if (
271+
requestAfterGate &&
272+
now - candidate.latestRequestAt < retryCooldownMs
273+
) {
274+
return;
275+
}
276+
277+
const comment = await github.rest.issues.createComment({
278+
owner,
279+
repo,
280+
issue_number: candidate.pull.number,
281+
body: '@codex review',
282+
});
283+
await github.rest.checks.update({
284+
owner,
285+
repo,
286+
check_run_id: candidate.run.id,
287+
status: 'in_progress',
288+
output: {
289+
title: 'Codex review requested',
290+
summary: [
291+
`Request comment: ${comment.data.id}`,
292+
`Head commit: ${candidate.pull.head.sha}`,
293+
'Waiting for the managed Codex result.',
294+
].join('\n'),
295+
},
296+
});

0 commit comments

Comments
 (0)