Pr Dashboard
GitHubOfficialFirst committed by jamcgrathDistributed by GitHub
- Skill
- Tool integration
- Software development
- MIT
- Low risk
- Has scripts
AutoGineer summary
Written by AutoGineer from the skill’s own files, not by the author
The PR Dashboard skill provides a streamlined way to view GitHub pull requests by leveraging the GitHub CLI. It interprets natural language requests to filter PRs by date range and user role, then launches a browser-based dashboard. It supports various timeframes, including relative dates, specific months, or years, and maps user intent to roles such as authored, requested reviews, or assigned PRs.
What it does
- Parses natural language queries to identify date ranges and PR roles.
- Maps user intent to specific GitHub PR categories like authored, assigned, or requested reviews.
- Executes a CLI script to generate and open a browser-based dashboard.
- Supports flexible date formats including relative time, specific months, and date ranges.
- Provides feedback on the status of the dashboard launch process.
When to use it
- When you need to quickly review your authored pull requests from the last week.
- When you want to see all pull requests assigned to you for a specific month.
- When you need to check the status of pull requests you have been requested to review.
- When you want to aggregate all pull requests involving you over a custom date range.
Advantages
- Reduces manual navigation time within the GitHub web interface.
- Supports intuitive natural language date and role filtering.
- Integrates directly with the GitHub CLI for secure authentication.
- Provides a focused view of relevant PRs based on your specific workflow needs.
Usage examples
- “show my PRs from last month”
- The agent identifies the role as 'Authored by me' and the date range as 'last month', then opens the corresponding GitHub dashboard.
- “open PR dashboard for reviews requested in march 2026”
- The agent maps the request to 'Requested reviews' for 'march 2026' and launches the filtered dashboard in your browser.
Install
Download the skill as a ZIP:
https://autogineer.com/agent-skills/github/pr-dashboard/downloadUnzip it into the cross-client skills folder of your project:
mkdir -p .agents/skills && unzip pr-dashboard.zip -d .agents/skills
Documentation by jamcgrath
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:
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 ofAuthored 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 daysthis week,last week,this month,last monthmarch 2026,feb 20252026-01-01 - 2026-03-312025(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.
Files
- ATTRIBUTION.mdAdded by AutoGineer859 B
- LICENSELicence file added by AutoGineer1.0 KiB
- SKILL.md2.1 KiB
- assets/dashboard.html7.0 KiB
- scripts/lib/utils.mjsScript5.0 KiB
- scripts/pr-dashboard-cli.mjsScript13.1 KiB
scripts/pr-dashboard-cli.mjs
#!/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);
}
})();
Requirements and compatibility
Written for: GitHub Copilot
Works with: Claude Code, Claude apps, OpenAI Codex, GitHub Copilot, Cursor, Gemini CLI, Kiro, OpenCode, Goose, Amp, Roo Code, JetBrains Junie, Factory Droid, Devin (ex-Windsurf), Mistral Vibe, OpenHands, Letta
“Works with” means the tool reads the open Agent Skills format; a skill written for one tool may use features others lack.
Needs: Needs Node.js
Security
What our scanner noticed· Low risk
- Scripts that your agent may run
This skill includes scripts that your agent may run. Read them before you use it.
A static scan is not a guarantee. Skills are third-party content: review a skill before you let an agent use it.
Attribution and licence
- First committed by
jamcgrath
- Publisher
- GitHub (Official)
- Distributed by
- GitHub
- Source
- github/awesome-copilot · skills/pr-dashboard · commit 797337f
- Licence
- MIT License (redistribution allowed)
- LICENSE (added by AutoGineer from LICENSE)
- ATTRIBUTION.md (written by AutoGineer)
- Synced
- Contributors
- jamcgrath
AutoGineer is not affiliated with or endorsed by jamcgrath or GitHub.
Download ZIP
The ZIP holds the skill’s files, its licence files and an ATTRIBUTION.md. Download pr-dashboard.zip