Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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`.
39 changes: 39 additions & 0 deletions openspec/specs/vscode-extension/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

30 changes: 8 additions & 22 deletions packages/core/src/sprint-report-pdf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
6 changes: 6 additions & 0 deletions packages/extension/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
6 changes: 5 additions & 1 deletion packages/extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -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"
Expand Down
Loading
Loading