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:
parent
988ef61488
commit
29a1882c04
3 changed files with 223 additions and 1 deletions
213
src/resources/extensions/gsd/changelog.ts
Normal file
213
src/resources/extensions/gsd/changelog.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue