Merge pull request #967 from jeremymcs/feat/export-html-all

feat: add /gsd export --html --all for retrospective milestone reports
This commit is contained in:
TÂCHES 2026-03-17 16:25:42 -06:00 committed by GitHub
commit ecedbfe9df
3 changed files with 197 additions and 29 deletions

View file

@ -182,7 +182,7 @@ export function registerGSDCommand(pi: ExtensionAPI): void {
if (parts[0] === "export" && parts.length <= 2) {
const flagPrefix = parts[1] ?? "";
return ["--json", "--markdown"]
return ["--json", "--markdown", "--html", "--html --all"]
.filter((f) => f.startsWith(flagPrefix))
.map((f) => ({ value: `export ${f}`, label: f }));
}
@ -631,7 +631,7 @@ function showHelp(ctx: ExtensionCommandContext): void {
"",
"MAINTENANCE",
" /gsd doctor Diagnose and repair .gsd/ state [audit|fix|heal] [scope]",
" /gsd export Export milestone/slice results [--json|--markdown|--html]",
" /gsd export Export milestone/slice results [--json|--markdown|--html] [--all]",
" /gsd cleanup Remove merged branches or snapshots [branches|snapshots]",
" /gsd migrate Upgrade .gsd/ structures to new format",
" /gsd remote Control remote auto-mode [slack|discord|status|disconnect]",

View file

@ -98,43 +98,106 @@ export function writeExportFile(
export async function handleExport(args: string, ctx: ExtensionCommandContext, basePath: string): Promise<void> {
// HTML report — delegates to the full visualizer-data pipeline
if (args.includes("--html")) {
const generateAll = args.includes("--all");
try {
const { loadVisualizerData } = await import("./visualizer-data.js");
const { generateHtmlReport } = await import("./export-html.js");
const { writeReportSnapshot, reportsDir } = await import("./reports.js");
const { writeReportSnapshot, loadReportsIndex } = await import("./reports.js");
const { basename: bn } = await import("node:path");
const data = await loadVisualizerData(basePath);
const projName = basename(basePath);
const gsdVersion = process.env.GSD_VERSION ?? "0.0.0";
const doneSlices = data.milestones.reduce((s, m) => s + m.slices.filter(sl => sl.done).length, 0);
const totalSlices = data.milestones.reduce((s, m) => s + m.slices.length, 0);
const outPath = writeReportSnapshot({
basePath,
html: generateHtmlReport(data, {
projectName: projName,
projectPath: basePath,
gsdVersion,
indexRelPath: "index.html",
}),
milestoneId: data.milestones.find(m => m.status === "active")?.id ?? "manual",
milestoneTitle: data.milestones.find(m => m.status === "active")?.title ?? "",
kind: "manual",
const doneMilestones = data.milestones.filter(m => m.status === "complete").length;
const htmlOpts = {
projectName: projName,
projectPath: basePath,
gsdVersion,
totalCost: data.totals?.cost ?? 0,
totalTokens: data.totals?.tokens.total ?? 0,
totalDuration: data.totals?.duration ?? 0,
doneSlices,
totalSlices,
doneMilestones: data.milestones.filter(m => m.status === "complete").length,
totalMilestones: data.milestones.length,
phase: data.phase,
});
ctx.ui.notify(
`HTML report saved: .gsd/reports/${bn(outPath)}\nBrowse all reports: .gsd/reports/index.html`,
"success",
);
indexRelPath: "index.html",
};
if (generateAll) {
// Generate a report snapshot for every milestone
const existing = loadReportsIndex(basePath);
const existingIds = new Set(existing?.entries.map(e => e.milestoneId) ?? []);
const targets = data.milestones.filter(m => !existingIds.has(m.id));
if (targets.length === 0) {
ctx.ui.notify(
"All milestones already have report snapshots. Run without --all to create a new snapshot for the active milestone.",
"info",
);
return;
}
const html = generateHtmlReport(data, htmlOpts);
const paths: string[] = [];
for (const ms of targets) {
const msSlicesDone = ms.slices.filter(sl => sl.done).length;
const msSlicesTotal = ms.slices.length;
// Accumulate project-wide progress up to and including this milestone
const msIdx = data.milestones.indexOf(ms);
let cumulativeDone = 0;
let cumulativeTotal = 0;
for (let i = 0; i <= msIdx; i++) {
cumulativeDone += data.milestones[i].slices.filter(sl => sl.done).length;
cumulativeTotal += data.milestones[i].slices.length;
}
const outPath = writeReportSnapshot({
basePath,
html,
milestoneId: ms.id,
milestoneTitle: ms.title,
kind: ms.status === "complete" ? "milestone" : "manual",
projectName: projName,
projectPath: basePath,
gsdVersion,
totalCost: data.totals?.cost ?? 0,
totalTokens: data.totals?.tokens.total ?? 0,
totalDuration: data.totals?.duration ?? 0,
doneSlices: cumulativeDone,
totalSlices: cumulativeTotal,
doneMilestones: data.milestones.slice(0, msIdx + 1).filter(m => m.status === "complete").length,
totalMilestones: data.milestones.length,
phase: ms.status === "complete" ? "complete" : data.phase,
});
paths.push(bn(outPath));
}
ctx.ui.notify(
`Generated ${paths.length} report snapshot${paths.length !== 1 ? "s" : ""}:\n${paths.map(p => ` ${p}`).join("\n")}\nBrowse all reports: .gsd/reports/index.html`,
"success",
);
} else {
// Single report for the active milestone (existing behavior)
const doneSlices = data.milestones.reduce((s, m) => s + m.slices.filter(sl => sl.done).length, 0);
const totalSlices = data.milestones.reduce((s, m) => s + m.slices.length, 0);
const outPath = writeReportSnapshot({
basePath,
html: generateHtmlReport(data, htmlOpts),
milestoneId: data.milestones.find(m => m.status === "active")?.id ?? "manual",
milestoneTitle: data.milestones.find(m => m.status === "active")?.title ?? "",
kind: "manual",
projectName: projName,
projectPath: basePath,
gsdVersion,
totalCost: data.totals?.cost ?? 0,
totalTokens: data.totals?.tokens.total ?? 0,
totalDuration: data.totals?.duration ?? 0,
doneSlices,
totalSlices,
doneMilestones,
totalMilestones: data.milestones.length,
phase: data.phase,
});
ctx.ui.notify(
`HTML report saved: .gsd/reports/${bn(outPath)}\nBrowse all reports: .gsd/reports/index.html`,
"success",
);
}
} catch (err) {
ctx.ui.notify(
`HTML export failed: ${err instanceof Error ? err.message : String(err)}`,

View file

@ -0,0 +1,105 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdirSync, writeFileSync, readFileSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
// Test: --all flag generates snapshots for milestones not yet in the index
test("handleExport --html --all generates reports for milestones missing from the index", async () => {
// We test the export logic indirectly by verifying the flag parsing
// and the deduplication logic via loadReportsIndex + milestone filtering
const { loadReportsIndex } = await import("../reports.js");
const tmp = join(tmpdir(), `gsd-export-all-test-${Date.now()}`);
const gsdDir = join(tmp, ".gsd");
const reportsDir = join(gsdDir, "reports");
mkdirSync(reportsDir, { recursive: true });
// No existing reports — loadReportsIndex returns null
const noIndex = loadReportsIndex(tmp);
assert.equal(noIndex, null, "empty reports dir should return null index");
// Write a reports.json with M001 already present
const index = {
version: 1,
projectName: "test-project",
projectPath: tmp,
gsdVersion: "2.27.0",
entries: [
{
filename: "M001-2026-01-01T00-00-00.html",
generatedAt: "2026-01-01T00:00:00.000Z",
milestoneId: "M001",
milestoneTitle: "First Milestone",
label: "M001: First Milestone",
kind: "milestone",
totalCost: 0.5,
totalTokens: 10000,
totalDuration: 60000,
doneSlices: 3,
totalSlices: 3,
doneMilestones: 1,
totalMilestones: 3,
phase: "complete",
},
],
};
writeFileSync(join(reportsDir, "reports.json"), JSON.stringify(index), "utf-8");
// Now loadReportsIndex should find M001
const loaded = loadReportsIndex(tmp);
assert.ok(loaded, "should load existing reports index");
assert.equal(loaded.entries.length, 1);
assert.equal(loaded.entries[0].milestoneId, "M001");
// Simulate the deduplication logic from handleExport --all
const existingIds = new Set(loaded.entries.map(e => e.milestoneId));
const allMilestones = [
{ id: "M001", title: "First Milestone", status: "complete" },
{ id: "M002", title: "Second Milestone", status: "complete" },
{ id: "M003", title: "Third Milestone", status: "active" },
];
const targets = allMilestones.filter(m => !existingIds.has(m.id));
assert.equal(targets.length, 2, "should skip M001 and target M002 + M003");
assert.equal(targets[0].id, "M002");
assert.equal(targets[1].id, "M003");
// Cleanup
rmSync(tmp, { recursive: true, force: true });
});
test("handleExport --html --all sets milestone kind based on status", async () => {
const completeMilestone = { id: "M001", status: "complete" };
const activeMilestone = { id: "M002", status: "active" };
// Logic from the implementation
const completeKind = completeMilestone.status === "complete" ? "milestone" : "manual";
const activeKind = activeMilestone.status === "complete" ? "milestone" : "manual";
assert.equal(completeKind, "milestone", "completed milestones get kind 'milestone'");
assert.equal(activeKind, "manual", "active milestones get kind 'manual'");
});
test("export completions include --html and --html --all", async () => {
const { registerGSDCommand } = await import("../commands.js");
const commands = new Map<string, any>();
const pi = {
registerCommand(name: string, options: any) { commands.set(name, options); },
registerTool() {},
registerShortcut() {},
on() {},
sendMessage() {},
};
registerGSDCommand(pi as any);
const gsd = commands.get("gsd");
assert.ok(gsd, "should register /gsd command");
const completions = gsd.getArgumentCompletions("export --");
const labels = completions.map((c: any) => c.label);
assert.ok(labels.includes("--html"), "completions should include --html");
assert.ok(labels.includes("--html --all"), "completions should include --html --all");
});