diff --git a/.github/extensions/loc-breakdown/README.md b/.github/extensions/loc-breakdown/README.md new file mode 100644 index 00000000000..0209897b0b6 --- /dev/null +++ b/.github/extensions/loc-breakdown/README.md @@ -0,0 +1,60 @@ +# LOC breakdown canvas extension + +A Copilot CLI canvas extension that shows a breakdown of the lines of code +changed in the current branch, grouped by project/category, with a heuristic +characterization of each group (mechanical / detailed / careful review etc). + +![Screenshot of the LOC breakdown canvas](screenshot.png) + +## What it does + +When opened, the canvas: + +1. Resolves the merge-base of the current branch against `origin/HEAD` (or + `main`/`master` as a fallback). +2. Runs `git diff --numstat` between that merge-base and `HEAD`. +3. Groups files into buckets that reflect the Aspire repo layout — each + `src/`, `src/Components/`, `tests/`, + `playground/`, `tools/`, plus catch-all buckets for + `docs`, `eng`, `.github`, the VS Code extension, and `.agents`. +4. Sorts categories by total lines touched (added + removed), descending. +5. Tags each category with a short characterization derived from file types + and the add/remove ratio: + + | Tone | Examples | + |------|----------| + | Mechanical | localization, generated files, snapshots, assets, trivial edits | + | Documentation | `.md`/`.mdx`/`.txt` only | + | Detailed | tests, small additions, moderate edits | + | Careful | CI workflows, significant new code, large removals, big refactors | + +Rows are expandable to show the per-file added/removed counts within a +category. + +## Usage + +In any Copilot CLI session inside this repo, ask the agent to open the +"LOC breakdown" canvas (or any phrasing that implies wanting a per-project +summary of the diff). The canvas has a ↻ Refresh button and a `refresh` +action the agent can invoke to recompute the report. + +Optional inputs when opening the canvas: + +- `cwd` — working directory inside a git repo (defaults to the agent's cwd) +- `base` — base ref to diff against (defaults to `origin/HEAD`) +- `head` — head ref (defaults to `HEAD`) + +## Implementation notes + +- Single-file extension; no dependencies beyond the Node.js standard library + and `@github/copilot-sdk/extension`. +- Spins up a loopback HTTP server on an ephemeral port per canvas instance + and tears it down on close. +- The HTML shell fetches `/data` and renders client-side, so refresh is + cheap and doesn't require restarting the server. + +## Discovery + +Project-scoped extensions in `.github/extensions/` are picked up +automatically by the Copilot CLI for anyone working in this repo — no +install step is required. diff --git a/.github/extensions/loc-breakdown/extension.mjs b/.github/extensions/loc-breakdown/extension.mjs new file mode 100644 index 00000000000..1c7f60583c0 --- /dev/null +++ b/.github/extensions/loc-breakdown/extension.mjs @@ -0,0 +1,538 @@ +// Extension: loc-breakdown +// Canvas showing lines-of-code changes grouped by project/category, with a +// heuristic characterization (mechanical / detailed / careful review) per group. + +import { createServer } from "node:http"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { joinSession, createCanvas } from "@github/copilot-sdk/extension"; + +const execFileP = promisify(execFile); +const servers = new Map(); // instanceId -> { server, url, opts } + +// ---------- git plumbing ---------- + +async function git(cwd, args) { + const { stdout } = await execFileP("git", args, { + cwd, + maxBuffer: 64 * 1024 * 1024, + }); + return stdout; +} + +// Reject anything that could be parsed by git as an option, contain a path +// traversal into another revision range, or smuggle shell-like control chars. +// git's own rules already forbid most of these in real ref names +// (https://git-scm.com/docs/git-check-ref-format), so this is conservative. +// We allow `A...B` / `A..B` range syntax because callers may pass those. +function assertSafeRef(value, label) { + if (typeof value !== "string" || value.length === 0) { + throw new Error(`${label} must be a non-empty string`); + } + if (value.startsWith("-")) { + throw new Error(`${label} must not start with '-' (looks like a git option): ${value}`); + } + if (!/^[A-Za-z0-9._/@^~+\-]+(\.{2,3}[A-Za-z0-9._/@^~+\-]+)?$/.test(value)) { + throw new Error(`${label} contains characters not allowed in a git ref: ${value}`); + } +} + +async function detectRepoRoot(cwd) { + try { + return (await git(cwd, ["rev-parse", "--show-toplevel"])).trim(); + } catch { + return cwd; + } +} + +async function detectBase(cwd) { + // Prefer origin/HEAD (the default branch on the remote), then common names. + try { + const out = await git(cwd, ["symbolic-ref", "refs/remotes/origin/HEAD"]); + return out.trim().replace(/^refs\/remotes\//, ""); + } catch { + /* fall through */ + } + for (const ref of ["origin/main", "origin/master", "main", "master"]) { + try { + await git(cwd, ["rev-parse", "--verify", ref]); + return ref; + } catch { + /* try next */ + } + } + return "HEAD~1"; +} + +async function gatherDiff(cwd, base, head) { + let mergeBase; + try { + mergeBase = (await git(cwd, ["merge-base", base, head])).trim(); + } catch { + mergeBase = base; + } + // git diff --numstat outputs: \t\t + // For binary files: "-\t-\tpath". Path may include "{old => new}" for renames. + const out = await git(cwd, ["diff", "--numstat", `${mergeBase}...${head}`]); + const files = []; + for (const rawLine of out.split("\n")) { + const line = rawLine.trimEnd(); + if (!line) continue; + const tabIdx1 = line.indexOf("\t"); + const tabIdx2 = line.indexOf("\t", tabIdx1 + 1); + if (tabIdx1 < 0 || tabIdx2 < 0) continue; + const a = line.slice(0, tabIdx1); + const r = line.slice(tabIdx1 + 1, tabIdx2); + let file = line.slice(tabIdx2 + 1); + // Normalize rename syntax: "a/{old => new}/b" -> "a/new/b", and "old => new" -> "new". + file = file + .replace(/\{[^{}]*=>\s*([^{}]+)\}/g, "$1") + .replace(/^.+\s=>\s/, ""); + files.push({ + path: file, + added: a === "-" ? 0 : parseInt(a, 10) || 0, + removed: r === "-" ? 0 : parseInt(r, 10) || 0, + binary: a === "-" && r === "-", + }); + } + return { mergeBase, files }; +} + +// ---------- categorization ---------- + +// Group files into a meaningful "project" bucket. Tuned for the Aspire repo +// layout but falls back to top-level directory for anything unknown. +function categorize(filePath) { + const p = filePath.replace(/\\/g, "/"); + const parts = p.split("/"); + const top = parts[0]; + if (parts.length === 1) return "(repo root)"; + + if (top === "src") { + // src/Components/Aspire.X.Y/... -> "src/Components/Aspire.X.Y" + if (parts[1] === "Components" && parts.length > 2) { + return `src/Components/${parts[2]}`; + } + return `src/${parts[1]}`; + } + if (top === "tests") { + return `tests/${parts[1]}`; + } + if (top === "playground") { + return `playground/${parts[1]}`; + } + if (top === "tools") { + return `tools/${parts[1]}`; + } + if (top === ".github") return ".github (CI / automation)"; + if (top === "eng") return "eng (build infrastructure)"; + if (top === "docs") return "docs"; + if (top === "extension") return "extension (VS Code)"; + if (top === ".agents") return ".agents (skills)"; + return top; +} + +// ---------- characterization ---------- + +function characterize(files, addRatio, total) { + const reGenerated = /(?:\.Designer\.cs|\.g\.cs|\.generated\.cs|\/api\/.*\.cs|\.xlf)$/i; + const reLoc = /\.(resx|xlf)$/i; + const reDocs = /\.(md|mdx|txt)$/i; + const reCiYaml = /\.github\/.*\.ya?ml$/i; + const reSnapshot = /\.(verified|received)\.[A-Za-z0-9]+$/i; + const reTest = /(?:^|\/)tests?\//i; + const reAssets = /\.(svg|png|jpg|jpeg|gif|webp|ico)$/i; + + const allOf = (pred) => files.length > 0 && files.every(pred); + + if (allOf((f) => reLoc.test(f.path))) { + return { label: "Localization (mechanical)", tone: "mechanical" }; + } + if (allOf((f) => reGenerated.test(f.path) || reLoc.test(f.path))) { + return { label: "Generated files (mechanical)", tone: "mechanical" }; + } + if (allOf((f) => reSnapshot.test(f.path))) { + return { label: "Test snapshots (mechanical)", tone: "mechanical" }; + } + if (allOf((f) => reAssets.test(f.path))) { + return { label: "Assets (visual review)", tone: "mechanical" }; + } + if (allOf((f) => reDocs.test(f.path))) { + return { label: "Documentation (light review)", tone: "docs" }; + } + if (allOf((f) => reCiYaml.test(f.path))) { + return { label: "CI workflows (careful review)", tone: "careful" }; + } + if (allOf((f) => reTest.test(f.path))) { + return { label: "Tests (review for correctness & coverage)", tone: "detailed" }; + } + + if (total <= 10) { + return { label: "Trivial change (quick scan)", tone: "mechanical" }; + } + if (total <= 50) { + if (addRatio > 0.85) return { label: "Small additions (quick review)", tone: "detailed" }; + if (addRatio < 0.15) return { label: "Small cleanup (quick review)", tone: "mechanical" }; + return { label: "Small edit (quick review)", tone: "detailed" }; + } + if (total <= 250) { + if (addRatio > 0.85) return { label: "Net-new code (detailed review)", tone: "detailed" }; + if (addRatio < 0.15) return { label: "Notable removal (detailed review)", tone: "detailed" }; + return { label: "Moderate edit (detailed review)", tone: "detailed" }; + } + // > 250 + if (addRatio > 0.85) return { label: "Significant new code (careful review)", tone: "careful" }; + if (addRatio < 0.15) return { label: "Large removal (careful review)", tone: "careful" }; + return { label: "Significant refactor (careful review)", tone: "careful" }; +} + +// ---------- report build ---------- + +async function buildReport(opts) { + const cwd = await detectRepoRoot(opts.cwd); + if (opts.base !== undefined) assertSafeRef(opts.base, "base"); + if (opts.head !== undefined) assertSafeRef(opts.head, "head"); + const base = opts.base || (await detectBase(cwd)); + const head = opts.head || "HEAD"; + const branch = (await git(cwd, ["rev-parse", "--abbrev-ref", "HEAD"])).trim(); + const { mergeBase, files } = await gatherDiff(cwd, base, head); + + const groups = new Map(); + for (const f of files) { + const cat = categorize(f.path); + if (!groups.has(cat)) groups.set(cat, []); + groups.get(cat).push(f); + } + + const categories = [...groups.entries()].map(([name, fs]) => { + const added = fs.reduce((s, f) => s + f.added, 0); + const removed = fs.reduce((s, f) => s + f.removed, 0); + // git's line-based diff has no real notion of "changed" lines — a modified + // line shows up as 1 added + 1 removed. Approximate "changed" per file as + // min(added, removed), then sum, which is a closer proxy than doing it + // category-wide. + const changed = fs.reduce((s, f) => s + Math.min(f.added, f.removed), 0); + const total = added + removed; + const addRatio = total > 0 ? added / total : 0; + return { + name, + files: fs.length, + added, + removed, + changed, + total, + characterization: characterize(fs, addRatio, total), + filePaths: fs + .slice() + .sort((a, b) => b.added + b.removed - (a.added + a.removed)) + .map((f) => ({ path: f.path, added: f.added, removed: f.removed, binary: f.binary })), + }; + }); + categories.sort((a, b) => b.total - a.total); + + const totals = { + files: files.length, + added: categories.reduce((s, c) => s + c.added, 0), + removed: categories.reduce((s, c) => s + c.removed, 0), + changed: categories.reduce((s, c) => s + c.changed, 0), + }; + + return { + cwd, + branch, + base, + head, + mergeBase, + generatedAt: new Date().toISOString(), + totals, + categories, + }; +} + +// ---------- HTML ---------- + +function renderHtml() { + // The shell fetches /data on load and on refresh; rendering happens client-side. + return ` + + + +LOC breakdown + + + +
+
+

