From 9347dfde477acf2ae5064ab1ac691326d9e95970 Mon Sep 17 00:00:00 2001 From: Andrea Griffiths Date: Tue, 9 Jun 2026 17:25:40 -0400 Subject: [PATCH 1/4] Add maintainer-triage canvas extension Live triage board for github/maintainermonth. Shows open issues and PRs with labels, review status, assignees, and relative ages. Calls the gh CLI at open time to fetch live data; a refresh action (also wired to a Refresh button in the UI) re-fetches and pushes a reload via SSE. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../maintainer-triage/extension.mjs | 481 ++++++++++++++++++ 1 file changed, 481 insertions(+) create mode 100644 .github/extensions/maintainer-triage/extension.mjs diff --git a/.github/extensions/maintainer-triage/extension.mjs b/.github/extensions/maintainer-triage/extension.mjs new file mode 100644 index 000000000..feba75a16 --- /dev/null +++ b/.github/extensions/maintainer-triage/extension.mjs @@ -0,0 +1,481 @@ +import { createServer } from "node:http"; +import { execSync } from "node:child_process"; +import { joinSession, createCanvas, CanvasError } from "@github/copilot-sdk/extension"; + +const REPO = "github/maintainermonth"; + +function ghExec(args) { + return JSON.parse(execSync(`gh ${args} --repo ${REPO}`, { encoding: "utf8", timeout: 15000 })); +} + +function fetchRepoData() { + const issues = ghExec( + `issue list --state open --limit 50 --json number,title,labels,author,createdAt,updatedAt,assignees,comments` + ); + const prs = ghExec( + `pr list --state open --limit 30 --json number,title,labels,author,createdAt,updatedAt,reviewDecision,reviewRequests,changedFiles,additions,deletions,isDraft,assignees` + ); + return { issues, prs, fetchedAt: new Date().toISOString() }; +} + +// SSE clients per instance so /api/refresh can push updates to the iframe. +const sseClients = new Map(); // instanceId -> Set + +function broadcast(instanceId, payload) { + const clients = sseClients.get(instanceId); + if (!clients) return; + const line = `data: ${JSON.stringify(payload)}\n\n`; + for (const res of clients) res.write(line); +} + +function labelChip(label) { + const bg = `#${label.color}`; + // Decide text color based on perceived brightness of the hex background. + const r = parseInt(label.color.slice(0, 2), 16); + const g = parseInt(label.color.slice(2, 4), 16); + const b = parseInt(label.color.slice(4, 6), 16); + const brightness = (r * 299 + g * 587 + b * 114) / 1000; + const fg = brightness > 128 ? "#24292e" : "#ffffff"; + return `${escHtml(label.name)}`; +} + +function escHtml(str) { + return String(str) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function reviewBadge(pr) { + if (pr.isDraft) return `Draft`; + if (pr.reviewDecision === "APPROVED") return `Approved`; + if (pr.reviewDecision === "CHANGES_REQUESTED") return `Changes requested`; + if (pr.reviewRequests && pr.reviewRequests.length > 0) { + const names = pr.reviewRequests.map((r) => escHtml(r.login)).join(", "); + return `Review: ${names}`; + } + return `No reviewers`; +} + +function issueCard(issue) { + const labels = (issue.labels || []).map(labelChip).join(" "); + const commentCount = Array.isArray(issue.comments) ? issue.comments.length : 0; + const assigned = (issue.assignees || []).map((a) => escHtml(a.login)).join(", ") || "—"; + return ` + +
+
+ #${issue.number} + ${labels} +
+
${escHtml(issue.title)}
+ +
+
`; +} + +function prCard(pr) { + const labels = (pr.labels || []).map(labelChip).join(" "); + const diffSign = `+${pr.additions} -${pr.deletions}`; + return ` + +
+
+ #${pr.number} + ${reviewBadge(pr)} + ${labels} +
+
${escHtml(pr.title)}
+ +
+
`; +} + +function renderHtml(data) { + const issueCards = data.issues.length + ? data.issues.map(issueCard).join("") + : `

No open issues.

`; + const prCards = data.prs.length + ? data.prs.map(prCard).join("") + : `

No open pull requests.

`; + + return ` + + + +Maintainer Triage · ${REPO} + + + +
+

Maintainer Triage · ${REPO}

