#!/usr/bin/env node // pr-dashboard-cli.mjs // Standalone CLI for the PR dashboard — no Copilot SDK required. // Usage: node pr-dashboard-cli.mjs [query] [role] // query: natural-language date range, e.g. "last 2 weeks" (default: "last 7 days") // role: one of "Authored by me" | "Requested reviews" | "Assigned to me" | "All" // (default: "Authored by me") import fs from "fs"; import os from "os"; import path from "path"; import { promisify } from "util"; import { execFile, spawn } from "child_process"; import { fileURLToPath } from "url"; import { parseDateRange } from "./lib/utils.mjs"; const execFileP = promisify(execFile); function escapeHtml(s) { return String(s).replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]); } // ── CLI args ────────────────────────────────────────────────────────────────── const args = process.argv.slice(2); const query = args[0] || "last 7 days"; const role = args[1] || "Authored by me"; // ── Helpers ─────────────────────────────────────────────────────────────────── function formatHumanDate(d) { try { return new Date(d).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }); } catch (e) { return d; } } async function ghApi(args) { try { const { stdout } = await execFileP("gh", ["api", ...args]); return JSON.parse(stdout); } catch (err) { if (err?.code === "ENOENT") throw new Error("`gh` CLI not found. Install GitHub CLI and authenticate (gh auth login)."); let errorMessage = err?.message || String(err); if (err?.stdout) { try { const parsed = JSON.parse(err.stdout); if (parsed?.message) errorMessage = parsed.message; } catch (e) { /* fall through */ } } if (err?.stderr?.trim()) errorMessage = err.stderr.trim(); throw new Error(`gh api failed: ${errorMessage}`); } } async function getGhUsername() { const res = await ghApi(["user"]); return res.login; } async function searchIssues(qstr) { const q = encodeURIComponent(qstr); const perPage = 100; const maxResults = 1000; const items = []; for (let page = 1; items.length < maxResults; page++) { const res = await ghApi([`/search/issues?q=${q}&per_page=${perPage}&page=${page}`]); const pageItems = res.items || []; if (pageItems.length === 0) break; items.push(...pageItems); if (pageItems.length < perPage) break; } return items.slice(0, maxResults); } async function getPrDetails(item) { try { const prHtml = item.html_url || item.pull_request?.html_url; const m = /github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/.exec(prHtml); if (!m) return null; const [, owner, repo, number] = m; const pr = await ghApi([`/repos/${owner}/${repo}/pulls/${number}`]); const out = { repo: `${owner}/${repo}`, number: pr.number, title: pr.title, html_url: pr.html_url, createdAt: formatHumanDate(pr.created_at), updatedAt: formatHumanDate(pr.updated_at), summary: (pr.body || "").split("\n").slice(0, 3).join(" "), status: "OPEN", review: "—", ci: "—", draft: pr.draft || false, bodyHtml: null, bodyMarkdown: pr.body || "", }; if (out.draft) out.status = "DRAFT"; else if (pr.merged) out.status = "MERGED"; else if (pr.state === "closed") out.status = "CLOSED"; // reviews try { const reviews = await ghApi([`/repos/${owner}/${repo}/pulls/${number}/reviews?per_page=100`]); if (Array.isArray(reviews) && reviews.length) { const rev = [...reviews].reverse().find(r => ["APPROVED", "CHANGES_REQUESTED", "DISMISSED", "COMMENTED"].includes((r.state || "").toUpperCase()) ); out.review = rev ? (rev.state || "").toUpperCase() : (reviews[reviews.length - 1].state || "").toUpperCase(); } else { out.review = "REVIEW_REQUIRED"; } } catch (e) { /* ignore */ } // CI status try { if (pr.head?.sha) { const status = await ghApi([`/repos/${owner}/${repo}/commits/${pr.head.sha}/status`]); if (status?.state) out.ci = (status.state || "").toUpperCase(); } } catch (e) { /* ignore */ } // Render first paragraph to HTML via GitHub Markdown API try { if (pr.body && String(pr.body).trim()) { let firstPara = String(pr.body).split(/\r?\n\r?\n/)[0] || ""; firstPara = firstPara.replace(/\s*\*{1,2}\s*([^*]+?)\s*\*{1,2}\s*:\s*$/, "").trim(); if (firstPara) { const { stdout } = await execFileP("gh", [ "api", "-X", "POST", "/markdown", "-f", `text=${firstPara}`, "-f", "mode=gfm", "-f", `context=${owner}/${repo}`, ]).catch(err => ({ stdout: err?.stdout || "" })); if (stdout && String(stdout).trim()) out.bodyHtml = stdout; out.bodyMarkdown = firstPara; out.summary = firstPara.replace(/\n+/g, " ").trim(); } else { out.bodyHtml = null; out.bodyMarkdown = ""; out.summary = ""; } } else { out.bodyHtml = null; out.bodyMarkdown = ""; out.summary = ""; } } catch (e) { out.bodyHtml = null; } return out; } catch (e) { return null; } } async function pMap(list, mapper, concurrency = 5) { const results = new Array(list.length); let i = 0; const workers = Array.from({ length: Math.min(concurrency, list.length) }, async () => { while (i < list.length) { const idx = i++; try { results[idx] = await mapper(list[idx]); } catch (e) { results[idx] = null; } } }); await Promise.all(workers); return results.filter(Boolean); } function buildMarkdown(prs, label) { const open = prs.filter(p => p.status === "OPEN").length; const merged = prs.filter(p => p.status === "MERGED").length; const closed = prs.filter(p => p.status === "CLOSED").length; const draft = prs.filter(p => p.status === "DRAFT").length; const lines = [`## PR Dashboard — ${label}\n`, `**${prs.length} total** · ✅ ${open} open · 🔀 ${merged} merged · ❌ ${closed} closed · 📝 ${draft}\n\n`]; for (const pr of prs) { lines.push(`**[${pr.title}](${pr.html_url})** · \`${pr.repo}\` · ${pr.status} · ${pr.review} · CI: ${pr.ci} · ${pr.createdAt}\n\n`); if (pr.summary) lines.push(`${pr.summary}\n\n`); } return lines.join(""); } async function renderHtml(md, label = "PR Dashboard", prs = []) { const extDir = path.dirname(fileURLToPath(import.meta.url)); const templatePath = path.join(extDir, "../assets/dashboard.html"); let template = ""; try { template = fs.readFileSync(templatePath, "utf8"); } catch (e) { template = `
${escapeHtml(JSON.stringify(md))}`;
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
}
function statusColor(s) {
return { DRAFT: "#848d97", MERGED: "#6f42c1", CLOSED: "#da3633", OPEN: "#d29922" }[(s || "").toUpperCase()] || "#848d97";
}
function reviewColor(r) {
return { APPROVED: "#2ea043", CHANGES_REQUESTED: "#da3633", REVIEW_REQUIRED: "#d29922" }[(r || "").toUpperCase()] || "#848d97";
}
function ciColor(c) {
return { SUCCESS: "#2ea043", FAILURE: "#da3633", PENDING: "#d29922" }[(c || "").toUpperCase()] || "#848d97";
}
const rows = prs.map(pr => {
const previewHtml = (pr.bodyHtml && String(pr.bodyHtml).trim())
? pr.bodyHtml
: pr.summary
? `