From 29a1882c04f79a0bb5c867a796cc07cd9043978a Mon Sep 17 00:00:00 2001 From: Jean-Dominique Stepek Date: Thu, 19 Mar 2026 17:36:43 -0400 Subject: [PATCH] feat(gsd): add /gsd changelog command with LLM-summarized release notes (#1465) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a new /gsd changelog command that fetches releases from the GitHub API, filters by version, and sends the raw changelog into the conversation for the LLM to summarize the most important changes. - New changelog.ts module: GitHub API fetch, semver filtering, body parsing - Routing block in commands.ts with lazy import (same pattern as forensics) - Tab completion in commands-bootstrap.ts TOP_LEVEL_SUBCOMMANDS - Help text under VISIBILITY section in showHelp() - No new npm dependencies — uses built-in fetch() --- src/resources/extensions/gsd/changelog.ts | 213 ++++++++++++++++++ .../extensions/gsd/commands-bootstrap.ts | 1 + src/resources/extensions/gsd/commands.ts | 10 +- 3 files changed, 223 insertions(+), 1 deletion(-) create mode 100644 src/resources/extensions/gsd/changelog.ts diff --git a/src/resources/extensions/gsd/changelog.ts b/src/resources/extensions/gsd/changelog.ts new file mode 100644 index 000000000..b90df2b6b --- /dev/null +++ b/src/resources/extensions/gsd/changelog.ts @@ -0,0 +1,213 @@ +/** + * GSD Changelog — Fetch and display categorized release notes from GitHub + * + * Fetches releases from the gsd-build/gsd-2 GitHub repository, + * prompts the user for a version filter, and sends raw release notes + * into the conversation for the LLM to summarize. + * + * Entry point: handleChangelog() called from commands.ts + */ + +import type { ExtensionAPI, ExtensionCommandContext } from "@gsd/pi-coding-agent"; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +interface GitHubRelease { + tag_name: string; + name: string; + body: string; +} + +// ─── Semver comparison ──────────────────────────────────────────────────────── + +function compareSemver(a: string, b: string): number { + const pa = a.split(".").map(Number); + const pb = b.split(".").map(Number); + for (let i = 0; i < Math.max(pa.length, pb.length); i++) { + const va = pa[i] || 0; + const vb = pb[i] || 0; + if (va > vb) return 1; + if (va < vb) return -1; + } + return 0; +} + +function stripV(tag: string): string { + return tag.startsWith("v") ? tag.slice(1) : tag; +} + +// ─── Body parsing ───────────────────────────────────────────────────────────── + +interface CategorySection { + heading: string; + content: string; +} + +function parseReleaseBody(body: string): CategorySection[] { + if (!body) return []; + + const sections: CategorySection[] = []; + const lines = body.split("\n"); + let currentHeading: string | null = null; + let currentLines: string[] = []; + + for (const line of lines) { + if (line.startsWith("### ")) { + if (currentHeading !== null) { + const content = currentLines.join("\n").trim(); + if (content) { + sections.push({ heading: currentHeading, content }); + } + } + currentHeading = line.slice(4).trim(); + currentLines = []; + } else if (currentHeading !== null) { + currentLines.push(line); + } + } + + if (currentHeading !== null) { + const content = currentLines.join("\n").trim(); + if (content) { + sections.push({ heading: currentHeading, content }); + } + } + + return sections; +} + +// ─── Display formatting ────────────────────────────────────────────────────── + +function formatRelease(release: GitHubRelease): string { + const version = stripV(release.tag_name); + const title = release.name || `v${version}`; + const sections = parseReleaseBody(release.body); + + const parts: string[] = [`## ${title}`]; + + if (sections.length === 0) { + if (release.body?.trim()) { + parts.push(release.body.trim()); + } else { + parts.push("_No release notes._"); + } + } else { + for (const section of sections) { + parts.push(`### ${section.heading}`); + parts.push(section.content); + } + } + + return parts.join("\n\n"); +} + +// ─── Entry Point ────────────────────────────────────────────────────────────── + +const RELEASES_URL = "https://api.github.com/repos/gsd-build/gsd-2/releases?per_page=100"; + +export async function handleChangelog( + args: string, + ctx: ExtensionCommandContext, + pi: ExtensionAPI, +): Promise { + // ── Fetch releases ────────────────────────────────────────────────────── + let releases: GitHubRelease[]; + try { + const response = await fetch(RELEASES_URL, { + headers: { "User-Agent": "gsd-changelog" }, + }); + + if (!response.ok) { + ctx.ui.notify( + `Failed to fetch changelog: GitHub API returned ${response.status} ${response.statusText}`, + "error", + ); + return; + } + + releases = (await response.json()) as GitHubRelease[]; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + ctx.ui.notify(`Failed to fetch changelog: ${message}`, "error"); + return; + } + + if (!releases.length) { + ctx.ui.notify("No releases found in the repository.", "warning"); + return; + } + + // ── Determine version filter ──────────────────────────────────────────── + const currentVersion = process.env.GSD_VERSION || ""; + let sinceVersion: string | undefined; + let showCurrentOnly = false; + + if (args.trim()) { + sinceVersion = stripV(args.trim()); + } else { + const input = await ctx.ui.input( + "Show changes since version:", + currentVersion || "latest", + ); + + if (input === undefined) { + return; + } + + if (input.trim() === "") { + showCurrentOnly = true; + } else { + sinceVersion = stripV(input.trim()); + } + } + + // ── Filter releases ───────────────────────────────────────────────────── + let matched: GitHubRelease[]; + + if (showCurrentOnly) { + if (!currentVersion) { + ctx.ui.notify( + "GSD_VERSION is not set — cannot determine current release. Provide a version instead.", + "warning", + ); + return; + } + const found = releases.find((r) => stripV(r.tag_name) === currentVersion); + if (!found) { + ctx.ui.notify(`No release found matching current version v${currentVersion}`, "warning"); + return; + } + matched = [found]; + } else if (sinceVersion) { + matched = releases + .filter((r) => compareSemver(stripV(r.tag_name), sinceVersion!) > 0) + .sort((a, b) => compareSemver(stripV(b.tag_name), stripV(a.tag_name))); + + if (!matched.length) { + ctx.ui.notify(`No releases found since v${sinceVersion}`, "warning"); + return; + } + } else { + matched = [releases[0]]; + } + + // ── Send to LLM for summarization ─────────────────────────────────────── + const rawOutput = matched.map(formatRelease).join("\n\n---\n\n"); + const versionRange = sinceVersion + ? `since v${sinceVersion} (${matched.length} release${matched.length === 1 ? "" : "s"})` + : `for current release ${matched[0].name || matched[0].tag_name}`; + + const prompt = [ + `Here are the raw GSD changelog entries ${versionRange}.`, + "Summarize the most important changes — group by category (Added, Changed, Fixed, etc.),", + "keep only the most impactful items (max 5 per category), skip trivial changes,", + "and include the version where each item appeared. Keep it concise and scannable.", + "", + rawOutput, + ].join("\n"); + + pi.sendMessage( + { customType: "gsd-changelog", content: prompt, display: true }, + { triggerTurn: true }, + ); +} diff --git a/src/resources/extensions/gsd/commands-bootstrap.ts b/src/resources/extensions/gsd/commands-bootstrap.ts index 8347598a6..9a973c2d9 100644 --- a/src/resources/extensions/gsd/commands-bootstrap.ts +++ b/src/resources/extensions/gsd/commands-bootstrap.ts @@ -12,6 +12,7 @@ const TOP_LEVEL_SUBCOMMANDS = [ { cmd: "quick", desc: "Execute a quick task without full planning overhead" }, { cmd: "discuss", desc: "Discuss architecture and decisions" }, { cmd: "capture", desc: "Fire-and-forget thought capture" }, + { cmd: "changelog", desc: "Show categorized release notes" }, { cmd: "triage", desc: "Manually trigger triage of pending captures" }, { cmd: "dispatch", desc: "Dispatch a specific phase directly" }, { cmd: "history", desc: "View execution history" }, diff --git a/src/resources/extensions/gsd/commands.ts b/src/resources/extensions/gsd/commands.ts index 342f8e0a1..dc47e9bf0 100644 --- a/src/resources/extensions/gsd/commands.ts +++ b/src/resources/extensions/gsd/commands.ts @@ -71,7 +71,7 @@ export function projectRoot(): string { export function registerGSDCommand(pi: ExtensionAPI): void { pi.registerCommand("gsd", { - description: "GSD — Get Shit Done: /gsd help|start|templates|next|auto|stop|pause|status|visualize|queue|quick|capture|triage|dispatch|history|undo|skip|export|cleanup|mode|prefs|config|keys|hooks|run-hook|skill-health|doctor|forensics|migrate|remote|steer|knowledge|new-milestone|parallel|update", + description: "GSD — Get Shit Done: /gsd help|start|templates|next|auto|stop|pause|status|visualize|queue|quick|capture|triage|dispatch|history|undo|skip|export|cleanup|mode|prefs|config|keys|hooks|run-hook|skill-health|doctor|forensics|changelog|migrate|remote|steer|knowledge|new-milestone|parallel|update", getArgumentCompletions: (prefix: string) => { const subcommands = [ { cmd: "help", desc: "Categorized command reference with descriptions" }, @@ -85,6 +85,7 @@ export function registerGSDCommand(pi: ExtensionAPI): void { { cmd: "quick", desc: "Execute a quick task without full planning overhead" }, { cmd: "discuss", desc: "Discuss architecture and decisions" }, { cmd: "capture", desc: "Fire-and-forget thought capture" }, + { cmd: "changelog", desc: "Show categorized release notes" }, { cmd: "triage", desc: "Manually trigger triage of pending captures" }, { cmd: "dispatch", desc: "Dispatch a specific phase directly" }, { cmd: "history", desc: "View execution history" }, @@ -499,6 +500,12 @@ export async function handleGSDCommand( return; } + if (trimmed === "changelog" || trimmed.startsWith("changelog ")) { + const { handleChangelog } = await import("./changelog.js"); + await handleChangelog(trimmed.replace(/^changelog\s*/, "").trim(), ctx, pi); + return; + } + if (trimmed === "next" || trimmed.startsWith("next ")) { if (trimmed.includes("--dry-run")) { await handleDryRun(ctx, projectRoot()); @@ -928,6 +935,7 @@ function showHelp(ctx: ExtensionCommandContext): void { " /gsd visualize Interactive 10-tab TUI (progress, timeline, deps, metrics, health, agent, changes, knowledge, captures, export)", " /gsd queue Show queued/dispatched units and execution order", " /gsd history View execution history [--cost] [--phase] [--model] [N]", + " /gsd changelog Show categorized release notes [version]", "", "COURSE CORRECTION", " /gsd steer Apply user override to active work",