diff --git a/.github/extensions/maintainer-triage/extension.mjs b/.github/extensions/maintainer-triage/extension.mjs new file mode 100644 index 000000000..eafbd1055 --- /dev/null +++ b/.github/extensions/maintainer-triage/extension.mjs @@ -0,0 +1,627 @@ +import { createServer } from "node:http"; +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` + ); + const prs = ghExec( + `pr list --state open --limit 30 --json number,title,labels,author,createdAt,updatedAt,reviewDecision,reviewRequests,changedFiles,additions,deletions,isDraft,assignees` + ); + // 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. +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) { + if (res.destroyed || res.writableEnded) { + clients.delete(res); + continue; + } + try { + res.write(line); + } catch { + clients.delete(res); + } + } +} + +function closeSseClients(instanceId) { + const clients = sseClients.get(instanceId); + if (!clients) return; + for (const res of clients) { + if (!res.destroyed) res.destroy(); + } + clients.clear(); + sseClients.delete(instanceId); +} + +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 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 = typeof issue.comments === "number" + ? issue.comments + : Array.isArray(issue.comments) + ? issue.comments.length + : 0; + 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)}
+ +
+
`; +} + +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} + ${attention || reviewBadge(pr)} + ${labels} +
+
${escHtml(pr.title)}
+ +
+
`; +} + +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 refreshSummary(data) { + return { + issues: data.issues.length, + prs: data.prs.length, + merged: data.merged.length, + stale: data.stats.stale, + fetchedAt: data.fetchedAt, + }; +} + +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 ` + + + +Maintainer Triage · ${REPO} + + + +
+

Maintainer Triage · ${REPO}

+ + +
+ +
+ ${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} +
+
+
Pull Requests ${data.prs.length}
+ ${prCards} +
+
+ +
+
Merged last ${MERGED_WINDOW_DAYS} days ${data.merged.length}
+
+ ${mergedCards} +
+
+ + + +`; +} + +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, ...refreshSummary(cached) })); + } catch (err) { + console.error("Failed to refresh repo data:", err); + res.statusCode = 500; + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify({ ok: false, error: "Internal server error" })); + } + 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 response = await fetch(new URL("/api/refresh", entry.url), { method: "POST" }); + const result = await response.json(); + if (!response.ok) throw new CanvasError("refresh_failed", result.error || "Refresh failed."); + return result; + }, + }, + ], + 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); + closeSseClients(ctx.instanceId); + await new Promise((resolve) => entry.server.close(() => resolve())); + } + }, + }), + ], +});