Lines of code changed by category

+
Loading…
+
+ +
+
+
Loading…
+ + + +`; +} + +// ---------- server wiring ---------- + +async function startServer(opts) { + const server = createServer(async (req, res) => { + try { + const url = new URL(req.url, "http://127.0.0.1"); + if (url.pathname === "/data") { + const report = await buildReport(opts); + res.setHeader("Content-Type", "application/json; charset=utf-8"); + res.setHeader("Cache-Control", "no-store"); + res.end(JSON.stringify(report)); + return; + } + res.setHeader("Content-Type", "text/html; charset=utf-8"); + res.setHeader("Cache-Control", "no-store"); + res.end(renderHtml()); + } catch (err) { + res.statusCode = 500; + res.setHeader("Content-Type", "text/plain; charset=utf-8"); + res.end(String(err && err.stack ? err.stack : err)); + } + }); + 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: "loc-breakdown", + displayName: "LOC breakdown", + description: "Lines of code changed grouped by project/category with review characterization.", + inputSchema: { + type: "object", + properties: { + cwd: { type: "string", description: "Working directory inside the target git repo. Defaults to the canvas process cwd." }, + base: { type: "string", description: "Base ref to diff against. Defaults to origin/HEAD." }, + head: { type: "string", description: "Head ref. Defaults to HEAD." }, + }, + }, + actions: [ + { + name: "refresh", + description: "Recompute the LOC breakdown by re-running git diff and return the latest report as JSON.", + handler: async (ctx) => { + const entry = servers.get(ctx.instanceId); + const opts = entry ? entry.opts : { cwd: process.cwd() }; + const report = await buildReport(opts); + return { + ok: true, + totals: report.totals, + categories: report.categories.map((c) => ({ + name: c.name, + files: c.files, + added: c.added, + removed: c.removed, + changed: c.changed, + characterization: c.characterization.label, + })), + }; + }, + }, + ], + open: async (ctx) => { + const input = ctx.input || {}; + const opts = { + cwd: input.cwd || process.cwd(), + base: input.base, + head: input.head, + }; + let entry = servers.get(ctx.instanceId); + if (!entry) { + const started = await startServer(opts); + entry = { ...started, opts }; + servers.set(ctx.instanceId, entry); + } + return { title: "LOC breakdown", url: entry.url }; + }, + onClose: async (ctx) => { + const entry = servers.get(ctx.instanceId); + if (entry) { + servers.delete(ctx.instanceId); + await new Promise((resolve) => entry.server.close(() => resolve())); + } + }, + }), + ], +}); diff --git a/.github/extensions/loc-breakdown/screenshot.png b/.github/extensions/loc-breakdown/screenshot.png new file mode 100644 index 00000000000..61721f58633 Binary files /dev/null and b/.github/extensions/loc-breakdown/screenshot.png differ