singularity-forge/scripts/ci_monitor.cjs
2026-05-05 14:46:18 +02:00

561 lines
16 KiB
JavaScript
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.

#!/usr/bin/env node
/**
* GitHub Actions CI/CD Workflow Monitor - Pure Node.js implementation
*/
const { spawnSync } = require("node:child_process");
const fs = require("node:fs");
const path = require("node:path");
const EMOJI = {
success: "✅",
failure: "❌",
cancelled: "🚫",
skipped: "⏭️",
timed_out: "⏱️",
in_progress: "▶️",
queued: "⏳",
};
const INTERVAL = 10,
TIMEOUT = 3600,
MAXBUF = 50 * 1024 * 1024;
// Pure Node.js gh CLI helpers - no shell strings
const gh = (args, opts = {}) => {
const r = spawnSync("gh", args, {
encoding: "utf-8",
maxBuffer: opts.maxBuffer || MAXBUF,
cwd: opts.cwd,
});
if (r.error) throw r.error;
if (r.status !== 0 && !opts.allowFail)
throw new Error(r.stderr || `gh exited ${r.status}`);
return r.stdout;
};
const ghJson = (args, opts) => JSON.parse(gh(args, opts));
const cliRepo = (() => {
const a = process.argv;
const i = a.findIndex((x) => x === "--repo" || x === "-R");
return i >= 0 && a[i + 1] ? a[i + 1] : null;
})();
let _repo = null;
const getRepo = () =>
_repo ||
(_repo =
cliRepo ||
process.env.GITHUB_REPOSITORY ||
ghJson(["repo", "view", "--json", "nameWithOwner"]).nameWithOwner);
const runView = (id, f = "status,conclusion,jobs") =>
ghJson(["run", "view", String(id), "--repo", getRepo(), "--json", f]);
const runList = (opts = {}) => {
const args = [
"run",
"list",
"--repo",
getRepo(),
"--limit",
String(opts.limit || 10),
"--json",
"databaseId,status,conclusion,headBranch,createdAt,displayTitle,event",
];
if (opts.branch) args.push("--branch", opts.branch);
return ghJson(args);
};
const getLogs = (runId, jobId) =>
gh(
[
"run",
"view",
String(runId),
"--repo",
getRepo(),
"--log",
"--job",
String(jobId),
],
{ maxBuffer: MAXBUF },
);
const findJob = (runId, name) => {
const job = runView(runId, "jobs").jobs?.find((j) => j.name === name);
if (!job) {
console.error(`❌ Job "${name}" not found`);
process.exit(1);
}
return job;
};
const emoji = (s, c) => EMOJI[c || s] || "❓";
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// Commands
const cmd = {
runs: (opts = {}) => {
const list = runList({ ...opts, limit: parseInt(opts.limit, 10) || 15 });
console.log(
`\n📋 Recent runs${opts.branch ? ` for "${opts.branch}"` : ""}:\n`,
);
for (const r of list) {
console.log(
`${emoji(r.status, r.conclusion)} ${String(r.databaseId).padEnd(12)} ${new Date(r.createdAt).toLocaleDateString()} [${(r.headBranch || "").padEnd(20)}] (${r.event || ""})`,
);
if (r.displayTitle)
console.log(` ${r.displayTitle.substring(0, 60)}`);
}
return list;
},
watch: async (id, opts = {}) => {
const int = parseInt(opts.interval, 10) || INTERVAL;
console.log(`👁️ Watching run ${id}...\n`);
const last = new Map();
while (true) {
const run = runView(id);
const rs = `${run.status}:${run.conclusion}`;
if (last.get("run") !== rs) {
console.log(
`${emoji(run.status, run.conclusion)} Run: ${run.status}${run.conclusion ? " → " + run.conclusion : ""}`,
);
last.set("run", rs);
}
for (const j of run.jobs || []) {
const js = `${j.status}:${j.conclusion}`;
if (last.get(`job:${j.id}`) !== js) {
console.log(
` ${emoji(j.status, j.conclusion)} ${j.name}: ${j.status}${j.conclusion ? " → " + j.conclusion : ""}`,
);
last.set(`job:${j.id}`, js);
}
}
if (run.status === "completed") {
console.log(
`\n${emoji(run.status, run.conclusion)} Completed: ${run.conclusion}`,
);
process.exit(run.conclusion === "success" ? 0 : 1);
}
await sleep(int * 1000);
}
},
"fail-fast": async (id, opts = {}) => {
const int = parseInt(opts.interval, 10) || INTERVAL;
console.log(`🔍 Watching run ${id} (fail-fast)...\n`);
const seen = new Set();
while (true) {
const run = runView(id);
for (const j of run.jobs || []) {
if (!seen.has(j.id)) {
console.log(
`${emoji(j.status, j.conclusion)} ${j.name}: ${j.conclusion || j.status}`,
);
seen.add(j.id);
}
if (j.conclusion === "failure") {
console.log(
`\n❌ Job "${j.name}" failed!\n📋 Run: ci_monitor.cjs log-failed ${id}`,
);
process.exit(1);
}
}
if (run.status === "completed") {
console.log(
`\n${emoji(run.status, run.conclusion)} Run completed: ${run.conclusion}`,
);
process.exit(run.conclusion === "success" ? 0 : 1);
}
await sleep(int * 1000);
}
},
"list-jobs": (id, opts = {}) => {
let jobs = runView(id).jobs || [];
if (opts.status)
jobs = jobs.filter(
(j) => j.conclusion === opts.status || j.status === opts.status,
);
console.log(`\n📋 Jobs in run ${id}:\n`);
for (const j of jobs)
console.log(
`${emoji(j.status, j.conclusion)} ${(j.conclusion || j.status || "?").padEnd(12)} ${j.name}`,
);
},
"log-failed": (id, opts = {}) => {
const run = runView(id, "jobs");
if (!(run.jobs || []).some((j) => j.conclusion === "failure")) {
console.log("✅ No failed jobs found.");
return;
}
console.log(`\n❌ Failed jobs in run ${id}:\n`);
try {
console.log(
gh(["run", "view", String(id), "--repo", getRepo(), "--log-failed"], {
maxBuffer: MAXBUF,
})
.split(/\r?\n/)
.slice(-(parseInt(opts.lines, 10) || 200))
.join("\n"),
);
} catch (e) {
console.error(`Could not fetch logs: ${e.message}`);
}
},
log: (id, opts = {}) => {
console.log(`\n📋 Full logs for run ${id}:\n`);
try {
let lines = gh(
["run", "view", String(id), "--repo", getRepo(), "--log"],
{ maxBuffer: MAXBUF },
).split(/\r?\n/);
if (opts.filter) {
const re = new RegExp(opts.filter, "gi");
lines = lines.filter((l) => re.test(l));
console.log(`🔍 Filtered (${lines.length} lines):\n`);
}
console.log(lines.slice(-(parseInt(opts.lines, 10) || 500)).join("\n"));
} catch (e) {
console.error(`Could not fetch logs: ${e.message}`);
}
},
grep: (id, opts = {}) => {
if (!opts.pattern) {
console.error("❌ --pattern required");
process.exit(1);
}
console.log(`\n🔍 Searching for "${opts.pattern}" in run ${id}:\n`);
try {
const lines = gh(
["run", "view", String(id), "--repo", getRepo(), "--log"],
{ maxBuffer: MAXBUF },
).split(/\r?\n/);
const re = new RegExp(opts.pattern, "gi");
const matches = lines
.map((l, i) => (re.test(l) ? { i, l } : null))
.filter(Boolean);
if (!matches.length) {
console.log("No matches found.");
return;
}
console.log(`Found ${matches.length} matches:\n`);
const ctx = parseInt(opts.context, 10) || 3;
for (const m of matches.slice(0, 20)) {
console.log(`--- Line ${m.i} ---`);
for (
let j = Math.max(0, m.i - ctx);
j < Math.min(lines.length, m.i + ctx + 1);
j++
)
console.log(`${j === m.i ? ">>>" : " "} ${lines[j]}`);
}
if (matches.length > 20)
console.log(`\n... and ${matches.length - 20} more`);
} catch (e) {
console.error(`Could not fetch logs: ${e.message}`);
}
},
"test-summary": (id, _opts = {}) => {
console.log(`\n📊 Test summary for run ${id}:\n`);
try {
const logs = gh(
["run", "view", String(id), "--repo", getRepo(), "--log"],
{ maxBuffer: MAXBUF },
);
const t = logs.match(/# tests[\s:]+(\d+)/i),
p = logs.match(/# pass[\s:]+(\d+)/i),
f = logs.match(/# fail[\s:]+(\d+)/i);
const notOk = logs.match(/^not ok .+$/gm);
if (t) console.log(` Total tests: ${t[1]}`);
if (p) console.log(` ✅ Passed: ${p[1]}`);
if (f) console.log(` ❌ Failed: ${f[1]}`);
if (notOk?.length) {
console.log(`\nFailed tests:`);
notOk.slice(0, 15).forEach((x) => console.log(` ${x}`));
if (notOk.length > 15)
console.log(` ... and ${notOk.length - 15} more`);
}
} catch (e) {
console.error(`Could not fetch logs: ${e.message}`);
}
},
tail: (id, job, opts = {}) =>
console.log(
getLogs(id, findJob(id, job).id)
.split(/\r?\n/)
.slice(-(parseInt(opts.lines, 10) || 100))
.join("\n"),
),
"wait-for": async (id, jobName, opts = {}) => {
if (!opts.keyword) {
console.error("❌ --keyword required");
process.exit(1);
}
const to = (parseInt(opts.timeout, 10) || TIMEOUT) * 1000,
int = (parseInt(opts.interval, 10) || 5) * 1000;
console.log(`🔍 Waiting for "${opts.keyword}" in "${jobName}"...\n`);
const start = Date.now();
let job = null;
while (!job && Date.now() - start < to) {
job = runView(id).jobs?.find((j) => j.name === jobName);
if (!job) {
console.log(`⏳ Waiting...`);
await sleep(int);
}
}
if (!job) {
console.error("❌ Timeout waiting for job");
process.exit(1);
}
console.log(`▶️ Job started (ID: ${job.id})`);
while (Date.now() - start < to) {
try {
const logs = getLogs(id, job.id);
if (logs.includes(opts.keyword)) {
console.log(`\n✅ Found "${opts.keyword}"!`);
const lines = logs.split(/\r?\n/),
idx = lines.findIndex((l) => l.includes(opts.keyword));
if (idx >= 0)
console.log(
"\n" + lines.slice(Math.max(0, idx - 2), idx + 3).join("\n"),
);
process.exit(0);
}
console.log(
`📝 Log: ${logs.length} chars (${Math.floor((Date.now() - start) / 1000)}s)`,
);
} catch (_e) {
/* ignore */
}
await sleep(int);
}
console.error(`❌ Timeout waiting for "${opts.keyword}"`);
process.exit(1);
},
analyze: (id, jobName) => {
const logs = getLogs(id, findJob(id, jobName).id);
const patterns = [
["Errors", /error[:]\s*(.+)/gi],
["NPM Errors", /npm ERR!\s*(.+)/gi],
["TypeScript", /error TS\d+:\s*(.+)/gi],
["Timeout", /timeout|timed?\s*out/gi],
["OOM", /out of memory|OOM|heap.*exceeded/gi],
["Network", /ECONNREFUSED|ETIMEDOUT|ENOTFOUND/gi],
["Bad Option", /bad option[:]\s*(.+)/gi],
];
console.log(`🔍 Analyzing "${jobName}"...\n`);
for (const [name, re] of patterns) {
const m = [...logs.matchAll(re)].slice(0, 5);
if (m.length) {
console.log(`${name}:`);
m.forEach((x) =>
console.log(`${(x[1] || x[0]).trim().substring(0, 80)}`),
);
}
}
},
compare: (id1, id2) => {
const j1 = new Map(
(runView(id1, "jobs").jobs || []).map((j) => [j.name, j]),
);
const j2 = new Map(
(runView(id2, "jobs").jobs || []).map((j) => [j.name, j]),
);
console.log(`\n🔍 Comparing ${id1} vs ${id2}:\n`);
for (const name of new Set([...j1.keys(), ...j2.keys()])) {
const a = j1.get(name)?.conclusion || "missing",
b = j2.get(name)?.conclusion || "missing";
console.log(
`${emoji(0, a)} ${emoji(0, b)} ${name.padEnd(25)} ${a.padEnd(10)}${b}${a !== b ? " ⚠️" : ""}`,
);
}
},
"branch-runs": (branch, opts = {}) => {
const list = runList({ branch, limit: parseInt(opts.limit, 10) || 10 });
console.log(`\n📋 Runs for "${branch}":\n`);
for (const r of list)
console.log(
`${emoji(r.status, r.conclusion)} ${String(r.databaseId).padEnd(10)} ${new Date(r.createdAt).toLocaleDateString()} ${r.displayTitle?.substring(0, 40) || ""}`,
);
},
"list-workflows": (_opts = {}) => {
const dir = path.join(".github", "workflows");
if (!fs.existsSync(dir)) {
console.error("❌ No .github/workflows directory");
process.exit(1);
}
const files = fs
.readdirSync(dir)
.filter((f) => f.endsWith(".yml") || f.endsWith(".yaml"))
.sort();
if (!files.length) {
console.log("No workflow files found.");
return [];
}
console.log("\n📋 Workflow files:\n");
for (const f of files) {
const c = fs.readFileSync(path.join(dir, f), "utf-8");
const nm = c.match(/^name:\s*['"]?(.+?)['"]?\s*$/m)?.[1] || "(unnamed)";
const tr = [
"push",
"pull_request",
"schedule",
"workflow_dispatch",
"release",
].filter((x) => c.includes(`${x}:`));
console.log(
`📄 ${f.padEnd(30)} ${nm.padEnd(30)} ${tr.length ? `[${tr.join(", ")}]` : ""}`,
);
}
return files;
},
"check-actions": (wf, _opts = {}) => {
const fp = wf || path.join(".github", "workflows", "ci.yml");
if (!fs.existsSync(fp)) {
console.error(`❌ File not found: ${fp}`);
process.exit(1);
}
const c = fs.readFileSync(fp, "utf-8");
// Find all uses: statements
const actions = new Set();
const lines = c.split(/\r?\n/);
for (const line of lines) {
const m = line.match(/uses:\s*['"]?([^'"\s]+)['"]?/);
if (m && !m[1].startsWith("./") && !m[1].startsWith("docker://")) {
actions.add(m[1].split("@")[0]);
}
}
if (!actions.size) {
console.log("No external actions found.");
return;
}
console.log(`\n🔍 Checking ${actions.size} actions in ${fp}:\n`);
for (const a of actions) {
const [owner, repo] = a.split("/");
if (!owner || !repo) continue;
try {
const res = ghJson([
"api",
"graphql",
"-f",
`query=query { repository(owner: "${owner}", name: "${repo}") { latestRelease { tagName } } }`,
]);
const latest = res?.data?.repository?.latestRelease?.tagName;
const curMatch = c.match(
new RegExp(`${a.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}@([\\w.-]+)`),
);
const cur = curMatch?.[1] || "unknown";
if (latest) {
const ok = cur === latest || cur === latest.replace(/^v/, "");
console.log(
`${ok ? "✅" : "⚠️"} ${a.padEnd(35)} current: ${cur.padEnd(15)} latest: ${latest}`,
);
} else
console.log(
`${a.padEnd(35)} current: ${cur.padEnd(15)} (no releases)`,
);
} catch (e) {
console.log(
`${a.padEnd(35)} Error: ${e.message?.substring(0, 50) || e}`,
);
}
}
},
};
// CLI
const parseArgs = (args) => {
const r = { command: null, positional: [], options: {} };
for (let i = 0; i < args.length; i++) {
const a = args[i];
if (a.startsWith("--")) {
const k = a.slice(2);
const n = args[i + 1];
if (n && !n.startsWith("-")) {
r.options[k] = n;
i++;
} else r.options[k] = true;
} else if (a.startsWith("-")) {
const k = a.slice(1);
const n = args[i + 1];
if (n && !n.startsWith("-")) {
r.options[k] = n;
i++;
} else r.options[k] = true;
} else if (r.command === null) r.command = a;
else r.positional.push(a);
}
return r;
};
const HELP = `
GitHub Actions CI/CD Workflow Monitor
COMMANDS:
runs [--branch <name>] List recent runs
watch <run-id> Watch run with status changes
fail-fast <run-id> Watch run, exit 1 on first failure
list-jobs <run-id> List jobs in run
log-failed <run-id> Show logs for failed jobs
log <run-id> [--filter <regex>] Show full run logs
grep <run-id> --pattern <regex> Search logs with context
test-summary <run-id> Extract test pass/fail counts
tail <run-id> <job-name> Get last N lines of job log
wait-for <run-id> <job> --keyword Block until keyword appears
analyze <run-id> <job> Pattern analysis for failures
compare <run1> <run2> Compare job statuses between runs
branch-runs <branch> List recent runs for branch
list-workflows List all workflow files
check-actions [file] Check action versions via GraphQL
OPTIONS: --interval, --timeout, --lines, --filter, --pattern, --context, --branch, --keyword, --limit, --repo/-R
`;
const REQ = {
watch: ["run-id"],
"fail-fast": ["run-id"],
"list-jobs": ["run-id"],
"log-failed": ["run-id"],
log: ["run-id"],
grep: ["run-id"],
"test-summary": ["run-id"],
tail: ["run-id", "job-name"],
"wait-for": ["run-id", "job-name"],
analyze: ["run-id", "job-name"],
compare: ["run-id-1", "run-id-2"],
"branch-runs": ["branch"],
};
async function main() {
const args = process.argv.slice(2);
if (!args.length || args[0] === "help" || args[0] === "--help") {
console.log(HELP);
process.exit(0);
}
const { command, positional, options } = parseArgs(args);
if (!cmd[command]) {
console.error(`❌ Unknown command: ${command}`);
console.log(HELP);
process.exit(1);
}
const req = REQ[command] || [];
if (req.some((_, i) => !positional[i])) {
console.error(
`❌ Missing: ${req.filter((_, i) => !positional[i]).join(", ")}`,
);
process.exit(1);
}
if (command === "grep" && !options.pattern) {
console.error("❌ --pattern required");
process.exit(1);
}
if (command === "wait-for" && !options.keyword) {
console.error("❌ --keyword required");
process.exit(1);
}
try {
await cmd[command](...positional, options);
} catch (e) {
console.error(`❌ Error: ${e.message}`);
if (process.env.DEBUG) console.error(e.stack);
process.exit(1);
}
}
main();