feat(gsd): add /gsd changelog command with LLM-summarized release notes (#1465)

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()
This commit is contained in:
Jean-Dominique Stepek 2026-03-19 17:36:43 -04:00 committed by GitHub
parent 988ef61488
commit 29a1882c04
3 changed files with 223 additions and 1 deletions

View file

@ -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<void> {
// ── 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 },
);
}

View file

@ -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" },

View file

@ -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 <desc> Apply user override to active work",