diff --git a/openspec/changes/archive/2026-08-27-add-sprint-report-vscode-command/design.md b/openspec/changes/archive/2026-08-27-add-sprint-report-vscode-command/design.md new file mode 100644 index 00000000..d74b814b --- /dev/null +++ b/openspec/changes/archive/2026-08-27-add-sprint-report-vscode-command/design.md @@ -0,0 +1,110 @@ +## Context + +Researched directly against this codebase before writing any code. +`packages/extension/src/commands.ts` already has every building block +this command needs except the date-range prompt and the save/open +flow: `pickChangesForTimeline` (multi-select picker, used unchanged), +and `buildSprintReport`/`renderSprintReportPdf` (from +`add-sprint-report-pdf`, already exported by `@openspec-ui/core`'s +Node-only barrel). No prior command in this extension uses +`showSaveDialog`/`workspace.fs.writeFile`/`env.openExternal` — those +are new, standard VS Code APIs, not a gap in an existing pattern. + +Critically, `add-sprint-report-pdf` (PR #96) already discovered and +fixed a real bundling hazard: esbuild's CJS bundle of the extension +host could not shim `pdfkit`'s ESM build's `import.meta.url`, and +crashed *all* extension activation the moment `@openspec-ui/core`'s +barrel made `pdfkit` reachable — even though nothing called it yet. +The fix (`packages/extension/scripts/build-options.mjs`, an `alias` +mapping `pdfkit` to its own CommonJS build at bundle time) was verified +by loading the rebuilt `dist/extension.js` in plain Node with a +stubbed `vscode` module and confirming activation no longer throws. +This change is the first to actually call `renderSprintReportPdf` from +within the bundled extension, so it re-verifies that fix under real +use, not just under activation. + +## Goals / Non-Goals + +**Goals:** +- Match `showAllChangesTimeline`'s established shape exactly: + Command Palette only, `pickChangesForTimeline` reused unchanged, + errors reported via the existing `showCommandError` helper. +- A real user-specified sprint start/end date, per the original + request (the user explicitly asked to set the sprint's start and end + dates) — not an auto-derived range like `computeDefaultRange`. +- No server/REST dependency for this command — direct in-process core + calls, per ADR-0001. + +**Non-Goals:** +- Not a tree-item context-menu entry — a sprint report spans multiple + changes by nature (like the comparison timeline), so it has no single + natural tree item to attach to. +- Not a native date picker — VS Code's own prompt UI has none; + validated free-text `YYYY-MM-DD` input is standard for this kind of + extension. +- Not a rebuild of `add-sprint-report-pdf`'s core logic — this change + is purely a new UI entry point onto already-shipped, already-tested + `buildSprintReport`/`renderSprintReportPdf`. + +## Decisions + +### Direct core calls, not the optional local server's REST endpoint + +`packages/extension` already imports `@openspec-ui/core` directly for +every other command (`showChangeTimeline`, `showAllChangesTimeline`, +etc.); the optional local server (`optional-server.ts`) exists only to +embed the standalone webview shell, not as a required dependency for +core operations. **Rejected**: routing through `POST +/api/sprint-report` (the endpoint `add-sprint-report-pdf` added to +`packages/server`) — this would make the command depend on the +optional server being started, and would contradict ADR-0001's +"extension: direct import ... as the primary mode" decision without a +new reason to revisit it. + +### Two validated `showInputBox` prompts for the date range + +**Rejected**: reusing `computeDefaultRange` (the multi-change +timeline's auto-derived range) — that function exists specifically +*because* no user-specified range is needed there; this command's +whole premise is the opposite (the user explicitly asked to set sprint +start/end dates). A single combined prompt ("start,end") was also +considered and rejected: two prompts let `showInputBox`'s +`validateInput` give a specific, per-field error message rather than +parsing a combined string and guessing which half was wrong. + +### `showSaveDialog` + `workspace.fs.writeFile`, not a fixed output path + +**Rejected**: writing the PDF straight to a fixed location (e.g. the +workspace root) without asking — sprint reports are typically shared +outside the repository (attached to an email, a chat message), so +letting the user pick the destination and filename via the standard +VS Code "export" pattern (`showSaveDialog`) is both more useful and +more conventional than silently dropping a file into the workspace. + +### "Open" action uses `vscode.env.openExternal`, not "reveal in file explorer" + +Launches the OS's default PDF viewer directly from the confirmation +message's action button — one click to actually see the report, versus +"reveal in file explorer" which requires a second manual open. + +## Risks / Trade-offs + +- **[Risk]** The `pdfkit`/esbuild bundling hazard from + `add-sprint-report-pdf` (see Context) is exactly the kind of failure + that a plain unit test suite (which mocks `@openspec-ui/core` + entirely) cannot catch — it only manifests in the real bundled + `dist/extension.js`. → **Mitigation**: after implementing, rebuild + `dist/extension.js` and load it in plain Node with a stubbed + `vscode` module (the same technique that caught and verified the fix + for PR #96), confirming both that activation succeeds and that + `pdfkit`'s code is genuinely inlined (not left as a runtime + `require("pdfkit")` that `vsce package --no-dependencies` would ship + without a resolvable `pdfkit` in `node_modules`). +- **[Risk]** The real VS Code Extension Development Host integration + test is known-broken on this development machine (documented since + the `signal-run-completion` change in this session's history) — it + cannot be used to verify this command interactively end-to-end here. + → **Mitigation**: same substitution used for the stale-task-detection + change — verify against the real bundled artifact via a direct + plain-Node script, closer to the real path than a mocked unit test, + even though it is not the full VS Code host. diff --git a/openspec/changes/archive/2026-08-27-add-sprint-report-vscode-command/proposal.md b/openspec/changes/archive/2026-08-27-add-sprint-report-vscode-command/proposal.md new file mode 100644 index 00000000..026552b0 --- /dev/null +++ b/openspec/changes/archive/2026-08-27-add-sprint-report-vscode-command/proposal.md @@ -0,0 +1,64 @@ +## Why + +Second half of the sprint-report feature approved in the 2026-08-26/27 +product-direction discussion; `add-sprint-report-pdf` (merged, PR #96) +shipped the standalone-only half. Per +[ADR-0001](../../../docs/adr/0001-shared-core-two-delivery-targets.md), +the extension's primary mode is direct import of `@openspec-ui/core` +in-process, not a REST round-trip through `packages/server` — this +command follows that decision exactly as `showChangeTimeline`/ +`showAllChangesTimeline` already do, calling `buildSprintReport`/ +`renderSprintReportPdf` directly rather than adding a local-HTTP-only +code path. + +This change is also what first makes the extension actually *execute* +`renderSprintReportPdf` at runtime, not just export it: PR #96's own +CI run caught `pdfkit` breaking extension activation entirely once it +was reachable through `@openspec-ui/core`'s barrel (esbuild's CJS +bundle couldn't shim pdfkit's ESM build's `import.meta.url`, throwing +"Invalid URL" for every command, not just the sprint report one) — that +was fixed by aliasing `pdfkit` to its CommonJS build at bundle time +(`packages/extension/scripts/build-options.mjs`), but the fix was +never actually exercised by a real pdfkit call from within the bundled +extension until this change's own command runs it. + +## What Changes + +- Add `openspec-ui.generateSprintReport`, a Command Palette-only + command (no tree-item entry, matching `showAllChangesTimeline`'s + pattern) in `packages/extension/src/commands.ts`: + 1. Reuses `pickChangesForTimeline` unchanged (multi-select across + active/archived changes). + 2. Two validated `showInputBox` prompts (`YYYY-MM-DD`) for the + sprint's start/end date — VS Code has no native date picker, and + unlike `computeDefaultRange` this command needs a real + user-specified range, not one auto-derived from data. + 3. Calls `buildSprintReport` then `renderSprintReportPdf` from + `@openspec-ui/core` directly (already imported host-side; no + server/REST involved). + 4. `vscode.window.showSaveDialog` (PDF filter) → + `vscode.workspace.fs.writeFile` → a confirmation message with an + "Open" action (`vscode.env.openExternal`). +- Add `contributes.commands` entry in + `packages/extension/package.json`. + +## Capabilities + +### New Capabilities + +(none) + +### Modified Capabilities + +- `vscode-extension`: adds a Requirement for the sprint report + Command Palette command. + +## Impact + +- `packages/extension/src/commands.ts` +- `packages/extension/src/commands.test.ts` +- `packages/extension/src/test-utils/vscode-mock.ts` (adds + `showSaveDialog`, `workspace.fs.writeFile`, `env.openExternal` + stubs — no prior command needed them) +- `packages/extension/package.json` (`contributes.commands`) +- `.changeset/*.md` (new changeset file) diff --git a/openspec/changes/archive/2026-08-27-add-sprint-report-vscode-command/specs/vscode-extension/spec.md b/openspec/changes/archive/2026-08-27-add-sprint-report-vscode-command/specs/vscode-extension/spec.md new file mode 100644 index 00000000..a8f16c54 --- /dev/null +++ b/openspec/changes/archive/2026-08-27-add-sprint-report-vscode-command/specs/vscode-extension/spec.md @@ -0,0 +1,40 @@ +## ADDED Requirements + +### Requirement: A global command generates a downloadable sprint report + +The system SHALL offer a Command Palette command, not tied to any +single tree item, that lets the user select multiple active and/or +archived changes, enter a sprint start and end date, and save a +generated PDF sprint report to a location of their choosing. + +#### Scenario: User generates and saves a sprint report + +- **WHEN** the user invokes "Generate Sprint Report (PDF)", selects one + or more changes, enters a valid start and end date, and confirms a + save location +- **THEN** a PDF file is written to that location and a confirmation + message offers to open it + +#### Scenario: User selects no changes + +- **WHEN** the user cancels the change selection without picking any + change +- **THEN** no date prompt appears and no report is generated + +#### Scenario: User enters a malformed date + +- **WHEN** the user types a value that is not a valid `YYYY-MM-DD` date + into either date prompt +- **THEN** the prompt reports the problem and does not accept the value + +#### Scenario: User cancels the save dialog + +- **WHEN** the user picks changes and a valid date range but dismisses + the save dialog +- **THEN** no PDF file is written + +#### Scenario: Report generation fails + +- **WHEN** building the sprint report or rendering the PDF throws +- **THEN** the extension shows an error message and does not write a + file diff --git a/openspec/changes/archive/2026-08-27-add-sprint-report-vscode-command/tasks.md b/openspec/changes/archive/2026-08-27-add-sprint-report-vscode-command/tasks.md new file mode 100644 index 00000000..9766fadc --- /dev/null +++ b/openspec/changes/archive/2026-08-27-add-sprint-report-vscode-command/tasks.md @@ -0,0 +1,50 @@ +## 1. Extension: command + +- [x] 1.1 Add `promptSprintRange()` in `commands.ts`: two validated + `showInputBox` prompts (`YYYY-MM-DD`), returning full-day ISO + `rangeStart`/`rangeEnd` bounds. +- [x] 1.2 Register `openspec-ui.generateSprintReport`: reuses + `pickChangesForTimeline`, then `promptSprintRange`, then calls + `buildSprintReport`/`renderSprintReportPdf` from `@openspec-ui/core` + directly, then `showSaveDialog` → `workspace.fs.writeFile` → a + confirmation message with an "Open" action + (`vscode.env.openExternal`). Errors reported via the existing + `showCommandError` helper. +- [x] 1.3 Add the `contributes.commands` entry in + `packages/extension/package.json` (Command Palette only, no tree + item, matching `showAllChangesTimeline`). + +## 2. Tests + +- [x] 2.1 Add `showSaveDialog`, `workspace.fs.writeFile`, and + `env.openExternal` stubs to `test-utils/vscode-mock.ts`. +- [x] 2.2 Add tests in `commands.test.ts` mirroring + `showAllChangesTimeline`'s shape: builds the report for the picked + range/changes and saves+offers to open the PDF; does nothing when no + changes are picked; does nothing when either date prompt is + dismissed; the date validator rejects a malformed date; does not + write a file when the save dialog is dismissed; does not open the + PDF when the confirmation message is dismissed; reports an error and + writes no file when building the report fails. +- [x] 2.3 Add `openspec-ui.generateSprintReport` to the "registers all + expected command ids" test. + +## 3. Verification + +- [x] 3.1 `npm run typecheck` and `npm run lint` (including + `lint:english`) pass workspace-wide. +- [x] 3.2 `npm run test` passes workspace-wide, including all new + test cases. +- [x] 3.3 Rebuild `packages/extension/dist/extension.js` + (`npm run build --workspace openspec-ui-vscode`) and load it in + plain Node with a stubbed `vscode` module: activation succeeds, and + the bundle contains `pdfkit`'s real CommonJS build inline (not a + runtime `require("pdfkit")`) — the same verification used to catch + and confirm the fix for PR #96's bundling crash, now re-run with + this command actually reachable. +- [x] 3.4 Propose a changeset (`npx changeset`) for + `openspec-ui-vscode` (minor: new command, no breaking change) + instead of hand-editing `version`/`CHANGELOG.md`; apply it via + `npx changeset version`. +- [x] 3.5 Run `openspec change validate --strict + add-sprint-report-vscode-command`. diff --git a/openspec/specs/vscode-extension/spec.md b/openspec/specs/vscode-extension/spec.md index 453e913b..0e55147a 100644 --- a/openspec/specs/vscode-extension/spec.md +++ b/openspec/specs/vscode-extension/spec.md @@ -389,3 +389,42 @@ configurable via the `openspec-ui.staleTaskThresholdDays` setting different value and reopens the Change Timeline - **THEN** the new threshold is used to determine staleness +### Requirement: A global command generates a downloadable sprint report + +The system SHALL offer a Command Palette command, not tied to any +single tree item, that lets the user select multiple active and/or +archived changes, enter a sprint start and end date, and save a +generated PDF sprint report to a location of their choosing. + +#### Scenario: User generates and saves a sprint report + +- **WHEN** the user invokes "Generate Sprint Report (PDF)", selects one + or more changes, enters a valid start and end date, and confirms a + save location +- **THEN** a PDF file is written to that location and a confirmation + message offers to open it + +#### Scenario: User selects no changes + +- **WHEN** the user cancels the change selection without picking any + change +- **THEN** no date prompt appears and no report is generated + +#### Scenario: User enters a malformed date + +- **WHEN** the user types a value that is not a valid `YYYY-MM-DD` date + into either date prompt +- **THEN** the prompt reports the problem and does not accept the value + +#### Scenario: User cancels the save dialog + +- **WHEN** the user picks changes and a valid date range but dismisses + the save dialog +- **THEN** no PDF file is written + +#### Scenario: Report generation fails + +- **WHEN** building the sprint report or rendering the PDF throws +- **THEN** the extension shows an error message and does not write a + file + diff --git a/packages/core/src/sprint-report-pdf.ts b/packages/core/src/sprint-report-pdf.ts index 9278da5f..a72ee714 100644 --- a/packages/core/src/sprint-report-pdf.ts +++ b/packages/core/src/sprint-report-pdf.ts @@ -4,30 +4,16 @@ // "plain and functional over polished" bias (e.g. the multi-change // timeline's plain-CSS-position choice over a charting library). -import { createRequire } from "node:module"; -import { fileURLToPath } from "node:url"; -import type PDFKitDocument from "pdfkit"; +import PDFDocument from "pdfkit"; import type { SprintReport } from "./sprint-report.js"; -// pdfkit's package.json "exports" map points `import` at an ESM build -// (js/pdfkit.node.mjs) that uses real `import.meta.url` syntax. When -// esbuild bundles this package to a single CJS file (the VS Code -// extension host build), it cannot preserve `import.meta.url` and -// substitutes an empty object, which crashes that ESM build at load -// time ("TypeError: Invalid URL") -- breaking extension activation -// entirely, even though nothing calls PDF/A features that need it. -// Requiring "pdfkit" instead resolves the package's `require` -// condition (js/pdfkit.js), a genuinely CommonJS build that reads -// `__filename` instead, which esbuild *can* shim correctly for a -// node/cjs bundle. `__filename` is unavailable in this file's own -// plain-ESM runtime (server/core, unbundled) instead, hence the -// `typeof` guard -- safe because `typeof` never throws on an -// undeclared identifier. -declare const __filename: string | undefined; -const require = createRequire( - typeof __filename !== "undefined" ? __filename : fileURLToPath(import.meta.url), -); -const PDFDocument = require("pdfkit") as typeof PDFKitDocument; +// A plain static import: correct and sufficient for every plain-Node/ESM +// consumer (server, core's own tests). pdfkit's package.json "exports" +// resolves this to its ESM build (js/pdfkit.node.mjs), which needs real +// `import.meta.url` support -- something the VS Code extension's esbuild +// CJS bundle cannot provide (see build-options.mjs's `alias` entry for +// "pdfkit" in `extensionHostBuildOptions`, which redirects that one +// consumer to pdfkit's CommonJS build instead, at bundle time). function formatDate(date: string | null): string { return date ? new Date(date).toLocaleDateString() : "unknown"; diff --git a/packages/extension/CHANGELOG.md b/packages/extension/CHANGELOG.md index 6d5d3971..3ddaeced 100644 --- a/packages/extension/CHANGELOG.md +++ b/packages/extension/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.26.0 + +### Minor Changes + +- Add "OpenSpec UI: Generate Sprint Report (PDF)" Command Palette command: pick a date range and one or more changes, then save a generated PDF sprint report and optionally open it. + ## 0.25.0 ### Minor Changes diff --git a/packages/extension/package.json b/packages/extension/package.json index acd41f37..ba59ef96 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -4,7 +4,7 @@ "displayName": "OpenSpec Workbench", "description": "A dashboard + VS Code extension for OpenSpec, with Claude, Copilot, Codex, and Gemini agents built in.", "publisher": "openspec-ui", - "version": "0.25.0", + "version": "0.26.0", "icon": "media/icon.png", "license": "MIT", "repository": { @@ -111,6 +111,10 @@ "command": "openspec-ui.showAllChangesTimeline", "title": "OpenSpec UI: Show Change Comparison Timeline" }, + { + "command": "openspec-ui.generateSprintReport", + "title": "OpenSpec UI: Generate Sprint Report (PDF)" + }, { "command": "openspec-ui.showChangeDetails", "title": "OpenSpec UI: Show Change Details" diff --git a/packages/extension/scripts/build-options.mjs b/packages/extension/scripts/build-options.mjs index c8f7efba..0a8b124c 100644 --- a/packages/extension/scripts/build-options.mjs +++ b/packages/extension/scripts/build-options.mjs @@ -3,10 +3,30 @@ // by the integration test, which ensures dist/*.js exists before launching // a live VS Code instance (see src/test/). +import { createRequire } from "node:module"; import path from "node:path"; import { fileURLToPath } from "node:url"; const here = path.dirname(fileURLToPath(import.meta.url)); +const require = createRequire(import.meta.url); + +// pdfkit's package.json "exports" resolves a static `import` to its ESM +// build (js/pdfkit.node.mjs), which uses real `import.meta.url` syntax +// esbuild cannot preserve when bundling to a single CJS file -- it +// substitutes an empty object, and pdfkit's top-level `new URL(...)` +// then throws ("Invalid URL"), crashing extension activation entirely +// (core's `@openspec-ui/core` barrel re-exports the sprint-report PDF +// renderer for every consumer, extension included). Aliasing to the +// package's own CommonJS build sidesteps this: that file reads +// `__filename` instead, which esbuild *can* shim correctly for a +// node/cjs bundle, and it's a genuine bundle-time alias (not a runtime +// `require`), so the code still ends up inlined -- required, since +// `npm run package` builds the .vsix with `--no-dependencies` (no +// node_modules shipped; the extension itself has no direct dependency +// on pdfkit either, only a transitive one via `@openspec-ui/core`). +// `require.resolve` (via this file's own `createRequire`) follows the +// package's `require` export condition directly to js/pdfkit.js. +const pdfkitCjsEntry = require.resolve("pdfkit"); export function extensionHostBuildOptions() { return { @@ -17,6 +37,7 @@ export function extensionHostBuildOptions() { platform: "node", target: "node20", external: ["vscode"], + alias: { pdfkit: pdfkitCjsEntry }, sourcemap: true, logLevel: "info", }; diff --git a/packages/extension/src/commands.test.ts b/packages/extension/src/commands.test.ts index 876789ff..dd1d5315 100644 --- a/packages/extension/src/commands.test.ts +++ b/packages/extension/src/commands.test.ts @@ -13,6 +13,8 @@ const archiveChangeMock = vi.fn(); const checkChangesetReminderMock = vi.fn(); const getChangeTimelineMock = vi.fn(); const getChangeTimelinesMock = vi.fn(); +const buildSprintReportMock = vi.fn(); +const renderSprintReportPdfMock = vi.fn(); const discoverOpenSpecWorkspaceMock = vi.fn(); const createChangeMock = vi.fn(); const deleteChangeMock = vi.fn(); @@ -33,6 +35,7 @@ const TASK_CHECKBOX_LINE_RE = /^[ \t]*-\s\[([ xX])\]\s*(.*)$/; vi.mock("@openspec-ui/core", () => ({ DEFAULT_STALE_TASK_THRESHOLD_DAYS: 14, archiveChange: (...args: unknown[]) => archiveChangeMock(...args), + buildSprintReport: (...args: unknown[]) => buildSprintReportMock(...args), checkChangesetReminder: (...args: unknown[]) => checkChangesetReminderMock(...args), createChange: (...args: unknown[]) => createChangeMock(...args), customizeTemplate: (...args: unknown[]) => customizeTemplateMock(...args), @@ -50,6 +53,7 @@ vi.mock("@openspec-ui/core", () => ({ listChanges: (...args: unknown[]) => listChangesMock(...args), listSpecs: (...args: unknown[]) => listSpecsMock(...args), readArchivedChangeTasksTemplate: (...args: unknown[]) => readArchivedChangeTasksTemplateMock(...args), + renderSprintReportPdf: (...args: unknown[]) => renderSprintReportPdfMock(...args), renderTemplate: (...args: unknown[]) => renderTemplateMock(...args), showChange: (...args: unknown[]) => showChangeMock(...args), TASK_CHECKBOX_LINE_RE, @@ -148,6 +152,7 @@ describe("registerCommands", () => { "openspec-ui.validateSelectedChange", "openspec-ui.showChangeTimeline", "openspec-ui.showAllChangesTimeline", + "openspec-ui.generateSprintReport", "openspec-ui.archiveChange", "openspec-ui.unarchiveChange", "openspec-ui.deleteChange", @@ -296,6 +301,154 @@ describe("registerCommands", () => { expect(timelinePanelShowMultiMock).not.toHaveBeenCalled(); }); + describe("generateSprintReport", () => { + function setUpPicker() { + discoverOpenSpecWorkspaceMock.mockResolvedValue({ + changes: [{ name: "active-change" }], + archivedChanges: [{ name: "2026-01-01-old-change" }], + }); + vscodeMock.window.showQuickPick.mockResolvedValue([ + { label: "active-change", description: "active", archived: false }, + { label: "2026-01-01-old-change", description: "archived", archived: true }, + ]); + } + + it("builds a sprint report for the picked range and changes, then saves and offers to open the PDF", async () => { + setUpPicker(); + vscodeMock.window.showInputBox + .mockResolvedValueOnce("2026-08-01") + .mockResolvedValueOnce("2026-08-14"); + const report = { rangeStart: "2026-08-01T00:00:00.000Z", rangeEnd: "2026-08-14T23:59:59.999Z", entries: [], stats: {} }; + buildSprintReportMock.mockResolvedValue(report); + const pdf = Buffer.from("pdf-bytes"); + renderSprintReportPdfMock.mockResolvedValue(pdf); + const target = vscodeMock.Uri.file("/workspace/repo/sprint-report-2026-08-01-2026-08-14.pdf"); + vscodeMock.window.showSaveDialog.mockResolvedValue(target); + vscodeMock.window.showInformationMessage.mockResolvedValue("Open"); + const deps = makeDeps(); + registerCommands(makeContext() as unknown as import("vscode").ExtensionContext, deps); + + await vscodeMock._registeredCommands.get("openspec-ui.generateSprintReport")?.(); + + expect(buildSprintReportMock).toHaveBeenCalledWith( + "/workspace/repo", + [ + { changeName: "active-change", archived: false }, + { changeName: "2026-01-01-old-change", archived: true }, + ], + "2026-08-01T00:00:00.000Z", + "2026-08-14T23:59:59.999Z", + ); + expect(renderSprintReportPdfMock).toHaveBeenCalledWith(report); + expect(vscodeMock.workspace.fs.writeFile).toHaveBeenCalledWith(target, pdf); + expect(vscodeMock.env.openExternal).toHaveBeenCalledWith(target); + }); + + it("does nothing when no changes are picked", async () => { + discoverOpenSpecWorkspaceMock.mockResolvedValue({ + changes: [{ name: "active-change" }], + archivedChanges: [], + }); + vscodeMock.window.showQuickPick.mockResolvedValue(undefined); + const deps = makeDeps(); + registerCommands(makeContext() as unknown as import("vscode").ExtensionContext, deps); + + await vscodeMock._registeredCommands.get("openspec-ui.generateSprintReport")?.(); + + expect(vscodeMock.window.showInputBox).not.toHaveBeenCalled(); + expect(buildSprintReportMock).not.toHaveBeenCalled(); + }); + + it("does nothing when the start date prompt is dismissed", async () => { + setUpPicker(); + vscodeMock.window.showInputBox.mockResolvedValueOnce(undefined); + const deps = makeDeps(); + registerCommands(makeContext() as unknown as import("vscode").ExtensionContext, deps); + + await vscodeMock._registeredCommands.get("openspec-ui.generateSprintReport")?.(); + + expect(buildSprintReportMock).not.toHaveBeenCalled(); + }); + + it("does nothing when the end date prompt is dismissed", async () => { + setUpPicker(); + vscodeMock.window.showInputBox.mockResolvedValueOnce("2026-08-01").mockResolvedValueOnce(undefined); + const deps = makeDeps(); + registerCommands(makeContext() as unknown as import("vscode").ExtensionContext, deps); + + await vscodeMock._registeredCommands.get("openspec-ui.generateSprintReport")?.(); + + expect(buildSprintReportMock).not.toHaveBeenCalled(); + }); + + it("wires a YYYY-MM-DD validator into both date prompts", async () => { + setUpPicker(); + vscodeMock.window.showInputBox.mockResolvedValue(undefined); + const deps = makeDeps(); + registerCommands(makeContext() as unknown as import("vscode").ExtensionContext, deps); + + await vscodeMock._registeredCommands.get("openspec-ui.generateSprintReport")?.(); + + const options = vscodeMock.window.showInputBox.mock.calls[0]?.[0] as { + validateInput?: (value: string) => string | undefined; + }; + expect(options.validateInput?.("not-a-date")).toBe("Enter a date as YYYY-MM-DD."); + expect(options.validateInput?.("2026-08-01")).toBeUndefined(); + }); + + it("does not write a file when the save dialog is dismissed", async () => { + setUpPicker(); + vscodeMock.window.showInputBox + .mockResolvedValueOnce("2026-08-01") + .mockResolvedValueOnce("2026-08-14"); + buildSprintReportMock.mockResolvedValue({ entries: [] }); + renderSprintReportPdfMock.mockResolvedValue(Buffer.from("pdf-bytes")); + vscodeMock.window.showSaveDialog.mockResolvedValue(undefined); + const deps = makeDeps(); + registerCommands(makeContext() as unknown as import("vscode").ExtensionContext, deps); + + await vscodeMock._registeredCommands.get("openspec-ui.generateSprintReport")?.(); + + expect(vscodeMock.workspace.fs.writeFile).not.toHaveBeenCalled(); + expect(vscodeMock.env.openExternal).not.toHaveBeenCalled(); + }); + + it("does not open the PDF when the confirmation message is dismissed", async () => { + setUpPicker(); + vscodeMock.window.showInputBox + .mockResolvedValueOnce("2026-08-01") + .mockResolvedValueOnce("2026-08-14"); + buildSprintReportMock.mockResolvedValue({ entries: [] }); + renderSprintReportPdfMock.mockResolvedValue(Buffer.from("pdf-bytes")); + vscodeMock.window.showSaveDialog.mockResolvedValue(vscodeMock.Uri.file("/workspace/repo/report.pdf")); + vscodeMock.window.showInformationMessage.mockResolvedValue(undefined); + const deps = makeDeps(); + registerCommands(makeContext() as unknown as import("vscode").ExtensionContext, deps); + + await vscodeMock._registeredCommands.get("openspec-ui.generateSprintReport")?.(); + + expect(vscodeMock.workspace.fs.writeFile).toHaveBeenCalled(); + expect(vscodeMock.env.openExternal).not.toHaveBeenCalled(); + }); + + it("reports an error and writes no file when building the report fails", async () => { + setUpPicker(); + vscodeMock.window.showInputBox + .mockResolvedValueOnce("2026-08-01") + .mockResolvedValueOnce("2026-08-14"); + buildSprintReportMock.mockRejectedValue(new Error("boom")); + const deps = makeDeps(); + registerCommands(makeContext() as unknown as import("vscode").ExtensionContext, deps); + + await vscodeMock._registeredCommands.get("openspec-ui.generateSprintReport")?.(); + + expect(vscodeMock.workspace.fs.writeFile).not.toHaveBeenCalled(); + expect(vscodeMock.window.showErrorMessage).toHaveBeenCalledWith( + expect.stringContaining("generate sprint report failed"), + ); + }); + }); + it("archives a confirmed active change and refreshes", async () => { vscodeMock.window.showWarningMessage.mockResolvedValue("Archive"); archiveChangeMock.mockResolvedValue({ ok: true }); diff --git a/packages/extension/src/commands.ts b/packages/extension/src/commands.ts index c0768e4f..073ff3c0 100644 --- a/packages/extension/src/commands.ts +++ b/packages/extension/src/commands.ts @@ -11,6 +11,7 @@ import { TemplateAlreadyExistsError, UnknownProjectTemplateError, archiveChange, + buildSprintReport, checkChangesetReminder, createChange, customizeTemplate, @@ -25,6 +26,7 @@ import { listChanges, listSpecs, readArchivedChangeTasksTemplate, + renderSprintReportPdf, renderTemplate, showChange, unarchiveChange, @@ -296,6 +298,34 @@ function computeDefaultRange(timelines: ChangeTimeline[]): { rangeStart: string; return { rangeStart: sorted[0] as string, rangeEnd: sorted[sorted.length - 1] as string }; } +const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; + +/** VS Code has no native date picker, so the sprint range is two + * validated `showInputBox` prompts rather than the auto-derived range + * `computeDefaultRange` uses elsewhere — this command needs a real + * user-specified sprint boundary, not a default. Returns full-day ISO + * bounds (start of `start`, end of `end`) so a task completed anywhere + * during either boundary date is included. */ +async function promptSprintRange(): Promise<{ rangeStart: string; rangeEnd: string } | undefined> { + const validateInput = (value: string): string | undefined => + DATE_RE.test(value) && !Number.isNaN(Date.parse(value)) ? undefined : "Enter a date as YYYY-MM-DD."; + const start = await vscode.window.showInputBox({ + title: "Sprint Report: Start Date", + prompt: "First day of the sprint (YYYY-MM-DD)", + placeHolder: "2026-08-01", + validateInput, + }); + if (!start) return undefined; + const end = await vscode.window.showInputBox({ + title: "Sprint Report: End Date", + prompt: "Last day of the sprint (YYYY-MM-DD)", + placeHolder: "2026-08-14", + validateInput, + }); + if (!end) return undefined; + return { rangeStart: `${start}T00:00:00.000Z`, rangeEnd: `${end}T23:59:59.999Z` }; +} + export function registerCommands(context: vscode.ExtensionContext, deps: CommandsDeps): void { const timelinePanel = new TimelineWebviewPanel({ extensionUri: context.extensionUri }); context.subscriptions.push( @@ -872,6 +902,38 @@ export function registerCommands(context: vscode.ExtensionContext, deps: Command }), ); + context.subscriptions.push( + vscode.commands.registerCommand("openspec-ui.generateSprintReport", async () => { + const workspaceRoot = deps.getWorkspaceRoot(); + if (!workspaceRoot) { + void vscode.window.showErrorMessage("OpenSpec UI: open a folder or workspace first."); + return; + } + const entries = await pickChangesForTimeline(workspaceRoot); + if (!entries) return; + const range = await promptSprintRange(); + if (!range) return; + try { + const report = await buildSprintReport(workspaceRoot, entries, range.rangeStart, range.rangeEnd); + const pdf = await renderSprintReportPdf(report); + const defaultName = `sprint-report-${range.rangeStart.slice(0, 10)}-${range.rangeEnd.slice(0, 10)}.pdf`; + const target = await vscode.window.showSaveDialog({ + filters: { PDF: ["pdf"] }, + defaultUri: vscode.Uri.joinPath(vscode.Uri.file(workspaceRoot), defaultName), + }); + if (!target) return; + await vscode.workspace.fs.writeFile(target, pdf); + const action = await vscode.window.showInformationMessage( + `OpenSpec UI: sprint report saved to ${target.fsPath}.`, + "Open", + ); + if (action === "Open") await vscode.env.openExternal(target); + } catch (error) { + await showCommandError("generate sprint report", error); + } + }), + ); + context.subscriptions.push( vscode.commands.registerCommand("openspec-ui.showChangeDetails", async () => { const workspaceRoot = deps.getWorkspaceRoot(); diff --git a/packages/extension/src/test-utils/vscode-mock.ts b/packages/extension/src/test-utils/vscode-mock.ts index cfa6c202..e0f20be1 100644 --- a/packages/extension/src/test-utils/vscode-mock.ts +++ b/packages/extension/src/test-utils/vscode-mock.ts @@ -113,6 +113,7 @@ export function createVscodeMock() { selection: undefined as unknown, revealRange: vi.fn(), })), + showSaveDialog: vi.fn(), withProgress: vi.fn(async (_options: unknown, task: (progress: unknown, token: unknown) => unknown) => { const token = { onCancellationRequested: vi.fn(() => ({ dispose: vi.fn() })) }; return task({ report: vi.fn() }, token); @@ -152,6 +153,12 @@ export function createVscodeMock() { } return true; }), + fs: { + writeFile: vi.fn(async () => undefined), + }, + }, + env: { + openExternal: vi.fn(async () => true), }, _documentContents: documentContents, commands: {