diff --git a/.github/scripts/sync-agent.test.ts b/.github/scripts/sync-agent.test.ts index 8db92aa61c..cb8a51aa18 100644 --- a/.github/scripts/sync-agent.test.ts +++ b/.github/scripts/sync-agent.test.ts @@ -7,6 +7,7 @@ import { type CheckRun, REQUIRED_CHECKS } from "./fast-forward.ts"; import { createTestRepo, git, type TestRepo } from "./promotion-test-repo.ts"; import { buildSuggestions, + failureExcerpt, leftoverConflictMarkers, planRepair, type ReviewThread, @@ -340,3 +341,24 @@ describe("leftoverConflictMarkers", () => { } }); }); + +test("failure excerpt keeps an early test failure that the log tail would drop", () => { + const log = [ + "2026-10-13T10:48:27.3385710Z supabase:test:integration: \x1b[41m FAIL \x1b[49m src/signing-key.integration.test.ts > flushes telemetry", + "2026-10-13T10:48:27.3386440Z AssertionError: expected 'supabase/.env' to be '.env'", + ...Array.from( + { length: 1000 }, + (_, index) => `2026-10-13T10:49:30.0000000Z stack output ${index}`, + ), + "2026-10-13T10:49:31.0000000Z ##[error]Process completed with exit code 1.", + ].join("\n"); + + const excerpt = failureExcerpt(log); + + expect(excerpt).toContain("AssertionError: expected 'supabase/.env' to be '.env'"); + expect(excerpt).toContain("##[error]Process completed with exit code 1."); + expect(excerpt).not.toContain("stack output 100\n"); + expect(excerpt).toContain("[...]"); + expect(excerpt).not.toContain("\x1b"); + expect(excerpt).not.toMatch(/^\S+Z /m); +}); diff --git a/.github/scripts/sync-agent.ts b/.github/scripts/sync-agent.ts index 77fe12a382..b5427aa171 100644 --- a/.github/scripts/sync-agent.ts +++ b/.github/scripts/sync-agent.ts @@ -584,7 +584,11 @@ async function fetchOpenFindings( }); } -async function logTail(token: string, repository: string, jobId: number): Promise { +async function jobFailureExcerpt( + token: string, + repository: string, + jobId: number, +): Promise { const response = await fetch( `https://api.github.com/repos/${repository}/actions/jobs/${jobId}/logs`, { @@ -594,7 +598,46 @@ async function logTail(token: string, repository: string, jobId: number): Promis if (!response.ok) { return `(log unavailable: ${response.status})`; } - return (await response.text()).split("\n").slice(-LOG_TAIL_LINES).join("\n"); + return failureExcerpt(await response.text()); +} + +const ERROR_CONTEXT_LINES = 40; +const MAX_ERROR_WINDOWS = 10; + +/** + * Keeps the lines after each error annotation plus the log tail, because Turbo interleaves packages and the failing + * one's output often ends long before the job does. + */ +export function failureExcerpt(log: string): string { + const lines = log + // oxlint-disable-next-line no-control-regex -- strips ANSI colour codes from job logs + .replace(/\x1b\[[0-9;]*m/g, "") + .split("\n") + .map((line) => line.replace(/^\S+Z /, "")); + const kept = new Set(); + let windows = 0; + lines.forEach((line, index) => { + // Turbo prefixes each line with its task name when it streams several packages. + if (windows < MAX_ERROR_WINDOWS && /^(\S+:\s)?\s*(##\[error\]|FAIL\s)/.test(line)) { + windows += 1; + for (let at = index; at < Math.min(index + ERROR_CONTEXT_LINES, lines.length); at += 1) { + kept.add(at); + } + } + }); + for (let at = Math.max(0, lines.length - LOG_TAIL_LINES); at < lines.length; at += 1) { + kept.add(at); + } + let previous = -1; + const excerpt: string[] = []; + for (const index of [...kept].sort((a, b) => a - b)) { + if (index !== previous + 1) { + excerpt.push("[...]"); + } + excerpt.push(lines[index] ?? ""); + previous = index; + } + return excerpt.join("\n"); } /** Whether an `AI Review` run for this pull request is queued or running; its run name carries the PR number. */ @@ -688,7 +731,7 @@ async function collect(outputDir: string): Promise { const failures = await Promise.all( plan.failures.map( async (run) => - `## ${run.name} (${run.conclusion})\n\n\`\`\`\`text\n${await logTail(token, repository, run.id)}\n\`\`\`\`\n`, + `## ${run.name} (${run.conclusion})\n\n\`\`\`\`text\n${await jobFailureExcerpt(token, repository, run.id)}\n\`\`\`\`\n`, ), ); writeFileSync(join(contextDir, "ci-failures.md"), failures.join("\n") || "No CI job failed.\n"); diff --git a/.github/sync-branches/repair-prompt.md b/.github/sync-branches/repair-prompt.md index 33b9622e3e..9c78ecd6e9 100644 --- a/.github/sync-branches/repair-prompt.md +++ b/.github/sync-branches/repair-prompt.md @@ -8,7 +8,7 @@ major; `next` collects breaking changes for the following major. The working dir sync branch: `develop` with `next` merged in and its conflicts already resolved, but some checks fail. Repository conventions live in `CLAUDE.md`, `AGENTS.md`, and `docs/adr/`. Read these first: -- `ci-failures.md` in the added context directory: the failed checks and the end of their output. +- `ci-failures.md` in the added context directory: the failed checks, the output after each error, and the end of their logs. - `review-findings.md` in the same directory: the AI review's open findings, numbered. ## Your task