diff --git a/.github/scripts/codex-review.cjs b/.github/scripts/codex-review.cjs new file mode 100644 index 00000000..0cf85940 --- /dev/null +++ b/.github/scripts/codex-review.cjs @@ -0,0 +1,192 @@ +'use strict'; + +const CONTEXT = 'codex-review'; +const EVIDENCE = 'codex-review-evidence'; +const MAINTAINER_ID = 8227352; +const CODEX = 'chatgpt-codex-connector[bot]'; + +// Only a structured result from the connected app can satisfy the ordinary gate. +function codexVerdict(comments, head) { + const summaries = comments.filter(comment => comment.user?.login === CODEX && comment.user?.type === 'Bot') + .sort((a, b) => Date.parse(b.updated_at) - Date.parse(a.updated_at)); + for (const comment of summaries) { + if (!comment.body?.includes('/); + if (!match) return malformed; + let review; + try { review = JSON.parse(match[1]).code_review; } catch { return malformed; } + if (!review || typeof review.commit_sha !== 'string') return malformed; + if (review.commit_sha !== head) continue; + if (review.status !== 'completed') return { state: 'pending', description: 'Current-head Codex review is running' }; + if (review.has_any_findings === false && review.has_unresolved_findings === false) { + return { state: 'success', description: 'Codex reviewed this exact head with no findings', target_url: comment.html_url }; + } + return { state: 'failure', description: 'Codex findings or an incomplete verdict need attention', target_url: comment.html_url }; + } + return { state: 'pending', description: 'A clean Codex review of this exact head is required' }; +} + +// A maintainer-published cross-model review is the documented alternative when +// connected Codex is unavailable. It is review evidence, never a bypass flag. +function fallbackReceipt(review, head) { + if (review.user?.id !== MAINTAINER_ID || review.user?.type !== 'User' || + review.commit_id !== head || review.state !== 'COMMENTED') return false; + const match = review.body?.match(/^/); + if (!match) return false; + try { + const receipt = JSON.parse(match[1]); + return receipt.head === head && receipt.clean === true && receipt.provider === 'claude' && + receipt.model === 'claude-opus-5-5' && ['hung', 'unavailable'].includes(receipt.codex); + } catch { return false; } +} + +function eligibleEvent(context) { + if (context.eventName === 'issue_comment') { + const comment = context.payload.comment; + return !!context.payload.issue?.pull_request && comment?.user?.login === CODEX && + comment.user.type === 'Bot'; + } + if (context.eventName === 'pull_request_review') return context.payload.review?.user?.id === MAINTAINER_ID; + return true; +} + +function triggerIncluded(context, comments, reviews) { + const event = context.payload; + const comment = event.comment; + if (comment?.user?.login === CODEX && comment.user.type === 'Bot') { + const actual = comments.find(item => item.id === comment.id); + if (event.action === 'deleted') return !actual; + if (!actual) return false; + if (Date.parse(actual.updated_at) > Date.parse(comment.updated_at)) return true; + return actual.updated_at === comment.updated_at && actual.body === comment.body; + } + const review = event.review; + if (review?.user?.id === MAINTAINER_ID) { + const actual = reviews.find(item => item.id === review.id); + return actual?.body === review.body && actual?.state.toUpperCase() === review.state.toUpperCase(); + } + return true; +} + +async function snapshot(github, owner, repo, number) { + const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number: number, per_page: 100 }); + const reviews = await github.paginate(github.rest.pulls.listReviews, { owner, repo, pull_number: number, per_page: 100 }); + for (const comment of comments.filter(item => item.user?.login === CODEX && item.user?.type === 'Bot')) { + const { node } = await github.graphql(`query($id: ID!) { node(id: $id) { ... on IssueComment { + body updatedAt lastEditedAt author { login __typename } editor { login __typename } + } } }`, { id: comment.node_id }); + const bot = actor => actor?.__typename === 'Bot' && actor.login === 'chatgpt-codex-connector'; + comment.authenticated = !!node && bot(node.author) && + (node.lastEditedAt ? bot(node.editor) : node.editor === null) && + node.body === comment.body && node.updatedAt === comment.updated_at; + } + return { comments, reviews }; +} + +// Compare all mutable fields that can affect the decision, including deletion +// and later maintainer reviews. Ordinary conversation cannot invalidate it. +function fingerprint({ comments, reviews }) { + return JSON.stringify({ + comments: comments.filter(item => item.user?.login === CODEX).map(item => + [item.id, item.user, item.updated_at, item.body, item.authenticated]), + reviews: reviews.filter(item => item.user?.id === MAINTAINER_ID).map(item => + [item.id, item.user, item.commit_id, item.state, item.body]) + }); +} + +// A head-bound, Actions-authored watermark survives deletion and later dispatches. +// A tamper/deletion latch clears only after a newer authenticated bot update. +async function evidenceVerdict(github, owner, repo, head, context, evidence) { + const statuses = await github.paginate(github.rest.repos.listCommitStatusesForRef, + { owner, repo, ref: head, per_page: 100 }); + const prior = statuses.find(item => item.context === EVIDENCE && + item.creator?.login === 'github-actions[bot]' && item.creator?.type === 'Bot'); + let watermark; + if (prior) { + try { watermark = JSON.parse(prior.description); } catch { throw new Error('Invalid review evidence watermark'); } + if (!Number.isSafeInteger(watermark.id) || !Number.isFinite(Date.parse(watermark.at))) { + throw new Error('Invalid review evidence watermark'); + } + } + const summaries = evidence.comments.filter(item => item.user?.login === CODEX && + (item.body?.includes('`, updated_at: '2026-10-22', html_url: 'https://github.com/review' +}); + +test('only a completed clean bot verdict for the exact head passes', () => { + assert.equal(codexVerdict([summary(clean)], head).state, 'success'); + for (const result of [ { ...clean, commit_sha: 'b'.repeat(40) }, { ...clean, status: 'running' }, + { ...clean, has_any_findings: true }, { ...clean, has_unresolved_findings: true }, + { ...clean, has_any_findings: null }, { ...clean, has_unresolved_findings: undefined } ]) { + assert.notEqual(codexVerdict([summary(result)], head).state, 'success'); + } + assert.equal(codexVerdict([], head).state, 'pending'); + assert.equal(codexVerdict([summary(clean, { login: 'someone', type: 'User' })], head).state, 'pending'); + assert.equal(codexVerdict([summary(clean, { login: 'chatgpt-codex-connector[bot]', type: 'User' })], head).state, 'pending'); + assert.equal(codexVerdict([{ ...summary(clean), body: '' }], head).state, 'failure'); +}); + +test('a later running or failed result replaces an earlier clean result', () => { + const later = { ...summary({ ...clean, status: 'running' }), updated_at: '2026-10-23' }; + assert.equal(codexVerdict([summary(clean), later], head).state, 'pending'); +}); + +const receipt = { head, clean: true, provider: 'claude', model: 'claude-opus-5-5', codex: 'hung' }; +const fallback = { user: { id: 8227352, login: 'HemSoft', type: 'User' }, commit_id: head, state: 'COMMENTED', + body: ``, html_url: 'https://github.com/fallback' }; +test('fallback requires the named maintainer, exact head, model and explicit clean receipt', () => { + assert.equal(fallbackReceipt(fallback, head), true); + for (const change of [{ commit_id: 'b'.repeat(40) }, { state: 'CHANGES_REQUESTED' }, + { user: { login: 'someone', type: 'User' } }, { body: 'clean' }, + { body: `` }]) { + assert.equal(fallbackReceipt({ ...fallback, ...change }, head), false); + } +}); + +const merge = 'c'.repeat(40); +function client({ moved = false, mergeMoved = false, reviews = [], comments = [], + freshComments = comments, freshReviews = reviews, permission = 'admin', priorStatuses = [], statusError, payload = { pull_request: { number: 1 } } } = {}) { + const statuses = []; let reads = 0; let commentReads = 0; let reviewReads = 0; + const github = { graphql: async (_query, { id }) => { + const comment = [...freshComments, ...comments].find(item => item.node_id === id); + return { node: comment ? { body: comment.body, updatedAt: comment.updated_at, + author: { login: 'chatgpt-codex-connector', __typename: 'Bot' }, + lastEditedAt: comment.editedAt ?? null, editor: comment.editor ?? null } : null }; + }, paginate: async method => method(), rest: { + pulls: { get: async () => { + const later = reads++ > 0; + return { data: { state: 'open', base: { ref: 'main' }, head: { sha: moved && later ? 'b'.repeat(40) : head }, + merge_commit_sha: mergeMoved && later ? 'd'.repeat(40) : merge } }; + }, listReviews: async () => reviewReads++ ? freshReviews : reviews }, + issues: { listComments: async () => commentReads++ ? freshComments : comments }, + repos: { listCommitStatusesForRef: async () => priorStatuses, createCommitStatus: async value => { + if (statusError?.(value)) throw new Error('status write failed'); + statuses.push(value); + }, getCollaboratorPermissionLevel: async () => ({ data: { permission } }) } + } }; + return { github, statuses, context: { repo: { owner: 'hemsoft-dev', repo: 'codexbar' }, payload }, core: { info() {} } }; +} +const result = fixture => fixture.statuses.at(-1).state; + +test('a concurrent head or merge change cannot publish success', async () => { + for (const change of [{ moved: true }, { mergeMoved: true }]) { + const fixture = client({ ...change, comments: [summary(clean)] }); await run(fixture); + const gates = fixture.statuses.filter(item => item.context === 'codex-review'); + assert.equal(gates.length, 2); + assert.ok(gates.every(status => status.state === 'pending')); + } +}); +test('missing evidence publishes pending on both head and test merge', async () => { + const fixture = client(); await run(fixture); + assert.deepEqual(fixture.statuses.slice(-2).map(status => status.sha), [merge, head]); + assert.equal(result(fixture), 'pending'); +}); +test('clean review publishes success on head and test merge', async () => { + const fixture = client({ comments: [summary(clean)] }); await run(fixture); + assert.deepEqual(fixture.statuses.slice(-2).map(status => [status.sha, status.state]), [[merge, 'success'], [head, 'success']]); +}); +test('fallback also verifies live maintainer permission', async () => { + const allowed = client({ reviews: [fallback] }); await run(allowed); assert.equal(result(allowed), 'success'); + const refused = client({ reviews: [fallback], permission: 'write' }); await run(refused); assert.equal(result(refused), 'pending'); +}); +test('fallback cannot hide Codex findings or a later non-clean maintainer review', async () => { + const findings = client({ reviews: [fallback], comments: [summary({ ...clean, has_any_findings: true })] }); + await run(findings); assert.equal(result(findings), 'failure'); + const later = client({ reviews: [fallback, { ...fallback, body: 'P2: fix this' }] }); + await run(later); assert.equal(result(later), 'pending'); +}); +test('the maintainer may rename without trusting an unrelated account with the old login', () => { + assert.equal(fallbackReceipt({ ...fallback, user: { ...fallback.user, login: 'renamed' } }, head), true); + assert.equal(fallbackReceipt({ ...fallback, user: { ...fallback.user, id: 1 } }, head), false); +}); +test('summary or fallback mutation or deletion before publication leaves pending', async () => { + for (const freshComments of [[], [summary({ ...clean, status: 'running' })], [summary({ ...clean, has_any_findings: true })]]) { + const fixture = client({ comments: [summary(clean)], freshComments }); await run(fixture); + assert.equal(result(fixture), 'pending'); + } + for (const freshReviews of [[], [{ ...fallback, body: 'findings' }]]) { + const fixture = client({ reviews: [fallback], freshReviews }); await run(fixture); + assert.equal(result(fixture), 'pending'); + } +}); +test('a stale API snapshot cannot ignore the triggering edit or deletion', async () => { + const old = { ...summary(clean), id: 42 }; + const edited = { ...old, body: 'running', updated_at: '2026-10-23' }; + for (const [action, comment] of [['edited', edited], ['deleted', old]]) { + const fixture = client({ comments: [old], payload: { issue: { number: 1 }, action, comment } }); + await run(fixture); assert.equal(result(fixture), 'pending'); + } + assert.equal(triggerIncluded({ payload: { action: 'deleted', comment: old } }, [], []), true); +}); + +test('a newer malformed summary cannot resurrect an older clean result', () => { + for (const body of ['', '', + '', ' +``` + +Use `unavailable` instead of `hung` for an explicit integration refusal. +The gate verifies the review's actual commit, stable author ID and live +maintainer permission using the author's current login. +The latest review from that user on that head must carry the clean receipt. It cannot +override Codex's completed findings. This is a reviewed alternative to a hung +integration, not a switch that skips review. Unresolved threads still block +merge through native conversation protection. + +The gate refreshes on PR head changes, bot comment updates, reviews and CI +completion. It publishes to both the reviewed head and its current test merge +revision, and rejects a concurrent change to either. It revokes prior success +on the test merge revision first, then the head, before evaluation. It checks +that the triggering edit or deletion is visible, +and reads mutable evidence again immediately before publishing a verdict. A +maintainer can refresh existing evidence with +`gh workflow run codex-review.yml -f number=PR_NUMBER`. This does not request a +review or change the evidence requirements. There is no polling or schedule. + +The Actions-authored `codex-review-evidence` status on the reviewed head records +which summary and update the gate observed. It is an audit watermark, not a +substitute required check. Deleting recorded evidence, or a deletion event for +another review summary, latches failure rather than reviving older clean text. +A newer authenticated bot update clears the latch. Refreshes preserve it. +Unrelated public comments and reviews are excluded before job concurrency. +Eligible jobs use the documented `queue: max` mode so pending evidence events +are queued rather than replaced. The installed actionlint 1.7.12 does not yet +recognize that field; validation suppresses only its specific unknown-key +error. See GitHub's [concurrency syntax](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#concurrency). + +## Required check setup + +During the initial PR, review events can run its workflow before the helper +exists on trusted main. That bootstrap case emits a notice and issues no +review status. It cannot satisfy a required `codex-review` check. Never load +the helper from a PR branch to work around the bootstrap. + +After the review workflow has merged to main, preview the additive settings: + +```sh +python3 .github/scripts/require-merge-checks.py +``` + +Then apply them with `--apply`. The helper discovers GitHub Actions' app ID +from main's security job and changes only required statuses. It preserves +existing requirements and strictness, conversation resolution, administrator +enforcement, review settings and bypass policy. Read back +`gh api repos/hemsoft-dev/codexbar/branches/main/protection` and verify the six +contexts and source app IDs. Repository settings are live configuration; +merging this document alone does not activate them. + +The path-filtered Package workflow is deliberately not globally required. +GitHub can leave a required check pending when its workflow is skipped by a +path filter. Packaging changes still run Package, and its applicable results +must pass before merge. Normal CI and the review status run for every PR. + +Validate the live protection with a disposable PR: missing review must leave +`codex-review` pending, and an old-head result must not pass its successor. +Confirm `mergeStateStatus` is blocked with missing/failed requirements. Close +the fixture without merging. Never attempt an admin merge to test protection. + +Policy references: [required status checks](https://docs.github.com/en/pull-requests/how-tos/merge-and-close-pull-requests/troubleshooting-required-status-checks), +[protected branches](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches), +and [safe metadata workflows](https://docs.github.com/en/actions/reference/security/securely-using-pull_request_target).