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/.github/workflows/codex-review-gate.yml b/.github/workflows/codex-review-gate.yml index 8e15424..0f3c844 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.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/AGENTS.md b/AGENTS.md index 3748659..cd8eabc 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 8ef225f..b803587 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -35,7 +35,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/react-doctor.yml`: gate React Doctor dedicato. - `.github/workflows/governance.yml`: controllo periodico di ruleset e workflow obbligatori. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index c7b1b94..6c1610e 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 ed20c71..d5eac0e 100644 --- a/docs/TOOLCHAIN.md +++ b/docs/TOOLCHAIN.md @@ -48,7 +48,6 @@ Questa pagina descrive runtime, comandi e guardrail effettivi di Sentinel. - test: `npm test`. - coverage core: `npm run test:coverage`. - gate completo locale e CI: `npm run check` (React Doctor, typecheck, build e test). -- 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 `CI` con il job obbligatorio `verify` e il workflow dedicato con il job obbligatorio `react-doctor`. La ruleset `main governance` richiede entrambi con strict checking; `Governance` ne controlla mensilmente diff --git a/docs/decisions/0006-gate-codex-review-exact-head.md b/docs/decisions/0006-gate-codex-review-exact-head.md index 062a0f5..ef018c4 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/scripts/codex-review-gate.mjs b/scripts/codex-review-gate.mjs index f6c1a52..05d7222 100644 --- a/scripts/codex-review-gate.mjs +++ b/scripts/codex-review-gate.mjs @@ -17,11 +17,13 @@ export function classifyCodexReview({ now = Date.now(), comments, exactReactions = [], + attemptStartedAt = requestedAt, reactions, progressReactions = reactions, requiresReviewedCommit = false, reviews = [], reviewComments, + unambiguousAttempt = false, }) { const completions = []; const cleanComments = []; @@ -33,6 +35,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 ( @@ -87,11 +97,16 @@ export function classifyCodexReview({ if ( (commit ? headSha.startsWith(commit) - : !requiresReviewedCommit && timestamp(requestedAt) > 0) && + : unambiguousAttempt) && timestamp(requestedAt) > 0 && timestamp(comment.created_at) >= timestamp(requestedAt) && now - timestamp(requestedAt) >= 30_000 && - timestamp(comment.created_at) >= latestEyesAt && + timestamp(comment.created_at) >= + (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, ) @@ -175,7 +190,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) => @@ -184,7 +199,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); @@ -195,6 +213,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, @@ -246,7 +273,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`) : []; @@ -256,6 +284,8 @@ async function reviewSignals(repository, number, requestedAt) { reviews, reviewComments, invocationReactions, + invocations.length, + invocation?.created_at ?? requestedAt, ]; } @@ -269,6 +299,42 @@ 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")) { + 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), + ); + 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; + } + } const reusesExistingReview = process.env.GITHUB_EVENT_NAME === "workflow_dispatch" || event.action === "reopened"; @@ -303,16 +369,26 @@ 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, + invocationCount, + attemptStartedAt, + ] = signals; const result = classifyCodexReview({ headSha, requestedAt, comments, exactReactions, + attemptStartedAt, reactions, requiresReviewedCommit: !freshReview, reviews, reviewComments, + 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 f4a96a0..860ab64 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, @@ -94,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, @@ -167,6 +179,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({ @@ -227,7 +269,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,9 +326,57 @@ 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", + ); + 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], + unambiguousAttempt: 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], + 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", + ); }); test("trova solo l'ultima invocazione umana del tentativo corrente", () => { @@ -368,4 +461,6 @@ 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"/); }); 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 dcb5115..d454267 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", @@ -68,6 +59,8 @@ describe("workflow Sentinel", () => { 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");