From a7d30eb96a28f2876d7b87bcb61d15fc5673ffaf Mon Sep 17 00:00:00 2001 From: Matteo <30387529+max23468@users.noreply.github.com> Date: Thu, 6 Aug 2026 15:05:14 +0200 Subject: [PATCH 01/12] ci: add exact-head Codex review gate --- .github/workflows/codex-review-gate.yml | 38 +++ docs/INDEX.md | 1 + docs/TOOLCHAIN.md | 11 +- scripts/codex-review-gate.mjs | 336 ++++++++++++++++++++++++ test/codex-review-gate.test.mjs | 306 +++++++++++++++++++++ test/workflow.test.ts | 24 ++ 6 files changed, 712 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/codex-review-gate.yml create mode 100644 scripts/codex-review-gate.mjs create mode 100644 test/codex-review-gate.test.mjs diff --git a/.github/workflows/codex-review-gate.yml b/.github/workflows/codex-review-gate.yml new file mode 100644 index 0000000..8e15424 --- /dev/null +++ b/.github/workflows/codex-review-gate.yml @@ -0,0 +1,38 @@ +name: Codex review gate + +on: + pull_request_target: + types: [opened, synchronize, reopened, ready_for_review] + workflow_dispatch: + inputs: + pull_request: + description: Numero della PR da verificare + required: true + type: number + +permissions: + contents: read + issues: read + pull-requests: read + statuses: write + +concurrency: + group: codex-review-${{ github.event.pull_request.number || inputs.pull_request }} + cancel-in-progress: true + +jobs: + gate: + name: Aggiorna gate Codex + runs-on: ubuntu-latest + timeout-minutes: 310 + steps: + # `pull_request_target` concede scritture anche a Dependabot: eseguiamo solo + # il codice già presente sul branch predefinito, mai quello della PR. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ github.event.repository.default_branch }} + - name: Attendi la review Codex + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PULL_REQUEST_NUMBER: ${{ inputs.pull_request }} + run: node scripts/codex-review-gate.mjs diff --git a/docs/INDEX.md b/docs/INDEX.md index 3aa89f6..a04f062 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -34,6 +34,7 @@ Sentinel usa la root per ingresso operativo, configurazione e codice. Usa - `.github/workflows/sentinel.yml`: workflow operativo schedulato e manuale. - `.github/workflows/codex-pr-comments.yml`: sincronizzazione della Codex feedback inbox. +- `.github/workflows/codex-review-gate.yml`: gate Codex exact-HEAD sulle PR. - `.github/workflows/pr-title.yml`: controllo titolo PR. - `.github/PULL_REQUEST_TEMPLATE.md`: template PR. - `.github/ISSUE_TEMPLATE/`: template issue. diff --git a/docs/TOOLCHAIN.md b/docs/TOOLCHAIN.md index c0a4957..17a71d2 100644 --- a/docs/TOOLCHAIN.md +++ b/docs/TOOLCHAIN.md @@ -50,6 +50,8 @@ Questa pagina descrive runtime, comandi e guardrail effettivi di Sentinel. - gate PR quality automatico: non presente finché manca una decisione esplicita per reintrodurre un workflow test/coverage/build su `pull_request`. - Codex comments dry-run: workflow `Codex PR comments` con input `dry_run=true`. +- Codex review gate: workflow `Codex review gate`, status `codex-review` + associato all'HEAD esatto della PR; il codice eseguito arriva sempre da `main`. - scan: `npm run sentinel -- scan`. - dry-run scan: `npm run sentinel -- scan --dry-run`. - report: `npm run sentinel -- report`. @@ -92,10 +94,11 @@ stati vuoti/errore/loading quando il diff li può alterare. completa di `pubblica` significa anche pulire branch/worktree locali e remoti assorbiti al termine. - La Codex feedback inbox è gestita dal workflow `Codex PR comments`. -- Le PR verso `main` girano il workflow `CI` (typecheck + build CLI/web + test): - è segnale, non gate. Su `main` non ci sono check obbligatori né ruleset, - secondo ADR `docs/decisions/0005-niente-ruleset-ci-su-main.md`: controlla i - check prima di mergiare, GitHub non li impone. +- Le PR verso `main` girano il workflow `CI` (typecheck + build CLI/web + test), + che resta un segnale. Il gate separato `codex-review` pubblica invece uno + status exact-HEAD; ADR `docs/decisions/0005-niente-ruleset-ci-su-main.md` + continua a vietare un ruleset che blocchi il push diretto degli output dello + scan schedulato. - Aggiornamenti dipendenze: Dependabot settimanale (npm + github-actions), minor/patch raggruppati. Le PR si mergiano a mano dopo aver controllato la CI: l'auto-merge richiede almeno un check obbligatorio su `main` e non è più diff --git a/scripts/codex-review-gate.mjs b/scripts/codex-review-gate.mjs new file mode 100644 index 0000000..313e7c1 --- /dev/null +++ b/scripts/codex-review-gate.mjs @@ -0,0 +1,336 @@ +import { pathToFileURL } from "node:url"; + +const CODEX_BOT = "chatgpt-codex-connector[bot]"; +const isDirectExecution = + process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href; +// ponytail: 180 s limita cinque PR concorrenti a circa 500 richieste/ora; passare a +// un'unica query GraphQL se la concorrenza reale cresce oltre questo livello. +export const CODEX_REVIEW_POLLING = { attempts: 100, intervalMs: 180_000 }; + +const timestamp = (value) => new Date(value ?? 0).getTime(); +const reviewedCommit = (body = "") => + body.match(/\*\*Reviewed commit:\*\*\s*`([0-9a-f]{10,40})`/i)?.[1]; + +export function classifyCodexReview({ + headSha, + requestedAt, + now = Date.now(), + comments, + exactReactions = [], + reactions, + progressReactions = reactions, + requiresReviewedCommit = false, + reviews = [], + reviewComments, +}) { + const completions = []; + const cleanComments = []; + const latestEyesAt = progressReactions + .filter( + (reaction) => + reaction.user?.login === CODEX_BOT && + reaction.content === "eyes" && + timestamp(reaction.created_at) >= timestamp(requestedAt), + ) + .reduce((latest, reaction) => Math.max(latest, timestamp(reaction.created_at)), 0); + + for (const comment of reviewComments) { + if ( + comment.user?.login === CODEX_BOT && + (comment.original_commit_id ?? comment.commit_id) === headSha && + timestamp(comment.created_at) >= timestamp(requestedAt) && + /\bP[0-3]\b/.test(comment.body) + ) { + completions.push({ + state: "failure", + at: timestamp(comment.created_at), + description: "Codex ha trovato problemi nell'ultimo commit", + }); + } + } + + if (completions.length) { + return completions.sort((left, right) => right.at - left.at)[0]; + } + + for (const comment of comments) { + if (comment.user?.login !== CODEX_BOT) continue; + + const commit = reviewedCommit(comment.body); + if ( + (commit ? headSha.startsWith(commit) : timestamp(requestedAt) > 0) && + timestamp(comment.created_at) >= timestamp(requestedAt) && + /\bP[0-3]\b/.test(comment.body) + ) { + completions.push({ + state: "failure", + at: timestamp(comment.created_at), + description: "Codex ha trovato problemi nell'ultimo commit", + }); + } + + if ( + commit && + headSha.startsWith(commit) && + timestamp(comment.created_at) >= timestamp(requestedAt) && + /^Codex Review: Didn't find any major issues\./m.test(comment.body) + ) { + completions.push({ + state: "success", + at: timestamp(comment.created_at), + description: "Codex ha approvato l'ultimo commit", + }); + } + + if ( + timestamp(requestedAt) > 0 && + timestamp(comment.created_at) >= timestamp(requestedAt) && + now - timestamp(requestedAt) >= 30_000 && + timestamp(comment.created_at) >= latestEyesAt && + /reached your Codex usage limits|could not complete|unable to review|something went wrong|unknown error/i.test( + comment.body, + ) + ) { + completions.push({ + state: "failure", + at: timestamp(comment.created_at), + description: "La review Codex non è stata completata", + }); + } + } + + const commentFailure = completions + .filter((completion) => completion.state === "failure") + .sort((left, right) => right.at - left.at)[0]; + if (commentFailure) return commentFailure; + + for (const review of reviews) { + const commit = review.commit_id ?? reviewedCommit(review.body); + if ( + review.user?.login === CODEX_BOT && + commit && + headSha.startsWith(commit) && + timestamp(review.submitted_at) >= timestamp(requestedAt) + ) { + cleanComments.push(timestamp(review.submitted_at)); + } + } + + const thumbsUpAt = reactions + .filter( + (reaction) => + reaction.user?.login === CODEX_BOT && + reaction.content === "+1" && + timestamp(reaction.created_at) >= timestamp(requestedAt), + ) + .reduce((latest, reaction) => Math.max(latest, timestamp(reaction.created_at)), 0); + const exactThumbsUpAt = exactReactions + .filter( + (reaction) => + timestamp(requestedAt) > 0 && + reaction.user?.login === CODEX_BOT && + reaction.content === "+1" && + timestamp(reaction.created_at) >= timestamp(requestedAt), + ) + .reduce((latest, reaction) => Math.max(latest, timestamp(reaction.created_at)), 0); + + if (thumbsUpAt) { + if (!requiresReviewedCommit || exactThumbsUpAt) { + cleanComments.push(exactThumbsUpAt || thumbsUpAt); + } + for (const commentAt of cleanComments) { + if (thumbsUpAt < commentAt) continue; + completions.push({ + state: "success", + at: Math.max(thumbsUpAt, commentAt), + description: "Codex ha approvato l'ultimo commit", + }); + } + } + + return ( + completions.sort((left, right) => right.at - left.at)[0] ?? { + state: "pending", + description: "In attesa della review Codex sull'ultimo commit", + } + ); +} + +export const hasSuccessfulCodexStatus = (statuses) => + statuses.find((status) => status.context === "codex-review")?.state === "success"; + +export const latestCodexInvocation = (comments, requestedAt) => + comments + .filter( + (comment) => + timestamp(requestedAt) > 0 && + comment.user?.login !== CODEX_BOT && + /@codex\s+review\b/i.test(comment.body) && + timestamp(comment.created_at) >= timestamp(requestedAt), + ) + .sort((left, right) => timestamp(right.created_at) - timestamp(left.created_at))[0]; + +export function pullRequestNumber(event, input) { + const number = String(event.pull_request?.number ?? input); + if (!/^\d+$/.test(number)) throw new Error("Numero PR non valido"); + return number; +} + +export const isRetryableGitHubResponse = (status, remaining) => + status === 429 || status >= 500 || (status === 403 && remaining === "0"); + +async function request(path, options = {}) { + const response = await fetch(`https://api.github.com${path}`, { + ...options, + headers: { + accept: "application/vnd.github+json", + authorization: `Bearer ${process.env.GITHUB_TOKEN}`, + "x-github-api-version": "2022-11-28", + ...options.headers, + }, + }); + if (!response.ok) { + const error = new Error(`${options.method ?? "GET"} ${path}: ${response.status}`); + error.retryable = isRetryableGitHubResponse( + response.status, + response.headers.get("x-ratelimit-remaining"), + ); + throw error; + } + return response.json(); +} + +async function all(path) { + const items = []; + for (let page = 1; ; page += 1) { + const batch = await request( + `${path}${path.includes("?") ? "&" : "?"}per_page=100&page=${page}`, + ); + items.push(...batch); + if (batch.length < 100) return items; + } +} + +async function setStatus(repository, sha, state, description) { + await request(`/repos/${repository}/statuses/${sha}`, { + method: "POST", + body: JSON.stringify({ + state, + context: "codex-review", + description, + target_url: `${process.env.GITHUB_SERVER_URL}/${repository}/actions/runs/${process.env.GITHUB_RUN_ID}`, + }), + }); +} + +async function reviewSignals(repository, number, requestedAt) { + const [comments, reactions, reviews, reviewComments] = await Promise.all([ + all(`/repos/${repository}/issues/${number}/comments`), + all(`/repos/${repository}/issues/${number}/reactions`), + all(`/repos/${repository}/pulls/${number}/reviews`), + all(`/repos/${repository}/pulls/${number}/comments`), + ]); + const invocation = latestCodexInvocation(comments, requestedAt); + const invocationReactions = invocation + ? await all(`/repos/${repository}/issues/comments/${invocation.id}/reactions`) + : []; + return [ + comments, + [...reactions, ...invocationReactions], + reviews, + reviewComments, + invocationReactions, + ]; +} + +async function main() { + const event = JSON.parse( + await (await import("node:fs/promises")).readFile(process.env.GITHUB_EVENT_PATH), + ); + const repository = process.env.GITHUB_REPOSITORY; + const requestedNumber = pullRequestNumber(event, process.env.PULL_REQUEST_NUMBER); + const pullRequest = + event.pull_request ?? (await request(`/repos/${repository}/pulls/${requestedNumber}`)); + const number = pullRequest.number; + const headSha = pullRequest.head.sha; + const reusesExistingReview = + process.env.GITHUB_EVENT_NAME === "workflow_dispatch" || event.action === "reopened"; + + if (reusesExistingReview) { + const statuses = await all(`/repos/${repository}/commits/${headSha}/statuses`); + if (hasSuccessfulCodexStatus(statuses)) return; + } + + await setStatus( + repository, + headSha, + "pending", + "In attesa della review Codex sull'ultimo commit", + ); + if (pullRequest.draft) return; + + if (["opened", "ready_for_review"].includes(event.action)) { + await new Promise((resolve) => setTimeout(resolve, 30_000)); + const currentPullRequest = await request(`/repos/${repository}/pulls/${number}`); + if (currentPullRequest.head.sha !== headSha) return; + } + + const freshReview = ["opened", "ready_for_review"].includes(event.action); + const requestedAt = reusesExistingReview ? 0 : pullRequest.updated_at; + for (let attempt = 0; attempt < CODEX_REVIEW_POLLING.attempts; attempt += 1) { + let signals; + try { + signals = await reviewSignals(repository, number, requestedAt); + } catch (error) { + if (!(error instanceof TypeError) && !error.retryable) throw error; + console.warn(`Lettura GitHub transitoria, nuovo tentativo: ${error.message}`); + await new Promise((resolve) => setTimeout(resolve, CODEX_REVIEW_POLLING.intervalMs)); + continue; + } + const [comments, reactions, reviews, reviewComments, exactReactions] = signals; + const result = classifyCodexReview({ + headSha, + requestedAt, + comments, + exactReactions, + reactions, + requiresReviewedCommit: !freshReview, + reviews, + reviewComments, + }); + if (result.state !== "pending") { + await setStatus(repository, headSha, result.state, result.description); + return; + } + await new Promise((resolve) => setTimeout(resolve, CODEX_REVIEW_POLLING.intervalMs)); + } + + await setStatus(repository, headSha, "error", "Review Codex non conclusa entro cinque ore"); +} + +if (process.env.GITHUB_ACTIONS === "true" && isDirectExecution) { + await main().catch(async (error) => { + console.error(error); + const event = JSON.parse( + await (await import("node:fs/promises")).readFile(process.env.GITHUB_EVENT_PATH), + ); + let requestedNumber; + try { + requestedNumber = pullRequestNumber(event, process.env.PULL_REQUEST_NUMBER); + } catch { + return; + } + const pullRequest = + event.pull_request ?? + (await request(`/repos/${process.env.GITHUB_REPOSITORY}/pulls/${requestedNumber}`).catch( + () => null, + )); + if (!pullRequest) return; + await setStatus( + process.env.GITHUB_REPOSITORY, + pullRequest.head.sha, + "error", + "Impossibile verificare la review Codex", + ).catch(console.error); + }); +} diff --git a/test/codex-review-gate.test.mjs b/test/codex-review-gate.test.mjs new file mode 100644 index 0000000..104dab6 --- /dev/null +++ b/test/codex-review-gate.test.mjs @@ -0,0 +1,306 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { test } from "vitest"; +import { + CODEX_REVIEW_POLLING, + classifyCodexReview, + hasSuccessfulCodexStatus, + isRetryableGitHubResponse, + latestCodexInvocation, + pullRequestNumber, +} from "../scripts/codex-review-gate.mjs"; + +const headSha = "0123456789abcdef0123456789abcdef01234567"; +const requestedAt = "2026-08-04T12:00:00Z"; +const bot = { login: "chatgpt-codex-connector[bot]" }; + +const classify = (overrides = {}) => + classifyCodexReview({ + headSha, + requestedAt, + now: new Date(requestedAt).getTime() + 60_000, + comments: [], + reactions: [], + reviewComments: [], + ...overrides, + }); + +test("resta pending senza un esito Codex", () => { + assert.equal(classify().state, "pending"); +}); + +test("approva la review iniziale soltanto con la reazione del bot", () => { + assert.equal( + classify({ + reactions: [{ user: bot, content: "+1", created_at: "2026-08-04T12:00:03Z" }], + }).state, + "success", + ); + assert.equal( + classify({ + reactions: [ + { user: { login: "utente" }, content: "+1", created_at: "2026-08-04T12:00:03Z" }, + ], + }).state, + "pending", + ); +}); + +test("approva tramite review dell'HEAD e commit_id con corpo vuoto", () => { + for (const review of [ + { + user: bot, + submitted_at: "2026-08-04T12:00:02Z", + body: `**Reviewed commit:** \`${headSha.slice(0, 10)}\``, + }, + { user: bot, commit_id: headSha, submitted_at: "2026-08-04T12:00:02Z", body: "" }, + ]) { + assert.equal( + classify({ + requiresReviewedCommit: true, + reviews: [review], + reactions: [{ user: bot, content: "+1", created_at: "2026-08-04T12:00:03Z" }], + }).state, + "success", + ); + } +}); + +test("approva tramite reazione sulla singola invocazione corrente", () => { + const reaction = { user: bot, content: "+1", created_at: "2026-08-04T12:00:01Z" }; + assert.equal( + classify({ + exactReactions: [reaction], + reactions: [reaction], + requiresReviewedCommit: true, + }).state, + "success", + ); +}); + +test("non riusa approvazioni o reazioni di SHA e tentativi precedenti", () => { + const oldReaction = { user: bot, content: "+1", created_at: "2026-08-04T11:59:59Z" }; + assert.equal( + classify({ + requiresReviewedCommit: true, + reviews: [ + { + user: bot, + submitted_at: "2026-08-04T12:00:02Z", + body: "**Reviewed commit:** `abcdef0123`", + }, + ], + reactions: [oldReaction], + }).state, + "pending", + ); + assert.equal( + classify({ + requestedAt: 0, + exactReactions: [{ ...oldReaction, created_at: "2026-08-04T12:00:01Z" }], + reactions: [{ ...oldReaction, created_at: "2026-08-04T12:00:01Z" }], + requiresReviewedCommit: true, + }).state, + "pending", + ); +}); + +test("un finding P0-P3 corrente prevale sempre sull'approvazione", () => { + assert.equal( + classify({ + reviewComments: [ + { + user: bot, + original_commit_id: headSha, + created_at: "2026-08-04T12:00:01Z", + body: "**P1** Correggi questo caso", + }, + ], + reactions: [{ user: bot, content: "+1", created_at: "2026-08-04T12:00:02Z" }], + }).state, + "failure", + ); + assert.equal( + classify({ + requiresReviewedCommit: true, + comments: [ + { + user: bot, + created_at: "2026-08-04T12:00:01Z", + body: `**P3** Problema.\n\n**Reviewed commit:** \`${headSha.slice(0, 10)}\``, + }, + { + user: bot, + created_at: "2026-08-04T12:00:02Z", + body: `Codex Review: Didn't find any major issues.\n\n**Reviewed commit:** \`${headSha.slice(0, 10)}\``, + }, + ], + }).state, + "failure", + ); +}); + +test("rebase e nuovo commit invalidano finding e approvazioni precedenti", () => { + assert.equal( + classify({ + reviewComments: [ + { + user: bot, + commit_id: headSha, + original_commit_id: "abcdef0123456789abcdef0123456789abcdef01", + created_at: "2026-08-04T12:00:01Z", + body: "**P1** Finding già corretto", + }, + ], + reviews: [ + { + user: bot, + submitted_at: "2026-08-04T12:00:02Z", + body: "**Reviewed commit:** `abcdef0123`", + }, + ], + reactions: [{ user: bot, content: "+1", created_at: "2026-08-04T12:00:03Z" }], + requiresReviewedCommit: true, + }).state, + "pending", + ); +}); + +test("un retry pulito sullo stesso SHA ignora finding ed errori precedenti", () => { + assert.equal( + classify({ + reviewComments: [ + { + user: bot, + original_commit_id: headSha, + created_at: "2026-08-04T11:59:59Z", + body: "**P2** Finding precedente", + }, + ], + comments: [ + { + user: bot, + created_at: "2026-08-04T11:59:59Z", + body: "Codex could not complete the review", + }, + ], + reviews: [ + { user: bot, commit_id: headSha, submitted_at: "2026-08-04T12:00:02Z", body: "" }, + ], + reactions: [{ user: bot, content: "+1", created_at: "2026-08-04T12:00:03Z" }], + requiresReviewedCommit: true, + }).state, + "success", + ); +}); + +test("usage limit e unknown error falliscono il tentativo corrente", () => { + for (const body of [ + "You have reached your Codex usage limits for code reviews.", + "Codex Review: Something went wrong. Try again later. Unknown error", + ]) { + assert.equal( + classify({ comments: [{ user: bot, created_at: "2026-08-04T12:00:01Z", body }] }).state, + "failure", + ); + } +}); + +test("eyes mantiene pending finché non arriva un errore successivo", () => { + const progressReactions = [ + { user: bot, content: "eyes", created_at: "2026-08-04T12:00:02Z" }, + ]; + assert.equal( + classify({ + comments: [ + { user: bot, created_at: "2026-08-04T12:00:01Z", body: "Codex could not complete" }, + ], + progressReactions, + }).state, + "pending", + ); + assert.equal( + classify({ + comments: [ + { user: bot, created_at: "2026-08-04T12:00:03Z", body: "Codex could not complete" }, + ], + progressReactions, + }).state, + "failure", + ); +}); + +test("trova solo l'ultima invocazione umana del tentativo corrente", () => { + assert.equal( + latestCodexInvocation( + [ + { id: 1, user: bot, body: "@codex review", created_at: "2026-08-04T12:00:03Z" }, + { + id: 2, + user: { login: "max23468" }, + body: "@codex review", + created_at: "2026-08-04T12:00:01Z", + }, + { + id: 3, + user: { login: "max23468" }, + body: "@codex review", + created_at: "2026-08-04T12:00:02Z", + }, + ], + requestedAt, + ).id, + 3, + ); + assert.equal(latestCodexInvocation([], 0), undefined); +}); + +test("valida rigidamente il numero PR", () => { + assert.equal(pullRequestNumber({ pull_request: { number: 42 } }), "42"); + assert.equal(pullRequestNumber({}, "208"), "208"); + assert.throws(() => pullRequestNumber({}, "208/merge"), /Numero PR non valido/); +}); + +test("ritenta soltanto errori GitHub recuperabili", () => { + assert.equal(isRetryableGitHubResponse(429, null), true); + assert.equal(isRetryableGitHubResponse(502, null), true); + assert.equal(isRetryableGitHubResponse(403, "0"), true); + assert.equal(isRetryableGitHubResponse(403, "4999"), false); + assert.equal(isRetryableGitHubResponse(404, null), false); +}); + +test("il polling copre cinque ore senza saturare la quota con cinque PR", () => { + assert.equal(CODEX_REVIEW_POLLING.attempts, 100); + assert.equal(CODEX_REVIEW_POLLING.intervalMs, 180_000); + assert.equal(CODEX_REVIEW_POLLING.attempts * CODEX_REVIEW_POLLING.intervalMs, 5 * 60 * 60 * 1000); + assert.ok((5 * 5 * 60 * 60 * 1000) / CODEX_REVIEW_POLLING.intervalMs <= 500); +}); + +test("un rerun riusa solo lo status Codex più recente dello stesso SHA", () => { + assert.equal( + hasSuccessfulCodexStatus([ + { context: "codex-review", state: "success" }, + { context: "codex-review", state: "pending" }, + ]), + true, + ); + assert.equal( + hasSuccessfulCodexStatus([ + { context: "codex-review", state: "failure" }, + { context: "codex-review", state: "success" }, + ]), + false, + ); +}); + +test("l'import in GitHub Actions non avvia la CLI", () => { + const result = spawnSync( + process.execPath, + ["--input-type=module", "--eval", `import(${JSON.stringify(import.meta.resolve("../scripts/codex-review-gate.mjs"))})`], + { + env: { ...process.env, GITHUB_ACTIONS: "true", GITHUB_EVENT_PATH: "/non-esiste" }, + encoding: "utf8", + }, + ); + assert.equal(result.status, 0, result.stderr); +}); diff --git a/test/workflow.test.ts b/test/workflow.test.ts index 0fbd3fd..50d5e0a 100644 --- a/test/workflow.test.ts +++ b/test/workflow.test.ts @@ -57,4 +57,28 @@ describe("workflow Sentinel", () => { expect(source).toContain("github.event.comment.author_association"); expect(source).toContain("github.event.pull_request.author_association"); }); + + it("esegue il gate Codex sul codice fidato del branch predefinito", async () => { + const source = await readFile( + ".github/workflows/codex-review-gate.yml", + "utf8" + ); + + expect(source).toContain("pull_request_target:"); + expect(source).toContain( + "types: [opened, synchronize, reopened, ready_for_review]" + ); + expect(source).toContain("workflow_dispatch:"); + expect(source).toContain("contents: read"); + expect(source).toContain("issues: read"); + expect(source).toContain("pull-requests: read"); + expect(source).toContain("statuses: write"); + expect(source).toMatch(/actions\/checkout@[0-9a-f]{40}/); + expect(source).toContain( + "ref: ${{ github.event.repository.default_branch }}" + ); + expect(source).toContain("timeout-minutes: 310"); + expect(source).toContain("cancel-in-progress: true"); + expect(source).toContain("node scripts/codex-review-gate.mjs"); + }); }); From 381417b881b4dad7f4882305612ee4daf39a7444 Mon Sep 17 00:00:00 2001 From: Matteo <30387529+max23468@users.noreply.github.com> Date: Thu, 6 Aug 2026 15:14:16 +0200 Subject: [PATCH 02/12] docs: record Codex gate decision --- docs/DECISIONS.md | 1 + docs/INDEX.md | 1 + .../0006-gate-codex-review-exact-head.md | 53 +++++++++++++++++++ scripts/codex-review-gate.mjs | 17 +++--- test/codex-review-gate.test.mjs | 8 +++ 5 files changed, 74 insertions(+), 6 deletions(-) create mode 100644 docs/decisions/0006-gate-codex-review-exact-head.md diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 5326aa4..d58109b 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -9,6 +9,7 @@ Questo indice raccoglie le decisioni stabili della repo. - [0003 - Tag e GitHub Release](decisions/0003-tag-e-github-release.md): accettata. - [0004 - Dashboard su Vite invece di Next.js](decisions/0004-dashboard-vite-invece-di-next.md): accettata. - [0005 - Niente ruleset CI obbligatoria su main](decisions/0005-niente-ruleset-ci-su-main.md): accettata. +- [0006 - Gate Codex review exact-HEAD](decisions/0006-gate-codex-review-exact-head.md): accettata. ## Decisioni sostituite o superate diff --git a/docs/INDEX.md b/docs/INDEX.md index a04f062..71200e7 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -29,6 +29,7 @@ Sentinel usa la root per ingresso operativo, configurazione e codice. Usa - `docs/decisions/0003-tag-e-github-release.md`: policy tag e GitHub Release. - `docs/decisions/0004-dashboard-vite-invece-di-next.md`: dashboard su Vite invece di Next.js. - `docs/decisions/0005-niente-ruleset-ci-su-main.md`: niente ruleset CI obbligatoria su `main`. +- `docs/decisions/0006-gate-codex-review-exact-head.md`: gate Codex exact-HEAD e bootstrap. ## Pubblicazione e operatività diff --git a/docs/decisions/0006-gate-codex-review-exact-head.md b/docs/decisions/0006-gate-codex-review-exact-head.md new file mode 100644 index 0000000..10970ba --- /dev/null +++ b/docs/decisions/0006-gate-codex-review-exact-head.md @@ -0,0 +1,53 @@ +# 0006 - Gate Codex review exact-HEAD + +Data: 2026-08-06 + +Stato: Accettata + +## Contesto + +Le review Codex non erano un gate: la repository manteneva soltanto una inbox +dei thread e GitHub poteva riusare segnali appartenenti a commit o tentativi +precedenti. Serve uno status distinto che rappresenti esclusivamente la review +dell'HEAD corrente della PR. + +## Decisione + +Adottare un solo workflow `Codex review gate`, allineato a SyncBay, che: + +- usa `pull_request_target` e fa checkout esclusivamente del branch predefinito; +- ha permessi `contents`, `issues` e `pull-requests` in lettura e `statuses` in + scrittura; +- pubblica `codex-review` sull'HEAD esatto e invalida ogni prova al nuovo SHA; +- accetta soltanto segnali di `chatgpt-codex-connector[bot]` legati al tentativo + corrente; finding P0-P3 correnti prevalgono sempre; +- resta separato dalla workflow `Codex PR comments`, che mantiene la inbox. + +La PR di bootstrap non può eseguire il workflow nuovo perché +`pull_request_target` usa la versione già presente su `main`: il gate va provato +con dispatch solo dopo il merge. + +## Interazione con ADR 0005 + +ADR 0005 continua a proteggere il push diretto di `data/`, `snapshots/` e +`reports/` prodotto dal workflow schedulato. Un required status applicato a +`main` blocca anche i push diretti privi dello status; quindi l'enforcement di +`codex-review` non deve essere attivato con bypass né rompere lo scan. Prima +dell'attivazione serve una decisione compatibile sul canale degli output. + +## Impatti + +- Sicurezza: il workflow non esegue codice della PR e usa permessi minimi. +- Merge: lo status è exact-HEAD; senza un Ruleset resta informativo. +- Runtime, deploy e release: invariati. + +## Verifiche + +- Test dinamici del classificatore e test statico del workflow. +- `npm test`, typecheck e build completa. +- Dispatch reale e controllo dello status sull'HEAD dopo il merge. + +## Collegamenti + +- [0005 - Niente ruleset CI obbligatoria su main](0005-niente-ruleset-ci-su-main.md) +- Toolchain: `docs/TOOLCHAIN.md` diff --git a/scripts/codex-review-gate.mjs b/scripts/codex-review-gate.mjs index 313e7c1..eefd7f1 100644 --- a/scripts/codex-review-gate.mjs +++ b/scripts/codex-review-gate.mjs @@ -326,11 +326,16 @@ if (process.env.GITHUB_ACTIONS === "true" && isDirectExecution) { () => null, )); if (!pullRequest) return; - await setStatus( - process.env.GITHUB_REPOSITORY, - pullRequest.head.sha, - "error", - "Impossibile verificare la review Codex", - ).catch(console.error); + try { + await setStatus( + process.env.GITHUB_REPOSITORY, + pullRequest.head.sha, + "error", + "Impossibile verificare la review Codex", + ); + } catch (statusError) { + console.error(statusError); + process.exitCode = 1; + } }); } diff --git a/test/codex-review-gate.test.mjs b/test/codex-review-gate.test.mjs index 104dab6..fd24ce9 100644 --- a/test/codex-review-gate.test.mjs +++ b/test/codex-review-gate.test.mjs @@ -304,3 +304,11 @@ test("l'import in GitHub Actions non avvia la CLI", () => { ); assert.equal(result.status, 0, result.stderr); }); + +test("un doppio errore API non lascia verde il job senza status", async () => { + const source = await import("node:fs/promises").then((fs) => + fs.readFile(new URL("../scripts/codex-review-gate.mjs", import.meta.url), "utf8"), + ); + + assert.match(source, /catch \(statusError\)[\s\S]*process\.exitCode = 1/); +}); From bdd2ac1ace4254979a41cdcb5db0813e5fee0473 Mon Sep 17 00:00:00 2001 From: Matteo <30387529+max23468@users.noreply.github.com> Date: Thu, 6 Aug 2026 15:21:33 +0200 Subject: [PATCH 03/12] fix(ci): block findings in Codex review bodies --- scripts/codex-review-gate.mjs | 13 ++++++++++++- test/codex-review-gate.test.mjs | 14 ++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/scripts/codex-review-gate.mjs b/scripts/codex-review-gate.mjs index eefd7f1..d279de9 100644 --- a/scripts/codex-review-gate.mjs +++ b/scripts/codex-review-gate.mjs @@ -112,10 +112,21 @@ export function classifyCodexReview({ headSha.startsWith(commit) && timestamp(review.submitted_at) >= timestamp(requestedAt) ) { - cleanComments.push(timestamp(review.submitted_at)); + if (/\bP[0-3]\b/.test(review.body)) { + completions.push({ + state: "failure", + at: timestamp(review.submitted_at), + description: "Codex ha trovato problemi nell'ultimo commit", + }); + } else { + cleanComments.push(timestamp(review.submitted_at)); + } } } + const reviewFailure = completions.find((completion) => completion.state === "failure"); + if (reviewFailure) return reviewFailure; + const thumbsUpAt = reactions .filter( (reaction) => diff --git a/test/codex-review-gate.test.mjs b/test/codex-review-gate.test.mjs index fd24ce9..7a3f956 100644 --- a/test/codex-review-gate.test.mjs +++ b/test/codex-review-gate.test.mjs @@ -138,6 +138,20 @@ test("un finding P0-P3 corrente prevale sempre sull'approvazione", () => { }).state, "failure", ); + assert.equal( + classify({ + reviews: [ + { + user: bot, + commit_id: headSha, + submitted_at: "2026-08-04T12:00:01Z", + body: "**P2** Finding nel corpo della review", + }, + ], + reactions: [{ user: bot, content: "+1", created_at: "2026-08-04T12:00:02Z" }], + }).state, + "failure", + ); }); test("rebase e nuovo commit invalidano finding e approvazioni precedenti", () => { From a4e6985a2ad564c0f7b4e6e15355bbbe617f246a Mon Sep 17 00:00:00 2001 From: Matteo <30387529+max23468@users.noreply.github.com> Date: Thu, 6 Aug 2026 15:29:18 +0200 Subject: [PATCH 04/12] fix(ci): reject stale unmarked Codex findings --- scripts/codex-review-gate.mjs | 10 ++++++++-- test/codex-review-gate.test.mjs | 14 ++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/scripts/codex-review-gate.mjs b/scripts/codex-review-gate.mjs index d279de9..54e6c75 100644 --- a/scripts/codex-review-gate.mjs +++ b/scripts/codex-review-gate.mjs @@ -58,7 +58,9 @@ export function classifyCodexReview({ const commit = reviewedCommit(comment.body); if ( - (commit ? headSha.startsWith(commit) : timestamp(requestedAt) > 0) && + (commit + ? headSha.startsWith(commit) + : !requiresReviewedCommit && timestamp(requestedAt) > 0) && timestamp(comment.created_at) >= timestamp(requestedAt) && /\bP[0-3]\b/.test(comment.body) ) { @@ -329,6 +331,7 @@ if (process.env.GITHUB_ACTIONS === "true" && isDirectExecution) { try { requestedNumber = pullRequestNumber(event, process.env.PULL_REQUEST_NUMBER); } catch { + process.exitCode = 1; return; } const pullRequest = @@ -336,7 +339,10 @@ if (process.env.GITHUB_ACTIONS === "true" && isDirectExecution) { (await request(`/repos/${process.env.GITHUB_REPOSITORY}/pulls/${requestedNumber}`).catch( () => null, )); - if (!pullRequest) return; + if (!pullRequest) { + process.exitCode = 1; + return; + } try { await setStatus( process.env.GITHUB_REPOSITORY, diff --git a/test/codex-review-gate.test.mjs b/test/codex-review-gate.test.mjs index 7a3f956..3eb8f67 100644 --- a/test/codex-review-gate.test.mjs +++ b/test/codex-review-gate.test.mjs @@ -94,6 +94,19 @@ test("non riusa approvazioni o reazioni di SHA e tentativi precedenti", () => { }).state, "pending", ); + assert.equal( + classify({ + requiresReviewedCommit: true, + comments: [ + { + user: bot, + created_at: "2026-08-04T12:00:01Z", + body: "**P2** Finding tardivo del commit precedente", + }, + ], + }).state, + "pending", + ); assert.equal( classify({ requestedAt: 0, @@ -325,4 +338,5 @@ test("un doppio errore API non lascia verde il job senza status", async () => { ); assert.match(source, /catch \(statusError\)[\s\S]*process\.exitCode = 1/); + assert.match(source, /if \(!pullRequest\) \{\s*process\.exitCode = 1/); }); From 226aac5a555e3bf4af2c0bc192ec248810c395b4 Mon Sep 17 00:00:00 2001 From: Matteo <30387529+max23468@users.noreply.github.com> Date: Thu, 6 Aug 2026 15:40:37 +0200 Subject: [PATCH 05/12] fix(ci): bind Codex errors to exact invocation --- scripts/codex-review-gate.mjs | 12 +++++++++++- test/codex-review-gate.test.mjs | 24 ++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/scripts/codex-review-gate.mjs b/scripts/codex-review-gate.mjs index 54e6c75..552937f 100644 --- a/scripts/codex-review-gate.mjs +++ b/scripts/codex-review-gate.mjs @@ -33,6 +33,14 @@ export function classifyCodexReview({ timestamp(reaction.created_at) >= timestamp(requestedAt), ) .reduce((latest, reaction) => Math.max(latest, timestamp(reaction.created_at)), 0); + const exactEyesAt = exactReactions + .filter( + (reaction) => + reaction.user?.login === CODEX_BOT && + reaction.content === "eyes" && + timestamp(reaction.created_at) >= timestamp(requestedAt), + ) + .reduce((latest, reaction) => Math.max(latest, timestamp(reaction.created_at)), 0); for (const comment of reviewComments) { if ( @@ -88,7 +96,9 @@ export function classifyCodexReview({ timestamp(requestedAt) > 0 && timestamp(comment.created_at) >= timestamp(requestedAt) && now - timestamp(requestedAt) >= 30_000 && - timestamp(comment.created_at) >= latestEyesAt && + (!requiresReviewedCommit || exactEyesAt > 0) && + timestamp(comment.created_at) >= + (requiresReviewedCommit ? exactEyesAt : latestEyesAt) && /reached your Codex usage limits|could not complete|unable to review|something went wrong|unknown error/i.test( comment.body, ) diff --git a/test/codex-review-gate.test.mjs b/test/codex-review-gate.test.mjs index 3eb8f67..46ffa21 100644 --- a/test/codex-review-gate.test.mjs +++ b/test/codex-review-gate.test.mjs @@ -255,6 +255,30 @@ test("eyes mantiene pending finché non arriva un errore successivo", () => { }).state, "failure", ); + assert.equal( + classify({ + requiresReviewedCommit: true, + comments: [ + { user: bot, created_at: "2026-08-04T12:00:03Z", body: "Codex could not complete" }, + ], + progressReactions: [ + { user: bot, content: "eyes", created_at: "2026-08-04T12:00:02Z" }, + ], + }).state, + "pending", + ); + const exactEyes = { user: bot, content: "eyes", created_at: "2026-08-04T12:00:02Z" }; + assert.equal( + classify({ + requiresReviewedCommit: true, + comments: [ + { user: bot, created_at: "2026-08-04T12:00:03Z", body: "Codex could not complete" }, + ], + exactReactions: [exactEyes], + progressReactions: [exactEyes], + }).state, + "failure", + ); }); test("trova solo l'ultima invocazione umana del tentativo corrente", () => { From 36b7f0b69637c919d41cff5a8ca3b3d2e2747dfe Mon Sep 17 00:00:00 2001 From: Matteo <30387529+max23468@users.noreply.github.com> Date: Thu, 6 Aug 2026 15:45:52 +0200 Subject: [PATCH 06/12] refactor(ci): remove legacy Codex feedback inbox --- .github/scripts/codex-author.mjs | 10 - .github/scripts/handle-codex-pr-comments.mjs | 838 ------------------ .github/workflows/codex-pr-comments.yml | 57 -- AGENTS.md | 4 +- README.md | 4 - docs/INDEX.md | 1 - docs/ROADMAP.md | 3 - docs/TOOLCHAIN.md | 2 - .../0006-gate-codex-review-exact-head.md | 8 +- test/security.test.ts | 18 - test/workflow.test.ts | 9 - 11 files changed, 5 insertions(+), 949 deletions(-) delete mode 100644 .github/scripts/codex-author.mjs delete mode 100644 .github/scripts/handle-codex-pr-comments.mjs delete mode 100644 .github/workflows/codex-pr-comments.yml diff --git a/.github/scripts/codex-author.mjs b/.github/scripts/codex-author.mjs deleted file mode 100644 index 0a6ba75..0000000 --- a/.github/scripts/codex-author.mjs +++ /dev/null @@ -1,10 +0,0 @@ -const CODEX_AUTHOR_ID = 199175422; -const CODEX_AUTHOR_LOGIN = "chatgpt-codex-connector"; - -export function isTrustedCodexAuthor(author) { - return ( - author?.__typename === "Bot" && - author.databaseId === CODEX_AUTHOR_ID && - author.login === CODEX_AUTHOR_LOGIN - ); -} diff --git a/.github/scripts/handle-codex-pr-comments.mjs b/.github/scripts/handle-codex-pr-comments.mjs deleted file mode 100644 index ad59946..0000000 --- a/.github/scripts/handle-codex-pr-comments.mjs +++ /dev/null @@ -1,838 +0,0 @@ -#!/usr/bin/env node - -import { readFile } from "node:fs/promises"; -import process from "node:process"; -import { isTrustedCodexAuthor } from "./codex-author.mjs"; - -const repository = process.env.GITHUB_REPOSITORY; -const token = process.env.GITHUB_TOKEN; -const inboxIssueTitle = process.env.CODEX_INBOX_ISSUE_TITLE ?? "Codex feedback inbox"; -const inboxIssueLabel = process.env.CODEX_INBOX_ISSUE_LABEL ?? "codex-feedback-inbox"; -const repositoryName = repository?.split("/")[1] ?? "repository"; -const inboxMarker = - process.env.CODEX_INBOX_MARKER ?? - ``; -const dryRun = process.env.DRY_RUN === "true"; -const eventName = process.env.GITHUB_EVENT_NAME ?? ""; -const eventPayload = await readGitHubEventPayload(); -const fullScan = shouldRunFullScan(); -const eventPullRequestNumber = getEventPullRequestNumber(eventPayload); -const recentPrLimit = parsePositiveInteger(process.env.CODEX_RECENT_PR_LIMIT, 50); -const recentPrDays = parsePositiveInteger(process.env.CODEX_RECENT_PR_DAYS, 30); -const historyPrLimit = parsePositiveInteger(process.env.CODEX_HISTORY_PR_LIMIT, 8); -const historyThreadLimit = parsePositiveInteger(process.env.CODEX_HISTORY_THREAD_LIMIT, 5); -const actionablePrLimit = parsePositiveInteger(process.env.CODEX_ACTIONABLE_PR_LIMIT, 20); -const actionableThreadLimit = parsePositiveInteger(process.env.CODEX_ACTIONABLE_THREAD_LIMIT, 20); -const githubApiAttempts = parsePositiveInteger(process.env.CODEX_GITHUB_API_ATTEMPTS, 4); -const githubApiRetryBaseMs = parsePositiveInteger(process.env.CODEX_GITHUB_API_RETRY_BASE_MS, 1500); -const githubApiSecondaryRateLimitDelayMs = parsePositiveInteger( - process.env.CODEX_GITHUB_API_SECONDARY_RATE_LIMIT_DELAY_MS, - 60_000, -); - -if (!repository) { - fail("GITHUB_REPOSITORY non impostato."); -} - -if (!token) { - fail("GITHUB_TOKEN non impostato."); -} - -const [owner, repo] = repository.split("/"); - -if (!owner || !repo) { - fail(`GITHUB_REPOSITORY non valido: ${repository}`); -} - -const prs = await listPullRequests(); -const inboxEntries = []; - -for (const pr of prs) { - const threads = await listReviewThreads(pr.number); - const codexThreads = threads.filter(isCodexThread); - - if (codexThreads.length === 0) continue; - - const actionableThreads = codexThreads.filter(isActionableThread); - const historicalThreads = codexThreads.filter((thread) => !isActionableThread(thread)); - - inboxEntries.push({ - actionableThreads, - historicalThreads, - number: pr.number, - state: pr.state, - title: pr.title, - url: pr.html_url, - wasMerged: Boolean(pr.merged_at), - }); -} - -const inboxIssue = await upsertInboxIssue(inboxEntries); - -console.log( - JSON.stringify( - { - automaticPrComments: false, - closedDuplicateInboxIssues: inboxIssue.closedDuplicateNumbers, - dryRun, - eventName, - fullScan, - inboxIssue: inboxIssue.issue?.html_url ?? null, - prsScanned: prs.length, - prsWithCodexThreads: inboxEntries.length, - totalActionableThreads: inboxEntries.reduce( - (total, entry) => total + entry.actionableThreads.length, - 0, - ), - totalHistoricalThreads: inboxEntries.reduce( - (total, entry) => total + entry.historicalThreads.length, - 0, - ), - }, - null, - 2, - ), -); - -async function listPullRequests() { - if (fullScan) return listAllPullRequests(); - - const prsByNumber = new Map(); - - for (const pr of await listOpenPullRequests()) { - prsByNumber.set(pr.number, pr); - } - - for (const pr of await listRecentPullRequests()) { - prsByNumber.set(pr.number, pr); - } - - for (const prNumber of await listInboxPullRequestNumbers()) { - if (prsByNumber.has(prNumber)) continue; - - const inboxPr = await getPullRequestFromInbox(prNumber); - if (!inboxPr) continue; - - prsByNumber.set(inboxPr.number, inboxPr); - } - - if (eventPullRequestNumber && !prsByNumber.has(eventPullRequestNumber)) { - const eventPr = await githubJson(`/repos/${owner}/${repo}/pulls/${eventPullRequestNumber}`); - prsByNumber.set(eventPr.number, eventPr); - } - - return [...prsByNumber.values()].sort( - (left, right) => new Date(right.updated_at) - new Date(left.updated_at), - ); -} - -async function listAllPullRequests() { - return listPullRequestPages({ state: "all" }); -} - -async function getPullRequestFromInbox(prNumber) { - try { - return await githubJson(`/repos/${owner}/${repo}/pulls/${prNumber}`); - } catch (error) { - if (error.status === 404) { - console.warn(`PR #${prNumber} presente nella inbox ma non trovata: la salto.`); - return null; - } - - throw error; - } -} - -async function listInboxPullRequestNumbers() { - const inboxIssues = await findInboxIssues(); - - return [ - ...new Set(inboxIssues.flatMap((issue) => extractInboxPullRequestNumbers(issue.body ?? ""))), - ]; -} - -function extractInboxPullRequestNumbers(body) { - return [ - ...new Set( - [...body.matchAll(/^### PR #(\d+) - /gm)] - .map((match) => Number.parseInt(match[1], 10)) - .filter(Number.isInteger), - ), - ]; -} - -async function listOpenPullRequests() { - return listPullRequestPages({ state: "open" }); -} - -async function listRecentPullRequests() { - const cutoff = Date.now() - recentPrDays * 24 * 60 * 60 * 1000; - - return listPullRequestPages({ - limit: recentPrLimit, - state: "all", - stopAfterBatch: (batch) => { - const oldestPr = batch.at(-1); - return oldestPr ? new Date(oldestPr.updated_at).getTime() < cutoff : true; - }, - }); -} - -async function listPullRequestPages({ limit = Infinity, state, stopAfterBatch } = {}) { - const results = []; - - for (let page = 1; results.length < limit; page++) { - const query = new URLSearchParams({ - direction: "desc", - page: String(page), - per_page: "100", - sort: "updated", - state, - }); - const batch = await githubJson(`/repos/${owner}/${repo}/pulls?${query}`); - - if (batch.length === 0) break; - - results.push(...batch); - - if (stopAfterBatch?.(batch)) break; - } - - return results.slice(0, limit); -} - -async function listReviewThreads(prNumber) { - const query = `query($owner: String!, $repo: String!, $number: Int!, $cursor: String) { - repository(owner: $owner, name: $repo) { - pullRequest(number: $number) { - reviewThreads(first: 100, after: $cursor) { - pageInfo { - hasNextPage - endCursor - } - nodes { - id - isResolved - isOutdated - path - line - originalLine - comments(first: 100) { - pageInfo { - hasNextPage - endCursor - } - nodes { - id - author { - __typename - login - ... on Bot { - databaseId - } - ... on User { - databaseId - } - } - body - createdAt - url - } - } - } - } - } - } - }`; - - const threads = []; - let cursor = null; - - do { - const data = await githubGraphql(query, { - cursor, - number: prNumber, - owner, - repo, - }); - const page = data.repository.pullRequest.reviewThreads; - - threads.push(...page.nodes); - cursor = page.pageInfo.hasNextPage ? page.pageInfo.endCursor : null; - } while (cursor); - - for (const thread of threads) { - if (!thread.comments.pageInfo.hasNextPage) continue; - - thread.comments.nodes.push( - ...(await listReviewThreadComments(thread.id, thread.comments.pageInfo.endCursor)), - ); - } - - return threads; -} - -async function listReviewThreadComments(threadId, cursor) { - const query = `query($threadId: ID!, $cursor: String) { - node(id: $threadId) { - ... on PullRequestReviewThread { - comments(first: 100, after: $cursor) { - pageInfo { - hasNextPage - endCursor - } - nodes { - id - author { - __typename - login - ... on Bot { - databaseId - } - ... on User { - databaseId - } - } - body - createdAt - url - } - } - } - } - }`; - const comments = []; - - do { - const data = await githubGraphql(query, { - cursor, - threadId, - }); - const page = data.node.comments; - - comments.push(...page.nodes); - cursor = page.pageInfo.hasNextPage ? page.pageInfo.endCursor : null; - } while (cursor); - - return comments; -} - -function isCodexThread(thread) { - return thread.comments.nodes.some((comment) => isTrustedCodexAuthor(comment.author)); -} - -function isActionableThread(thread) { - return isCodexThread(thread) && !thread.isResolved && !thread.isOutdated; -} - -async function upsertInboxIssue(entries) { - const body = buildInboxBody(entries); - await ensureInboxLabel(); - const inboxIssues = await findInboxIssues(); - const existingIssue = chooseCanonicalInboxIssue(inboxIssues); - const duplicateIssues = inboxIssues.filter( - (issue) => issue.state === "open" && issue.number !== existingIssue?.number, - ); - const closedDuplicateNumbers = await closeDuplicateInboxIssues(duplicateIssues, existingIssue); - - if (dryRun) { - console.log(`DRY RUN: issue inbox non aggiornata.\n${body}`); - return { - closedDuplicateNumbers, - issue: existingIssue, - }; - } - - if (existingIssue) { - return { - closedDuplicateNumbers, - issue: await githubJson( - `/repos/${owner}/${repo}/issues/${existingIssue.number}`, - { - body, - labels: [inboxIssueLabel], - state: "open", - title: inboxIssueTitle, - }, - "PATCH", - ), - }; - } - - return { - closedDuplicateNumbers, - issue: await githubJson(`/repos/${owner}/${repo}/issues`, { - body, - labels: [inboxIssueLabel], - title: inboxIssueTitle, - }), - }; -} - -async function ensureInboxLabel() { - if (dryRun) return; - - try { - await githubJson(`/repos/${owner}/${repo}/labels/${encodeURIComponent(inboxIssueLabel)}`); - } catch (error) { - if (error.status !== 404) throw error; - - await githubJson(`/repos/${owner}/${repo}/labels`, { - color: "5319e7", - description: "Issue gestita automaticamente per i commenti Codex sulle PR", - name: inboxIssueLabel, - }); - } -} - -async function findInboxIssues() { - const query = new URLSearchParams({ - per_page: "100", - q: `repo:${owner}/${repo} is:issue in:title "${inboxIssueTitle}"`, - }); - const result = await githubJson(`/search/issues?${query}`); - const exactTitleIssues = result.items.filter((issue) => issue.title === inboxIssueTitle); - const issues = []; - - for (const issue of exactTitleIssues) { - const issueDetails = await githubJson(`/repos/${owner}/${repo}/issues/${issue.number}`); - - if (isManagedInboxIssue(issueDetails)) { - issues.push(issueDetails); - } - } - - return issues; -} - -function isManagedInboxIssue(issue) { - return isLabeledInboxIssue(issue) || isMigratableInboxIssue(issue); -} - -function isLabeledInboxIssue(issue) { - return hasInboxIdentity(issue) && issue.labels?.some((label) => label.name === inboxIssueLabel); -} - -function isMigratableInboxIssue(issue) { - return hasInboxIdentity(issue) && isTrustedInboxIssueCreator(issue); -} - -function hasInboxIdentity(issue) { - return issue.title === inboxIssueTitle && issue.body?.includes(inboxMarker); -} - -function isTrustedInboxIssueCreator(issue) { - return ["app/github-actions", "github-actions[bot]"].includes(issue.user?.login); -} - -function chooseCanonicalInboxIssue(issues) { - const openIssues = issues.filter((issue) => issue.state === "open"); - - return ( - sortIssuesByUpdatedDesc(openIssues.filter(isLabeledInboxIssue))[0] ?? - sortIssuesByUpdatedDesc(openIssues.filter(isMigratableInboxIssue))[0] ?? - null - ); -} - -function sortIssuesByUpdatedDesc(issues) { - return [...issues].sort((left, right) => new Date(right.updated_at) - new Date(left.updated_at)); -} - -async function closeDuplicateInboxIssues(issues, canonicalIssue) { - const closedDuplicateNumbers = []; - - for (const issue of issues) { - const body = canonicalIssue - ? `Chiudo come duplicato della inbox attiva #${canonicalIssue.number}.` - : `Chiudo come duplicato: il workflow ricreerà la inbox canonica "${inboxIssueTitle}".`; - - if (dryRun) { - console.log(`DRY RUN: chiuderei la issue inbox duplicata #${issue.number}.`); - closedDuplicateNumbers.push(issue.number); - continue; - } - - await githubJson(`/repos/${owner}/${repo}/issues/${issue.number}/comments`, { - body, - }); - await githubJson( - `/repos/${owner}/${repo}/issues/${issue.number}`, - { - state: "closed", - state_reason: "not_planned", - }, - "PATCH", - ); - closedDuplicateNumbers.push(issue.number); - } - - return closedDuplicateNumbers; -} - -function buildInboxBody(entries) { - const actionableEntries = entries - .map((entry) => ({ - ...entry, - threads: entry.actionableThreads, - })) - .filter((entry) => entry.threads.length > 0); - const historicalEntries = entries - .map((entry) => ({ - ...entry, - threads: entry.historicalThreads, - })) - .filter((entry) => entry.threads.length > 0); - const totalActionable = actionableEntries.reduce( - (total, entry) => total + entry.threads.length, - 0, - ); - const totalHistorical = historicalEntries.reduce( - (total, entry) => total + entry.threads.length, - 0, - ); - - const lines = [ - inboxMarker, - "# Codex feedback inbox", - "", - "Issue aggiornata automaticamente dal workflow `Codex PR comments`.", - "Fonte di verità: review thread GitHub su tutte le PR, aperte, chiuse e mergiate.", - "", - "## Da risolvere ora", - "", - ]; - - if (totalActionable === 0) { - lines.push("Nessun thread Codex actionable al momento.", ""); - } else { - const compactActionableEntries = compactActionableEntriesForInbox(actionableEntries); - const displayedActionable = compactActionableEntries.reduce( - (total, entry) => total + entry.threads.length, - 0, - ); - - lines.push( - `Thread actionable totali: ${totalActionable}. Mostro ${displayedActionable} thread recenti in ${compactActionableEntries.length} PR.`, - "", - ); - appendEntrySection(lines, compactActionableEntries, true); - } - - lines.push("## Storico e audit", ""); - - if (totalHistorical === 0) { - lines.push("Nessun thread Codex storico da mostrare.", ""); - } else { - const compactHistoricalEntries = compactHistoricalEntriesForInbox(historicalEntries); - const displayedHistorical = compactHistoricalEntries.reduce( - (total, entry) => total + entry.threads.length, - 0, - ); - - lines.push( - `Thread storici totali: ${totalHistorical}. Mostro ${displayedHistorical} thread recenti in ${compactHistoricalEntries.length} PR.`, - "", - ); - appendEntrySection(lines, compactHistoricalEntries, false); - } - - lines.push( - "## Regola operativa", - "", - "Quando questa issue segnala thread actionable, Codex deve risolvere prima i commenti nuovi e poi controllare anche lo storico ancora rilevante. La inbox si aggiorna su eventi PR trusted, commenti issue, dispatch manuale e scansione programmata; se un thread viene solo marcato come risolto nella UI GitHub senza push o commenti, lascia un commento sulla inbox o avvia il workflow manuale per forzare il refresh. I thread pending non pubblicati da GitHub non sono leggibili via API finché la review non viene inviata.", - "", - ); - - return `${lines.join("\n").trimEnd()}\n`; -} - -function compactHistoricalEntriesForInbox(entries) { - return entries.slice(0, historyPrLimit).map((entry) => ({ - ...entry, - threads: entry.threads.slice(0, historyThreadLimit), - })); -} - -function compactActionableEntriesForInbox(entries) { - return entries.slice(0, actionablePrLimit).map((entry) => ({ - ...entry, - threads: entry.threads.slice(0, actionableThreadLimit), - })); -} - -function appendEntrySection(lines, entries, actionable) { - for (const entry of entries) { - lines.push(`### PR #${entry.number} - ${entry.title}`); - lines.push(`- URL: ${entry.url}`); - lines.push(`- Stato: ${renderPrState(entry)}`); - lines.push(`- Thread: ${entry.threads.length}`); - lines.push(""); - - for (const thread of entry.threads) { - const checkbox = actionable ? "[ ]" : "[x]"; - lines.push(`- ${checkbox} ${renderThread(thread)}`); - } - - lines.push(""); - } -} - -function renderPrState(entry) { - if (entry.state === "open") return "aperta"; - return entry.wasMerged ? "mergiata" : "chiusa"; -} - -function renderThread(thread) { - const firstCodexComment = getFirstCodexComment(thread); - const location = renderThreadLocation(thread); - const summary = firstLine(firstCodexComment?.body ?? "commento Codex") ?? "commento Codex"; - const threadUrl = firstCodexComment?.url; - const state = `resolved=${thread.isResolved ? "yes" : "no"}, outdated=${ - thread.isOutdated ? "yes" : "no" - }`; - const link = threadUrl ? ` ([thread](${threadUrl}))` : ""; - - return `\`${location}\` - ${summary} (autore @${firstCodexComment.author.login}; ${state})${link}`; -} - -function renderThreadLocation(thread) { - const line = thread.line ?? thread.originalLine; - - return line ? `${thread.path}:${line}` : thread.path; -} - -function getFirstCodexComment(thread) { - return thread.comments.nodes.find((comment) => isTrustedCodexAuthor(comment.author)); -} - -function stripTags(line) { - // Ripeti fino a stabilita': un solo passaggio e' aggirabile perche' la - // rimozione puo' ricomporre un nuovo tag (es. "ipt>"). - let previous; - let current = line; - do { - previous = current; - current = current.replace(/<[^>]+>/g, ""); - } while (current !== previous); - return current.trim(); -} - -function firstLine(value) { - return value - .split("\n") - .map(stripTags) - .find(Boolean) - ?.slice(0, 160); -} - -async function readGitHubEventPayload() { - const eventPath = process.env.GITHUB_EVENT_PATH; - - if (!eventPath) return null; - - try { - return JSON.parse(await readFile(eventPath, "utf8")); - } catch (error) { - console.warn(`Impossibile leggere GITHUB_EVENT_PATH: ${error.message}`); - return null; - } -} - -function shouldRunFullScan() { - if (process.env.CODEX_FULL_SCAN === "true") return true; - if (!eventName) return true; - if (eventName === "schedule" || eventName === "workflow_dispatch") return true; - - return eventName === "issue_comment" && eventPayload?.issue?.title === inboxIssueTitle; -} - -function getEventPullRequestNumber(payload) { - if (payload?.pull_request?.number) return payload.pull_request.number; - if (payload?.issue?.pull_request) return payload.issue.number; - - return null; -} - -function parsePositiveInteger(value, fallback) { - const parsed = Number.parseInt(value ?? "", 10); - - return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; -} - -function normalizeInboxMarkerName(value) { - return ( - value - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-|-$/g, "") || "repository" - ); -} - -async function githubJson(path, body, method) { - const { payload, response, text } = await githubRequest(path, { - body: body ? JSON.stringify(body) : undefined, - headers: { - Accept: "application/vnd.github+json", - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - "X-GitHub-Api-Version": "2022-11-28", - }, - method: method ?? (body ? "POST" : "GET"), - }); - - if (!response.ok) { - const error = new Error(`GitHub REST ${path} ha risposto ${response.status}: ${text}`); - error.status = response.status; - throw error; - } - - return payload; -} - -async function githubGraphql(query, variables) { - const { payload, response, text } = await githubRequest("/graphql", { - body: JSON.stringify({ - query, - variables, - }), - headers: { - Accept: "application/vnd.github+json", - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - }, - method: "POST", - }); - - if (!response.ok || payload?.errors || !payload?.data) { - fail( - `GitHub GraphQL ha risposto con errore: ${JSON.stringify(payload?.errors ?? payload ?? text)}`, - ); - } - - return payload.data; -} - -async function githubRequest(path, init) { - const url = `https://api.github.com${path}`; - - for (let attempt = 1; attempt <= githubApiAttempts; attempt++) { - const response = await fetch(url, init); - const text = await response.text(); - - if (!shouldRetryGitHubRequest(response, text) || attempt === githubApiAttempts) { - const payload = parseGitHubJson(text, path, { allowInvalidJson: !response.ok }); - - return { payload, response, text }; - } - - const delayMs = githubRetryDelayMs(response, text, attempt); - console.warn( - `GitHub API ${path} ha risposto ${response.status}; ritento tra ${Math.round( - delayMs / 1000, - )}s (${attempt}/${githubApiAttempts}).`, - ); - await sleep(delayMs); - } - - fail(`GitHub API ${path} non completata.`); -} - -function parseGitHubJson(text, path, options = {}) { - if (!text) return null; - - try { - return JSON.parse(text); - } catch (error) { - if (options.allowInvalidJson) return null; - - fail(`GitHub API ${path} ha restituito JSON non valido: ${error.message}`); - } -} - -function shouldRetryGitHubRequest(response, text) { - if ([500, 502, 503, 504].includes(response.status)) return true; - if (response.status === 429 && isRetryableGitHubRateLimitResponse(response, text)) return true; - if (response.status === 403 && isRetryableGitHubRateLimitResponse(response, text)) return true; - - return response.status === 401 && isRetryableGitHubAuthResponse(text); -} - -function isRetryableGitHubAuthResponse(text) { - const normalizedText = text.toLowerCase(); - - // GitHub talvolta restituisce 401 transitori con un token valido, soprattutto - // sull'endpoint GraphQL ("Requires authentication") o su REST ("Bad - // credentials"). Sono blip momentanei: vanno ritentati, non trattati come fatali. - return ( - normalizedText.includes("bad credentials") || - normalizedText.includes("requires authentication") - ); -} - -function isRetryableGitHubRateLimitResponse(response, text) { - if (!isGitHubRateLimitResponse(response, text)) return false; - if (githubRetryAfterDelayMs(response) !== null) return true; - - if (response.headers.get("x-ratelimit-remaining") === "0") { - return githubRateLimitResetDelayMs(response) !== null; - } - - return true; -} - -function isGitHubRateLimitResponse(response, text) { - if (response.status === 429) return true; - if (response.headers.get("x-ratelimit-remaining") === "0") return true; - - const normalizedText = text.toLowerCase(); - - return normalizedText.includes("rate limit") || normalizedText.includes("abuse detection"); -} - -function githubRetryDelayMs(response, text, attempt) { - const retryAfter = githubRetryAfterDelayMs(response); - - if (retryAfter !== null) return retryAfter; - - const rateLimitResetDelayMs = githubRateLimitResetDelayMs(response); - - if (rateLimitResetDelayMs !== null && isGitHubRateLimitResponse(response, text)) { - return rateLimitResetDelayMs; - } - - const backoffDelayMs = githubApiRetryBaseMs * 2 ** (attempt - 1); - - if ([403, 429].includes(response.status) && isGitHubRateLimitResponse(response, text)) { - return Math.max(githubApiSecondaryRateLimitDelayMs, backoffDelayMs); - } - - return backoffDelayMs; -} - -function githubRetryAfterDelayMs(response) { - const retryAfter = Number.parseInt(response.headers.get("retry-after") ?? "", 10); - - if (Number.isInteger(retryAfter) && retryAfter > 0) return retryAfter * 1000; - - return null; -} - -function githubRateLimitResetDelayMs(response) { - const resetEpochSeconds = Number.parseInt(response.headers.get("x-ratelimit-reset") ?? "", 10); - - if (!Number.isInteger(resetEpochSeconds) || resetEpochSeconds <= 0) return null; - - return Math.max(resetEpochSeconds * 1000 - Date.now() + 1000, 0); -} - -function sleep(delayMs) { - return new Promise((resolve) => { - setTimeout(resolve, delayMs); - }); -} - -function fail(message) { - console.error(message); - process.exit(1); -} diff --git a/.github/workflows/codex-pr-comments.yml b/.github/workflows/codex-pr-comments.yml deleted file mode 100644 index f81c987..0000000 --- a/.github/workflows/codex-pr-comments.yml +++ /dev/null @@ -1,57 +0,0 @@ -name: Codex PR comments - -on: - issue_comment: - types: [created, edited] - pull_request_target: - types: [opened, reopened, synchronize, closed] - schedule: - - cron: "17 */6 * * *" - workflow_dispatch: - inputs: - dry_run: - description: "Analizza senza aggiornare la issue inbox" - required: false - type: boolean - default: false - -permissions: - contents: read - issues: write - pull-requests: read - -concurrency: - group: codex-pr-comments - cancel-in-progress: true - -jobs: - codex-pr-comments: - name: Sync Codex feedback inbox - if: >- - github.event_name == 'schedule' || - github.event_name == 'workflow_dispatch' || - (github.event_name == 'issue_comment' && - github.event.issue.title == 'Codex feedback inbox' && - contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association)) || - (github.event_name == 'pull_request_target' && - contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.pull_request.author_association)) - runs-on: ubuntu-latest - timeout-minutes: 75 - - steps: - - name: Checkout trusted workflow code - uses: actions/checkout@v7 - with: - ref: ${{ github.event.repository.default_branch }} - persist-credentials: false - - - name: Setup Node - uses: actions/setup-node@v7 - with: - node-version: 24 - - - name: Sync Codex feedback inbox - env: - DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run && 'true' || 'false' }} - GITHUB_TOKEN: ${{ github.token }} - run: node .github/scripts/handle-codex-pr-comments.mjs diff --git a/AGENTS.md b/AGENTS.md index fcd5caf..a0cd10c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -68,8 +68,6 @@ lavoro che chiudi in pochi tool call e non per ricontrollare te stesso. - Conventional Commit coerenti con l'impatto reale, titolo PR incluso: il workflow `pr-title.yml` lo controlla e un nome di branch non è un titolo valido (`gh pr create --title "docs: ..."`). -- Prima di PR ready, merge, pubblicazione, deploy o release non banali controlla - la issue `Codex feedback inbox` (label `codex-feedback-inbox`). - Dopo il merge pulisci branch e worktree creati per il flusso, o dichiara cosa resta aperto. @@ -91,7 +89,7 @@ passo invece di lasciarlo implicito. ## Publish, release e deploy Non c'è VPS e non ci sono domini a pagamento. `pubblica` significa: PR/merge su -`main`, controllo inbox, verifica finale e cleanup del checkout. +`main`, verifica finale e cleanup del checkout. Il deploy operativo è lo scan schedulato su GitHub Actions (ADR 0001), che committa gli output e fallisce solo su errore tecnico o email necessaria non diff --git a/README.md b/README.md index e421b66..1b66ea0 100644 --- a/README.md +++ b/README.md @@ -138,7 +138,3 @@ La dashboard web online è pubblicabile su Vercel da CLI, senza dipendere unicamente da GitHub Actions. In questo scenario la scansione resta locale/manuale o affidata al workflow quando la pipeline è operativa; l'aggiornamento online dei dati passa da `publish-dashboard`. - -La issue GitHub `Codex feedback inbox` raccoglie i commenti Codex sulle PR; il -workflow `Codex PR comments` la mantiene sincronizzata e la marca con la label -`codex-feedback-inbox`. diff --git a/docs/INDEX.md b/docs/INDEX.md index 71200e7..ab72276 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -34,7 +34,6 @@ Sentinel usa la root per ingresso operativo, configurazione e codice. Usa ## Pubblicazione e operatività - `.github/workflows/sentinel.yml`: workflow operativo schedulato e manuale. -- `.github/workflows/codex-pr-comments.yml`: sincronizzazione della Codex feedback inbox. - `.github/workflows/codex-review-gate.yml`: gate Codex exact-HEAD sulle PR. - `.github/workflows/pr-title.yml`: controllo titolo PR. - `.github/PULL_REQUEST_TEMPLATE.md`: template PR. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index e04ba6a..9ce287b 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -17,9 +17,6 @@ non ancora scelte stanno in `docs/BACKLOG.md`. - Osservare il prossimo run schedulato del sabato alle 09:00 Europe/Rome e distinguere errori tecnici, cambiamenti reali dei siti e rumore di crawling. - Raffinare soglie, report o filtri solo dopo evidenza nei report generati. -- Mantenere la Codex feedback inbox come controllo prima di PR ready, merge, - pubblicazione, deploy o release. - ## Più avanti - Valutare nuovi siti monitorati solo con una decisione esplicita su utilità, diff --git a/docs/TOOLCHAIN.md b/docs/TOOLCHAIN.md index 17a71d2..264b057 100644 --- a/docs/TOOLCHAIN.md +++ b/docs/TOOLCHAIN.md @@ -49,7 +49,6 @@ Questa pagina descrive runtime, comandi e guardrail effettivi di Sentinel. - coverage core: `npm run test:coverage`. - gate PR quality automatico: non presente finché manca una decisione esplicita per reintrodurre un workflow test/coverage/build su `pull_request`. -- Codex comments dry-run: workflow `Codex PR comments` con input `dry_run=true`. - Codex review gate: workflow `Codex review gate`, status `codex-review` associato all'HEAD esatto della PR; il codice eseguito arriva sempre da `main`. - scan: `npm run sentinel -- scan`. @@ -93,7 +92,6 @@ stati vuoti/errore/loading quando il diff li può alterare. - La pubblicazione codice passa da commit, push e PR/merge su GitHub; su richiesta completa di `pubblica` significa anche pulire branch/worktree locali e remoti assorbiti al termine. -- La Codex feedback inbox è gestita dal workflow `Codex PR comments`. - Le PR verso `main` girano il workflow `CI` (typecheck + build CLI/web + test), che resta un segnale. Il gate separato `codex-review` pubblica invece uno status exact-HEAD; ADR `docs/decisions/0005-niente-ruleset-ci-su-main.md` diff --git a/docs/decisions/0006-gate-codex-review-exact-head.md b/docs/decisions/0006-gate-codex-review-exact-head.md index 10970ba..c73f0f3 100644 --- a/docs/decisions/0006-gate-codex-review-exact-head.md +++ b/docs/decisions/0006-gate-codex-review-exact-head.md @@ -6,9 +6,9 @@ Stato: Accettata ## Contesto -Le review Codex non erano un gate: la repository manteneva soltanto una inbox -dei thread e GitHub poteva riusare segnali appartenenti a commit o tentativi -precedenti. Serve uno status distinto che rappresenti esclusivamente la review +Le review Codex non erano un gate: la repository manteneva una inbox legacy dei +thread e GitHub poteva riusare segnali appartenenti a commit o tentativi +precedenti. Serve un solo status che rappresenti esclusivamente la review dell'HEAD corrente della PR. ## Decisione @@ -21,7 +21,7 @@ Adottare un solo workflow `Codex review gate`, allineato a SyncBay, che: - pubblica `codex-review` sull'HEAD esatto e invalida ogni prova al nuovo SHA; - accetta soltanto segnali di `chatgpt-codex-connector[bot]` legati al tentativo corrente; finding P0-P3 correnti prevalgono sempre; -- resta separato dalla workflow `Codex PR comments`, che mantiene la inbox. +- sostituisce la workflow e la issue legacy `Codex feedback inbox`. La PR di bootstrap non può eseguire il workflow nuovo perché `pull_request_target` usa la versione già presente su `main`: il gate va provato diff --git a/test/security.test.ts b/test/security.test.ts index a70092c..60672e5 100644 --- a/test/security.test.ts +++ b/test/security.test.ts @@ -1,5 +1,4 @@ import { describe, expect, it, vi } from "vitest"; -import { isTrustedCodexAuthor } from "../.github/scripts/codex-author.mjs"; import { isPublicAddress, OutboundClient, pinnedLookup } from "../src/outbound.js"; import type { SiteConfig } from "../src/types.js"; @@ -16,23 +15,6 @@ const site: SiteConfig = { }; describe("hardening input remoti", () => { - it("accetta solo l'identità immutabile del bot Codex", () => { - expect( - isTrustedCodexAuthor({ - __typename: "Bot", - databaseId: 199175422, - login: "chatgpt-codex-connector" - }) - ).toBe(true); - expect( - isTrustedCodexAuthor({ - __typename: "User", - databaseId: 1, - login: "my-codex-lookalike" - }) - ).toBe(false); - }); - it("blocca reti private e redirect fuori origine prima della seconda richiesta", async () => { expect(isPublicAddress("8.8.8.8", 4)).toBe(true); expect(isPublicAddress("127.0.0.1", 4)).toBe(false); diff --git a/test/workflow.test.ts b/test/workflow.test.ts index 50d5e0a..81483b6 100644 --- a/test/workflow.test.ts +++ b/test/workflow.test.ts @@ -49,15 +49,6 @@ describe("workflow Sentinel", () => { expect(source).toContain('[ "$sched" = "0 8 * * 6" ]'); }); - it("limita e coalesce gli eventi pubblici della inbox Codex", async () => { - const source = await readFile(".github/workflows/codex-pr-comments.yml", "utf8"); - - expect(source).toContain("cancel-in-progress: true"); - expect(source).toContain("github.event.issue.title == 'Codex feedback inbox'"); - expect(source).toContain("github.event.comment.author_association"); - expect(source).toContain("github.event.pull_request.author_association"); - }); - it("esegue il gate Codex sul codice fidato del branch predefinito", async () => { const source = await readFile( ".github/workflows/codex-review-gate.yml", From 69f7769985b6106c5cf6e56ecee4405ac9870d22 Mon Sep 17 00:00:00 2001 From: Matteo <30387529+max23468@users.noreply.github.com> Date: Thu, 6 Aug 2026 16:14:24 +0200 Subject: [PATCH 07/12] fix(ci): disambiguate overlapping Codex reviews --- scripts/codex-review-gate.mjs | 24 +++++++++++++++++++----- test/codex-review-gate.test.mjs | 13 +++++++++++++ 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/scripts/codex-review-gate.mjs b/scripts/codex-review-gate.mjs index 2bd64f4..7222d64 100644 --- a/scripts/codex-review-gate.mjs +++ b/scripts/codex-review-gate.mjs @@ -22,6 +22,7 @@ export function classifyCodexReview({ requiresReviewedCommit = false, reviews = [], reviewComments, + unambiguousInvocation = false, }) { const completions = []; const cleanComments = []; @@ -95,7 +96,7 @@ export function classifyCodexReview({ if ( (commit ? headSha.startsWith(commit) - : !requiresReviewedCommit || exactEyesAt > 0) && + : !requiresReviewedCommit || (unambiguousInvocation && exactEyesAt > 0)) && timestamp(requestedAt) > 0 && timestamp(comment.created_at) >= timestamp(requestedAt) && now - timestamp(requestedAt) >= 30_000 && @@ -184,7 +185,7 @@ export function classifyCodexReview({ export const hasSuccessfulCodexStatus = (statuses) => statuses.find((status) => status.context === "codex-review")?.state === "success"; -export const latestCodexInvocation = (comments, requestedAt) => +export const codexInvocations = (comments, requestedAt) => comments .filter( (comment) => @@ -193,7 +194,10 @@ export const latestCodexInvocation = (comments, requestedAt) => /@codex\s+review\b/i.test(comment.body) && timestamp(comment.created_at) >= timestamp(requestedAt), ) - .sort((left, right) => timestamp(right.created_at) - timestamp(left.created_at))[0]; + .sort((left, right) => timestamp(right.created_at) - timestamp(left.created_at)); + +export const latestCodexInvocation = (comments, requestedAt) => + codexInvocations(comments, requestedAt)[0]; export function pullRequestNumber(event, input) { const number = String(event.pull_request?.number ?? input); @@ -255,7 +259,8 @@ async function reviewSignals(repository, number, requestedAt) { all(`/repos/${repository}/pulls/${number}/reviews`), all(`/repos/${repository}/pulls/${number}/comments`), ]); - const invocation = latestCodexInvocation(comments, requestedAt); + const invocations = codexInvocations(comments, requestedAt); + const invocation = invocations[0]; const invocationReactions = invocation ? await all(`/repos/${repository}/issues/comments/${invocation.id}/reactions`) : []; @@ -265,6 +270,7 @@ async function reviewSignals(repository, number, requestedAt) { reviews, reviewComments, invocationReactions, + invocations.length === 1, ]; } @@ -312,7 +318,14 @@ async function main() { await new Promise((resolve) => setTimeout(resolve, CODEX_REVIEW_POLLING.intervalMs)); continue; } - const [comments, reactions, reviews, reviewComments, exactReactions] = signals; + const [ + comments, + reactions, + reviews, + reviewComments, + exactReactions, + unambiguousInvocation, + ] = signals; const result = classifyCodexReview({ headSha, requestedAt, @@ -322,6 +335,7 @@ async function main() { requiresReviewedCommit: !freshReview, reviews, reviewComments, + unambiguousInvocation, }); if (result.state !== "pending") { await setStatus(repository, headSha, result.state, result.description); diff --git a/test/codex-review-gate.test.mjs b/test/codex-review-gate.test.mjs index 98928ad..ef163f3 100644 --- a/test/codex-review-gate.test.mjs +++ b/test/codex-review-gate.test.mjs @@ -305,9 +305,22 @@ test("eyes mantiene pending finché non arriva un errore successivo", () => { ], exactReactions: [exactEyes], progressReactions: [exactEyes], + unambiguousInvocation: true, }).state, "failure", ); + assert.equal( + classify({ + requiresReviewedCommit: true, + comments: [ + { user: bot, created_at: "2026-08-04T12:00:03Z", body: "Codex could not complete" }, + ], + exactReactions: [exactEyes], + progressReactions: [exactEyes], + unambiguousInvocation: false, + }).state, + "pending", + ); }); test("trova solo l'ultima invocazione umana del tentativo corrente", () => { From 46510ce0f2f723a3f9e435bd17a0f7135a0104f4 Mon Sep 17 00:00:00 2001 From: Matteo <30387529+max23468@users.noreply.github.com> Date: Thu, 6 Aug 2026 16:19:47 +0200 Subject: [PATCH 08/12] fix(ci): reject ambiguous Codex errors --- scripts/codex-review-gate.mjs | 10 +++++----- test/codex-review-gate.test.mjs | 20 +++++++++++++++++--- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/scripts/codex-review-gate.mjs b/scripts/codex-review-gate.mjs index 7222d64..ea964c1 100644 --- a/scripts/codex-review-gate.mjs +++ b/scripts/codex-review-gate.mjs @@ -22,7 +22,7 @@ export function classifyCodexReview({ requiresReviewedCommit = false, reviews = [], reviewComments, - unambiguousInvocation = false, + unambiguousAttempt = false, }) { const completions = []; const cleanComments = []; @@ -96,7 +96,7 @@ export function classifyCodexReview({ if ( (commit ? headSha.startsWith(commit) - : !requiresReviewedCommit || (unambiguousInvocation && exactEyesAt > 0)) && + : unambiguousAttempt && (!requiresReviewedCommit || exactEyesAt > 0)) && timestamp(requestedAt) > 0 && timestamp(comment.created_at) >= timestamp(requestedAt) && now - timestamp(requestedAt) >= 30_000 && @@ -270,7 +270,7 @@ async function reviewSignals(repository, number, requestedAt) { reviews, reviewComments, invocationReactions, - invocations.length === 1, + invocations.length, ]; } @@ -324,7 +324,7 @@ async function main() { reviews, reviewComments, exactReactions, - unambiguousInvocation, + invocationCount, ] = signals; const result = classifyCodexReview({ headSha, @@ -335,7 +335,7 @@ async function main() { requiresReviewedCommit: !freshReview, reviews, reviewComments, - unambiguousInvocation, + unambiguousAttempt: freshReview ? invocationCount === 0 : invocationCount === 1, }); if (result.state !== "pending") { await setStatus(repository, headSha, result.state, result.description); diff --git a/test/codex-review-gate.test.mjs b/test/codex-review-gate.test.mjs index ef163f3..72cbdc1 100644 --- a/test/codex-review-gate.test.mjs +++ b/test/codex-review-gate.test.mjs @@ -227,7 +227,10 @@ test("usage limit e unknown error falliscono il tentativo corrente", () => { "Codex Review: Something went wrong. Try again later. Unknown error", ]) { assert.equal( - classify({ comments: [{ user: bot, created_at: "2026-08-04T12:00:01Z", body }] }).state, + classify({ + comments: [{ user: bot, created_at: "2026-08-04T12:00:01Z", body }], + unambiguousAttempt: true, + }).state, "failure", ); } @@ -281,6 +284,7 @@ test("eyes mantiene pending finché non arriva un errore successivo", () => { { user: bot, created_at: "2026-08-04T12:00:03Z", body: "Codex could not complete" }, ], progressReactions, + unambiguousAttempt: true, }).state, "failure", ); @@ -305,7 +309,7 @@ test("eyes mantiene pending finché non arriva un errore successivo", () => { ], exactReactions: [exactEyes], progressReactions: [exactEyes], - unambiguousInvocation: true, + unambiguousAttempt: true, }).state, "failure", ); @@ -317,7 +321,17 @@ test("eyes mantiene pending finché non arriva un errore successivo", () => { ], exactReactions: [exactEyes], progressReactions: [exactEyes], - unambiguousInvocation: false, + unambiguousAttempt: false, + }).state, + "pending", + ); + assert.equal( + classify({ + comments: [ + { user: bot, created_at: "2026-08-04T12:00:03Z", body: "Codex could not complete" }, + ], + progressReactions: [exactEyes], + unambiguousAttempt: false, }).state, "pending", ); From 8273fbb2e3e210061786d3cc6631d8b615894ee5 Mon Sep 17 00:00:00 2001 From: Matteo <30387529+max23468@users.noreply.github.com> Date: Thu, 6 Aug 2026 16:32:12 +0200 Subject: [PATCH 09/12] fix(ci): catch late Codex findings --- .github/workflows/codex-review-gate.yml | 6 ++++- scripts/codex-review-gate.mjs | 20 ++++++++++++++++ test/codex-review-gate.test.mjs | 31 +++++++++++++++++++++++++ test/workflow.test.ts | 26 ++------------------- 4 files changed, 58 insertions(+), 25 deletions(-) diff --git a/.github/workflows/codex-review-gate.yml b/.github/workflows/codex-review-gate.yml index 8e15424..6ac9d79 100644 --- a/.github/workflows/codex-review-gate.yml +++ b/.github/workflows/codex-review-gate.yml @@ -3,6 +3,10 @@ name: Codex review gate on: pull_request_target: types: [opened, synchronize, reopened, ready_for_review] + pull_request_review: + types: [submitted] + pull_request_review_comment: + types: [created] workflow_dispatch: inputs: pull_request: @@ -17,7 +21,7 @@ permissions: statuses: write concurrency: - group: codex-review-${{ github.event.pull_request.number || inputs.pull_request }} + group: codex-review-${{ github.event.pull_request.number || inputs.pull_request }}-${{ startsWith(github.event_name, 'pull_request_review') && github.run_id || 'poll' }} cancel-in-progress: true jobs: diff --git a/scripts/codex-review-gate.mjs b/scripts/codex-review-gate.mjs index ea964c1..90b7add 100644 --- a/scripts/codex-review-gate.mjs +++ b/scripts/codex-review-gate.mjs @@ -208,6 +208,15 @@ export function pullRequestNumber(event, input) { export const isRetryableGitHubResponse = (status, remaining) => status === 429 || status >= 500 || (status === 403 && remaining === "0"); +export const isCurrentCodexFinding = (event, headSha) => { + const signal = event.review ?? event.comment; + return ( + signal?.user?.login === CODEX_BOT && + (signal.original_commit_id ?? signal.commit_id) === headSha && + /\bP[0-3]\b/.test(signal.body ?? "") + ); +}; + async function request(path, options = {}) { const response = await fetch(`https://api.github.com${path}`, { ...options, @@ -284,6 +293,17 @@ async function main() { event.pull_request ?? (await request(`/repos/${repository}/pulls/${requestedNumber}`)); const number = pullRequest.number; const headSha = pullRequest.head.sha; + if (process.env.GITHUB_EVENT_NAME.startsWith("pull_request_review")) { + if (isCurrentCodexFinding(event, headSha)) { + await setStatus( + repository, + headSha, + "failure", + "Codex ha trovato problemi nell'ultimo commit", + ); + } + return; + } const reusesExistingReview = process.env.GITHUB_EVENT_NAME === "workflow_dispatch" || event.action === "reopened"; diff --git a/test/codex-review-gate.test.mjs b/test/codex-review-gate.test.mjs index 72cbdc1..ceb3e5d 100644 --- a/test/codex-review-gate.test.mjs +++ b/test/codex-review-gate.test.mjs @@ -5,6 +5,7 @@ import { CODEX_REVIEW_POLLING, classifyCodexReview, hasSuccessfulCodexStatus, + isCurrentCodexFinding, isRetryableGitHubResponse, latestCodexInvocation, pullRequestNumber, @@ -167,6 +168,36 @@ test("un finding P0-P3 corrente prevale sempre sull'approvazione", () => { ); }); +test("riattiva il gate soltanto per finding Codex exact-HEAD", () => { + assert.equal( + isCurrentCodexFinding( + { review: { user: bot, commit_id: headSha, body: "**P2** Finding tardivo" } }, + headSha, + ), + true, + ); + assert.equal( + isCurrentCodexFinding( + { + comment: { + user: bot, + original_commit_id: "abcdef0123456789abcdef0123456789abcdef01", + body: "**P1** Finding vecchio", + }, + }, + headSha, + ), + false, + ); + assert.equal( + isCurrentCodexFinding( + { comment: { user: { login: "utente" }, commit_id: headSha, body: "**P0** Falso" } }, + headSha, + ), + false, + ); +}); + test("rebase e nuovo commit invalidano finding e approvazioni precedenti", () => { assert.equal( classify({ diff --git a/test/workflow.test.ts b/test/workflow.test.ts index 85d3633..d454267 100644 --- a/test/workflow.test.ts +++ b/test/workflow.test.ts @@ -59,30 +59,8 @@ describe("workflow Sentinel", () => { expect(source).toContain( "types: [opened, synchronize, reopened, ready_for_review]" ); - expect(source).toContain("workflow_dispatch:"); - expect(source).toContain("contents: read"); - expect(source).toContain("issues: read"); - expect(source).toContain("pull-requests: read"); - expect(source).toContain("statuses: write"); - expect(source).toMatch(/actions\/checkout@[0-9a-f]{40}/); - expect(source).toContain( - "ref: ${{ github.event.repository.default_branch }}" - ); - expect(source).toContain("timeout-minutes: 310"); - expect(source).toContain("cancel-in-progress: true"); - expect(source).toContain("node scripts/codex-review-gate.mjs"); - }); - - it("esegue il gate Codex sul codice fidato del branch predefinito", async () => { - const source = await readFile( - ".github/workflows/codex-review-gate.yml", - "utf8" - ); - - expect(source).toContain("pull_request_target:"); - expect(source).toContain( - "types: [opened, synchronize, reopened, ready_for_review]" - ); + expect(source).toContain("pull_request_review:"); + expect(source).toContain("pull_request_review_comment:"); expect(source).toContain("workflow_dispatch:"); expect(source).toContain("contents: read"); expect(source).toContain("issues: read"); From 9aa0d2349b4547a4df0b59bb49775c8128248374 Mon Sep 17 00:00:00 2001 From: Matteo <30387529+max23468@users.noreply.github.com> Date: Thu, 6 Aug 2026 16:39:06 +0200 Subject: [PATCH 10/12] fix(ci): serialize late Codex findings --- .github/workflows/codex-review-gate.yml | 2 +- scripts/codex-review-gate.mjs | 21 ++++++++++++++++++++- test/codex-review-gate.test.mjs | 1 + 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codex-review-gate.yml b/.github/workflows/codex-review-gate.yml index 6ac9d79..0f3c844 100644 --- a/.github/workflows/codex-review-gate.yml +++ b/.github/workflows/codex-review-gate.yml @@ -21,7 +21,7 @@ permissions: statuses: write concurrency: - group: codex-review-${{ github.event.pull_request.number || inputs.pull_request }}-${{ startsWith(github.event_name, 'pull_request_review') && github.run_id || 'poll' }} + group: codex-review-${{ github.event.pull_request.number || inputs.pull_request }}-${{ startsWith(github.event_name, 'pull_request_review') && (github.event.review.user.login || github.event.comment.user.login) != 'chatgpt-codex-connector[bot]' && github.run_id || 'gate' }} cancel-in-progress: true jobs: diff --git a/scripts/codex-review-gate.mjs b/scripts/codex-review-gate.mjs index 90b7add..b6dbe0a 100644 --- a/scripts/codex-review-gate.mjs +++ b/scripts/codex-review-gate.mjs @@ -294,7 +294,26 @@ async function main() { const number = pullRequest.number; const headSha = pullRequest.head.sha; if (process.env.GITHUB_EVENT_NAME.startsWith("pull_request_review")) { - if (isCurrentCodexFinding(event, headSha)) { + const signal = event.review ?? event.comment; + if (signal?.user?.login !== CODEX_BOT) return; + + let finding = isCurrentCodexFinding(event, headSha); + if (event.review && event.review.commit_id === headSha) { + const reviewComments = await all(`/repos/${repository}/pulls/${number}/comments`); + finding ||= reviewComments.some( + (comment) => + comment.pull_request_review_id === event.review.id && + isCurrentCodexFinding({ comment }, headSha), + ); + await setStatus( + repository, + headSha, + finding ? "failure" : "success", + finding + ? "Codex ha trovato problemi nell'ultimo commit" + : "Codex ha approvato l'ultimo commit", + ); + } else if (finding) { await setStatus( repository, headSha, diff --git a/test/codex-review-gate.test.mjs b/test/codex-review-gate.test.mjs index ceb3e5d..0c3952c 100644 --- a/test/codex-review-gate.test.mjs +++ b/test/codex-review-gate.test.mjs @@ -450,4 +450,5 @@ test("un doppio errore API non lascia verde il job senza status", async () => { assert.match(source, /catch \(statusError\)[\s\S]*process\.exitCode = 1/); assert.match(source, /if \(!pullRequest\) \{\s*process\.exitCode = 1/); + assert.match(source, /comment\.pull_request_review_id === event\.review\.id/); }); From 3c81299d370dbf65d1a950bf0982842b94025fad Mon Sep 17 00:00:00 2001 From: Matteo <30387529+max23468@users.noreply.github.com> Date: Thu, 6 Aug 2026 16:45:38 +0200 Subject: [PATCH 11/12] fix(ci): preserve Codex failure precedence --- scripts/codex-review-gate.mjs | 38 +++++++++++++++++++-------------- test/codex-review-gate.test.mjs | 1 + 2 files changed, 23 insertions(+), 16 deletions(-) diff --git a/scripts/codex-review-gate.mjs b/scripts/codex-review-gate.mjs index b6dbe0a..9574142 100644 --- a/scripts/codex-review-gate.mjs +++ b/scripts/codex-review-gate.mjs @@ -305,23 +305,29 @@ async function main() { comment.pull_request_review_id === event.review.id && isCurrentCodexFinding({ comment }, headSha), ); - await setStatus( - repository, - headSha, - finding ? "failure" : "success", - finding - ? "Codex ha trovato problemi nell'ultimo commit" - : "Codex ha approvato l'ultimo commit", - ); - } else if (finding) { - await setStatus( - repository, - headSha, - "failure", - "Codex ha trovato problemi nell'ultimo commit", - ); + if (finding) { + await setStatus( + repository, + headSha, + "failure", + "Codex ha trovato problemi nell'ultimo commit", + ); + return; + } + const statuses = await all(`/repos/${repository}/commits/${headSha}/statuses`); + const currentStatus = statuses.find((status) => status.context === "codex-review"); + if (currentStatus && currentStatus.state !== "pending") return; + } else { + if (finding) { + await setStatus( + repository, + headSha, + "failure", + "Codex ha trovato problemi nell'ultimo commit", + ); + } + return; } - return; } const reusesExistingReview = process.env.GITHUB_EVENT_NAME === "workflow_dispatch" || event.action === "reopened"; diff --git a/test/codex-review-gate.test.mjs b/test/codex-review-gate.test.mjs index 0c3952c..09ce4dd 100644 --- a/test/codex-review-gate.test.mjs +++ b/test/codex-review-gate.test.mjs @@ -451,4 +451,5 @@ test("un doppio errore API non lascia verde il job senza status", async () => { assert.match(source, /catch \(statusError\)[\s\S]*process\.exitCode = 1/); assert.match(source, /if \(!pullRequest\) \{\s*process\.exitCode = 1/); assert.match(source, /comment\.pull_request_review_id === event\.review\.id/); + assert.match(source, /currentStatus && currentStatus\.state !== "pending"/); }); From b1d9decce3ff425c66a1ac0afa788a8fb2b73ba9 Mon Sep 17 00:00:00 2001 From: Matteo <30387529+max23468@users.noreply.github.com> Date: Thu, 6 Aug 2026 16:51:17 +0200 Subject: [PATCH 12/12] fix(ci): retain Codex attempt timestamps --- scripts/codex-review-gate.mjs | 12 ++++++++++-- test/codex-review-gate.test.mjs | 11 +++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/scripts/codex-review-gate.mjs b/scripts/codex-review-gate.mjs index 9574142..05d7222 100644 --- a/scripts/codex-review-gate.mjs +++ b/scripts/codex-review-gate.mjs @@ -17,6 +17,7 @@ export function classifyCodexReview({ now = Date.now(), comments, exactReactions = [], + attemptStartedAt = requestedAt, reactions, progressReactions = reactions, requiresReviewedCommit = false, @@ -96,12 +97,16 @@ export function classifyCodexReview({ if ( (commit ? headSha.startsWith(commit) - : unambiguousAttempt && (!requiresReviewedCommit || exactEyesAt > 0)) && + : unambiguousAttempt) && timestamp(requestedAt) > 0 && timestamp(comment.created_at) >= timestamp(requestedAt) && now - timestamp(requestedAt) >= 30_000 && timestamp(comment.created_at) >= - (commit ? timestamp(requestedAt) : requiresReviewedCommit ? exactEyesAt : latestEyesAt) && + (commit + ? timestamp(requestedAt) + : requiresReviewedCommit + ? exactEyesAt || timestamp(attemptStartedAt) + : latestEyesAt || timestamp(attemptStartedAt)) && /reached your Codex usage limits|could not complete|unable to review|something went wrong|unknown error/i.test( comment.body, ) @@ -280,6 +285,7 @@ async function reviewSignals(repository, number, requestedAt) { reviewComments, invocationReactions, invocations.length, + invocation?.created_at ?? requestedAt, ]; } @@ -370,12 +376,14 @@ async function main() { reviewComments, exactReactions, invocationCount, + attemptStartedAt, ] = signals; const result = classifyCodexReview({ headSha, requestedAt, comments, exactReactions, + attemptStartedAt, reactions, requiresReviewedCommit: !freshReview, reviews, diff --git a/test/codex-review-gate.test.mjs b/test/codex-review-gate.test.mjs index 09ce4dd..860ab64 100644 --- a/test/codex-review-gate.test.mjs +++ b/test/codex-review-gate.test.mjs @@ -95,6 +95,17 @@ test("non riusa approvazioni o reazioni di SHA e tentativi precedenti", () => { }).state, "pending", ); + assert.equal( + classify({ + requiresReviewedCommit: true, + comments: [ + { user: bot, created_at: "2026-08-04T12:00:03Z", body: "Unknown error" }, + ], + attemptStartedAt: "2026-08-04T12:00:02Z", + unambiguousAttempt: true, + }).state, + "failure", + ); assert.equal( classify({ requiresReviewedCommit: true,