+ + +
+
+
+
+ Issues ${data.issues.length} +
+
${issueCards}
+
+
+
+ Pull Requests ${data.prs.length} +
+
${prCards}
+
+
+ + +`; +} + +const servers = new Map(); + +async function startServer(instanceId) { + let cached = fetchRepoData(); + + const server = createServer((req, res) => { + const url = new URL(req.url, "http://127.0.0.1"); + + if (url.pathname === "/events") { + res.setHeader("Content-Type", "text/event-stream"); + res.setHeader("Cache-Control", "no-cache"); + res.setHeader("Connection", "keep-alive"); + res.setHeader("Access-Control-Allow-Origin", "*"); + res.write(": connected\n\n"); + if (!sseClients.has(instanceId)) sseClients.set(instanceId, new Set()); + sseClients.get(instanceId).add(res); + req.on("close", () => sseClients.get(instanceId)?.delete(res)); + return; + } + + if (url.pathname === "/api/refresh" && req.method === "POST") { + try { + cached = fetchRepoData(); + broadcast(instanceId, { type: "reload" }); + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify({ ok: true, fetchedAt: cached.fetchedAt })); + } catch (err) { + res.statusCode = 500; + res.end(JSON.stringify({ ok: false, error: String(err) })); + } + return; + } + + // Default: serve the triage page (re-renders with current cached data). + res.setHeader("Content-Type", "text/html; charset=utf-8"); + res.end(renderHtml(cached)); + }); + + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + const port = typeof address === "object" && address ? address.port : 0; + return { server, url: `http://127.0.0.1:${port}/` }; +} + +await joinSession({ + canvases: [ + createCanvas({ + id: "maintainer-triage", + displayName: "Maintainer Triage", + description: "Live triage board for github/maintainermonth: open issues and pull requests with labels, review status, and age.", + actions: [ + { + name: "refresh", + description: "Re-fetch open issues and PRs from GitHub and push updated data to the canvas.", + handler: async (ctx) => { + const entry = servers.get(ctx.instanceId); + if (!entry) throw new CanvasError("not_open", "Canvas is not open."); + const data = fetchRepoData(); + broadcast(ctx.instanceId, { type: "reload" }); + return { + issues: data.issues.length, + prs: data.prs.length, + fetchedAt: data.fetchedAt, + }; + }, + }, + ], + open: async (ctx) => { + let entry = servers.get(ctx.instanceId); + if (!entry) { + entry = await startServer(ctx.instanceId); + servers.set(ctx.instanceId, entry); + } + return { title: "Maintainer Triage · github/maintainermonth", url: entry.url }; + }, + onClose: async (ctx) => { + const entry = servers.get(ctx.instanceId); + if (entry) { + servers.delete(ctx.instanceId); + sseClients.delete(ctx.instanceId); + await new Promise((resolve) => entry.server.close(() => resolve())); + } + }, + }), + ], +}); From bc722fe9c2f59dba202d309f60fbac5bb677c6fb Mon Sep 17 00:00:00 2001 From: Andrea Griffiths Date: Tue, 9 Jun 2026 17:30:12 -0400 Subject: [PATCH 2/4] Improve maintainer-triage canvas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Stats bar: open issues · open PRs · stale count · merged this week - Staleness left-border: transparent (fresh <3d), yellow (aging 3-7d), red (stale >7d) so problem items are visible instantly - Attention flags: 'Unassigned' badge on issues with no assignee, 'No reviewers' badge on PRs with no review requested — both floated to top of their column - Recently merged section: PRs merged in the last 14 days shown below the triage columns so you see what shipped - Staleness legend explaining the color coding Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../maintainer-triage/extension.mjs | 307 ++++++++++++------ 1 file changed, 210 insertions(+), 97 deletions(-) diff --git a/.github/extensions/maintainer-triage/extension.mjs b/.github/extensions/maintainer-triage/extension.mjs index feba75a16..dedafbf3f 100644 --- a/.github/extensions/maintainer-triage/extension.mjs +++ b/.github/extensions/maintainer-triage/extension.mjs @@ -3,11 +3,25 @@ import { execSync } from "node:child_process"; import { joinSession, createCanvas, CanvasError } from "@github/copilot-sdk/extension"; const REPO = "github/maintainermonth"; +const STALE_DAYS = 7; +const AGING_DAYS = 3; +const MERGED_WINDOW_DAYS = 14; function ghExec(args) { return JSON.parse(execSync(`gh ${args} --repo ${REPO}`, { encoding: "utf8", timeout: 15000 })); } +function daysSince(iso) { + return (Date.now() - new Date(iso).getTime()) / 86400000; +} + +function stalenessClass(updatedAt) { + const age = daysSince(updatedAt); + if (age > STALE_DAYS) return "stale"; + if (age > AGING_DAYS) return "aging"; + return "fresh"; +} + function fetchRepoData() { const issues = ghExec( `issue list --state open --limit 50 --json number,title,labels,author,createdAt,updatedAt,assignees,comments` @@ -15,7 +29,39 @@ function fetchRepoData() { const prs = ghExec( `pr list --state open --limit 30 --json number,title,labels,author,createdAt,updatedAt,reviewDecision,reviewRequests,changedFiles,additions,deletions,isDraft,assignees` ); - return { issues, prs, fetchedAt: new Date().toISOString() }; + // Merged PRs from the last MERGED_WINDOW_DAYS days + const since = new Date(Date.now() - MERGED_WINDOW_DAYS * 86400000).toISOString().slice(0, 10); + const merged = ghExec( + `pr list --state merged --search "merged:>${since}" --limit 20 --json number,title,author,mergedAt,labels,changedFiles,additions,deletions` + ); + + // Sort: needs-attention items first (no reviewer for PRs, no assignee for issues) + issues.sort((a, b) => { + const aNeedsAttention = a.assignees.length === 0 ? 0 : 1; + const bNeedsAttention = b.assignees.length === 0 ? 0 : 1; + return aNeedsAttention - bNeedsAttention; + }); + prs.sort((a, b) => { + const aNeedsReview = !a.isDraft && !a.reviewDecision && a.reviewRequests.length === 0 ? 0 : 1; + const bNeedsReview = !b.isDraft && !b.reviewDecision && b.reviewRequests.length === 0 ? 0 : 1; + return aNeedsReview - bNeedsReview; + }); + + const staleIssues = issues.filter((i) => daysSince(i.updatedAt) > STALE_DAYS).length; + const stalePrs = prs.filter((p) => daysSince(p.updatedAt) > STALE_DAYS).length; + + return { + issues, + prs, + merged, + stats: { + openIssues: issues.length, + openPrs: prs.length, + stale: staleIssues + stalePrs, + mergedRecently: merged.length, + }, + fetchedAt: new Date().toISOString(), + }; } // SSE clients per instance so /api/refresh can push updates to the iframe. @@ -58,22 +104,39 @@ function reviewBadge(pr) { return `No reviewers`; } +function attentionFlag(item, type) { + if (type === "issue") { + if (item.assignees.length === 0) return `Unassigned`; + const hasNeedsInfo = (item.labels || []).some((l) => l.name === "needs-info"); + if (hasNeedsInfo) return `Needs info`; + } + if (type === "pr") { + if (!item.isDraft && !item.reviewDecision && item.reviewRequests.length === 0) { + return `No reviewers`; + } + } + return ""; +} + function issueCard(issue) { const labels = (issue.labels || []).map(labelChip).join(" "); const commentCount = Array.isArray(issue.comments) ? issue.comments.length : 0; - const assigned = (issue.assignees || []).map((a) => escHtml(a.login)).join(", ") || "—"; + const assigned = (issue.assignees || []).map((a) => escHtml(a.login)).join(", "); + const staleness = stalenessClass(issue.updatedAt); + const attention = attentionFlag(issue, "issue"); return ` -
+
#${issue.number} + ${attention} ${labels}
${escHtml(issue.title)}
@@ -83,12 +146,14 @@ function issueCard(issue) { function prCard(pr) { const labels = (pr.labels || []).map(labelChip).join(" "); const diffSign = `+${pr.additions} -${pr.deletions}`; + const staleness = stalenessClass(pr.updatedAt); + const attention = attentionFlag(pr, "pr"); return `
-
+
#${pr.number} - ${reviewBadge(pr)} + ${attention || reviewBadge(pr)} ${labels}
${escHtml(pr.title)}
@@ -101,13 +166,40 @@ function prCard(pr) {
`; } +function mergedCard(pr) { + const diffSign = `+${pr.additions} -${pr.deletions}`; + return ` + +
+
+ #${pr.number} + Merged +
+
${escHtml(pr.title)}
+ +
+
`; +} + +function statPill(value, label, urgent) { + return `${value} ${label}`; +} + function renderHtml(data) { + const { stats } = data; const issueCards = data.issues.length ? data.issues.map(issueCard).join("") : `

No open issues.

`; const prCards = data.prs.length ? data.prs.map(prCard).join("") : `

No open pull requests.

`; + const mergedCards = data.merged.length + ? data.merged.map(mergedCard).join("") + : `

Nothing merged in the last ${MERGED_WINDOW_DAYS} days.

`; return ` @@ -123,14 +215,13 @@ function renderHtml(data) { font-family: var(--font-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif); font-size: var(--text-body-medium, 14px); line-height: var(--leading-body-medium, 20px); - padding: 0; } header { display: flex; align-items: center; gap: 8px; - padding: 12px 16px; + padding: 10px 16px; border-bottom: 1px solid var(--border-color-default, #d0d7de); background: var(--background-color-default, #ffffff); position: sticky; @@ -139,25 +230,19 @@ function renderHtml(data) { } header h1 { - font-size: var(--text-body-medium, 14px); + font-size: 13px; font-weight: var(--font-weight-semibold, 600); flex: 1; color: var(--text-color-default, #1f2328); } - header h1 span { - font-weight: 400; - color: var(--text-color-muted, #636c76); - } + header h1 span { font-weight: 400; color: var(--text-color-muted, #636c76); } - #last-updated { - font-size: 12px; - color: var(--text-color-muted, #636c76); - } + #last-updated { font-size: 11px; color: var(--text-color-muted, #636c76); } button#refresh-btn { - padding: 4px 10px; - font-size: 12px; + padding: 3px 9px; + font-size: 11px; font-family: inherit; cursor: pointer; border: 1px solid var(--border-color-default, #d0d7de); @@ -165,35 +250,48 @@ function renderHtml(data) { background: var(--background-color-default, #ffffff); color: var(--text-color-default, #1f2328); } + button#refresh-btn:hover { background: var(--background-color-subtle, #f6f8fa); } + button#refresh-btn:disabled { opacity: 0.5; cursor: default; } - button#refresh-btn:hover { + /* Stats bar */ + .stats-bar { + display: flex; + gap: 6px; + flex-wrap: wrap; + padding: 8px 16px; + border-bottom: 1px solid var(--border-color-default, #d0d7de); background: var(--background-color-subtle, #f6f8fa); } - button#refresh-btn:disabled { - opacity: 0.5; - cursor: default; + .stat-pill { + font-size: 11px; + color: var(--text-color-muted, #636c76); + background: var(--background-color-default, #ffffff); + border: 1px solid var(--border-color-default, #d0d7de); + border-radius: 12px; + padding: 2px 8px; } + .stat-pill strong { color: var(--text-color-default, #1f2328); } + .stat-pill--urgent { border-color: #cf222e; } + .stat-pill--urgent strong { color: #cf222e; } + /* Layout */ .grid { display: grid; grid-template-columns: 1fr 1fr; - gap: 0; - min-height: calc(100vh - 45px); } .column { - padding: 12px 16px; + padding: 12px 14px; border-right: 1px solid var(--border-color-default, #d0d7de); } - .column:last-child { border-right: none; } .column-header { - font-size: 12px; + font-size: 11px; font-weight: var(--font-weight-semibold, 600); text-transform: uppercase; - letter-spacing: 0.05em; + letter-spacing: 0.06em; color: var(--text-color-muted, #636c76); margin-bottom: 10px; display: flex; @@ -216,111 +314,111 @@ function renderHtml(data) { color: var(--text-color-default, #1f2328); } - .card-link { - text-decoration: none; - color: inherit; - display: block; - margin-bottom: 8px; - } + /* Cards */ + .card-link { text-decoration: none; color: inherit; display: block; margin-bottom: 7px; } .card { border: 1px solid var(--border-color-default, #d0d7de); border-radius: 6px; - padding: 10px 12px; + padding: 9px 11px 9px 14px; background: var(--background-color-default, #ffffff); - transition: border-color 0.15s; + border-left-width: 3px; + border-left-color: transparent; + transition: border-color 0.12s; } - .card-link:hover .card { - border-color: var(--color-accent-fg, #0969da); - } + .card-link:hover .card { border-color: var(--color-accent-fg, #0969da); } + + /* Staleness border */ + .card--fresh { border-left-color: transparent; } + .card--aging { border-left-color: #d4a72c; } + .card--stale { border-left-color: #cf222e; } + .card--merged { border-left-color: #8250df; opacity: 0.75; } .card-meta { display: flex; align-items: center; flex-wrap: wrap; - gap: 5px; - margin-bottom: 4px; - } - - .item-number { - font-size: 12px; - color: var(--text-color-muted, #636c76); - font-weight: var(--font-weight-semibold, 600); - flex-shrink: 0; + gap: 4px; + margin-bottom: 3px; } + .item-number { font-size: 11px; color: var(--text-color-muted, #636c76); font-weight: 600; flex-shrink: 0; } .labels { display: flex; flex-wrap: wrap; gap: 3px; } .label-chip { display: inline-block; padding: 1px 6px; border-radius: 12px; - font-size: 11px; + font-size: 10px; font-weight: 500; line-height: 18px; white-space: nowrap; } .card-title { - font-size: 13px; + font-size: 12px; font-weight: var(--font-weight-semibold, 600); color: var(--text-color-default, #1f2328); - margin-bottom: 6px; + margin-bottom: 5px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } - .card-footer { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 5px; - } + .card-footer { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; } - .avatar-chip { - font-size: 11px; - font-weight: 500; - color: var(--text-color-muted, #636c76); - } + .avatar-chip { font-size: 11px; font-weight: 500; color: var(--text-color-muted, #636c76); } - .meta-pill { - font-size: 11px; - color: var(--text-color-muted, #636c76); - } - - .meta-pill:not(:first-child)::before { - content: "·"; - margin-right: 5px; - color: var(--border-color-default, #d0d7de); - } + .meta-pill { font-size: 11px; color: var(--text-color-muted, #636c76); } + .meta-pill:not(:first-child)::before { content: "·"; margin-right: 4px; color: var(--border-color-default, #d0d7de); } .add { color: #1a7f37; font-weight: 500; } .del { color: #cf222e; font-weight: 500; } .badge { display: inline-block; - padding: 1px 7px; + padding: 1px 6px; border-radius: 12px; - font-size: 11px; + font-size: 10px; font-weight: 500; line-height: 18px; white-space: nowrap; } - .badge.approved { background: #dafbe1; color: #1a7f37; } - .badge.changes { background: #fff0b3; color: #9a6700; } - .badge.pending { background: #ddf4ff; color: #0969da; } - .badge.draft { background: var(--background-color-subtle, #f6f8fa); color: var(--text-color-muted, #636c76); border: 1px solid var(--border-color-default, #d0d7de); } - .badge.no-review { background: var(--background-color-subtle, #f6f8fa); color: var(--text-color-muted, #636c76); border: 1px solid var(--border-color-default, #d0d7de); } + .badge.approved { background: #dafbe1; color: #1a7f37; } + .badge.changes { background: #fff0b3; color: #9a6700; } + .badge.pending { background: #ddf4ff; color: #0969da; } + .badge.merged { background: #fbefff; color: #8250df; } + .badge.draft { background: var(--background-color-subtle, #f6f8fa); color: var(--text-color-muted, #636c76); border: 1px solid var(--border-color-default, #d0d7de); } + .badge.no-review { background: #fff8c5; color: #9a6700; } + + /* Recently merged section */ + .merged-section { + border-top: 1px solid var(--border-color-default, #d0d7de); + padding: 12px 14px; + } - .empty { - color: var(--text-color-muted, #636c76); - font-size: 13px; - padding: 8px 0; + .merged-section .column-header { margin-bottom: 10px; } + + .empty { color: var(--text-color-muted, #636c76); font-size: 12px; padding: 6px 0; } + + /* Staleness legend */ + .legend { + display: flex; + gap: 10px; + padding: 6px 16px; + border-bottom: 1px solid var(--border-color-default, #d0d7de); + background: var(--background-color-subtle, #f6f8fa); } + .legend-item { display: flex; align-items: center; gap: 5px; font-size: 11px; color: var(--text-color-muted, #636c76); } + .legend-dot { + width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; + } + .legend-dot--fresh { background: var(--border-color-default, #d0d7de); } + .legend-dot--aging { background: #d4a72c; } + .legend-dot--stale { background: #cf222e; } @@ -329,20 +427,38 @@ function renderHtml(data) { + +
+ ${statPill(stats.openIssues, "open issue" + (stats.openIssues !== 1 ? "s" : ""), false)} + ${statPill(stats.openPrs, "open PR" + (stats.openPrs !== 1 ? "s" : ""), false)} + ${statPill(stats.stale, "stale", stats.stale > 0)} + ${statPill(stats.mergedRecently, `merged (${MERGED_WINDOW_DAYS}d)`, false)} +
+ +
+ fresh (<${AGING_DAYS}d) + aging (${AGING_DAYS}–${STALE_DAYS}d) + stale (>${STALE_DAYS}d) +
+
-
-
- Issues ${data.issues.length} -
-
${issueCards}
+
+
Issues ${data.issues.length}
+ ${issueCards}
-
-
- Pull Requests ${data.prs.length} -
-
${prCards}
+
+
Pull Requests ${data.prs.length}
+ ${prCards} +
+
+ +
+
Merged last ${MERGED_WINDOW_DAYS} days ${data.merged.length}
+
+ ${mergedCards}
+