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:
commit
ecedbfe9df
3 changed files with 197 additions and 29 deletions
|
|
@ -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]",
|
||||
|
|
|
|||
|
|
@ -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)}`,
|
||||
|
|
|
|||
105
src/resources/extensions/gsd/tests/export-html-all.test.ts
Normal file
105
src/resources/extensions/gsd/tests/export-html-all.test.ts
Normal 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");
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue