561 lines
16 KiB
JavaScript
561 lines
16 KiB
JavaScript
#!/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();
|