import { execFileSync } from "node:child_process";
import { createHash } from "node:crypto";
let failed = new Set(["failure", "timed_out", "action_required", "startup_failure"]);
let ansi = new RegExp(`${String.fromCharCode(27)}\\[[0-?]*[ -/]*[@-~]`, "g");
export function failureFingerprint(log) {
if (typeof log !== "string" || Buffer.byteLength(log) > 2 * 1024 * 1024) return null;
let lines = log.replace(ansi, "").split(/\r?\n/).map(line =>
line
.replace(/^[^\t]*\t[^\t]*\t/, "")
.replace(/\b\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z\s*/g, "")
.replace(/\b[0-9a-f]{40}\b/gi, "
")
.replace(/\b\d+(?:\.\d+)?\s*(?:milliseconds|seconds|minutes|ms|s)\b/gi, "")
.replace(/::(error|warning)(?: [^:]+)?::/g, "$1: ")
.trimEnd()
);
let first = lines.findIndex(line =>
/\b(?:error|failed|failure|fatal|assertionerror)\b/i.test(line)
&& !/\b(?:process completed with exit code|script .+ exited with code|the process .+ failed with exit code)\b/i
.test(line)
);
if (first < 0) return null;
let context = lines.slice(Math.max(0, first - 2), first + 9).join("\n");
let bytes = Buffer.from(context).subarray(0, 32 * 1024);
return createHash("sha256").update("chopin:ci-failure:v1\n").update(bytes).digest("hex");
}
export function isFailedCI(repository, run, head = run?.head_sha, branch = run?.head_branch) {
return /^[-\w.]+\/[-\w.]+$/.test(repository ?? "")
&& repository.split("/").every(part => part !== "." && part !== "..")
&& /^[1-9][0-9]*$/.test(String(run?.id)) && Number.isSafeInteger(Number(run?.id))
&& /^[0-9a-f]{40}$/.test(run?.head_sha ?? "") && run.head_sha === head
&& typeof branch === "string" && branch.length > 0 && run.head_branch === branch
&& run.head_repository?.full_name === repository && run.path === ".github/workflows/ci.yml"
&& ["pull_request", "workflow_dispatch"].includes(run.event)
&& run.status === "completed" && failed.has(run.conclusion);
}
export function getFailureFingerprint(repository, run, read = execFileSync) {
if (!isFailedCI(repository, run)) return null;
try {
return failureFingerprint(
read("gh", ["run", "view", String(run.id), "--repo", repository, "--log-failed"], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
maxBuffer: 2 * 1024 * 1024,
timeout: 30_000,
}),
);
} catch {
return null;
}
}