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:
James
2026-04-30 08:59:44 +09:00
committed by GitHub
parent 76ac13a9b8
commit 4beca2f03b
5 changed files with 638 additions and 0 deletions

View File

@@ -256,6 +256,7 @@ See [CONTRIBUTING.md](../CONTRIBUTING.md#adding-skills) for guidelines on how to
| [power-platform-architect](../skills/power-platform-architect/SKILL.md)<br />`gh skills install github/awesome-copilot power-platform-architect` | Use this skill when the user needs to transform business requirements, use case descriptions, or meeting transcripts into a technical Power Platform solution architecture, including component selection and Mermaid.js diagrams. | None | | [power-platform-architect](../skills/power-platform-architect/SKILL.md)<br />`gh skills install github/awesome-copilot power-platform-architect` | Use this skill when the user needs to transform business requirements, use case descriptions, or meeting transcripts into a technical Power Platform solution architecture, including component selection and Mermaid.js diagrams. | None |
| [power-platform-mcp-connector-suite](../skills/power-platform-mcp-connector-suite/SKILL.md)<br />`gh skills install github/awesome-copilot power-platform-mcp-connector-suite` | Generate complete Power Platform custom connector with MCP integration for Copilot Studio - includes schema generation, troubleshooting, and validation | None | | [power-platform-mcp-connector-suite](../skills/power-platform-mcp-connector-suite/SKILL.md)<br />`gh skills install github/awesome-copilot power-platform-mcp-connector-suite` | Generate complete Power Platform custom connector with MCP integration for Copilot Studio - includes schema generation, troubleshooting, and validation | None |
| [powerbi-modeling](../skills/powerbi-modeling/SKILL.md)<br />`gh skills install github/awesome-copilot powerbi-modeling` | Power BI semantic modeling assistant for building optimized data models. Use when working with Power BI semantic models, creating measures, designing star schemas, configuring relationships, implementing RLS, or optimizing model performance. Triggers on queries about DAX calculations, table relationships, dimension/fact table design, naming conventions, model documentation, cardinality, cross-filter direction, calculation groups, and data model best practices. Always connects to the active model first using power-bi-modeling MCP tools to understand the data structure before providing guidance. | `references/MEASURES-DAX.md`<br />`references/PERFORMANCE.md`<br />`references/RELATIONSHIPS.md`<br />`references/RLS.md`<br />`references/STAR-SCHEMA.md` | | [powerbi-modeling](../skills/powerbi-modeling/SKILL.md)<br />`gh skills install github/awesome-copilot powerbi-modeling` | Power BI semantic modeling assistant for building optimized data models. Use when working with Power BI semantic models, creating measures, designing star schemas, configuring relationships, implementing RLS, or optimizing model performance. Triggers on queries about DAX calculations, table relationships, dimension/fact table design, naming conventions, model documentation, cardinality, cross-filter direction, calculation groups, and data model best practices. Always connects to the active model first using power-bi-modeling MCP tools to understand the data structure before providing guidance. | `references/MEASURES-DAX.md`<br />`references/PERFORMANCE.md`<br />`references/RELATIONSHIPS.md`<br />`references/RLS.md`<br />`references/STAR-SCHEMA.md` |
| [pr-dashboard](../skills/pr-dashboard/SKILL.md)<br />`gh skills install github/awesome-copilot pr-dashboard` | Open a GitHub PR dashboard in the browser. Use when the user asks to see their pull requests, open the PR dashboard, show PRs for a date range, or check PR status. Trigger phrases include "show my PRs", "open PR dashboard", "pull request dashboard". | `assets/dashboard.html`<br />`scripts/lib`<br />`scripts/pr-dashboard-cli.mjs` |
| [prd](../skills/prd/SKILL.md)<br />`gh skills install github/awesome-copilot prd` | Generate high-quality Product Requirements Documents (PRDs) for software systems and AI-powered features. Includes executive summaries, user stories, technical specifications, and risk analysis. | None | | [prd](../skills/prd/SKILL.md)<br />`gh skills install github/awesome-copilot prd` | Generate high-quality Product Requirements Documents (PRDs) for software systems and AI-powered features. Includes executive summaries, user stories, technical specifications, and risk analysis. | None |
| [premium-frontend-ui](../skills/premium-frontend-ui/SKILL.md)<br />`gh skills install github/awesome-copilot premium-frontend-ui` | A comprehensive guide for GitHub Copilot to craft immersive, high-performance web experiences with advanced motion, typography, and architectural craftsmanship. | None | | [premium-frontend-ui](../skills/premium-frontend-ui/SKILL.md)<br />`gh skills install github/awesome-copilot premium-frontend-ui` | A comprehensive guide for GitHub Copilot to craft immersive, high-performance web experiences with advanced motion, typography, and architectural craftsmanship. | None |
| [project-workflow-analysis-blueprint-generator](../skills/project-workflow-analysis-blueprint-generator/SKILL.md)<br />`gh skills install github/awesome-copilot project-workflow-analysis-blueprint-generator` | Comprehensive technology-agnostic prompt generator for documenting end-to-end application workflows. Automatically detects project architecture patterns, technology stacks, and data flow patterns to generate detailed implementation blueprints covering entry points, service layers, data access, error handling, and testing approaches across multiple technologies including .NET, Java/Spring, React, and microservices architectures. | None | | [project-workflow-analysis-blueprint-generator](../skills/project-workflow-analysis-blueprint-generator/SKILL.md)<br />`gh skills install github/awesome-copilot project-workflow-analysis-blueprint-generator` | Comprehensive technology-agnostic prompt generator for documenting end-to-end application workflows. Automatically detects project architecture patterns, technology stacks, and data flow patterns to generate detailed implementation blueprints covering entry points, service layers, data access, error handling, and testing approaches across multiple technologies including .NET, Java/Spring, React, and microservices architectures. | None |

View File

@@ -0,0 +1,54 @@
---
name: pr-dashboard
description: 'Open a GitHub PR dashboard in the browser. Use when the user asks to see their pull requests, open the PR dashboard, show PRs for a date range, or check PR status. Trigger phrases include "show my PRs", "open PR dashboard", "pull request dashboard".'
---
# PR Dashboard
Generates and opens a GitHub PR dashboard in the browser for a given date range and role filter.
**Prerequisites:** GitHub CLI (`gh`) must be installed and authenticated (`gh auth login`).
## What to do
Find the CLI script bundled with this skill and run it:
```bash
SKILL_SCRIPT=$(find ~/.copilot -name "pr-dashboard-cli.mjs" -path "*/pr-dashboard/scripts/*" 2>/dev/null | head -1)
node "$SKILL_SCRIPT" "<query>" "<role>"
```
- `<query>`: the date range the user specified (default: `last 7 days`)
- `<role>`: one of `Authored by me`, `Requested reviews`, `Assigned to me`, `All` (default: `Authored by me`)
## Parsing the user's request
Extract the date range and role from the user's message. Examples:
| User says | query | role |
|---|---|---|
| show my PRs | `last 7 days` | `Authored by me` |
| show my PRs last 2 weeks | `last 2 weeks` | `Authored by me` |
| PR dashboard this month reviews | `this month` | `Requested reviews` |
| PR dashboard march 2026 assigned | `march 2026` | `Assigned to me` |
| show all PRs last 30 days | `last 30 days` | `All` |
**Role keyword mapping:**
- "my PRs", "authored", "I wrote" → `Authored by me`
- "reviews", "review requested", "reviewing" → `Requested reviews`
- "assigned" → `Assigned to me`
- "all", "involves me" → `All`
## Supported date range formats
The script understands natural language — pass it through as-is:
- `last 7 days`, `last 2 weeks`, `last 30 days`
- `this week`, `last week`, `this month`, `last month`
- `march 2026`, `feb 2025`
- `2026-01-01 - 2026-03-31`
- `2025` (whole year)
## After running
Tell the user the dashboard is opening in their browser. The script outputs progress to stdout. If it exits with an error, show the error output and suggest they run `gh auth login` if it's an auth issue.

View File

@@ -0,0 +1,131 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>PR Dashboard</title>
<style>
:root {
--bg:#ffffff; --bg2:#f6f8fa; --bg3:#eaeef2; --border:#d0d7de;
--text:#1f2328; --muted:#636c76; --link:#0969da;
--hover:#f3f4f6; --thead:#f6f8fa;
}
[data-theme="dark"] {
--bg:#0d1117; --bg2:#161b22; --bg3:#21262d; --border:#30363d;
--text:#e6edf3; --muted:#8b949e; --link:#58a6ff;
--hover:#1c2128; --thead:#21262d;
}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;font-size:1rem;background:var(--bg);color:var(--text);padding:24px 32px;transition:background .2s,color .2s}
h1{font-size:2rem;font-weight:800;margin-bottom:8px;letter-spacing:-.5px}
.meta{color:var(--muted);font-size:.875rem;margin-bottom:32px}
.stats{display:flex;gap:16px;margin-bottom:32px;flex-wrap:wrap}
.stat{background:var(--bg2);border:1px solid var(--border);border-radius:12px;padding:20px 28px;text-align:center;transition:all .2s;cursor:default}
.stat:hover{border-color:var(--link);transform:translateY(-2px);box-shadow:0 4px 12px rgba(0,0,0,.08)}
.stat .n{font-size:2.5rem;font-weight:800;line-height:1}.stat .l{font-size:.85rem;color:var(--muted);margin-top:6px;font-weight:500}
.stat.open .n{color:#1a7f37}.stat.merged .n{color:#8250df}.stat.closed .n{color:#cf222e}.stat.draft .n{color:#636c76}
[data-theme="dark"] .stat.open .n{color:#2ea043}[data-theme="dark"] .stat.merged .n{color:#8957e5}
[data-theme="dark"] .stat.closed .n{color:#da3633}[data-theme="dark"] .stat.draft .n{color:#848d97}
.toolbar{display:flex;gap:12px;margin-bottom:20px;align-items:center;flex-wrap:wrap;padding:12px;background:var(--bg2);border-radius:8px}
.toolbar input,.toolbar select{background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:8px 12px;color:var(--text);font-size:.95rem;outline:none;transition:border .2s}
.toolbar input{flex:1;min-width:200px}.toolbar input:focus,.toolbar select:focus{border-color:var(--link);box-shadow:0 0 0 3px rgba(9,105,218,.1)}
.visible-count{margin-left:auto;font-size:.875rem;color:var(--muted);font-weight:500}
.theme-toggle{background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:8px 14px;color:var(--text);font-size:.875rem;cursor:pointer;white-space:nowrap;transition:all .2s;font-weight:500}
.theme-toggle:hover{background:var(--bg3);border-color:var(--link)}
table{width:100%;border-collapse:collapse;background:var(--bg);border:1px solid var(--border);border-radius:10px;overflow:hidden;font-size:.95rem;box-shadow:0 1px 3px rgba(0,0,0,.05)}
thead{background:var(--thead)}
th{padding:14px 16px;text-align:left;font-size:.8rem;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.5px}
td{padding:16px;border-top:1px solid var(--border);vertical-align:top}
tr:last-child td{border-bottom:none}
tr:hover td{background:var(--hover)}
a{color:var(--link);text-decoration:none;font-weight:500}a:hover{text-decoration:underline}
.title-line{font-weight:600;font-size:1rem;margin-bottom:6px}
.badge{display:inline-block;padding:4px 10px;border-radius:14px;font-size:.75rem;font-weight:700;color:#fff;letter-spacing:.3px;text-transform:uppercase}
.empty{text-align:center;padding:60px 40px;color:var(--muted);font-size:1.1rem}
.controls{display:inline-flex;gap:6px;margin-left:8px}
.mini-btn{background:var(--bg2);border:1px solid var(--border);padding:4px 8px;border-radius:6px;font-size:.75rem;cursor:pointer;transition:all .2s}
.mini-btn:hover{background:var(--bg3);border-color:var(--link)}
</style>
</head>
<body>
<h1>🔀 PR Dashboard</h1>
<div class="meta">PR Dashboard</div>
<div class="stats">
<div class="stat open"><div class="n">0</div><div class="l">Open</div></div>
<div class="stat merged"><div class="n">0</div><div class="l">Merged</div></div>
<div class="stat closed"><div class="n">0</div><div class="l">Closed</div></div>
<div class="stat draft"><div class="n">0</div><div class="l">Draft</div></div>
</div>
<div class="toolbar">
<input type="search" id="q" placeholder="Search title or repo…" aria-label="Search pull requests" oninput="filter()">
<select id="sf" aria-label="Filter by status" onchange="filter()">
<option value="">All statuses</option>
<option>OPEN</option><option>MERGED</option><option>CLOSED</option><option>DRAFT</option>
</select>
<select id="rf" aria-label="Filter by review status" onchange="filter()">
<option value="">All reviews</option>
<option>APPROVED</option><option>CHANGES_REQUESTED</option><option>REVIEW_REQUIRED</option>
</select>
<span class="visible-count" id="vc">0 PRs</span>
<button id="exportMdBtn" class="theme-toggle" onclick="downloadMarkdown()">📄 Export MD</button>
<button class="theme-toggle" onclick="toggleTheme()" id="themeBtn">🌙 Dark</button>
</div>
<table>
<thead><tr><th>Repository</th><th>Title</th><th>Status</th><th>Review</th><th>CI</th><th>Created</th><th>Updated</th></tr></thead>
<tbody id="tb">
<tr>
<td colspan="7" style="text-align:center;color:var(--muted);">No pull request data loaded.</td>
</tr>
</tbody>
</table>
<script>const __md = "";
function filter(){
const q=document.getElementById("q").value.toLowerCase();
const sf=document.getElementById("sf").value;
const rf=document.getElementById("rf").value;
let n=0;
document.querySelectorAll("#tb tr").forEach(r=>{
const t=r.textContent.toLowerCase();
const badges=[...r.querySelectorAll(".badge")].map(b=>b.textContent);
const show=(!q||t.includes(q))&&(!sf||badges[0]===sf)&&(!rf||badges[1]===rf);
r.style.display=show?"":"none";
if(show)n++;
});
document.getElementById("vc").textContent=n+" PR"+(n!==1?"s":"");
}
function toggleTheme(){
const html=document.documentElement;
const isDark=html.getAttribute("data-theme")==="dark";
html.setAttribute("data-theme",isDark?"light":"dark");
document.getElementById("themeBtn").textContent=isDark?"🌙 Dark":"☀️ Light";
localStorage.setItem("pr-dashboard-theme",isDark?"light":"dark");
}
function downloadMarkdown(){
try{
const md = __md || '';
const blob = new Blob([md], { type: 'text/markdown;charset=utf-8' });
const filename = 'pr-dashboard.md';
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = filename; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url);
} catch (e) { console.error(e); }
}
// Restore saved theme preference
const saved=localStorage.getItem("pr-dashboard-theme");
if(saved==="dark"){
document.documentElement.setAttribute("data-theme","dark");
document.getElementById("themeBtn").textContent="☀️ Light";
}
</script>
</body>
</html>

View File

@@ -0,0 +1,121 @@
export function dateToYMD(d) {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
export function parseDateRange(text) {
const now = new Date();
const todayStr = dateToYMD(now);
const lower = (text || '').toLowerCase();
let match;
if ((match = lower.match(/last\s+(\d+)\s+days?/))) {
const n = parseInt(match[1], 10);
const start = new Date();
start.setDate(start.getDate() - (n - 1));
return { start: dateToYMD(start), end: todayStr, label: `Last ${n} days` };
}
if ((match = lower.match(/last\s+(\d+)\s+weeks?/))) {
const n = parseInt(match[1], 10);
const start = new Date();
start.setDate(start.getDate() - (n * 7 - 1));
return { start: dateToYMD(start), end: todayStr, label: `Last ${n} weeks` };
}
if (lower.includes('this week')) {
const d = new Date();
const day = d.getDay();
const diff = (day + 6) % 7;
const start = new Date();
start.setDate(start.getDate() - diff);
return { start: dateToYMD(start), end: todayStr, label: 'This week' };
}
if (lower.includes('last week')) {
const currentWeekStart = new Date();
const day = currentWeekStart.getDay();
const diff = (day + 6) % 7;
currentWeekStart.setDate(currentWeekStart.getDate() - diff);
const end = new Date(currentWeekStart);
end.setDate(end.getDate() - 1);
const start = new Date(end);
start.setDate(start.getDate() - 6);
return { start: dateToYMD(start), end: dateToYMD(end), label: 'Last week' };
}
if (lower.includes('this month')) {
const start = new Date(now.getFullYear(), now.getMonth(), 1);
return { start: dateToYMD(start), end: todayStr, label: 'This month' };
}
if (lower.includes('last month')) {
const start = new Date(now.getFullYear(), now.getMonth() - 1, 1);
const end = new Date(now.getFullYear(), now.getMonth(), 0);
return { start: dateToYMD(start), end: dateToYMD(end), label: 'Last month' };
}
if (lower.includes('next month')) {
const start = new Date(now.getFullYear(), now.getMonth() + 1, 1);
const end = new Date(now.getFullYear(), now.getMonth() + 2, 0);
return { start: dateToYMD(start), end: dateToYMD(end), label: 'Next month' };
}
// month-year like "february 2006" or "feb 2006"
if ((match = lower.match(/\b(january|february|march|april|may|june|july|august|september|october|november|december|jan|feb|mar|apr|may|jun|jul|aug|sep|sept|oct|nov|dec)\s+(\d{4})\b/))) {
const monthStr = match[1];
const year = parseInt(match[2], 10);
const months = { january:0, jan:0, february:1, feb:1, march:2, mar:2, april:3, apr:3, may:4, june:5, jun:5, july:6, jul:6, august:7, aug:7, september:8, sep:8, sept:8, october:9, oct:9, november:10, nov:10, december:11, dec:11 };
const mIdx = months[monthStr];
if (mIdx !== undefined && !isNaN(year)) {
const start = new Date(year, mIdx, 1);
const end = new Date(year, mIdx + 1, 0);
const label = `${monthStr.charAt(0).toUpperCase()}${monthStr.slice(1)} ${year}`;
return { start: dateToYMD(start), end: dateToYMD(end), label };
}
}
// explicit YYYY-MM-DD - YYYY-MM-DD or YYYY-MM-DD to YYYY-MM-DD
if ((match = text.match(/(\d{4}-\d{2}-\d{2})(?:\s*-\s*|\s+to\s+)(\d{4}-\d{2}-\d{2})/))) {
return { start: match[1], end: match[2], label: `${match[1]}${match[2]}` };
}
// explicit like "Apr 1 - Apr 5" or "Apr 1 to Apr 5"
if ((match = text.match(/([A-Za-z]{3,}\s+\d{1,2}(?:,\s*\d{4})?)(?:\s*-\s*|\s+to\s+)([A-Za-z]{3,}\s+\d{1,2}(?:,\s*\d{4})?)/))) {
const s = new Date(match[1]);
const e = new Date(match[2]);
if (!isNaN(s) && !isNaN(e)) {
return { start: dateToYMD(s), end: dateToYMD(e), label: `${match[1]}${match[2]}` };
}
}
// year-only like "2006"
if ((match = lower.match(/\b(\d{4})\b/))) {
const y = parseInt(match[1], 10);
const start = new Date(y, 0, 1);
const end = new Date(y, 11, 31);
return { start: dateToYMD(start), end: dateToYMD(end), label: `${y}` };
}
// month-only like "february" or "feb" (assume current year)
if ((match = lower.match(/\b(january|february|march|april|may|june|july|august|september|october|november|december|jan|feb|mar|apr|may|jun|jul|aug|sep|sept|oct|nov|dec)\b/))) {
const monthStr = match[1];
const months = { january:0, jan:0, february:1, feb:1, march:2, mar:2, april:3, apr:3, may:4, june:5, jun:5, july:6, jul:6, august:7, aug:7, september:8, sep:8, sept:8, october:9, oct:9, november:10, nov:10, december:11, dec:11 };
const mIdx = months[monthStr];
if (mIdx !== undefined) {
const start = new Date(now.getFullYear(), mIdx, 1);
const end = new Date(now.getFullYear(), mIdx + 1, 0);
const label = `${monthStr.charAt(0).toUpperCase()}${monthStr.slice(1)} ${now.getFullYear()}`;
return { start: dateToYMD(start), end: dateToYMD(end), label };
}
}
const start = new Date();
start.setDate(start.getDate() - 6);
return { start: dateToYMD(start), end: todayStr, label: 'Last 7 days' };
}

View 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 => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[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 => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[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);
}
})();