From 5d372733d0a66f54f73328170abb49c8f9d96a20 Mon Sep 17 00:00:00 2001 From: Arjun Aletty Date: Tue, 8 Sep 2026 18:43:17 -0700 Subject: [PATCH 1/6] chore: block merges while Codex reviews are unfinished --- .github/policy-tests/codex-stack-policy.cjs | 197 ++++++++++++++++++ .../policy-tests/codex-stack-policy.test.cjs | 93 +++++++++ .github/workflows/stack-policy.yml | 42 +++- AGENTS.md | 1 + docs/stacked-prs.md | 8 + 5 files changed, 338 insertions(+), 3 deletions(-) create mode 100644 .github/policy-tests/codex-stack-policy.cjs create mode 100644 .github/policy-tests/codex-stack-policy.test.cjs diff --git a/.github/policy-tests/codex-stack-policy.cjs b/.github/policy-tests/codex-stack-policy.cjs new file mode 100644 index 0000000..f34593a --- /dev/null +++ b/.github/policy-tests/codex-stack-policy.cjs @@ -0,0 +1,197 @@ +// Stack policy v2: wait for Codex review completion. Trusted metadata only; never executes pull request code. +'use strict'; + +function dependency(body) { + const clean = (body || '').replace(//g, '').replace(/```[\s\S]*?```/g, ''); + const lines = clean.split(/\r?\n/).filter(x => /^Depends on:/i.test(x.trim())); + if (lines.length !== 1) throw new Error('Include exactly one line: Depends on: none or Depends on: #123.'); + const match = lines[0].trim().match(/^Depends on:\s*(none|#[1-9]\d*)\s*$/i); + if (!match) throw new Error('Dependency must be none or one immediate parent PR number, e.g. #123.'); + return match[1].toLowerCase() === 'none' ? null : Number(match[1].slice(1)); +} + +const CODEX_BOT = 'chatgpt-codex-connector[bot]'; +const SUMMARY_MARKER = ''; +function codexReview(comments, reactions) { + const summaries = comments.filter(c => c.user?.login === CODEX_BOT && c.user?.type === 'Bot' && c.body?.includes(SUMMARY_MARKER)); + // Read only the status column, never explanatory text or findings. + for (const summary of summaries) { + const rows = summary.body.split(/\r?\n/).filter(line => /^\|/.test(line) && /\*\*(?:Code Review|Security Review)\*\*/i.test(line)); + if (!rows.length) return {ok:false, message:'Codex review status is unrecognized; wait for a valid completion summary.'}; + for (const row of rows) { + const status = row.split('|')[2] || ''; + if (!/\*\*Completed\*\*/i.test(status)) + return {ok:false, message:'Codex review has not completed. Wait for its response; retry a failed review.'}; + } + } + const completedAt = Math.max(0, ...summaries.map(c => Date.parse(c.updated_at) || 0)); + const requests = comments.filter(c => /^\s*@codex\s+(?:security\s+)?review\b/im.test(c.body || '')); + if (requests.some(c => (Date.parse(c.created_at) || Infinity) > completedAt)) + return {ok:false, message:'A Codex review was requested; waiting for the bot completion summary.'}; + const eyes = reactions.filter(r => r.user?.login === CODEX_BOT && r.user?.type === 'Bot' && r.content === 'eyes'); + if (eyes.some(r => !completedAt || (Date.parse(r.created_at) || Infinity) > completedAt)) + return {ok:false, message:'Codex is reviewing (eyes reaction); wait for completion.'}; + return {ok:true, message:summaries.length ? 'Codex review completed; findings are advisory.' : 'No active Codex review detected.'}; +} + +function validate(snapshot) { + const {pulls, stacks, defaultBranch, repository, ancestors} = snapshot; + const byNumber = new Map(pulls.map(p => [p.number, p])); + const results = new Map(); + const activeStacks = stacks.filter(s => s.open); + const membership = new Map(); + for (const stack of activeStacks) { + for (const entry of stack.pull_requests) { + const list = membership.get(entry.number) || []; + list.push(stack); membership.set(entry.number, list); + } + } + for (const p of pulls.filter(p => p.state === 'open')) { + try { + const dep = dependency(p.body); + const memberships = membership.get(p.number) || []; + if (memberships.length > 1) throw new Error('PR belongs to multiple active stacks.'); + const stack = memberships[0]; + if (!!p.stack !== !!stack || (stack && p.stack.number !== stack.number)) + throw new Error('Native stack metadata is inconsistent; resubmit/sync and rerun.'); + if (stack && stack.base.ref !== defaultBranch) throw new Error('Stack must target the repository default branch.'); + let expected = null; + if (stack) { + const active = stack.pull_requests.filter(e => e.state === 'open'); + const index = active.findIndex(e => e.number === p.number); + if (index < 0) throw new Error('PR missing from native stack order.'); + expected = index > 0 ? active[index - 1].number : null; + if (p.head.repo?.full_name !== repository) throw new Error('Native stack branches must be in this repository.'); + } + const parent = dep === null ? null : byNumber.get(dep); + if (dep !== null && !parent) throw new Error(`Parent #${dep} is missing or inaccessible.`); + if (dep === p.number) throw new Error('A PR cannot depend on itself.'); + if (parent && parent.state === 'open') { + if (!stack) throw new Error('Dependent PR must be linked into a native GitHub stack.'); + if (expected !== dep) throw new Error('Declared parent does not match immediate native stack predecessor.'); + if (parent.head.repo?.full_name !== repository || p.base.ref !== parent.head.ref) + throw new Error('PR base must be the immediate parent branch in this repository.'); + if (!ancestors[`${parent.head.sha}:${p.head.sha}`]) throw new Error('Parent tip is not an ancestor; rebase the stack.'); + } else { + if (expected !== null) throw new Error(`Declare immediate parent #${expected}.`); + if (p.base.ref !== defaultBranch) throw new Error('Standalone or bottom PR must target the default branch.'); + if (parent && (!parent.merged_at || parent.base.ref !== defaultBranch)) + throw new Error('Closed parent was not merged into the default branch.'); + } + // A parent with an unregistered dependent child must not pass as standalone. + const children = pulls.filter(c => c.state === 'open' && c.number !== p.number && + c.base.ref === p.head.ref && c.base.repo?.full_name === repository && p.head.repo?.full_name === repository); + if (children.length > 1) throw new Error('Multiple child branches: split into separate linear stacks.'); + for (const child of children) { + if (!stack || !(membership.get(child.number) || []).some(s => s.number === stack.number)) + throw new Error(`Dependent PR #${child.number} is not linked into the same native stack.`); + } + const review = snapshot.codex?.[p.number]; + if (review && !review.ok) throw new Error(review.message); + results.set(p.number, {ok:true, message: parent?.merged_at ? 'Parent merged; this layer now targets trunk.' : 'Dependency and native stack structure verified.'}); + } catch (error) { results.set(p.number, {ok:false, message:error.message}); } + } + // A broken declaration on a native layer invalidates the whole stack, preventing partial bypass. + for (const stack of activeStacks) { + const bad = stack.pull_requests.find(e => results.get(e.number)?.ok === false); + if (bad) for (const entry of stack.pull_requests) { + if (results.get(entry.number)?.ok) results.set(entry.number, {ok:false, message:`Stack layer #${bad.number} fails policy: ${results.get(bad.number).message}`}); + } + } + return results; +} + +async function run({github, context, core}) { + const {owner, repo} = context.repo; + const repository = `${owner}/${repo}`; + const headers = {'X-GitHub-Api-Version':'2026-03-10'}; + const args = {owner, repo, headers}; + const checks = new Map(); + const details_url = `${context.serverUrl}/${repository}/actions/runs/${context.runId}`; + async function listOpen() { return github.paginate(github.rest.pulls.list, {...args, state:'open', per_page:100}); } + async function pending(pulls) { + for (const p of pulls) if (!checks.has(p.head.sha)) { + const {data} = await github.rest.checks.create({...args, name:'Stack policy', head_sha:p.head.sha, + status:'in_progress', details_url, output:{title:'Validating current dependency graph', summary:'Validation is pending; no PR code is executed.'}}); + checks.set(p.head.sha, data.id); + } + } + async function snapshot(open) { + const {data: repositoryData} = await github.rest.repos.get(args); + const stacks = await github.paginate('GET /repos/{owner}/{repo}/stacks', {...args, per_page:100}); + const pulls = [...open]; + const seen = new Set(pulls.map(p => p.number)); + for (const p of open) { + let dep; try { dep = dependency(p.body); } catch { continue; } + if (dep !== null && !seen.has(dep)) { + try { + const {data} = await github.rest.pulls.get({...args, pull_number:dep}); + pulls.push(data); seen.add(dep); + } catch (error) { + if (error.status !== 404) throw error; // A nonexistent parent fails its PR, not unrelated PRs. + seen.add(dep); + } + } + } + const codex = {}; + for (const p of open) { + const comments = await github.paginate(github.rest.issues.listComments, {...args, issue_number:p.number, per_page:100}); + const reactions = await github.paginate(github.rest.reactions.listForIssue, {...args, issue_number:p.number, per_page:100}); + codex[p.number] = codexReview(comments, reactions); + } + return {repository, defaultBranch:repositoryData.default_branch, pulls, stacks, ancestors:{}, codex}; + } + function fingerprint(s) { + return JSON.stringify({defaultBranch:s.defaultBranch, codex:s.codex, + pulls:s.pulls.map(p => ({number:p.number, state:p.state, merged_at:p.merged_at, body:p.body, + head:[p.head.ref,p.head.sha,p.head.repo?.full_name],base:[p.base.ref,p.base.sha,p.base.repo?.full_name],stack:p.stack})).sort((a,b)=>a.number-b.number), + stacks:s.stacks.filter(s=>s.open).map(s=>({number:s.number,base:s.base,prs:s.pull_requests.map(p=>[p.number,p.state,p.head.sha])})).sort((a,b)=>a.number-b.number)}); + } + try { + // Invalidate old success before querying stacks or ancestors. Failed API calls leave failures. + if (context.payload.pull_request?.head?.sha) await pending([context.payload.pull_request]); + let open = await listOpen(); + await pending(open); + let stable, results; + for (let attempt=0; attempt<3; attempt++) { + const before = await snapshot(open); + for (const p of open) { + let dep; try { dep=dependency(p.body); } catch { continue; } + const parent = before.pulls.find(q=>q.number===dep && q.state==='open'); + if (parent && parent.number !== p.number && parent.head.repo?.full_name === repository && + p.head.repo?.full_name === repository && p.base.ref === parent.head.ref) { + const {data} = await github.rest.repos.compareCommitsWithBasehead({...args, basehead:`${parent.head.sha}...${p.head.sha}`}); + before.ancestors[`${parent.head.sha}:${p.head.sha}`] = data.merge_base_commit.sha === parent.head.sha; + } + } + results = validate(before); + open = await listOpen(); await pending(open); + const after = await snapshot(open); + if (fingerprint(before) === fingerprint(after)) { stable=after; break; } + } + if (!stable) throw new Error('Dependency graph changed repeatedly during validation; rerun when stable.'); + const rows=[]; + for (const [sha,id] of checks) { + // Re-read all dependency metadata before issuing any success for this SHA. + const fresh = await snapshot(await listOpen()); + if (fingerprint(stable) !== fingerprint(fresh)) throw new Error('PR metadata changed before publication; rerun required.'); + const prs = stable.pulls.filter(p=>p.state==='open' && p.head.sha===sha); + const bad = prs.filter(p=>!results.get(p.number)?.ok); + const summary = prs.map(p=>`#${p.number}: ${results.get(p.number)?.message || 'Missing validation.'}`).join('\n') || 'PR is closed.'; + await github.rest.checks.update({...args,check_run_id:id,status:'completed',conclusion:bad.length?'failure':'success', + output:{title:bad.length?'Stack policy failed':'Stack policy passed',summary}}); + rows.push(summary); + } + await core.summary.addHeading('Stack policy').addRaw(rows.join('\n\n')).write(); + } catch(error) { + // Revoke even successes already published during this run if a later snapshot/API fails. + for (const id of checks.values()) { + try { await github.rest.checks.update({...args,check_run_id:id,status:'completed',conclusion:'failure', + output:{title:'Stack validation could not complete',summary:String(error.message).slice(0,60000)}}); } + catch (updateError) { core.error(`Failed to revoke check ${id}: ${updateError.message}`); } + } + core.setFailed(error.message); + } +} + +module.exports = {dependency, validate, run, codexReview}; diff --git a/.github/policy-tests/codex-stack-policy.test.cjs b/.github/policy-tests/codex-stack-policy.test.cjs new file mode 100644 index 0000000..f8fe904 --- /dev/null +++ b/.github/policy-tests/codex-stack-policy.test.cjs @@ -0,0 +1,93 @@ +'use strict'; +const test = require('node:test'); +const assert = require('node:assert/strict'); +const {dependency,validate,run} = require('./codex-stack-policy.cjs'); +function pr(number,base,dep,stack=null) { + return {number,state:'open',body:`Change\n\nDepends on: ${dep === null?'none':'#'+dep}`,stack, + head:{ref:`layer-${number}`,sha:`sha${number}`,repo:{full_name:'aletty/test'}}, + base:{ref:base,sha:'base',repo:{full_name:'aletty/test'}}}; +} +function fixture() { + const pulls=[pr(1,'main',null,{number:10}),pr(2,'layer-1',1,{number:10}),pr(3,'layer-2',2,{number:10})]; + return {repository:'aletty/test',defaultBranch:'main',pulls, + stacks:[{number:10,open:true,base:{ref:'main'},pull_requests:structuredClone(pulls)}],ancestors:{'sha1:sha2':true,'sha2:sha3':true}}; +} +test('standalone passes',()=>{const s=fixture();s.pulls=[pr(1,'main',null)];s.stacks=[];assert.equal(validate(s).get(1).ok,true);}); +test('three-layer native stack passes',()=>assert.ok([...validate(fixture()).values()].every(r=>r.ok))); +test('missing, duplicate and malformed declarations fail',()=>{ + for(const body of ['', 'Depends on: none\nDepends on: #1','Depends on: #0','Depends on: #1 #2','']) assert.throws(()=>dependency(body)); + assert.equal(dependency('\nDepends on: #12'),12); +}); +test('unregistered chain blocks child and parent',()=>{const s=fixture();s.stacks=[];for(const p of s.pulls)p.stack=null;assert.ok([...validate(s).values()].every(r=>!r.ok));}); +test('incorrect parent fails entire native stack',()=>{const s=fixture();s.pulls[2].body='Depends on: #1';assert.ok([...validate(s).values()].every(r=>!r.ok));}); +test('stale ancestry blocks entire native stack',()=>{const s=fixture();s.ancestors['sha1:sha2']=false;assert.ok([...validate(s).values()].every(r=>!r.ok));}); +test('merged parent transition passes after retargeting',()=>{ + const s=fixture();s.pulls[0].state='closed';s.pulls[0].merged_at='now';s.stacks[0].pull_requests[0].state='closed';s.pulls[1].base.ref='main'; + assert.ok([...validate(s).values()].every(r=>r.ok)); +}); +test('closed unmerged parent fails',()=>{const s=fixture();s.pulls[0].state='closed';s.stacks[0].pull_requests[0].state='closed';s.pulls[1].base.ref='main';assert.equal(validate(s).get(2).ok,false);}); +test('missing and self parents fail',()=>{const s=fixture();s.pulls[1].body='Depends on: #999';assert.equal(validate(s).get(2).ok,false);s.pulls[1].body='Depends on: #2';assert.equal(validate(s).get(2).ok,false);}); +test('non-default trunk and cross-fork stack fail',()=>{const s=fixture();s.stacks[0].base.ref='release';assert.equal(validate(s).get(1).ok,false);s.stacks[0].base.ref='main';s.pulls[1].head.repo.full_name='someone/test';assert.equal(validate(s).get(2).ok,false);}); +test('wrong base, missing native membership and duplicate stack membership fail',()=>{ + const s=fixture();s.pulls[1].base.ref='main';assert.equal(validate(s).get(2).ok,false); + const t=fixture();t.pulls[0].stack=null;assert.equal(validate(t).get(1).ok,false); + const u=fixture();u.stacks.push({...structuredClone(u.stacks[0]),number:11});assert.equal(validate(u).get(1).ok,false); +}); +test('fork standalone allowed',()=>{const s=fixture();s.pulls=[pr(1,'main',null)];s.pulls[0].head.repo.full_name='fork/test';s.stacks=[];assert.equal(validate(s).get(1).ok,true);}); +function harness({failure=false,change=false,shared=false}={}) { + let reads=0;const updates=[],created=[],failures=[]; + const pulls=[pr(1,'main',null)]; + if(shared){const p=pr(2,'main',null);p.head.sha='sha1';p.body='';pulls.push(p);} + const rest={pulls:{list:Symbol('list'),get:async()=>{throw new Error('not expected')}}, + issues:{listComments:Symbol('comments')},reactions:{listForIssue:Symbol('reactions')}, + repos:{get:async()=>({data:{default_branch:'main'}})}, + checks:{create:async p=>{created.push(p);return {data:{id:created.length}}},update:async p=>{updates.push(p);return {data:p}}}}; + const github={rest,paginate:async route=>{ + if(route===rest.pulls.list){reads++;const result=structuredClone(pulls);if(change&&reads>1)result[0].body+='\n'+reads;return result;} + if(failure)throw new Error('GitHub unavailable');return []; + }}; + const core={summary:{addHeading(){return this},addRaw(){return this},async write(){}},error(){},setFailed(x){failures.push(x)}}; + return {github,core,context:{repo:{owner:'aletty',repo:'test'},payload:{pull_request:pulls[0]},serverUrl:'https://github.com',runId:1},updates,created,failures}; +} +test('controller issues pending then success on stable snapshot',async()=>{const h=harness();await run(h);assert.equal(h.created[0].status,'in_progress');assert.equal(h.updates.at(-1).conclusion,'success');assert.equal(h.failures.length,0);}); +test('API failure revokes check and fails controller',async()=>{const h=harness({failure:true});await run(h);assert.equal(h.updates.at(-1).conclusion,'failure');assert.equal(h.failures.length,1);}); +test('changing metadata never succeeds',async()=>{const h=harness({change:true});await run(h);assert.ok(h.updates.every(u=>u.conclusion==='failure'));assert.equal(h.failures.length,1);}); +test('PRs sharing head SHA receive most restrictive result',async()=>{const h=harness({shared:true});await run(h);assert.equal(h.created.length,1);assert.equal(h.updates.at(-1).conclusion,'failure');}); +const {codexReview} = require('./codex-stack-policy.cjs'); +const bot={login:'chatgpt-codex-connector[bot]',type:'Bot'}; +const at='2026-09-09T01:00:00Z', later='2026-09-09T02:00:00Z'; +function summary(status='Completed', user=bot) { + return {user,updated_at:at,body:`\n| Review | Status | Commit | Review trigger |\n| --- | --- | --- | --- |\n| 📝 **Code Review** | ✅ **${status}** | \`abc1234\` | PR opened |\nCodex reacts with eyes while Running.`}; +} +test('completed review passes regardless of findings or explanatory Running text',()=>assert.equal(codexReview([summary()],[]).ok,true)); +test('running, failed, cancelled, queued and unknown summaries block',()=>{ + for(const status of ['Running','Failed','Cancelled','Queued','New Status']) assert.equal(codexReview([summary(status)],[]).ok,false); + const c=summary(); c.body='\nunknown format';assert.equal(codexReview([c],[]).ok,false); +}); +test('all concurrent review types must finish',()=>{ + const c=summary();c.body+='\n| 🔒 **Security Review** | 🔄 **Running** | `abc1234` | PR opened |';assert.equal(codexReview([c],[]).ok,false); +}); +test('human cannot spoof completion or active bot status',()=>{ + assert.equal(codexReview([summary('Running',{login:'aletty',type:'User'})],[]).ok,true); + assert.equal(codexReview([summary('Completed',{login:bot.login,type:'User'})],[{user:bot,content:'eyes',created_at:at}]).ok,false); +}); +test('eyes-only review blocks and completion supersedes earlier eyes',()=>{ + const r={user:bot,content:'eyes',created_at:at};assert.equal(codexReview([],[r]).ok,false); + assert.equal(codexReview([summary()],[r]).ok,true); + assert.equal(codexReview([summary()],[{...r,created_at:later}]).ok,false); +}); +test('new manual review requests block even after previous completion',()=>{ + for(const body of ['@codex review','@codex security review']) { + const r={body,created_at:later};assert.equal(codexReview([summary(),r],[]).ok,false); + r.created_at=at;assert.equal(codexReview([summary(),r],[]).ok,true); + } +}); +test('no requested or active review passes',()=>assert.equal(codexReview([],[]).ok,true)); +test('active Codex review blocks entire native stack',()=>{ + const s=fixture();s.codex={2:codexReview([summary('Running')],[])};assert.ok([...validate(s).values()].every(r=>!r.ok)); +}); +test('review activity changing during publication cannot pass',async()=>{ + const h=harness();const paginate=h.github.paginate;let n=0; + h.github.paginate=async(route,args)=>route===h.github.rest.issues.listComments ? [summary(++n%2?'Completed':'Running')] : paginate(route,args); + await run(h);assert.ok(h.updates.every(u=>u.conclusion==='failure'));assert.equal(h.failures.length,1); +}); diff --git a/.github/workflows/stack-policy.yml b/.github/workflows/stack-policy.yml index b8798f2..1be7af3 100644 --- a/.github/workflows/stack-policy.yml +++ b/.github/workflows/stack-policy.yml @@ -3,6 +3,8 @@ name: Stack policy controller on: pull_request_target: types: [opened, reopened, synchronize, edited, closed, ready_for_review, converted_to_draft, stacked, unstacked] + issue_comment: + types: [created, edited, deleted] push: branches: ["master"] delete: @@ -10,6 +12,7 @@ on: permissions: contents: read pull-requests: read + issues: read checks: write concurrency: group: stack-policy-reconcile @@ -17,13 +20,14 @@ concurrency: jobs: reconcile: name: Reconcile stack policy + if: github.event_name != 'issue_comment' || github.event.issue.pull_request runs-on: ubuntu-latest timeout-minutes: 10 steps: - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 with: script: | - // Stack policy v1. Trusted metadata only; never executes pull request code. + // Stack policy v2: wait for Codex review completion. Trusted metadata only; never executes pull request code. 'use strict'; function dependency(body) { @@ -35,6 +39,30 @@ jobs: return match[1].toLowerCase() === 'none' ? null : Number(match[1].slice(1)); } + const CODEX_BOT = 'chatgpt-codex-connector[bot]'; + const SUMMARY_MARKER = ''; + function codexReview(comments, reactions) { + const summaries = comments.filter(c => c.user?.login === CODEX_BOT && c.user?.type === 'Bot' && c.body?.includes(SUMMARY_MARKER)); + // Read only the status column, never explanatory text or findings. + for (const summary of summaries) { + const rows = summary.body.split(/\r?\n/).filter(line => /^\|/.test(line) && /\*\*(?:Code Review|Security Review)\*\*/i.test(line)); + if (!rows.length) return {ok:false, message:'Codex review status is unrecognized; wait for a valid completion summary.'}; + for (const row of rows) { + const status = row.split('|')[2] || ''; + if (!/\*\*Completed\*\*/i.test(status)) + return {ok:false, message:'Codex review has not completed. Wait for its response; retry a failed review.'}; + } + } + const completedAt = Math.max(0, ...summaries.map(c => Date.parse(c.updated_at) || 0)); + const requests = comments.filter(c => /^\s*@codex\s+(?:security\s+)?review\b/im.test(c.body || '')); + if (requests.some(c => (Date.parse(c.created_at) || Infinity) > completedAt)) + return {ok:false, message:'A Codex review was requested; waiting for the bot completion summary.'}; + const eyes = reactions.filter(r => r.user?.login === CODEX_BOT && r.user?.type === 'Bot' && r.content === 'eyes'); + if (eyes.some(r => !completedAt || (Date.parse(r.created_at) || Infinity) > completedAt)) + return {ok:false, message:'Codex is reviewing (eyes reaction); wait for completion.'}; + return {ok:true, message:summaries.length ? 'Codex review completed; findings are advisory.' : 'No active Codex review detected.'}; + } + function validate(snapshot) { const {pulls, stacks, defaultBranch, repository, ancestors} = snapshot; const byNumber = new Map(pulls.map(p => [p.number, p])); @@ -87,6 +115,8 @@ jobs: if (!stack || !(membership.get(child.number) || []).some(s => s.number === stack.number)) throw new Error(`Dependent PR #${child.number} is not linked into the same native stack.`); } + const review = snapshot.codex?.[p.number]; + if (review && !review.ok) throw new Error(review.message); results.set(p.number, {ok:true, message: parent?.merged_at ? 'Parent merged; this layer now targets trunk.' : 'Dependency and native stack structure verified.'}); } catch (error) { results.set(p.number, {ok:false, message:error.message}); } } @@ -132,10 +162,16 @@ jobs: } } } - return {repository, defaultBranch:repositoryData.default_branch, pulls, stacks, ancestors:{}}; + const codex = {}; + for (const p of open) { + const comments = await github.paginate(github.rest.issues.listComments, {...args, issue_number:p.number, per_page:100}); + const reactions = await github.paginate(github.rest.reactions.listForIssue, {...args, issue_number:p.number, per_page:100}); + codex[p.number] = codexReview(comments, reactions); + } + return {repository, defaultBranch:repositoryData.default_branch, pulls, stacks, ancestors:{}, codex}; } function fingerprint(s) { - return JSON.stringify({defaultBranch:s.defaultBranch, + return JSON.stringify({defaultBranch:s.defaultBranch, codex:s.codex, pulls:s.pulls.map(p => ({number:p.number, state:p.state, merged_at:p.merged_at, body:p.body, head:[p.head.ref,p.head.sha,p.head.repo?.full_name],base:[p.base.ref,p.base.sha,p.base.repo?.full_name],stack:p.stack})).sort((a,b)=>a.number-b.number), stacks:s.stacks.filter(s=>s.open).map(s=>({number:s.number,base:s.base,prs:s.pull_requests.map(p=>[p.number,p.state,p.head.sha])})).sort((a,b)=>a.number-b.number)}); diff --git a/AGENTS.md b/AGENTS.md index fa30f0a..d586e1b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,4 +9,5 @@ - Follow repository validation requirements for every affected layer and wait for required checks. Do not use admin bypass or disable policy to ship feature work. Merge a native stack only with an explicit target, e.g. `gh stack merge --yes --squash` when squash is the repository's allowed method. That operation also merges every unmerged layer below the target: confirm the intended set and existing merge authorization first. Use `gh pr merge` only for standalone PRs. - On first setup of a repository owned by `aletty`, audit enrollment using `python3 ~/.codex/stack-policy/enroll.py audit --repo OWNER/REPO`. For a non-archived, non-fork repository, apply missing policy through its setup PR and activate protection after the setup lands and a real policy check succeeds. The helper's `apply`, `enforce`, and `rollback` subcommands are documented in its README. Empty repositories wait for their initial branch. If tools, permissions, or account features prevent enrollment, report the exact gap; do not claim enforcement exists. - Global defaults apply to local Codex work in all repositories. Repository changes and GitHub administration are limited to enrolled, user-owned repositories. Respect explicit user instructions and higher-priority instructions; flag conflicting repository policy rather than silently weakening enforcement. +- Wait for every active Codex GitHub review to complete before merging. The required Stack policy check reads the authenticated Codex bot summary and review requests; unfinished, failed, or unknown states block merging. Completed findings are advisory and may be deliberately ignored. Do not bypass a running review. diff --git a/docs/stacked-prs.md b/docs/stacked-prs.md index 2dec442..2709069 100644 --- a/docs/stacked-prs.md +++ b/docs/stacked-prs.md @@ -44,3 +44,11 @@ Wait for checks and existing merge authorization. Verify the exact target and it The policy bundle and enrollment helper live at `~/.codex/stack-policy` on the configured Codex host. The helper audits before applying and saves original branch protection and repository file contents. Roll back protection with its `rollback --repo OWNER/REPO` command; revert the setup PR through a new PR to remove committed policy. Rollback does not reset developer branches or undo unrelated repository settings. GitHub may change its public-preview stack APIs. A failing validation must be investigated, not silently bypassed. Repository owners can deliberately change GitHub settings; these controls govern normal contribution and merge paths, not owner authority. + + +## Codex review completion + +The required `Stack policy` check blocks while Codex's authenticated summary reports a running, failed, or unknown review, while a new `@codex review` / `@codex security review` request awaits completion, or while an active bot eyes reaction has no later completion. Every code and security review must finish. Completed findings are advisory: maintainers may choose to ignore them. Retry a failed review; do not bypass an unfinished review. + +PR and comment events update the check. Manually dispatch the controller if a legacy reaction-only review does not emit a summary event. GitHub event delivery is asynchronous, leaving a brief detection window when a new review starts. No review is required when none is requested or active. This does not require a fresh review of every push. Existing stack, CI, and review requirements still apply. The controller only reads GitHub metadata and never executes PR code. + From 5c25db7cd3127c359a14436594774be8ebbdf838 Mon Sep 17 00:00:00 2001 From: Arjun Aletty Date: Tue, 8 Sep 2026 18:48:48 -0700 Subject: [PATCH 2/6] chore: block merges while Codex reviews are unfinished --- .github/policy-tests/codex-stack-policy.cjs | 23 +++++++++++--- .../policy-tests/codex-stack-policy.test.cjs | 30 ++++++++++++++++++- .github/workflows/stack-policy.yml | 23 +++++++++++--- docs/stacked-prs.md | 2 +- 4 files changed, 68 insertions(+), 10 deletions(-) diff --git a/.github/policy-tests/codex-stack-policy.cjs b/.github/policy-tests/codex-stack-policy.cjs index f34593a..fe18a7f 100644 --- a/.github/policy-tests/codex-stack-policy.cjs +++ b/.github/policy-tests/codex-stack-policy.cjs @@ -12,6 +12,10 @@ function dependency(body) { const CODEX_BOT = 'chatgpt-codex-connector[bot]'; const SUMMARY_MARKER = ''; +function isReviewRequest(body) { + const clean = (body || '').replace(/```[\s\S]*?```/g, '').replace(/~~~[\s\S]*?~~~/g, ''); + return /^@codex\s+(?:security\s+)?review\b/im.test(clean); +} function codexReview(comments, reactions) { const summaries = comments.filter(c => c.user?.login === CODEX_BOT && c.user?.type === 'Bot' && c.body?.includes(SUMMARY_MARKER)); // Read only the status column, never explanatory text or findings. @@ -25,7 +29,7 @@ function codexReview(comments, reactions) { } } const completedAt = Math.max(0, ...summaries.map(c => Date.parse(c.updated_at) || 0)); - const requests = comments.filter(c => /^\s*@codex\s+(?:security\s+)?review\b/im.test(c.body || '')); + const requests = comments.filter(c => c.authorizedRequest === true && isReviewRequest(c.body)); if (requests.some(c => (Date.parse(c.created_at) || Infinity) > completedAt)) return {ok:false, message:'A Codex review was requested; waiting for the bot completion summary.'}; const eyes = reactions.filter(r => r.user?.login === CODEX_BOT && r.user?.type === 'Bot' && r.content === 'eyes'); @@ -134,8 +138,17 @@ async function run({github, context, core}) { } } const codex = {}; + const permissions = new Map(); for (const p of open) { const comments = await github.paginate(github.rest.issues.listComments, {...args, issue_number:p.number, per_page:100}); + for (const comment of comments.filter(c => isReviewRequest(c.body) && c.user?.type === 'User')) { + const username = comment.user.login; + if (!permissions.has(username)) { + const {data} = await github.rest.repos.getCollaboratorPermissionLevel({...args, username}); + permissions.set(username, ['admin','maintain','write'].includes(data.permission)); + } + comment.authorizedRequest = permissions.get(username); + } const reactions = await github.paginate(github.rest.reactions.listForIssue, {...args, issue_number:p.number, per_page:100}); codex[p.number] = codexReview(comments, reactions); } @@ -170,11 +183,11 @@ async function run({github, context, core}) { if (fingerprint(before) === fingerprint(after)) { stable=after; break; } } if (!stable) throw new Error('Dependency graph changed repeatedly during validation; rerun when stable.'); + // Check once around the publication batch: O(N) review reads, not O(N squared). + const fresh = await snapshot(await listOpen()); + if (fingerprint(stable) !== fingerprint(fresh)) throw new Error('PR metadata changed before publication; rerun required.'); const rows=[]; for (const [sha,id] of checks) { - // Re-read all dependency metadata before issuing any success for this SHA. - const fresh = await snapshot(await listOpen()); - if (fingerprint(stable) !== fingerprint(fresh)) throw new Error('PR metadata changed before publication; rerun required.'); const prs = stable.pulls.filter(p=>p.state==='open' && p.head.sha===sha); const bad = prs.filter(p=>!results.get(p.number)?.ok); const summary = prs.map(p=>`#${p.number}: ${results.get(p.number)?.message || 'Missing validation.'}`).join('\n') || 'PR is closed.'; @@ -182,6 +195,8 @@ async function run({github, context, core}) { output:{title:bad.length?'Stack policy failed':'Stack policy passed',summary}}); rows.push(summary); } + const published = await snapshot(await listOpen()); + if (fingerprint(stable) !== fingerprint(published)) throw new Error('PR metadata changed during publication; revoking results.'); await core.summary.addHeading('Stack policy').addRaw(rows.join('\n\n')).write(); } catch(error) { // Revoke even successes already published during this run if a later snapshot/API fails. diff --git a/.github/policy-tests/codex-stack-policy.test.cjs b/.github/policy-tests/codex-stack-policy.test.cjs index f8fe904..b77de2d 100644 --- a/.github/policy-tests/codex-stack-policy.test.cjs +++ b/.github/policy-tests/codex-stack-policy.test.cjs @@ -78,7 +78,7 @@ test('eyes-only review blocks and completion supersedes earlier eyes',()=>{ }); test('new manual review requests block even after previous completion',()=>{ for(const body of ['@codex review','@codex security review']) { - const r={body,created_at:later};assert.equal(codexReview([summary(),r],[]).ok,false); + const r={body,created_at:later,authorizedRequest:true};assert.equal(codexReview([summary(),r],[]).ok,false); r.created_at=at;assert.equal(codexReview([summary(),r],[]).ok,true); } }); @@ -91,3 +91,31 @@ test('review activity changing during publication cannot pass',async()=>{ h.github.paginate=async(route,args)=>route===h.github.rest.issues.listComments ? [summary(++n%2?'Completed':'Running')] : paginate(route,args); await run(h);assert.ok(h.updates.every(u=>u.conclusion==='failure'));assert.equal(h.failures.length,1); }); + +test('untrusted, quoted and fenced requests do not block',()=>{ + for (const r of [{body:'@codex review'}, {body:'> @codex review',authorizedRequest:true}, {body:'```\n@codex review\n```',authorizedRequest:true}, {body:'~~~\n@codex review\n~~~',authorizedRequest:true}]) + assert.equal(codexReview([summary(),{...r,created_at:later}],[]).ok,true); +}); +test('publication recheck revokes success if a review begins while publishing',async()=>{ + const h=harness();const paginate=h.github.paginate;let published=false; + const update=h.github.rest.checks.update;h.github.rest.checks.update=async p=>{if(p.conclusion==='success')published=true;return update(p);}; + h.github.paginate=async(route,args)=>route===h.github.rest.issues.listComments ? [summary(published?'Running':'Completed')] : paginate(route,args); + await run(h);assert.equal(h.updates.at(-1).conclusion,'failure');assert.equal(h.failures.length,1); +}); +test('review snapshot reads stay linear in number of heads',async()=>{ + const h=harness();const paginate=h.github.paginate;let reviewReads=0; + h.github.paginate=async(route,args)=>{ + if(route===h.github.rest.pulls.list)return Array.from({length:20},(_,i)=>pr(i+1,'main',null)); + if(route===h.github.rest.issues.listComments)reviewReads++; + return paginate(route,args); + }; + await run(h);assert.equal(h.failures.length,0);assert.equal(reviewReads,80); +}); +test('controller checks repository permission before accepting a manual request',async()=>{ + for(const permission of ['write','read']) { + const h=harness();const paginate=h.github.paginate; + h.github.rest.repos.getCollaboratorPermissionLevel=async()=>({data:{permission}}); + h.github.paginate=async(route,args)=>route===h.github.rest.issues.listComments ? [summary(),{body:'@codex review',created_at:later,user:{login:'visitor',type:'User'}}] : paginate(route,args); + await run(h);assert.equal(h.failures.length,0);assert.equal(h.updates.at(-1).conclusion,permission==='write'?'failure':'success'); + } +}); diff --git a/.github/workflows/stack-policy.yml b/.github/workflows/stack-policy.yml index 1be7af3..ac7a9c0 100644 --- a/.github/workflows/stack-policy.yml +++ b/.github/workflows/stack-policy.yml @@ -41,6 +41,10 @@ jobs: const CODEX_BOT = 'chatgpt-codex-connector[bot]'; const SUMMARY_MARKER = ''; + function isReviewRequest(body) { + const clean = (body || '').replace(/```[\s\S]*?```/g, '').replace(/~~~[\s\S]*?~~~/g, ''); + return /^@codex\s+(?:security\s+)?review\b/im.test(clean); + } function codexReview(comments, reactions) { const summaries = comments.filter(c => c.user?.login === CODEX_BOT && c.user?.type === 'Bot' && c.body?.includes(SUMMARY_MARKER)); // Read only the status column, never explanatory text or findings. @@ -54,7 +58,7 @@ jobs: } } const completedAt = Math.max(0, ...summaries.map(c => Date.parse(c.updated_at) || 0)); - const requests = comments.filter(c => /^\s*@codex\s+(?:security\s+)?review\b/im.test(c.body || '')); + const requests = comments.filter(c => c.authorizedRequest === true && isReviewRequest(c.body)); if (requests.some(c => (Date.parse(c.created_at) || Infinity) > completedAt)) return {ok:false, message:'A Codex review was requested; waiting for the bot completion summary.'}; const eyes = reactions.filter(r => r.user?.login === CODEX_BOT && r.user?.type === 'Bot' && r.content === 'eyes'); @@ -163,8 +167,17 @@ jobs: } } const codex = {}; + const permissions = new Map(); for (const p of open) { const comments = await github.paginate(github.rest.issues.listComments, {...args, issue_number:p.number, per_page:100}); + for (const comment of comments.filter(c => isReviewRequest(c.body) && c.user?.type === 'User')) { + const username = comment.user.login; + if (!permissions.has(username)) { + const {data} = await github.rest.repos.getCollaboratorPermissionLevel({...args, username}); + permissions.set(username, ['admin','maintain','write'].includes(data.permission)); + } + comment.authorizedRequest = permissions.get(username); + } const reactions = await github.paginate(github.rest.reactions.listForIssue, {...args, issue_number:p.number, per_page:100}); codex[p.number] = codexReview(comments, reactions); } @@ -199,11 +212,11 @@ jobs: if (fingerprint(before) === fingerprint(after)) { stable=after; break; } } if (!stable) throw new Error('Dependency graph changed repeatedly during validation; rerun when stable.'); + // Check once around the publication batch: O(N) review reads, not O(N squared). + const fresh = await snapshot(await listOpen()); + if (fingerprint(stable) !== fingerprint(fresh)) throw new Error('PR metadata changed before publication; rerun required.'); const rows=[]; for (const [sha,id] of checks) { - // Re-read all dependency metadata before issuing any success for this SHA. - const fresh = await snapshot(await listOpen()); - if (fingerprint(stable) !== fingerprint(fresh)) throw new Error('PR metadata changed before publication; rerun required.'); const prs = stable.pulls.filter(p=>p.state==='open' && p.head.sha===sha); const bad = prs.filter(p=>!results.get(p.number)?.ok); const summary = prs.map(p=>`#${p.number}: ${results.get(p.number)?.message || 'Missing validation.'}`).join('\n') || 'PR is closed.'; @@ -211,6 +224,8 @@ jobs: output:{title:bad.length?'Stack policy failed':'Stack policy passed',summary}}); rows.push(summary); } + const published = await snapshot(await listOpen()); + if (fingerprint(stable) !== fingerprint(published)) throw new Error('PR metadata changed during publication; revoking results.'); await core.summary.addHeading('Stack policy').addRaw(rows.join('\n\n')).write(); } catch(error) { // Revoke even successes already published during this run if a later snapshot/API fails. diff --git a/docs/stacked-prs.md b/docs/stacked-prs.md index 2709069..3fdaa47 100644 --- a/docs/stacked-prs.md +++ b/docs/stacked-prs.md @@ -48,7 +48,7 @@ GitHub may change its public-preview stack APIs. A failing validation must be in ## Codex review completion -The required `Stack policy` check blocks while Codex's authenticated summary reports a running, failed, or unknown review, while a new `@codex review` / `@codex security review` request awaits completion, or while an active bot eyes reaction has no later completion. Every code and security review must finish. Completed findings are advisory: maintainers may choose to ignore them. Retry a failed review; do not bypass an unfinished review. +The required `Stack policy` check blocks while Codex's authenticated summary reports a running, failed, or unknown review, while a new `@codex review` / `@codex security review` request from someone with repository write access awaits completion, or while an active bot eyes reaction has no later completion. Every code and security review must finish. Completed findings are advisory: maintainers may choose to ignore them. Retry a failed review; do not bypass an unfinished review. PR and comment events update the check. Manually dispatch the controller if a legacy reaction-only review does not emit a summary event. GitHub event delivery is asynchronous, leaving a brief detection window when a new review starts. No review is required when none is requested or active. This does not require a fresh review of every push. Existing stack, CI, and review requirements still apply. The controller only reads GitHub metadata and never executes PR code. From b00f0762ca4a33cffd4b683b7dc401cd69077ab7 Mon Sep 17 00:00:00 2001 From: Arjun Aletty Date: Tue, 8 Sep 2026 18:51:12 -0700 Subject: [PATCH 3/6] chore: block merges while Codex reviews are unfinished --- .github/policy-tests/codex-stack-policy.cjs | 9 +++++--- .../policy-tests/codex-stack-policy.test.cjs | 21 +++++++++++++++++-- .github/workflows/stack-policy.yml | 9 +++++--- 3 files changed, 31 insertions(+), 8 deletions(-) diff --git a/.github/policy-tests/codex-stack-policy.cjs b/.github/policy-tests/codex-stack-policy.cjs index fe18a7f..ffbc894 100644 --- a/.github/policy-tests/codex-stack-policy.cjs +++ b/.github/policy-tests/codex-stack-policy.cjs @@ -13,7 +13,7 @@ function dependency(body) { const CODEX_BOT = 'chatgpt-codex-connector[bot]'; const SUMMARY_MARKER = ''; function isReviewRequest(body) { - const clean = (body || '').replace(/```[\s\S]*?```/g, '').replace(/~~~[\s\S]*?~~~/g, ''); + const clean = (body || '').replace(//g, '').replace(/```[\s\S]*?```/g, '').replace(/~~~[\s\S]*?~~~/g, ''); return /^@codex\s+(?:security\s+)?review\b/im.test(clean); } function codexReview(comments, reactions) { @@ -30,10 +30,10 @@ function codexReview(comments, reactions) { } const completedAt = Math.max(0, ...summaries.map(c => Date.parse(c.updated_at) || 0)); const requests = comments.filter(c => c.authorizedRequest === true && isReviewRequest(c.body)); - if (requests.some(c => (Date.parse(c.created_at) || Infinity) > completedAt)) + if (requests.some(c => (Date.parse(c.updated_at || c.created_at) || Infinity) >= completedAt)) return {ok:false, message:'A Codex review was requested; waiting for the bot completion summary.'}; const eyes = reactions.filter(r => r.user?.login === CODEX_BOT && r.user?.type === 'Bot' && r.content === 'eyes'); - if (eyes.some(r => !completedAt || (Date.parse(r.created_at) || Infinity) > completedAt)) + if (eyes.some(r => !completedAt || (Date.parse(r.created_at) || Infinity) >= completedAt)) return {ok:false, message:'Codex is reviewing (eyes reaction); wait for completion.'}; return {ok:true, message:summaries.length ? 'Codex review completed; findings are advisory.' : 'No active Codex review detected.'}; } @@ -150,6 +150,9 @@ async function run({github, context, core}) { comment.authorizedRequest = permissions.get(username); } const reactions = await github.paginate(github.rest.reactions.listForIssue, {...args, issue_number:p.number, per_page:100}); + for (const comment of comments.filter(c => c.reactions?.eyes > 0)) { + reactions.push(...await github.paginate(github.rest.reactions.listForIssueComment, {...args, comment_id:comment.id, per_page:100})); + } codex[p.number] = codexReview(comments, reactions); } return {repository, defaultBranch:repositoryData.default_branch, pulls, stacks, ancestors:{}, codex}; diff --git a/.github/policy-tests/codex-stack-policy.test.cjs b/.github/policy-tests/codex-stack-policy.test.cjs index b77de2d..9a22dec 100644 --- a/.github/policy-tests/codex-stack-policy.test.cjs +++ b/.github/policy-tests/codex-stack-policy.test.cjs @@ -73,13 +73,14 @@ test('human cannot spoof completion or active bot status',()=>{ }); test('eyes-only review blocks and completion supersedes earlier eyes',()=>{ const r={user:bot,content:'eyes',created_at:at};assert.equal(codexReview([],[r]).ok,false); - assert.equal(codexReview([summary()],[r]).ok,true); + assert.equal(codexReview([summary()],[r]).ok,false); + const done=summary();done.updated_at=later;assert.equal(codexReview([done],[r]).ok,true); assert.equal(codexReview([summary()],[{...r,created_at:later}]).ok,false); }); test('new manual review requests block even after previous completion',()=>{ for(const body of ['@codex review','@codex security review']) { const r={body,created_at:later,authorizedRequest:true};assert.equal(codexReview([summary(),r],[]).ok,false); - r.created_at=at;assert.equal(codexReview([summary(),r],[]).ok,true); + r.created_at='2026-09-09T00:59:59Z';assert.equal(codexReview([summary(),r],[]).ok,true); } }); test('no requested or active review passes',()=>assert.equal(codexReview([],[]).ok,true)); @@ -119,3 +120,19 @@ test('controller checks repository permission before accepting a manual request' await run(h);assert.equal(h.failures.length,0);assert.equal(h.updates.at(-1).conclusion,permission==='write'?'failure':'success'); } }); + +test('timestamp ties and edited requests stay blocked',()=>{ + assert.equal(codexReview([summary(),{body:'@codex review',created_at:at,authorizedRequest:true}],[]).ok,false); + assert.equal(codexReview([summary(),{body:'@codex review',created_at:'2026-09-08T00:00:00Z',updated_at:later,authorizedRequest:true}],[]).ok,false); + assert.equal(codexReview([summary(),{body:'',created_at:later,authorizedRequest:true}],[]).ok,true); +}); +test('controller detects authenticated eyes on triggering comments',async()=>{ + const h=harness();const paginate=h.github.paginate; + h.github.rest.reactions.listForIssueComment=Symbol('commentReactions'); + h.github.paginate=async(route,args)=>{ + if(route===h.github.rest.issues.listComments)return [{id:123,body:'legacy request',reactions:{eyes:1}}]; + if(route===h.github.rest.reactions.listForIssueComment)return [{user:bot,content:'eyes',created_at:at}]; + return paginate(route,args); + }; + await run(h);assert.equal(h.failures.length,0);assert.equal(h.updates.at(-1).conclusion,'failure'); +}); diff --git a/.github/workflows/stack-policy.yml b/.github/workflows/stack-policy.yml index ac7a9c0..71311f4 100644 --- a/.github/workflows/stack-policy.yml +++ b/.github/workflows/stack-policy.yml @@ -42,7 +42,7 @@ jobs: const CODEX_BOT = 'chatgpt-codex-connector[bot]'; const SUMMARY_MARKER = ''; function isReviewRequest(body) { - const clean = (body || '').replace(/```[\s\S]*?```/g, '').replace(/~~~[\s\S]*?~~~/g, ''); + const clean = (body || '').replace(//g, '').replace(/```[\s\S]*?```/g, '').replace(/~~~[\s\S]*?~~~/g, ''); return /^@codex\s+(?:security\s+)?review\b/im.test(clean); } function codexReview(comments, reactions) { @@ -59,10 +59,10 @@ jobs: } const completedAt = Math.max(0, ...summaries.map(c => Date.parse(c.updated_at) || 0)); const requests = comments.filter(c => c.authorizedRequest === true && isReviewRequest(c.body)); - if (requests.some(c => (Date.parse(c.created_at) || Infinity) > completedAt)) + if (requests.some(c => (Date.parse(c.updated_at || c.created_at) || Infinity) >= completedAt)) return {ok:false, message:'A Codex review was requested; waiting for the bot completion summary.'}; const eyes = reactions.filter(r => r.user?.login === CODEX_BOT && r.user?.type === 'Bot' && r.content === 'eyes'); - if (eyes.some(r => !completedAt || (Date.parse(r.created_at) || Infinity) > completedAt)) + if (eyes.some(r => !completedAt || (Date.parse(r.created_at) || Infinity) >= completedAt)) return {ok:false, message:'Codex is reviewing (eyes reaction); wait for completion.'}; return {ok:true, message:summaries.length ? 'Codex review completed; findings are advisory.' : 'No active Codex review detected.'}; } @@ -179,6 +179,9 @@ jobs: comment.authorizedRequest = permissions.get(username); } const reactions = await github.paginate(github.rest.reactions.listForIssue, {...args, issue_number:p.number, per_page:100}); + for (const comment of comments.filter(c => c.reactions?.eyes > 0)) { + reactions.push(...await github.paginate(github.rest.reactions.listForIssueComment, {...args, comment_id:comment.id, per_page:100})); + } codex[p.number] = codexReview(comments, reactions); } return {repository, defaultBranch:repositoryData.default_branch, pulls, stacks, ancestors:{}, codex}; From aeea67c0a570e940a56d49a86e8b46106aa7ffd9 Mon Sep 17 00:00:00 2001 From: Arjun Aletty Date: Tue, 8 Sep 2026 18:58:30 -0700 Subject: [PATCH 4/6] chore: block merges while Codex reviews are unfinished --- .github/policy-tests/codex-stack-policy.cjs | 76 +++++++++++++++---- .../policy-tests/codex-stack-policy.test.cjs | 39 +++++++++- .github/workflows/stack-policy.yml | 76 +++++++++++++++---- docs/stacked-prs.md | 2 +- 4 files changed, 158 insertions(+), 35 deletions(-) diff --git a/.github/policy-tests/codex-stack-policy.cjs b/.github/policy-tests/codex-stack-policy.cjs index ffbc894..e661ab8 100644 --- a/.github/policy-tests/codex-stack-policy.cjs +++ b/.github/policy-tests/codex-stack-policy.cjs @@ -16,26 +16,42 @@ function isReviewRequest(body) { const clean = (body || '').replace(//g, '').replace(/```[\s\S]*?```/g, '').replace(/~~~[\s\S]*?~~~/g, ''); return /^@codex\s+(?:security\s+)?review\b/im.test(clean); } -function codexReview(comments, reactions) { +function codexReview(comments, reactions, previous = {}) { + const memory = {seen:!!previous.seen, after:{...previous.after}}; const summaries = comments.filter(c => c.user?.login === CODEX_BOT && c.user?.type === 'Bot' && c.body?.includes(SUMMARY_MARKER)); - // Read only the status column, never explanatory text or findings. + const requests = comments.filter(c => c.authorizedRequest === true && isReviewRequest(c.body)); + const eyes = reactions.filter(r => r.user?.login === CODEX_BOT && r.user?.type === 'Bot' && r.content === 'eyes'); + memory.seen ||= !!(summaries.length || requests.length || eyes.length); + const completed = {code:0, security:0, any:0}; + let problem = ''; + const record = (kind, time) => { memory.after[kind] = Math.max(memory.after[kind] || 0, time || Number.MAX_SAFE_INTEGER); }; for (const summary of summaries) { const rows = summary.body.split(/\r?\n/).filter(line => /^\|/.test(line) && /\*\*(?:Code Review|Security Review)\*\*/i.test(line)); - if (!rows.length) return {ok:false, message:'Codex review status is unrecognized; wait for a valid completion summary.'}; + if (!rows.length) problem = 'Codex review status is unrecognized; wait for a valid completion summary.'; for (const row of rows) { + const kind = /\*\*Security Review\*\*/i.test(row) ? 'security' : 'code'; const status = row.split('|')[2] || ''; - if (!/\*\*Completed\*\*/i.test(status)) - return {ok:false, message:'Codex review has not completed. Wait for its response; retry a failed review.'}; + const timestamp = Date.parse(status.match(/datetime="([^"]+)"/)?.[1] || summary.updated_at) || 0; + if (/\*\*Completed\*\*/i.test(status)) { + completed[kind] = Math.max(completed[kind], timestamp); + completed.any = Math.max(completed.any, timestamp); + } else { + record(kind, timestamp); + problem = 'Codex review has not completed. Wait for its response; retry a failed review.'; + } } } - const completedAt = Math.max(0, ...summaries.map(c => Date.parse(c.updated_at) || 0)); - const requests = comments.filter(c => c.authorizedRequest === true && isReviewRequest(c.body)); - if (requests.some(c => (Date.parse(c.updated_at || c.created_at) || Infinity) >= completedAt)) - return {ok:false, message:'A Codex review was requested; waiting for the bot completion summary.'}; - const eyes = reactions.filter(r => r.user?.login === CODEX_BOT && r.user?.type === 'Bot' && r.content === 'eyes'); - if (eyes.some(r => !completedAt || (Date.parse(r.created_at) || Infinity) >= completedAt)) - return {ok:false, message:'Codex is reviewing (eyes reaction); wait for completion.'}; - return {ok:true, message:summaries.length ? 'Codex review completed; findings are advisory.' : 'No active Codex review detected.'}; + for (const request of requests) { + const clean = request.body.replace(//g, '').replace(/```[\s\S]*?```/g, '').replace(/~~~[\s\S]*?~~~/g, ''); + for (const match of clean.matchAll(/^@codex\s+(security\s+)?review\b/gim)) + record(match[1] ? 'security' : 'code', Date.parse(request.updated_at || request.created_at)); + } + for (const reaction of eyes) record('any', Date.parse(reaction.created_at)); + if (!problem && memory.seen && !summaries.length) + problem = 'Codex review activity was recorded; its completion summary is missing. Deleting a marker does not cancel the gate.'; + if (!problem && Object.entries(memory.after).some(([kind,time]) => completed[kind] <= time)) + problem = 'Waiting for a later completion of each requested or previously active Codex review.'; + return {ok:!problem, message:problem || (summaries.length ? 'Codex review completed; findings are advisory.' : 'No active Codex review detected.'), memory}; } function validate(snapshot) { @@ -111,6 +127,8 @@ async function run({github, context, core}) { const headers = {'X-GitHub-Api-Version':'2026-03-10'}; const args = {owner, repo, headers}; const checks = new Map(); + const reviewMemory = {}; + const MEMORY_MARKER = "codex-review-memory-v2:"; const details_url = `${context.serverUrl}/${repository}/actions/runs/${context.runId}`; async function listOpen() { return github.paginate(github.rest.pulls.list, {...args, state:'open', per_page:100}); } async function pending(pulls) { @@ -120,6 +138,26 @@ async function run({github, context, core}) { checks.set(p.head.sha, data.id); } } + async function loadMemory(open) { + const refs = new Set(open.map(p => p.head.sha)); + if (context.payload.before) refs.add(context.payload.before); + for (const ref of refs) { + const runs = await github.paginate(github.rest.checks.listForRef, {...args, ref, check_name:'Stack policy', filter:'all', per_page:100}); + for (const check of runs) { + if (check.app?.slug !== 'github-actions' || !check.output?.text?.startsWith(MEMORY_MARKER)) continue; + const saved = JSON.parse(check.output.text.slice(MEMORY_MARKER.length)); + for (const [number,state] of Object.entries(saved)) { + const old = reviewMemory[number] || {seen:false, after:{}}; + old.seen ||= state.seen; + for (const [kind,time] of Object.entries(state.after || {})) old.after[kind] = Math.max(old.after[kind] || 0, time); + reviewMemory[number] = old; + } + } + } + } + function memoryText(prs, snapshot) { + return MEMORY_MARKER + JSON.stringify(Object.fromEntries(prs.map(p => [p.number, snapshot?.codex[p.number]?.memory || reviewMemory[p.number] || {}]))); + } async function snapshot(open) { const {data: repositoryData} = await github.rest.repos.get(args); const stacks = await github.paginate('GET /repos/{owner}/{repo}/stacks', {...args, per_page:100}); @@ -141,6 +179,9 @@ async function run({github, context, core}) { const permissions = new Map(); for (const p of open) { const comments = await github.paginate(github.rest.issues.listComments, {...args, issue_number:p.number, per_page:100}); + const deleted = context.eventName === 'issue_comment' && context.payload.action === 'deleted' && context.payload.issue?.number === p.number ? context.payload.comment : null; + // Preserve a deleted command/active bot marker from the trusted event payload. + if (deleted) comments.push(deleted); for (const comment of comments.filter(c => isReviewRequest(c.body) && c.user?.type === 'User')) { const username = comment.user.login; if (!permissions.has(username)) { @@ -153,7 +194,8 @@ async function run({github, context, core}) { for (const comment of comments.filter(c => c.reactions?.eyes > 0)) { reactions.push(...await github.paginate(github.rest.reactions.listForIssueComment, {...args, comment_id:comment.id, per_page:100})); } - codex[p.number] = codexReview(comments, reactions); + codex[p.number] = codexReview(comments, reactions, reviewMemory[p.number]); + reviewMemory[p.number] = codex[p.number].memory; } return {repository, defaultBranch:repositoryData.default_branch, pulls, stacks, ancestors:{}, codex}; } @@ -168,6 +210,7 @@ async function run({github, context, core}) { if (context.payload.pull_request?.head?.sha) await pending([context.payload.pull_request]); let open = await listOpen(); await pending(open); + await loadMemory(open); let stable, results; for (let attempt=0; attempt<3; attempt++) { const before = await snapshot(open); @@ -195,9 +238,10 @@ async function run({github, context, core}) { const bad = prs.filter(p=>!results.get(p.number)?.ok); const summary = prs.map(p=>`#${p.number}: ${results.get(p.number)?.message || 'Missing validation.'}`).join('\n') || 'PR is closed.'; await github.rest.checks.update({...args,check_run_id:id,status:'completed',conclusion:bad.length?'failure':'success', - output:{title:bad.length?'Stack policy failed':'Stack policy passed',summary}}); + output:{title:bad.length?'Stack policy failed':'Stack policy passed',summary,text:memoryText(prs,stable)}}); rows.push(summary); } + for (const p of stable.pulls.filter(p=>p.state==='open')) reviewMemory[p.number] = stable.codex[p.number].memory; const published = await snapshot(await listOpen()); if (fingerprint(stable) !== fingerprint(published)) throw new Error('PR metadata changed during publication; revoking results.'); await core.summary.addHeading('Stack policy').addRaw(rows.join('\n\n')).write(); @@ -205,7 +249,7 @@ async function run({github, context, core}) { // Revoke even successes already published during this run if a later snapshot/API fails. for (const id of checks.values()) { try { await github.rest.checks.update({...args,check_run_id:id,status:'completed',conclusion:'failure', - output:{title:'Stack validation could not complete',summary:String(error.message).slice(0,60000)}}); } + output:{title:'Stack validation could not complete',summary:String(error.message).slice(0,60000),text:MEMORY_MARKER+JSON.stringify(reviewMemory)}}); } catch (updateError) { core.error(`Failed to revoke check ${id}: ${updateError.message}`); } } core.setFailed(error.message); diff --git a/.github/policy-tests/codex-stack-policy.test.cjs b/.github/policy-tests/codex-stack-policy.test.cjs index 9a22dec..db86b6b 100644 --- a/.github/policy-tests/codex-stack-policy.test.cjs +++ b/.github/policy-tests/codex-stack-policy.test.cjs @@ -41,7 +41,7 @@ function harness({failure=false,change=false,shared=false}={}) { const rest={pulls:{list:Symbol('list'),get:async()=>{throw new Error('not expected')}}, issues:{listComments:Symbol('comments')},reactions:{listForIssue:Symbol('reactions')}, repos:{get:async()=>({data:{default_branch:'main'}})}, - checks:{create:async p=>{created.push(p);return {data:{id:created.length}}},update:async p=>{updates.push(p);return {data:p}}}}; + checks:{listForRef:Symbol('listChecks'),create:async p=>{created.push(p);return {data:{id:created.length}}},update:async p=>{updates.push(p);return {data:p}}}}; const github={rest,paginate:async route=>{ if(route===rest.pulls.list){reads++;const result=structuredClone(pulls);if(change&&reads>1)result[0].body+='\n'+reads;return result;} if(failure)throw new Error('GitHub unavailable');return []; @@ -78,7 +78,7 @@ test('eyes-only review blocks and completion supersedes earlier eyes',()=>{ assert.equal(codexReview([summary()],[{...r,created_at:later}]).ok,false); }); test('new manual review requests block even after previous completion',()=>{ - for(const body of ['@codex review','@codex security review']) { + for(const body of ['@codex review']) { const r={body,created_at:later,authorizedRequest:true};assert.equal(codexReview([summary(),r],[]).ok,false); r.created_at='2026-09-09T00:59:59Z';assert.equal(codexReview([summary(),r],[]).ok,true); } @@ -136,3 +136,38 @@ test('controller detects authenticated eyes on triggering comments',async()=>{ }; await run(h);assert.equal(h.failures.length,0);assert.equal(h.updates.at(-1).conclusion,'failure'); }); +test('code completion cannot satisfy a security review request',()=>{ + const c=summary();c.updated_at=later; + const request={body:'@codex security review',created_at:at,authorizedRequest:true}; + assert.equal(codexReview([c,request],[]).ok,false); + c.body+='\n| 🔒 **Security Review** | ✅ **Completed** | `abc1234` | manual |'; + assert.equal(codexReview([c,request],[]).ok,true); +}); +test('row completion time wins over a later unrelated summary edit',()=>{ + const c=summary();c.updated_at=later;c.body=c.body.replace('**Completed**','**Completed** old'); + assert.equal(codexReview([c,{body:'@codex review',created_at:at,authorizedRequest:true}],[]).ok,false); +}); +test('deleting running summary preserves the wait until a later completion',()=>{ + const first=codexReview([summary('Running')],[]); + assert.equal(codexReview([],[],first.memory).ok,false); + const done=summary();done.updated_at=later; + assert.equal(codexReview([done],[],first.memory).ok,true); +}); +test('deleting an accepted request does not let an older completion pass',()=>{ + const first=codexReview([summary(),{body:'@codex review',created_at:later,authorizedRequest:true}],[]); + assert.equal(codexReview([summary()],[],first.memory).ok,false); +}); +test('controller persists and reloads active state from trusted check output',async()=>{ + const h=harness();const paginate=h.github.paginate; + h.github.paginate=async(route,args)=>route===h.github.rest.issues.listComments ? [summary('Running')] : paginate(route,args); + await run(h); + const saved=h.updates.at(-1); + const next=harness();const nextPaginate=next.github.paginate; + next.github.paginate=async(route,args)=>route===next.github.rest.checks.listForRef ? [{app:{slug:'github-actions'},output:saved.output}] : nextPaginate(route,args); + await run(next);assert.equal(next.failures.length,0);assert.equal(next.updates.at(-1).conclusion,'failure'); +}); +test('synchronize carries recorded active state from the preceding head',async()=>{ + const h=harness();h.context.payload.before='oldsha';const paginate=h.github.paginate; + h.github.paginate=async(route,args)=>route===h.github.rest.checks.listForRef && args.ref==='oldsha' ? [{app:{slug:'github-actions'},output:{text:'codex-review-memory-v2:'+JSON.stringify({1:{seen:true,after:{code:Date.parse(at)}}})}}] : paginate(route,args); + await run(h);assert.equal(h.failures.length,0);assert.equal(h.updates.at(-1).conclusion,'failure'); +}); diff --git a/.github/workflows/stack-policy.yml b/.github/workflows/stack-policy.yml index 71311f4..477de4a 100644 --- a/.github/workflows/stack-policy.yml +++ b/.github/workflows/stack-policy.yml @@ -45,26 +45,42 @@ jobs: const clean = (body || '').replace(//g, '').replace(/```[\s\S]*?```/g, '').replace(/~~~[\s\S]*?~~~/g, ''); return /^@codex\s+(?:security\s+)?review\b/im.test(clean); } - function codexReview(comments, reactions) { + function codexReview(comments, reactions, previous = {}) { + const memory = {seen:!!previous.seen, after:{...previous.after}}; const summaries = comments.filter(c => c.user?.login === CODEX_BOT && c.user?.type === 'Bot' && c.body?.includes(SUMMARY_MARKER)); - // Read only the status column, never explanatory text or findings. + const requests = comments.filter(c => c.authorizedRequest === true && isReviewRequest(c.body)); + const eyes = reactions.filter(r => r.user?.login === CODEX_BOT && r.user?.type === 'Bot' && r.content === 'eyes'); + memory.seen ||= !!(summaries.length || requests.length || eyes.length); + const completed = {code:0, security:0, any:0}; + let problem = ''; + const record = (kind, time) => { memory.after[kind] = Math.max(memory.after[kind] || 0, time || Number.MAX_SAFE_INTEGER); }; for (const summary of summaries) { const rows = summary.body.split(/\r?\n/).filter(line => /^\|/.test(line) && /\*\*(?:Code Review|Security Review)\*\*/i.test(line)); - if (!rows.length) return {ok:false, message:'Codex review status is unrecognized; wait for a valid completion summary.'}; + if (!rows.length) problem = 'Codex review status is unrecognized; wait for a valid completion summary.'; for (const row of rows) { + const kind = /\*\*Security Review\*\*/i.test(row) ? 'security' : 'code'; const status = row.split('|')[2] || ''; - if (!/\*\*Completed\*\*/i.test(status)) - return {ok:false, message:'Codex review has not completed. Wait for its response; retry a failed review.'}; + const timestamp = Date.parse(status.match(/datetime="([^"]+)"/)?.[1] || summary.updated_at) || 0; + if (/\*\*Completed\*\*/i.test(status)) { + completed[kind] = Math.max(completed[kind], timestamp); + completed.any = Math.max(completed.any, timestamp); + } else { + record(kind, timestamp); + problem = 'Codex review has not completed. Wait for its response; retry a failed review.'; + } } } - const completedAt = Math.max(0, ...summaries.map(c => Date.parse(c.updated_at) || 0)); - const requests = comments.filter(c => c.authorizedRequest === true && isReviewRequest(c.body)); - if (requests.some(c => (Date.parse(c.updated_at || c.created_at) || Infinity) >= completedAt)) - return {ok:false, message:'A Codex review was requested; waiting for the bot completion summary.'}; - const eyes = reactions.filter(r => r.user?.login === CODEX_BOT && r.user?.type === 'Bot' && r.content === 'eyes'); - if (eyes.some(r => !completedAt || (Date.parse(r.created_at) || Infinity) >= completedAt)) - return {ok:false, message:'Codex is reviewing (eyes reaction); wait for completion.'}; - return {ok:true, message:summaries.length ? 'Codex review completed; findings are advisory.' : 'No active Codex review detected.'}; + for (const request of requests) { + const clean = request.body.replace(//g, '').replace(/```[\s\S]*?```/g, '').replace(/~~~[\s\S]*?~~~/g, ''); + for (const match of clean.matchAll(/^@codex\s+(security\s+)?review\b/gim)) + record(match[1] ? 'security' : 'code', Date.parse(request.updated_at || request.created_at)); + } + for (const reaction of eyes) record('any', Date.parse(reaction.created_at)); + if (!problem && memory.seen && !summaries.length) + problem = 'Codex review activity was recorded; its completion summary is missing. Deleting a marker does not cancel the gate.'; + if (!problem && Object.entries(memory.after).some(([kind,time]) => completed[kind] <= time)) + problem = 'Waiting for a later completion of each requested or previously active Codex review.'; + return {ok:!problem, message:problem || (summaries.length ? 'Codex review completed; findings are advisory.' : 'No active Codex review detected.'), memory}; } function validate(snapshot) { @@ -140,6 +156,8 @@ jobs: const headers = {'X-GitHub-Api-Version':'2026-03-10'}; const args = {owner, repo, headers}; const checks = new Map(); + const reviewMemory = {}; + const MEMORY_MARKER = "codex-review-memory-v2:"; const details_url = `${context.serverUrl}/${repository}/actions/runs/${context.runId}`; async function listOpen() { return github.paginate(github.rest.pulls.list, {...args, state:'open', per_page:100}); } async function pending(pulls) { @@ -149,6 +167,26 @@ jobs: checks.set(p.head.sha, data.id); } } + async function loadMemory(open) { + const refs = new Set(open.map(p => p.head.sha)); + if (context.payload.before) refs.add(context.payload.before); + for (const ref of refs) { + const runs = await github.paginate(github.rest.checks.listForRef, {...args, ref, check_name:'Stack policy', filter:'all', per_page:100}); + for (const check of runs) { + if (check.app?.slug !== 'github-actions' || !check.output?.text?.startsWith(MEMORY_MARKER)) continue; + const saved = JSON.parse(check.output.text.slice(MEMORY_MARKER.length)); + for (const [number,state] of Object.entries(saved)) { + const old = reviewMemory[number] || {seen:false, after:{}}; + old.seen ||= state.seen; + for (const [kind,time] of Object.entries(state.after || {})) old.after[kind] = Math.max(old.after[kind] || 0, time); + reviewMemory[number] = old; + } + } + } + } + function memoryText(prs, snapshot) { + return MEMORY_MARKER + JSON.stringify(Object.fromEntries(prs.map(p => [p.number, snapshot?.codex[p.number]?.memory || reviewMemory[p.number] || {}]))); + } async function snapshot(open) { const {data: repositoryData} = await github.rest.repos.get(args); const stacks = await github.paginate('GET /repos/{owner}/{repo}/stacks', {...args, per_page:100}); @@ -170,6 +208,9 @@ jobs: const permissions = new Map(); for (const p of open) { const comments = await github.paginate(github.rest.issues.listComments, {...args, issue_number:p.number, per_page:100}); + const deleted = context.eventName === 'issue_comment' && context.payload.action === 'deleted' && context.payload.issue?.number === p.number ? context.payload.comment : null; + // Preserve a deleted command/active bot marker from the trusted event payload. + if (deleted) comments.push(deleted); for (const comment of comments.filter(c => isReviewRequest(c.body) && c.user?.type === 'User')) { const username = comment.user.login; if (!permissions.has(username)) { @@ -182,7 +223,8 @@ jobs: for (const comment of comments.filter(c => c.reactions?.eyes > 0)) { reactions.push(...await github.paginate(github.rest.reactions.listForIssueComment, {...args, comment_id:comment.id, per_page:100})); } - codex[p.number] = codexReview(comments, reactions); + codex[p.number] = codexReview(comments, reactions, reviewMemory[p.number]); + reviewMemory[p.number] = codex[p.number].memory; } return {repository, defaultBranch:repositoryData.default_branch, pulls, stacks, ancestors:{}, codex}; } @@ -197,6 +239,7 @@ jobs: if (context.payload.pull_request?.head?.sha) await pending([context.payload.pull_request]); let open = await listOpen(); await pending(open); + await loadMemory(open); let stable, results; for (let attempt=0; attempt<3; attempt++) { const before = await snapshot(open); @@ -224,9 +267,10 @@ jobs: const bad = prs.filter(p=>!results.get(p.number)?.ok); const summary = prs.map(p=>`#${p.number}: ${results.get(p.number)?.message || 'Missing validation.'}`).join('\n') || 'PR is closed.'; await github.rest.checks.update({...args,check_run_id:id,status:'completed',conclusion:bad.length?'failure':'success', - output:{title:bad.length?'Stack policy failed':'Stack policy passed',summary}}); + output:{title:bad.length?'Stack policy failed':'Stack policy passed',summary,text:memoryText(prs,stable)}}); rows.push(summary); } + for (const p of stable.pulls.filter(p=>p.state==='open')) reviewMemory[p.number] = stable.codex[p.number].memory; const published = await snapshot(await listOpen()); if (fingerprint(stable) !== fingerprint(published)) throw new Error('PR metadata changed during publication; revoking results.'); await core.summary.addHeading('Stack policy').addRaw(rows.join('\n\n')).write(); @@ -234,7 +278,7 @@ jobs: // Revoke even successes already published during this run if a later snapshot/API fails. for (const id of checks.values()) { try { await github.rest.checks.update({...args,check_run_id:id,status:'completed',conclusion:'failure', - output:{title:'Stack validation could not complete',summary:String(error.message).slice(0,60000)}}); } + output:{title:'Stack validation could not complete',summary:String(error.message).slice(0,60000),text:MEMORY_MARKER+JSON.stringify(reviewMemory)}}); } catch (updateError) { core.error(`Failed to revoke check ${id}: ${updateError.message}`); } } core.setFailed(error.message); diff --git a/docs/stacked-prs.md b/docs/stacked-prs.md index 3fdaa47..485183e 100644 --- a/docs/stacked-prs.md +++ b/docs/stacked-prs.md @@ -48,7 +48,7 @@ GitHub may change its public-preview stack APIs. A failing validation must be in ## Codex review completion -The required `Stack policy` check blocks while Codex's authenticated summary reports a running, failed, or unknown review, while a new `@codex review` / `@codex security review` request from someone with repository write access awaits completion, or while an active bot eyes reaction has no later completion. Every code and security review must finish. Completed findings are advisory: maintainers may choose to ignore them. Retry a failed review; do not bypass an unfinished review. +The required `Stack policy` check blocks while Codex's authenticated summary reports a running, failed, or unknown review, while a new `@codex review` / `@codex security review` request from someone with repository write access awaits completion, or while an active bot eyes reaction has no later completion. Code and security requests are matched to their own completion timestamps. Recorded activity is retained in trusted check output across reruns and head updates, so deleting a marker does not cancel the wait. Every code and security review must finish. Completed findings are advisory: maintainers may choose to ignore them. Retry a failed review; do not bypass an unfinished review. PR and comment events update the check. Manually dispatch the controller if a legacy reaction-only review does not emit a summary event. GitHub event delivery is asynchronous, leaving a brief detection window when a new review starts. No review is required when none is requested or active. This does not require a fresh review of every push. Existing stack, CI, and review requirements still apply. The controller only reads GitHub metadata and never executes PR code. From f13bf86397b2c8e8d4a22d27957512fa0d6afbe7 Mon Sep 17 00:00:00 2001 From: Arjun Aletty Date: Tue, 8 Sep 2026 19:03:33 -0700 Subject: [PATCH 5/6] chore: block merges while Codex reviews are unfinished --- .github/policy-tests/codex-stack-policy.cjs | 16 ++++++++++++++-- .github/policy-tests/codex-stack-policy.test.cjs | 13 +++++++++++++ .github/workflows/stack-policy.yml | 16 ++++++++++++++-- 3 files changed, 41 insertions(+), 4 deletions(-) diff --git a/.github/policy-tests/codex-stack-policy.cjs b/.github/policy-tests/codex-stack-policy.cjs index e661ab8..4710492 100644 --- a/.github/policy-tests/codex-stack-policy.cjs +++ b/.github/policy-tests/codex-stack-policy.cjs @@ -13,7 +13,7 @@ function dependency(body) { const CODEX_BOT = 'chatgpt-codex-connector[bot]'; const SUMMARY_MARKER = ''; function isReviewRequest(body) { - const clean = (body || '').replace(//g, '').replace(/```[\s\S]*?```/g, '').replace(/~~~[\s\S]*?~~~/g, ''); + const clean = (body || '').replace(/|$)/g, '').replace(/```[\s\S]*?(?:```|$)/g, '').replace(/~~~[\s\S]*?(?:~~~|$)/g, ''); return /^@codex\s+(?:security\s+)?review\b/im.test(clean); } function codexReview(comments, reactions, previous = {}) { @@ -42,7 +42,7 @@ function codexReview(comments, reactions, previous = {}) { } } for (const request of requests) { - const clean = request.body.replace(//g, '').replace(/```[\s\S]*?```/g, '').replace(/~~~[\s\S]*?~~~/g, ''); + const clean = request.body.replace(/|$)/g, '').replace(/```[\s\S]*?(?:```|$)/g, '').replace(/~~~[\s\S]*?(?:~~~|$)/g, ''); for (const match of clean.matchAll(/^@codex\s+(security\s+)?review\b/gim)) record(match[1] ? 'security' : 'code', Date.parse(request.updated_at || request.created_at)); } @@ -206,6 +206,18 @@ async function run({github, context, core}) { stacks:s.stacks.filter(s=>s.open).map(s=>({number:s.number,base:s.base,prs:s.pull_requests.map(p=>[p.number,p.state,p.head.sha])})).sort((a,b)=>a.number-b.number)}); } try { + // Discussion and unaccepted commands do not invalidate unrelated PR checks. + if (context.eventName === 'issue_comment') { + const comment = context.payload.comment; + const oldBody = context.payload.changes?.body?.from || ''; + const botSummary = comment?.user?.login === CODEX_BOT && comment.user.type === 'Bot' && + (comment.body?.includes(SUMMARY_MARKER) || oldBody.includes(SUMMARY_MARKER)); + if (!botSummary) { + if (comment?.user?.type !== 'User' || (!isReviewRequest(comment.body) && !isReviewRequest(oldBody))) return; + const {data} = await github.rest.repos.getCollaboratorPermissionLevel({...args, username:comment.user.login}); + if (!['admin','maintain','write'].includes(data.permission)) return; + } + } // Invalidate old success before querying stacks or ancestors. Failed API calls leave failures. if (context.payload.pull_request?.head?.sha) await pending([context.payload.pull_request]); let open = await listOpen(); diff --git a/.github/policy-tests/codex-stack-policy.test.cjs b/.github/policy-tests/codex-stack-policy.test.cjs index db86b6b..cdb5e70 100644 --- a/.github/policy-tests/codex-stack-policy.test.cjs +++ b/.github/policy-tests/codex-stack-policy.test.cjs @@ -171,3 +171,16 @@ test('synchronize carries recorded active state from the preceding head',async() h.github.paginate=async(route,args)=>route===h.github.rest.checks.listForRef && args.ref==='oldsha' ? [{app:{slug:'github-actions'},output:{text:'codex-review-memory-v2:'+JSON.stringify({1:{seen:true,after:{code:Date.parse(at)}}})}}] : paginate(route,args); await run(h);assert.equal(h.failures.length,0);assert.equal(h.updates.at(-1).conclusion,'failure'); }); +test('unterminated fenced and commented examples are not review requests',()=>{ + for(const body of ['```\n@codex review','~~~\n@codex review',''; function isReviewRequest(body) { - const clean = (body || '').replace(//g, '').replace(/```[\s\S]*?```/g, '').replace(/~~~[\s\S]*?~~~/g, ''); + const clean = (body || '').replace(/|$)/g, '').replace(/```[\s\S]*?(?:```|$)/g, '').replace(/~~~[\s\S]*?(?:~~~|$)/g, ''); return /^@codex\s+(?:security\s+)?review\b/im.test(clean); } function codexReview(comments, reactions, previous = {}) { @@ -71,7 +71,7 @@ jobs: } } for (const request of requests) { - const clean = request.body.replace(//g, '').replace(/```[\s\S]*?```/g, '').replace(/~~~[\s\S]*?~~~/g, ''); + const clean = request.body.replace(/|$)/g, '').replace(/```[\s\S]*?(?:```|$)/g, '').replace(/~~~[\s\S]*?(?:~~~|$)/g, ''); for (const match of clean.matchAll(/^@codex\s+(security\s+)?review\b/gim)) record(match[1] ? 'security' : 'code', Date.parse(request.updated_at || request.created_at)); } @@ -235,6 +235,18 @@ jobs: stacks:s.stacks.filter(s=>s.open).map(s=>({number:s.number,base:s.base,prs:s.pull_requests.map(p=>[p.number,p.state,p.head.sha])})).sort((a,b)=>a.number-b.number)}); } try { + // Discussion and unaccepted commands do not invalidate unrelated PR checks. + if (context.eventName === 'issue_comment') { + const comment = context.payload.comment; + const oldBody = context.payload.changes?.body?.from || ''; + const botSummary = comment?.user?.login === CODEX_BOT && comment.user.type === 'Bot' && + (comment.body?.includes(SUMMARY_MARKER) || oldBody.includes(SUMMARY_MARKER)); + if (!botSummary) { + if (comment?.user?.type !== 'User' || (!isReviewRequest(comment.body) && !isReviewRequest(oldBody))) return; + const {data} = await github.rest.repos.getCollaboratorPermissionLevel({...args, username:comment.user.login}); + if (!['admin','maintain','write'].includes(data.permission)) return; + } + } // Invalidate old success before querying stacks or ancestors. Failed API calls leave failures. if (context.payload.pull_request?.head?.sha) await pending([context.payload.pull_request]); let open = await listOpen(); From 4732d78fb42df0f2d1ddc55949611ddfd69f0295 Mon Sep 17 00:00:00 2001 From: Arjun Aletty Date: Tue, 8 Sep 2026 19:07:58 -0700 Subject: [PATCH 6/6] chore: block merges while Codex reviews are unfinished --- .github/policy-tests/codex-stack-policy.cjs | 19 +++++---- .../policy-tests/codex-stack-policy.test.cjs | 40 +++++++++++++++---- .github/workflows/stack-policy.yml | 19 +++++---- docs/stacked-prs.md | 2 +- 4 files changed, 55 insertions(+), 25 deletions(-) diff --git a/.github/policy-tests/codex-stack-policy.cjs b/.github/policy-tests/codex-stack-policy.cjs index 4710492..5e5a87c 100644 --- a/.github/policy-tests/codex-stack-policy.cjs +++ b/.github/policy-tests/codex-stack-policy.cjs @@ -17,12 +17,12 @@ function isReviewRequest(body) { return /^@codex\s+(?:security\s+)?review\b/im.test(clean); } function codexReview(comments, reactions, previous = {}) { - const memory = {seen:!!previous.seen, after:{...previous.after}}; + const memory = {seen:!!previous.seen, after:{...previous.after}, completed:{code:0,security:0,any:0,...previous.completed}}; const summaries = comments.filter(c => c.user?.login === CODEX_BOT && c.user?.type === 'Bot' && c.body?.includes(SUMMARY_MARKER)); const requests = comments.filter(c => c.authorizedRequest === true && isReviewRequest(c.body)); const eyes = reactions.filter(r => r.user?.login === CODEX_BOT && r.user?.type === 'Bot' && r.content === 'eyes'); memory.seen ||= !!(summaries.length || requests.length || eyes.length); - const completed = {code:0, security:0, any:0}; + const completed = memory.completed; let problem = ''; const record = (kind, time) => { memory.after[kind] = Math.max(memory.after[kind] || 0, time || Number.MAX_SAFE_INTEGER); }; for (const summary of summaries) { @@ -31,13 +31,14 @@ function codexReview(comments, reactions, previous = {}) { for (const row of rows) { const kind = /\*\*Security Review\*\*/i.test(row) ? 'security' : 'code'; const status = row.split('|')[2] || ''; - const timestamp = Date.parse(status.match(/datetime="([^"]+)"/)?.[1] || summary.updated_at) || 0; + const timestamp = Date.parse(status.match(/datetime="([^"]+)"/)?.[1]) || 0; + if (!timestamp) { problem = 'Codex review row has no valid completion/activity timestamp; waiting for a supported summary.'; continue; } if (/\*\*Completed\*\*/i.test(status)) { completed[kind] = Math.max(completed[kind], timestamp); completed.any = Math.max(completed.any, timestamp); } else { record(kind, timestamp); - problem = 'Codex review has not completed. Wait for its response; retry a failed review.'; + // Historical failed/running rows are superseded only by a later matching completion. } } } @@ -47,11 +48,11 @@ function codexReview(comments, reactions, previous = {}) { record(match[1] ? 'security' : 'code', Date.parse(request.updated_at || request.created_at)); } for (const reaction of eyes) record('any', Date.parse(reaction.created_at)); - if (!problem && memory.seen && !summaries.length) + if (!problem && memory.seen && !summaries.length && !completed.any) problem = 'Codex review activity was recorded; its completion summary is missing. Deleting a marker does not cancel the gate.'; if (!problem && Object.entries(memory.after).some(([kind,time]) => completed[kind] <= time)) problem = 'Waiting for a later completion of each requested or previously active Codex review.'; - return {ok:!problem, message:problem || (summaries.length ? 'Codex review completed; findings are advisory.' : 'No active Codex review detected.'), memory}; + return {ok:!problem, message:problem || (completed.any ? 'Codex review completed; findings are advisory.' : 'No active Codex review detected.'), memory}; } function validate(snapshot) { @@ -147,9 +148,11 @@ async function run({github, context, core}) { if (check.app?.slug !== 'github-actions' || !check.output?.text?.startsWith(MEMORY_MARKER)) continue; const saved = JSON.parse(check.output.text.slice(MEMORY_MARKER.length)); for (const [number,state] of Object.entries(saved)) { - const old = reviewMemory[number] || {seen:false, after:{}}; + const old = reviewMemory[number] || {seen:false, after:{}, completed:{}}; + old.completed ||= {}; old.seen ||= state.seen; for (const [kind,time] of Object.entries(state.after || {})) old.after[kind] = Math.max(old.after[kind] || 0, time); + for (const [kind,time] of Object.entries(state.completed || {})) old.completed[kind] = Math.max(old.completed[kind] || 0, time); reviewMemory[number] = old; } } @@ -191,7 +194,7 @@ async function run({github, context, core}) { comment.authorizedRequest = permissions.get(username); } const reactions = await github.paginate(github.rest.reactions.listForIssue, {...args, issue_number:p.number, per_page:100}); - for (const comment of comments.filter(c => c.reactions?.eyes > 0)) { + for (const comment of comments.filter(c => c.id !== deleted?.id && c.reactions?.eyes > 0)) { reactions.push(...await github.paginate(github.rest.reactions.listForIssueComment, {...args, comment_id:comment.id, per_page:100})); } codex[p.number] = codexReview(comments, reactions, reviewMemory[p.number]); diff --git a/.github/policy-tests/codex-stack-policy.test.cjs b/.github/policy-tests/codex-stack-policy.test.cjs index cdb5e70..8ebfda6 100644 --- a/.github/policy-tests/codex-stack-policy.test.cjs +++ b/.github/policy-tests/codex-stack-policy.test.cjs @@ -57,7 +57,7 @@ const {codexReview} = require('./codex-stack-policy.cjs'); const bot={login:'chatgpt-codex-connector[bot]',type:'Bot'}; const at='2026-09-09T01:00:00Z', later='2026-09-09T02:00:00Z'; function summary(status='Completed', user=bot) { - return {user,updated_at:at,body:`\n| Review | Status | Commit | Review trigger |\n| --- | --- | --- | --- |\n| 📝 **Code Review** | ✅ **${status}** | \`abc1234\` | PR opened |\nCodex reacts with eyes while Running.`}; + return {user,updated_at:at,body:`\n| Review | Status | Commit | Review trigger |\n| --- | --- | --- | --- |\n| 📝 **Code Review** | ✅ **${status}** time | \`abc1234\` | PR opened |\nCodex reacts with eyes while Running.`}; } test('completed review passes regardless of findings or explanatory Running text',()=>assert.equal(codexReview([summary()],[]).ok,true)); test('running, failed, cancelled, queued and unknown summaries block',()=>{ @@ -65,7 +65,7 @@ test('running, failed, cancelled, queued and unknown summaries block',()=>{ const c=summary(); c.body='\nunknown format';assert.equal(codexReview([c],[]).ok,false); }); test('all concurrent review types must finish',()=>{ - const c=summary();c.body+='\n| 🔒 **Security Review** | 🔄 **Running** | `abc1234` | PR opened |';assert.equal(codexReview([c],[]).ok,false); + const c=summary();c.body+='\n| 🔒 **Security Review** | 🔄 **Running** time | `abc1234` | PR opened |';assert.equal(codexReview([c],[]).ok,false); }); test('human cannot spoof completion or active bot status',()=>{ assert.equal(codexReview([summary('Running',{login:'aletty',type:'User'})],[]).ok,true); @@ -74,7 +74,7 @@ test('human cannot spoof completion or active bot status',()=>{ test('eyes-only review blocks and completion supersedes earlier eyes',()=>{ const r={user:bot,content:'eyes',created_at:at};assert.equal(codexReview([],[r]).ok,false); assert.equal(codexReview([summary()],[r]).ok,false); - const done=summary();done.updated_at=later;assert.equal(codexReview([done],[r]).ok,true); + const done=summary();done.updated_at=later;done.body=done.body.replaceAll(at,later);assert.equal(codexReview([done],[r]).ok,true); assert.equal(codexReview([summary()],[{...r,created_at:later}]).ok,false); }); test('new manual review requests block even after previous completion',()=>{ @@ -90,7 +90,7 @@ test('active Codex review blocks entire native stack',()=>{ test('review activity changing during publication cannot pass',async()=>{ const h=harness();const paginate=h.github.paginate;let n=0; h.github.paginate=async(route,args)=>route===h.github.rest.issues.listComments ? [summary(++n%2?'Completed':'Running')] : paginate(route,args); - await run(h);assert.ok(h.updates.every(u=>u.conclusion==='failure'));assert.equal(h.failures.length,1); + await run(h);assert.ok(h.updates.every(u=>u.conclusion==='failure'));assert.equal(h.updates.at(-1).conclusion,'failure'); }); test('untrusted, quoted and fenced requests do not block',()=>{ @@ -137,20 +137,20 @@ test('controller detects authenticated eyes on triggering comments',async()=>{ await run(h);assert.equal(h.failures.length,0);assert.equal(h.updates.at(-1).conclusion,'failure'); }); test('code completion cannot satisfy a security review request',()=>{ - const c=summary();c.updated_at=later; + const c=summary();c.updated_at=later;c.body=c.body.replaceAll(at,later); const request={body:'@codex security review',created_at:at,authorizedRequest:true}; assert.equal(codexReview([c,request],[]).ok,false); - c.body+='\n| 🔒 **Security Review** | ✅ **Completed** | `abc1234` | manual |'; + c.body+='\n| 🔒 **Security Review** | ✅ **Completed** time | `abc1234` | manual |'; assert.equal(codexReview([c,request],[]).ok,true); }); test('row completion time wins over a later unrelated summary edit',()=>{ - const c=summary();c.updated_at=later;c.body=c.body.replace('**Completed**','**Completed** old'); + const c=summary();c.updated_at=later;c.body=c.body.replaceAll(at,later);c.body=c.body.replace('**Completed**','**Completed** old'); assert.equal(codexReview([c,{body:'@codex review',created_at:at,authorizedRequest:true}],[]).ok,false); }); test('deleting running summary preserves the wait until a later completion',()=>{ const first=codexReview([summary('Running')],[]); assert.equal(codexReview([],[],first.memory).ok,false); - const done=summary();done.updated_at=later; + const done=summary();done.updated_at=later;done.body=done.body.replaceAll(at,later); assert.equal(codexReview([done],[],first.memory).ok,true); }); test('deleting an accepted request does not let an older completion pass',()=>{ @@ -184,3 +184,27 @@ test('unauthorized commands do not invalidate PR checks',async()=>{ h.github.rest.repos.getCollaboratorPermissionLevel=async()=>({data:{permission:'read'}}); await run(h);assert.equal(h.created.length,0);assert.equal(h.failures.length,0); }); +test('deleted requests retain state without fetching their removed reactions',async()=>{ + const h=harness();h.context.eventName='issue_comment';h.context.payload.action='deleted';h.context.payload.issue={number:1}; + h.context.payload.comment={id:123,user:{login:'aletty',type:'User'},body:'@codex review',created_at:later,reactions:{eyes:1}}; + h.github.rest.repos.getCollaboratorPermissionLevel=async()=>({data:{permission:'write'}}); + const paginate=h.github.paginate; + h.github.rest.reactions.listForIssueComment=Symbol('deletedCommentReactions'); + h.github.paginate=async(route,args)=>{if(route===h.github.rest.reactions.listForIssueComment)throw new Error('deleted comment cannot be fetched');return paginate(route,args);}; + await run(h);assert.equal(h.failures.length,0);assert.equal(h.updates.at(-1).conclusion,'failure'); + assert.ok(h.updates.at(-1).output.text.includes(String(Date.parse(later)))); +}); +test('remembered completion survives summary deletion',()=>{ + const first=codexReview([summary()],[]); + assert.equal(codexReview([],[],first.memory).ok,true); +}); +test('later completion supersedes historical failed and running summaries',()=>{ + for(const status of ['Failed','Running']) { + const done=summary();done.body=done.body.replaceAll(at,later); + assert.equal(codexReview([summary(status),done],[]).ok,true); + } +}); +test('summary edit time cannot substitute for missing per-row timestamps',()=>{ + const c=summary();c.body=c.body.replace(/ /,''); + assert.equal(codexReview([c],[]).ok,false); +}); diff --git a/.github/workflows/stack-policy.yml b/.github/workflows/stack-policy.yml index 7f35266..17d8224 100644 --- a/.github/workflows/stack-policy.yml +++ b/.github/workflows/stack-policy.yml @@ -46,12 +46,12 @@ jobs: return /^@codex\s+(?:security\s+)?review\b/im.test(clean); } function codexReview(comments, reactions, previous = {}) { - const memory = {seen:!!previous.seen, after:{...previous.after}}; + const memory = {seen:!!previous.seen, after:{...previous.after}, completed:{code:0,security:0,any:0,...previous.completed}}; const summaries = comments.filter(c => c.user?.login === CODEX_BOT && c.user?.type === 'Bot' && c.body?.includes(SUMMARY_MARKER)); const requests = comments.filter(c => c.authorizedRequest === true && isReviewRequest(c.body)); const eyes = reactions.filter(r => r.user?.login === CODEX_BOT && r.user?.type === 'Bot' && r.content === 'eyes'); memory.seen ||= !!(summaries.length || requests.length || eyes.length); - const completed = {code:0, security:0, any:0}; + const completed = memory.completed; let problem = ''; const record = (kind, time) => { memory.after[kind] = Math.max(memory.after[kind] || 0, time || Number.MAX_SAFE_INTEGER); }; for (const summary of summaries) { @@ -60,13 +60,14 @@ jobs: for (const row of rows) { const kind = /\*\*Security Review\*\*/i.test(row) ? 'security' : 'code'; const status = row.split('|')[2] || ''; - const timestamp = Date.parse(status.match(/datetime="([^"]+)"/)?.[1] || summary.updated_at) || 0; + const timestamp = Date.parse(status.match(/datetime="([^"]+)"/)?.[1]) || 0; + if (!timestamp) { problem = 'Codex review row has no valid completion/activity timestamp; waiting for a supported summary.'; continue; } if (/\*\*Completed\*\*/i.test(status)) { completed[kind] = Math.max(completed[kind], timestamp); completed.any = Math.max(completed.any, timestamp); } else { record(kind, timestamp); - problem = 'Codex review has not completed. Wait for its response; retry a failed review.'; + // Historical failed/running rows are superseded only by a later matching completion. } } } @@ -76,11 +77,11 @@ jobs: record(match[1] ? 'security' : 'code', Date.parse(request.updated_at || request.created_at)); } for (const reaction of eyes) record('any', Date.parse(reaction.created_at)); - if (!problem && memory.seen && !summaries.length) + if (!problem && memory.seen && !summaries.length && !completed.any) problem = 'Codex review activity was recorded; its completion summary is missing. Deleting a marker does not cancel the gate.'; if (!problem && Object.entries(memory.after).some(([kind,time]) => completed[kind] <= time)) problem = 'Waiting for a later completion of each requested or previously active Codex review.'; - return {ok:!problem, message:problem || (summaries.length ? 'Codex review completed; findings are advisory.' : 'No active Codex review detected.'), memory}; + return {ok:!problem, message:problem || (completed.any ? 'Codex review completed; findings are advisory.' : 'No active Codex review detected.'), memory}; } function validate(snapshot) { @@ -176,9 +177,11 @@ jobs: if (check.app?.slug !== 'github-actions' || !check.output?.text?.startsWith(MEMORY_MARKER)) continue; const saved = JSON.parse(check.output.text.slice(MEMORY_MARKER.length)); for (const [number,state] of Object.entries(saved)) { - const old = reviewMemory[number] || {seen:false, after:{}}; + const old = reviewMemory[number] || {seen:false, after:{}, completed:{}}; + old.completed ||= {}; old.seen ||= state.seen; for (const [kind,time] of Object.entries(state.after || {})) old.after[kind] = Math.max(old.after[kind] || 0, time); + for (const [kind,time] of Object.entries(state.completed || {})) old.completed[kind] = Math.max(old.completed[kind] || 0, time); reviewMemory[number] = old; } } @@ -220,7 +223,7 @@ jobs: comment.authorizedRequest = permissions.get(username); } const reactions = await github.paginate(github.rest.reactions.listForIssue, {...args, issue_number:p.number, per_page:100}); - for (const comment of comments.filter(c => c.reactions?.eyes > 0)) { + for (const comment of comments.filter(c => c.id !== deleted?.id && c.reactions?.eyes > 0)) { reactions.push(...await github.paginate(github.rest.reactions.listForIssueComment, {...args, comment_id:comment.id, per_page:100})); } codex[p.number] = codexReview(comments, reactions, reviewMemory[p.number]); diff --git a/docs/stacked-prs.md b/docs/stacked-prs.md index 485183e..ffcef56 100644 --- a/docs/stacked-prs.md +++ b/docs/stacked-prs.md @@ -48,7 +48,7 @@ GitHub may change its public-preview stack APIs. A failing validation must be in ## Codex review completion -The required `Stack policy` check blocks while Codex's authenticated summary reports a running, failed, or unknown review, while a new `@codex review` / `@codex security review` request from someone with repository write access awaits completion, or while an active bot eyes reaction has no later completion. Code and security requests are matched to their own completion timestamps. Recorded activity is retained in trusted check output across reruns and head updates, so deleting a marker does not cancel the wait. Every code and security review must finish. Completed findings are advisory: maintainers may choose to ignore them. Retry a failed review; do not bypass an unfinished review. +The required `Stack policy` check blocks while Codex's authenticated summary reports a running, failed, or unknown review, while a new `@codex review` / `@codex security review` request from someone with repository write access awaits completion, or while an active bot eyes reaction has no later completion. Code and security requests are matched to their own completion timestamps. Recorded activity and matching completions are retained in trusted check output across reruns and head updates, so deleting a marker does not cancel the wait. Every code and security review must finish. Completed findings are advisory: maintainers may choose to ignore them. Retry a failed review; do not bypass an unfinished review. PR and comment events update the check. Manually dispatch the controller if a legacy reaction-only review does not emit a summary event. GitHub event delivery is asynchronous, leaving a brief detection window when a new review starts. No review is required when none is requested or active. This does not require a fresh review of every push. Existing stack, CI, and review requirements still apply. The controller only reads GitHub metadata and never executes PR code.