pr-monitor/server/failures.ts
2026-07-29 09:24:20 +00:00

193 lines
6.2 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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[],
};
}