#!/usr/bin/env node import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; const ANSI = [ /\x1b\[[0-9;]*m/g, /\x1b\][^\x07]*\x07/g, /\x1b\[[0-9;]*H/g, /\x1b\[([0-9]+)?J/g, /\x1b\[([0-9]+)?K/g, /\r/g, ]; function clean(s) { return ANSI.reduce((x, re) => x.replace(re, ""), s); } function arg(name) { const i = process.argv.indexOf(name); return i === -1 ? undefined : process.argv[i + 1]; } function has(name) { return process.argv.includes(name); } function checkGh() { const r = spawnSync("gh", ["auth", "status"], { stdio: "ignore" }); return !r.error && r.status === 0; } function runJson(args, timeout = 60) { const r = spawnSync("gh", args, { encoding: "utf8", timeout: timeout * 1000, maxBuffer: 64 * 1024 * 1024, }); if (r.error?.code === "ETIMEDOUT") throw new Error(`gh command timed out after ${timeout}s`); if (r.status !== 0) throw new Error( `Error running gh command: gh ${args.join(" ")}\nError: ${r.stderr}`, ); return r.stdout ? JSON.parse(r.stdout) : {}; } function repoArgs(repo) { return repo ? ["--repo", repo] : []; } function runById(id, repo) { try { return runJson([ "run", "view", String(id), "--json", "databaseId,number,conclusion,status,createdAt,displayTitle,url,headBranch,headSha,event", ...repoArgs(repo), ]); } catch { return null; } } function recentFailed(repo) { const runs = runJson([ "run", "list", "--status", "failure", "--limit", "1", "--json", "databaseId,number,conclusion,status,createdAt,displayTitle,url,headBranch,headSha,event", ...repoArgs(repo), ]); return runs?.[0] ?? null; } function failedJobs(id, repo) { const jobs = runJson(["run", "view", String(id), "--json", "jobs", ...repoArgs(repo)]) .jobs ?? []; return jobs.filter( (j) => !["success", "skipped", null, undefined].includes(j.conclusion), ); } function jobLogArgs(runId, jobId, repo) { return [ "run", "view", String(runId), "--log-failed", "--job", String(jobId), ...repoArgs(repo), ]; } function logs(runId, jobId, repo) { const r = spawnSync("gh", jobLogArgs(runId, jobId, repo), { encoding: "utf8", timeout: 120000, maxBuffer: 64 * 1024 * 1024, }); return r.status === 0 ? r.stdout : ""; } function excerpts(text, max = 50) { const patterns = [ /\berror\b/i, /\bfailed\b/i, /\bfailure\b/i, /\bexception\b/i, /\bcannot\b/i, /\bpanic\b/i, /Process completed with exit code [1-9]/i, /\btimeout\b/i, ]; const out = []; for (const raw of text.split("\n")) { const line = clean(raw).trim(); if (!line || /^##\[(command|group|endgroup)\]/.test(line)) continue; if (patterns.some((p) => p.test(line)) && !out.includes(line)) out.push(line); if (out.length >= max) break; } return out; } function analyze(repo, runId) { const run = runId ? runById(runId, repo) : recentFailed(repo); if (!run) return { error: `No failed runs found${runId ? ` for run ${runId}` : ""}`, repository: repo || "current", }; const result = { run: { number: run.number, database_id: run.databaseId, url: run.url, workflow: run.displayTitle, conclusion: run.conclusion, status: run.status, created_at: run.createdAt, branch: run.headBranch, commit: run.headSha, event: run.event, }, failed_jobs: [], repository: repo || "current", }; for (const job of failedJobs(run.databaseId, repo)) result.failed_jobs.push({ name: job.name, conclusion: job.conclusion, status: job.status, started_at: job.startedAt, completed_at: job.completedAt, error_excerpts: excerpts(logs(run.databaseId, job.databaseId, repo)), }); return result; } function main() { if (!checkGh()) { console.error("Error: gh CLI is not installed or not in PATH"); console.error("Install it from: https://cli.github.com/"); return 1; } const result = analyze( arg("--repo"), arg("--run-id") ? Number(arg("--run-id")) : undefined, ); console.log(JSON.stringify(result, null, has("--pretty") ? 2 : 0)); return 0; } export const __test = { clean, excerpts, jobLogArgs, repoArgs, }; if (process.argv[1] === fileURLToPath(import.meta.url)) { try { process.exit(main()); } catch (e) { console.error(e.message); process.exit(1); } }