#!/usr/bin/env node /** * GitHub Actions CI/CD Workflow Monitor - Pure Node.js implementation */ const { spawnSync } = require('child_process'); const fs = require('fs'); const path = require('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) || 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) || 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) || 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) || 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) || 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) || 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) || 100)).join('\n')), 'wait-for': async (id, jobName, opts = {}) => { if (!opts.keyword) { console.error('āŒ --keyword required'); process.exit(1); } const to = (parseInt(opts.timeout) || TIMEOUT) * 1000, int = (parseInt(opts.interval) || 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 }); 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 ] List recent runs watch Watch run with status changes fail-fast Watch run, exit 1 on first failure list-jobs List jobs in run log-failed Show logs for failed jobs log [--filter ] Show full run logs grep --pattern Search logs with context test-summary Extract test pass/fail counts tail Get last N lines of job log wait-for --keyword Block until keyword appears analyze Pattern analysis for failures compare Compare job statuses between runs branch-runs 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();