mirror of
https://github.com/github/awesome-copilot.git
synced 2026-05-01 12:45:56 +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:
54
skills/pr-dashboard/SKILL.md
Normal file
54
skills/pr-dashboard/SKILL.md
Normal 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.
|
||||
|
||||
131
skills/pr-dashboard/assets/dashboard.html
Normal file
131
skills/pr-dashboard/assets/dashboard.html
Normal 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>
|
||||
|
||||
121
skills/pr-dashboard/scripts/lib/utils.mjs
Normal file
121
skills/pr-dashboard/scripts/lib/utils.mjs
Normal 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' };
|
||||
}
|
||||
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