Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DgBEW6qAwgn2fcQbA2f4ZQ
193 lines
6.2 KiB
TypeScript
193 lines
6.2 KiB
TypeScript
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/<hash>.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<string>();
|
||
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<boolean> {
|
||
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<SpecFailure[]> {
|
||
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[],
|
||
};
|
||
}
|