mirror of
https://github.com/github/awesome-copilot.git
synced 2026-04-30 20:25:55 +00:00
Add pr-dashboard skill and plugin (#1444)
* Add pr-dashboard skill and plugin Adds a self-contained PR dashboard skill that generates and opens a rich HTML dashboard in the browser showing GitHub pull requests for a given date range and role filter. - Skill: skills/pr-dashboard/ — bundles pr-dashboard-cli.mjs, dashboard.html, and lib/utils.mjs - Plugin: plugins/pr-dashboard/ — makes it installable via `copilot skill install pr-dashboard@awesome-copilot` Requires GitHub CLI (gh) installed and authenticated. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Restore README.instructions.md to upstream sort order macOS locale sorts Japanese/Korean C# entries differently than Linux CI. Restore to the upstream/staged version since we don't add any instructions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address PR review comments - Fix regex character class bug: [-to]+ → (?:-|to) alternation in utils.mjs - Fix 'last week' to return previous calendar week (Mon–Sun) not last 7 days - Remove unused formatHumanDate and buildMarkdown exports from utils.mjs - Fix ghApi error handling: rethrow with helpful message instead of silently returning parsed JSON on failure (prevents silent auth errors) - Add pagination to searchIssues (up to 1000 results across pages) - Add rel="noopener noreferrer" to target=_blank links in generated rows - HTML-escape fallback template content in renderHtml to prevent injection - Move escapeHtml to module level so it's available before renderHtml body - Neutralise dashboard.html template: placeholder title/h1/meta/stats/tbody - Empty __md and filename in template (CLI populates at runtime) - Add aria-label to search input and status/review selects Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * remove newline * Regenerate docs/README.instructions.md * refactor(pr-dashboard): move scripts/assets per skills spec, remove plugin - Move pr-dashboard-cli.mjs and lib/utils.mjs into scripts/ per skills spec - Move dashboard.html into assets/ per skills spec - Update CLI template path and SKILL.md script path reference - Remove plugins/pr-dashboard (redundant now that gh skills install works) - Clean up marketplace.json and docs/README.plugins.md Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
331
skills/pr-dashboard/scripts/pr-dashboard-cli.mjs
Normal file
331
skills/pr-dashboard/scripts/pr-dashboard-cli.mjs
Normal file
@@ -0,0 +1,331 @@
|
||||
#!/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 = `<html><head><title>PR Dashboard — ${escapeHtml(label)}</title></head><body><pre>${escapeHtml(JSON.stringify(md))}</pre></body></html>`;
|
||||
}
|
||||
|
||||
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
|
||||
? `<div style="margin-top:6px;white-space:pre-wrap;font-size:.95em;color:var(--text);">${escapeHtml(pr.summary)}</div>`
|
||||
: `<div style="margin-top:6px;color:var(--muted);font-size:.9em">No description</div>`;
|
||||
|
||||
return `<tr>
|
||||
<td><a href="https://github.com/${escapeHtml(pr.repo)}" target="_blank" rel="noopener noreferrer">${escapeHtml(pr.repo)}</a></td>
|
||||
<td>
|
||||
<div class="title-line"><a href="${escapeHtml(pr.html_url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(pr.title)}</a></div>
|
||||
${previewHtml}
|
||||
</td>
|
||||
<td><span class="badge" style="background:${statusColor(pr.status)}">${escapeHtml(pr.status)}</span></td>
|
||||
<td><span class="badge" style="background:${reviewColor(pr.review)}">${escapeHtml(pr.review)}</span></td>
|
||||
<td><span class="badge" style="background:${ciColor(pr.ci)}">${escapeHtml(pr.ci)}</span></td>
|
||||
<td style="white-space:nowrap">${escapeHtml(pr.createdAt)}</td>
|
||||
<td style="white-space:nowrap">${escapeHtml(pr.updatedAt)}</td>
|
||||
</tr>`;
|
||||
}).join("\n");
|
||||
|
||||
let replaced = template;
|
||||
replaced = replaced.replace(/<tbody id="tb">[\s\S]*?<\/tbody>/, `<tbody id="tb">\n${rows}\n</tbody>`);
|
||||
replaced = replaced.replace(/const __md = [\s\S]*?;/, `const __md = ${JSON.stringify(md)};`);
|
||||
replaced = replaced.replace(/<span class="visible-count" id="vc">[^<]*<\/span>/, `<span class="visible-count" id="vc">${prs.length} PR${prs.length !== 1 ? "s" : ""}</span>`);
|
||||
|
||||
try { replaced = replaced.replace(/<title>[^<]*<\/title>/, `<title>PR Dashboard — ${escapeHtml(label)}</title>`); } catch (e) {}
|
||||
try { replaced = replaced.replace(/<h1[^>]*>[^<]*<\/h1>/, `<h1>🔀 PR Dashboard — ${escapeHtml(label)}</h1>`); } catch (e) {}
|
||||
|
||||
try {
|
||||
const nowStr = new Date().toLocaleString();
|
||||
replaced = replaced.replace(/<div class="meta">[^<]*<\/div>/, `<div class="meta">Generated ${escapeHtml(nowStr)} · ${prs.length} pull requests</div>`);
|
||||
|
||||
const counts = { open: 0, merged: 0, closed: 0, draft: 0 };
|
||||
for (const p of prs) counts[p.status.toLowerCase()] = (counts[p.status.toLowerCase()] || 0) + 1;
|
||||
|
||||
function replaceStat(cls, val) {
|
||||
const marker = `<div class="${cls}">`;
|
||||
const idx = replaced.indexOf(marker);
|
||||
if (idx === -1) return;
|
||||
const nStart = replaced.indexOf('<div class="n">', idx);
|
||||
if (nStart === -1) return;
|
||||
const nEnd = replaced.indexOf("</div>", nStart);
|
||||
if (nEnd === -1) return;
|
||||
replaced = replaced.slice(0, nStart + '<div class="n">'.length) + String(val) + replaced.slice(nEnd);
|
||||
}
|
||||
replaceStat("stat open", counts.open);
|
||||
replaceStat("stat merged", counts.merged);
|
||||
replaceStat("stat closed", counts.closed);
|
||||
replaceStat("stat draft", counts.draft);
|
||||
} catch (e) {}
|
||||
|
||||
try {
|
||||
const safe = String(label).replace(/[^a-z0-9]/gi, "_");
|
||||
replaced = replaced.replace(/const filename = '[^']*';/, `const filename = 'pr-dashboard-${safe}.md';`);
|
||||
} catch (e) {}
|
||||
|
||||
const outPath = path.join(os.tmpdir(), "pr-dashboard.html");
|
||||
fs.writeFileSync(outPath, replaced, "utf8");
|
||||
return outPath;
|
||||
}
|
||||
|
||||
function openInBrowser(filePath) {
|
||||
try {
|
||||
const platform = process.platform;
|
||||
const opener = platform === "win32" ? null : platform === "darwin" ? "open" : "xdg-open";
|
||||
const child = opener
|
||||
? spawn(opener, [filePath], { detached: true, stdio: "ignore" })
|
||||
: spawn("cmd", ["/c", "start", '""', filePath], { detached: true, stdio: "ignore" });
|
||||
child.unref();
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
// ── Main ──────────────────────────────────────────────────────────────────────
|
||||
(async () => {
|
||||
try {
|
||||
const { start, end, label } = parseDateRange(query);
|
||||
const labelWithRange = `${label} (${start} → ${end})`;
|
||||
|
||||
console.log(`[pr-dashboard] Fetching PRs for: ${labelWithRange} · role: ${role}`);
|
||||
|
||||
const username = await getGhUsername();
|
||||
|
||||
const roleMap = {
|
||||
"Authored by me": `author:${username}`,
|
||||
"Requested reviews": `review-requested:${username}`,
|
||||
"Assigned to me": `assignee:${username}`,
|
||||
"All": `involves:${username}`,
|
||||
};
|
||||
const roleQualifier = roleMap[role] || `author:${username}`;
|
||||
const qstr = `is:pr ${roleQualifier} created:${start}..${end}`;
|
||||
|
||||
console.log(`[pr-dashboard] Search: ${qstr}`);
|
||||
const items = await searchIssues(qstr);
|
||||
console.log(`[pr-dashboard] Found ${items.length} PR(s)`);
|
||||
|
||||
if (!items.length) {
|
||||
const extDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const noResultsPath = path.join(os.tmpdir(), "pr-dashboard-no-results.html");
|
||||
fs.writeFileSync(noResultsPath,
|
||||
`<html><head><title>PR Dashboard — ${labelWithRange}</title></head><body><h1>No PRs found</h1><p>No pull requests matched your query for ${labelWithRange}.</p></body></html>`,
|
||||
"utf8"
|
||||
);
|
||||
openInBrowser(noResultsPath);
|
||||
console.log("[pr-dashboard] No results — opened placeholder page.");
|
||||
return;
|
||||
}
|
||||
|
||||
const prs = await pMap(items, getPrDetails, 5);
|
||||
console.log(`[pr-dashboard] Fetched details for ${prs.length} PR(s)`);
|
||||
|
||||
const md = buildMarkdown(prs, labelWithRange);
|
||||
const out = await renderHtml(md, labelWithRange, prs);
|
||||
openInBrowser(out);
|
||||
console.log(`[pr-dashboard] Dashboard opened: ${out}`);
|
||||
} catch (e) {
|
||||
console.error("[pr-dashboard] Error:", e?.message || e);
|
||||
process.exit(1);
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user