import { unzipSync, strFromU8 } from 'fflate'; import type { FailureReport, PrSnapshot, SpecFailure } from '../shared/types.js'; import type { CompiledFailureRule, RepoRules } from './config.js'; import type { Db } from './db.js'; import { restJson, restZip } from './github/rest.js'; const MAX_ARTIFACT_BYTES = 80 * 1024 * 1024; const MAX_STORED_FAILURES = 1000; const MSG_LEN = 240; interface ArtifactInfo { id: number; name: string; expired: boolean; size_in_bytes: number; } function firstLine(text: string): string { const line = text.split('\n').find((l) => l.trim() !== '') ?? ''; return line.trim().slice(0, MSG_LEN); } /* ---------- parsers ---------- */ interface RspecJson { examples?: { status: string; file_path: string; line_number: number; full_description: string; exception?: { message?: string }; }[]; } function parseRspecZip(zip: Uint8Array): SpecFailure[] { const files = unzipSync(zip, { filter: (f) => f.name.endsWith('.json') }); const failures: SpecFailure[] = []; for (const [, data] of Object.entries(files)) { let parsed: RspecJson; try { parsed = JSON.parse(strFromU8(data)) as RspecJson; } catch { continue; } for (const ex of parsed.examples ?? []) { if (ex.status !== 'failed') continue; failures.push({ path: ex.file_path.replace(/^\.\//, ''), line: ex.line_number ?? null, name: ex.full_description, message: firstLine(ex.exception?.message ?? ''), }); } } return failures; } /** Playwright HTML-report artifacts carry one `data/.md` per failed test * (the "copy prompt" attachment) with Name / Location / Error details blocks. */ function parsePlaywrightZip(zip: Uint8Array): SpecFailure[] { const files = unzipSync(zip, { filter: (f) => f.name.startsWith('data/') && f.name.endsWith('.md'), }); const failures: SpecFailure[] = []; const seen = new Set(); for (const [, data] of Object.entries(files)) { const text = strFromU8(data); const name = /^- Name: (.+)$/m.exec(text)?.[1]?.trim(); const location = /^- Location: (.+)$/m.exec(text)?.[1]?.trim(); if (!name || seen.has(name + location)) continue; seen.add(name + location); const errorBlock = /# Error details\s*```([\s\S]*?)```/.exec(text)?.[1] ?? ''; const locMatch = location ? /^(.*?):(\d+)(?::\d+)?$/.exec(location) : null; failures.push({ path: locMatch?.[1] ?? location ?? '', line: locMatch?.[2] ? Number(locMatch[2]) : null, name: name.replace(/ >> /g, ' › '), message: firstLine(errorBlock), }); } return failures; } /* ---------- orchestration ---------- */ function runIdFromUrl(url: string | null): string | null { if (!url) return null; return /\/actions\/runs\/(\d+)\//.exec(url)?.[1] ?? null; } function artifactMatcher(pattern: string, prNumber: number): (name: string) => boolean { const concrete = pattern.replaceAll('{number}', String(prNumber)); if (!concrete.includes('*')) return (n) => n === concrete; const regex = new RegExp( `^${concrete.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*')}$`, ); return (n) => regex.test(n); } /** Sync failure reports for one PR: for each configured rule with at least one * failing check, download + parse that run's artifacts unless the stored report * already covers the same run. Reports for now-passing rules are deleted. */ export async function syncFailureReports( db: Db, rules: RepoRules | undefined, repo: string, snapshot: PrSnapshot, ): Promise { if (!rules || rules.failureRules.length === 0) return false; const failingChecks = snapshot.categories .flatMap((c) => c.checks) .filter((c) => c.state === 'fail'); const activeKeys: string[] = []; let changed = false; for (const rule of rules.failureRules) { const matches = failingChecks.filter((c) => rule.matchesCheck(c.name)); if (matches.length === 0) continue; activeKeys.push(rule.key); const runId = matches.map((m) => runIdFromUrl(m.url)).find((r) => r !== null); if (!runId) continue; const existing = db.getFailureReportRunId(snapshot.id, rule.key); if (existing === runId) continue; try { const failures = await fetchAndParse(repo, runId, rule, snapshot.number); db.upsertFailureReport(snapshot.id, rule.key, rule.label, runId, { failed: failures.length, truncated: failures.length > MAX_STORED_FAILURES, failures: failures.slice(0, MAX_STORED_FAILURES), }); changed = true; console.log( `[failures] ${repo}#${snapshot.number} ${rule.label}: ${failures.length} failing`, ); } catch (err) { console.error( `[failures] ${repo}#${snapshot.number} ${rule.key}: ${err instanceof Error ? err.message : err}`, ); } } changed = db.pruneFailureReports(snapshot.id, activeKeys) || changed; return changed; } async function fetchAndParse( repo: string, runId: string, rule: CompiledFailureRule, prNumber: number, ): Promise { const { artifacts } = await restJson<{ artifacts: ArtifactInfo[] }>( `/repos/${repo}/actions/runs/${runId}/artifacts?per_page=100`, ); const match = artifactMatcher(rule.artifact, prNumber); const wanted = artifacts.filter((a) => match(a.name) && !a.expired); const failures: SpecFailure[] = []; for (const artifact of wanted) { if (artifact.size_in_bytes > MAX_ARTIFACT_BYTES) { console.warn(`[failures] skipping oversized artifact ${artifact.name}`); continue; } const zip = await restZip(`/repos/${repo}/actions/artifacts/${artifact.id}/zip`, MAX_ARTIFACT_BYTES); if (!zip) continue; failures.push(...(rule.parser === 'rspec' ? parseRspecZip(zip) : parsePlaywrightZip(zip))); } failures.sort((a, b) => a.path.localeCompare(b.path) || (a.line ?? 0) - (b.line ?? 0)); return failures; } export function buildFailureReport(row: { key: string; label: string; fetched_at: string; failed: number; truncated: number; failures: string; }): FailureReport { return { key: row.key, label: row.label, fetchedAt: row.fetched_at, failed: row.failed, truncated: row.truncated === 1, failures: JSON.parse(row.failures) as SpecFailure[], }; }