diff --git a/docs/implementation/cli-commands.md b/docs/implementation/cli-commands.md index 0cab51ea..cf5a3eea 100644 --- a/docs/implementation/cli-commands.md +++ b/docs/implementation/cli-commands.md @@ -16,6 +16,7 @@ The CLI exposes three primary commands (`search`, `languages`, `feedback`) that | `pkg info ` | package spec | `--verbose`, `--json` | Show a package overview (latest version, downloads, license, vulnerabilities) | | `pkg vulns ` | package spec (optional `@version`) | `--severity`, `--include-withdrawn`, `--verbose`, `--json` | List known vulnerabilities for a package (npm/pypi/hex/crates) | | `pkg deps ` | package spec (optional `@version`) | `--groups`, `--lifecycle`, `--transitive`, `--depth`, `--verbose`, `--json` | Analyse dependencies: direct runtime deps, structured groups, optional transitive graph (npm/pypi/hex/crates/vcpkg/zig) | +| `pkg changelog [spec]` | package spec OR `--repo-url` | `--from`, `--to`, `--limit`, `--git-ref`, `--no-body`, `--verbose`, `--json` | Release notes / changelog entries for a package or GitHub repo (GitHub Releases, CHANGELOG.md, or HexDocs). Default shows each entry with a 10-line body preview; `--verbose` uncaps, `--no-body` drops. | ### `githits init` @@ -186,6 +187,43 @@ Analyses dependencies for a package on npm, PyPI, Hex, Crates, vcpkg, or Zig. De **Troubleshooting.** Same debug areas as `pkg info` / `pkg vulns` (`GITHITS_DEBUG=pkg-intel` for classified errors; `GITHITS_DEBUG=pkg-graphql` for transport failures). +### `githits pkg changelog` + +``` +githits pkg changelog npm:express +githits pkg changelog npm:express --from 4.0.0 +githits pkg changelog npm:express --to 4.18.0 --limit 5 +githits pkg changelog --repo-url https://github.com/expressjs/express --git-ref main +githits pkg changelog npm:express --json +githits pkg changelog pypi:requests --no-body --json # lean timeline +``` + +Fetches release notes or changelog entries for a package or GitHub repository. Output is a newest-first list with a summary header identifying the source (GitHub Releases, CHANGELOG.md, or HexDocs). + +**Addressing.** `` (`registry:name`, same parser as `pkg info` / `pkg vulns` / `pkg deps`) **or** `--repo-url `, mutually exclusive. Unlike the other `pkg` commands, `pkg changelog` is intrinsically repo-level on the backend — its sources live on the repo, not the registry — so repo-URL addressing is a first-class peer mode. + +**`@` rejected.** `pkg vulns` and `pkg deps` both treat `@version` as "for this exact version", but `pkg changelog` has no single-version query: all entries live on a timeline. Remapping `@version` to `--to` would be a silent semantic shift. CLI rejects with `INVALID_ARGUMENT` and a hint pointing to `--to ` (or `--from ` for range mode). + +**Two modes.** Latest mode is the default; `--limit ` (1–50, backend default 10) caps entry count. `--from ` switches to range mode — returns every entry between `--from` and `--to` (or latest) with no count cap. `--to ` works in either mode. `--from` + `--limit` together is rejected client-side with a hint. + +**Pre-release versions.** Backend-normalised versions flow through unchanged (`5.0.0-rc.1`, `2.32.0.dev0`, `1.7.0-rc.5` round-trip cleanly on `--from` / `--to`). Tag-style `v`-prefixed inputs are rejected on any version flag, consistent with `pkg vulns` / `pkg deps`. + +**Default terminal output.** Summary header (`name · registry · source · mode · entry count`) followed by each entry's `version date url` header plus the first 10 lines of its markdown body, indented and dimmed. Bodies longer than the cap show a footer `… (+N more lines — use --verbose for the full body)`. Missing dates render as `—`; missing versions render as `(unversioned)`. The version column is padded to the longest entry in the current response (no fixed width). + +**`--verbose`.** Uncaps the body preview — every entry's full markdown body renders, indented and dimmed, with no truncation footer. Terminal-only — does not change `--json` output. + +**`--no-body`.** Drops body fields from entries. Affects both terminal output (no body preview, no footer) and `--json` (entry objects lose the `body` field). Mirrors MCP's `include_bodies: false`. Default `--json` keeps full markdown bodies; use `--no-body` when you only need the version / date / URL timeline (drops 10 KB+ per entry on large release notes — measured 5.13× size reduction on `npm:typescript --limit 20`). + +**JSON envelope.** `{registry?, name?, repoUrl?, source, mode, entries: {count, items}, filter?}`. `source` is always present (the null-source case is promoted to `NOT_FOUND` at the service boundary and never reaches this shape). `entries.count` is computed client-side from `items.length`. `filter` emits only when the caller explicitly supplied one of `--from`, `--to`, `--limit`, `--git-ref`; backend defaults don't round-trip as caller intent. + +**Per-entry shape.** `{version, normalizedVersion?, publishedAt?, htmlUrl?, body?}`. `version` is kept even when null so agents can map `items.map(e => e.version)` without guarding; other nullable fields are stripped. The backend's opaque per-entry `metadata` GenericJSON is deliberately dropped from the envelope — revisit via agent feedback. + +**Errors.** `NOT_FOUND` covers both the backend's "package not found" case and the distinct "package exists but no changelog source resolved" case (typed `PackageIntelligenceChangelogSourceNotFoundError`; message names the sources that were tried). `VERSION_NOT_FOUND` enriches with structured `package` / `requested` / `available` detail lines from the shared `promoteGenericVersionNotFound` helper — which was extended in this PR to recognise `--from` and `--to` as promotable version inputs. + +**Capability gate.** Same as the rest of the `pkg` family (inherits from the `code_navigation` token capability). + +**Troubleshooting.** Same debug areas (`GITHITS_DEBUG=pkg-intel` for classified errors; `GITHITS_DEBUG=pkg-graphql` for transport failures). + ## Architecture ``` diff --git a/docs/implementation/mcp-cli-parity.md b/docs/implementation/mcp-cli-parity.md index 14aa172c..f170d7a7 100644 --- a/docs/implementation/mcp-cli-parity.md +++ b/docs/implementation/mcp-cli-parity.md @@ -152,20 +152,25 @@ When a new tool lands with both MCP and CLI surfaces: | `src/shared/package-vulnerabilities-response.ts` | Lean JSON envelope builder for `package_vulnerabilities` (shared); terminal formatter (CLI-only). | | `src/shared/package-dependencies-request.ts` | Shared request builder for `package_dependencies`; owns `supportsDependenciesRegistry` + lifecycle / depth validation. | | `src/shared/package-dependencies-response.ts` | Lean JSON envelope builder for `package_dependencies` (shared); terminal formatter (CLI-only). | +| `src/shared/package-changelog-request.ts` | Shared request builder for `package_changelog`; owns spec-XOR-repo-URL validation, `@` rejection, `--from` / `--limit` mutex, tag-style version rejection, and the `explicitFilterFields` tracker. | +| `src/shared/package-changelog-response.ts` | JSON envelope builder for `package_changelog` (shared); terminal formatter (CLI-only). | | `src/shared/package-intelligence-error-map.ts` | `mapPackageIntelligenceError` classifier (reuses `MappedError` from the code-nav map). | -| `src/services/promote-version-not-found.ts` | Shared helper that promotes generic backend errors with "no matching version" messages into typed `VERSION_NOT_FOUND`. Used by `packageVulnerabilities` and `packageDependencies` executors. | +| `src/services/promote-version-not-found.ts` | Shared helper that promotes generic backend errors with "no matching version" messages into typed `VERSION_NOT_FOUND`. Used by `packageVulnerabilities`, `packageDependencies`, and `packageChangelog` executors. Extended in P4 to recognise `fromVersion` / `toVersion` (and to skip the `details.package` synthesis when registry/name aren't available, i.e. repo-URL mode). | | `src/tools/search-symbols.ts` | MCP tool definition for `search_symbols`. | | `src/tools/package-summary.ts` | MCP tool definition for `package_summary`. | | `src/tools/package-vulnerabilities.ts` | MCP tool definition for `package_vulnerabilities`. | | `src/tools/package-dependencies.ts` | MCP tool definition for `package_dependencies`. | +| `src/tools/package-changelog.ts` | MCP tool definition for `package_changelog`. | | `src/commands/code/search-symbols.ts` | CLI command. | | `src/commands/pkg/info.ts` | CLI command for `pkg info`. | | `src/commands/pkg/vulns.ts` | CLI command for `pkg vulns`. | | `src/commands/pkg/deps.ts` | CLI command for `pkg deps`. | +| `src/commands/pkg/changelog.ts` | CLI command for `pkg changelog`. | | `src/tools/search-symbols-parity.test.ts` | Parity tests (cite rule IDs). | | `src/tools/package-summary-parity.test.ts` | Parity tests for `package_summary` (cite rule IDs). | | `src/tools/package-vulnerabilities-parity.test.ts` | Parity tests for `package_vulnerabilities` (cite rule IDs). | | `src/tools/package-dependencies-parity.test.ts` | Parity tests for `package_dependencies` (cite rule IDs). | +| `src/tools/package-changelog-parity.test.ts` | Parity tests for `package_changelog` (cite rule IDs). | ## Per-tool notes @@ -292,3 +297,75 @@ When a new tool lands with both MCP and CLI surfaces: - `toMatchObject` for builder-sourced `INVALID_ARGUMENT` cases: unsupported registry (`nuget`), tag-style version (`v4.18.0`), unknown lifecycle token (`dev`). + +### `package_changelog` + +- **Dual addressing — the only pkg-intel tool with it.** `registry` + + `package_name` XOR `repo_url` on both surfaces. Justified because + `packageChangelog` is intrinsically repo-level on the backend (its + sources are GitHub Releases, CHANGELOG.md, HexDocs); repo-URL + isn't a bolt-on, it's a peer addressing mode on the GraphQL + signature. P1 / P2 / P3 omit it because their backend queries are + registry-metadata APIs without repo-URL alternatives. Future + pkg-intel tool authors should not cargo-cult the asymmetry. +- **`@` rejected.** Other `pkg` commands give + `@version` a meaning (`for this exact version`), but changelog + has no single-version query — remapping to `to_version` would be + a client-invented semantic shift. Both CLI and MCP reject with + `INVALID_ARGUMENT` and redirect callers to `--to` / `to_version`. +- **Mode mutex enforced client-side.** `--from` / `from_version` + + `--limit` / `limit` together → `INVALID_ARGUMENT`. The backend's + same-shape rejection is generic; we catch it with a specific hint + before the wire. +- **`filter.*` echo tracks explicit fields only.** Request builder + exposes an `explicitFilterFields` set (`fromVersion`, `toVersion`, + `limit`, `gitRef`). The envelope builder consults the set before + emitting `filter.*`, so backend-default values + (e.g. `limit: 10` from the wire echo) never round-trip as caller + intent. +- **`entries: { count, items }` shape.** Matches the `runtime: + { count, items }` convention from `package_dependencies`. + `entries.count === entries.items.length` by construction; the + backend's count field is never selected on the wire. +- **`version` kept when null, other per-entry nullables stripped.** + `version` is the primary key agents index by, so the slot is + always present (possibly `null`). Other nullable fields + (`normalizedVersion`, `publishedAt`, `htmlUrl`, `body`) are + stripped when absent to keep the envelope lean. `body` is + additionally stripped when `include_bodies: false`. +- **`metadata` dropped.** `ChangelogEntry.metadata` is backend + `GenericJSON`; v1 envelope drops it entirely rather than + guessing at its shape. Revisit via agent feedback + (`TODO(pkgseer-backend)` anchor on the service type). +- **`source: null` promoted to `NOT_FOUND`.** The service layer + promotes the null-source case to a typed + `PackageIntelligenceChangelogSourceNotFoundError` which the + shared classifier routes to `NOT_FOUND` with a message naming + the sources tried (GitHub Releases, CHANGELOG.md, HexDocs). + `source: "releases"` + `entries.items: []` is success — "no + entries in this range" is a legitimate neutral outcome. +- **`--verbose` vs `--no-body` vs `--json` interaction.** + Default terminal output shows each entry's body truncated at + 10 lines with a `… (+N more lines — use --verbose …)` footer. + `--verbose` lifts the cap (terminal-only — does not change + `--json` output). `--no-body` mirrors MCP's `include_bodies: + false` and affects both terminal (no body preview, no footer) + and `--json` (entry objects lose the `body` field) — explicit + opt-out, not silent truncation. `--no-body` + `--verbose` is + rejected with a specific hint because the two flags contradict. +- **`promoteGenericVersionNotFound` extension.** The shared helper + now recognises `fromVersion` / `toVersion` in addition to + `version`. Preference order: `version → fromVersion → toVersion`. + In repo-URL mode (no `registry` / `packageName`), + `details.package` is omitted; the error-map handles + `details.package === undefined` gracefully. +- **Parity assertion policy** (coded in + `src/tools/package-changelog-parity.test.ts`): + - `toEqual` across service-sourced fixtures: happy latest mode, + range mode (`--from` / `from_version`), repo-URL addressing, + `--no-body` / `include_bodies: false`, default bodies, empty + entries, `NOT_FOUND` (no source), `PackageIntelligenceTargetNotFoundError` + (package missing), `VERSION_NOT_FOUND` with structured details, + `BACKEND_ERROR`. + - `toMatchObject` for builder-sourced `INVALID_ARGUMENT` cases: + `@` rejection, `--from` + `--limit` mutex. diff --git a/docs/implementation/tools.md b/docs/implementation/tools.md index 9527a804..7782823c 100644 --- a/docs/implementation/tools.md +++ b/docs/implementation/tools.md @@ -26,8 +26,9 @@ Both expose the same tools with identical names, parameters, and descriptions. T | `package_summary` | `registry`, `package_name` | Package overview: latest version, license, description, repository, downloads, GitHub metadata, install command, and known vulnerabilities. Always returns the latest published version. | | `package_vulnerabilities` | `registry`, `package_name`, `version?`, `min_severity?`, `include_withdrawn?` | Known vulnerabilities for a package on npm, PyPI, Hex, or Crates. Count summary, per-advisory OSV ID + severity + affected/fix ranges, and upgrade paths. Malware is surfaced in a disjoint bucket. | | `package_dependencies` | `registry`, `package_name`, `version?`, `lifecycle?`, `include_transitive?`, `include_importers?`, `max_depth?` | Direct runtime dependency list (each `{name, version, constraint}` — the backend resolves each constraint to a concrete version) plus, when the backend has them, structured groups for dev / peer / build / optional with registry-specific condition metadata (PyPI extras, Crates features). Optional transitive block with aggregate edge counts, the preprocessed install footprint as `{name, version}`, typed conflicts and circular-dependency cycles; opt into per-package importer provenance with `include_importers`. | +| `package_changelog` | `registry?`, `package_name?`, `repo_url?`, `from_version?`, `to_version?`, `limit?`, `git_ref?`, `include_bodies?` | Release notes or changelog entries for a package or GitHub repo. Default latest mode returns the ten most recent entries; `from_version` switches to range mode (no count cap). Dual addressing (spec vs repo URL) mutually exclusive. Response always includes `source` (`"releases"` / `"changelog_file"` / `"hexdocs"`), `mode` (`"latest"` / `"range"`), and `entries: { count, items }` with full markdown bodies by default; set `include_bodies: false` for a lean version / date / URL timeline. | -`search_symbols`, `package_summary`, `package_vulnerabilities`, and `package_dependencies` are only registered when the startup token advertises `code_navigation` capability. The backend endpoint can be overridden via `GITHITS_CODE_NAV_URL` for local development. Capability gating keeps the tools hidden from public/default flows while the feature is still rolling out. +`search_symbols`, `package_summary`, `package_vulnerabilities`, `package_dependencies`, and `package_changelog` are only registered when the startup token advertises `code_navigation` capability. The backend endpoint can be overridden via `GITHITS_CODE_NAV_URL` for local development. Capability gating keeps the tools hidden from public/default flows while the feature is still rolling out. `search_symbols` shares request-construction, error classification, and JSON-payload shape with the CLI `githits code search` command via shared helpers under `src/shared/`. The parity rules are codified in [`mcp-cli-parity.md`](./mcp-cli-parity.md); the parity test (`src/tools/search-symbols-parity.test.ts`) asserts that both surfaces emit identical JSON for equivalent inputs. @@ -83,6 +84,28 @@ Both expose the same tools with identical names, parameters, and descriptions. T `package_dependencies` shares its envelope builder with the CLI `githits pkg deps` command via `src/shared/package-dependencies-request.ts` and `src/shared/package-dependencies-response.ts`. The terminal formatter is CLI-only. The parity test (`src/tools/package-dependencies-parity.test.ts`) asserts `toEqual` across every service-sourced success / error fixture (runtime, zero-dep, full-view, optional-lifecycle, multi-lifecycle, filter-matched-nothing, Crates-target-cfg dedup round-trip, transitive, versioned match / diff, NOT_FOUND, VERSION_NOT_FOUND, BACKEND_ERROR) and `toMatchObject` for builder-sourced `INVALID_ARGUMENT` (unsupported registry, tag-style version, unknown lifecycle). +### `package_changelog` response shape + +**Data-first envelope.** The top level carries addressing (`registry` + `name` for spec addressing, or `repoUrl` for repo-URL addressing), `source` (`"releases"` / `"changelog_file"` / `"hexdocs"`), and `mode` (`"latest"` or `"range"`). Entries live under `entries: { count, items }` — matching the `{count, items}` shape used by `package_dependencies.runtime`. `count` is computed client-side from `items.length`, so the invariant holds regardless of backend drift. + +**Per-entry shape.** `{version, normalizedVersion?, publishedAt?, htmlUrl?, body?}`. `version` is kept in the envelope even when `null` so agents can write `items.map(e => e.version)` without guarding; every other nullable field is stripped when absent. `body` is additionally stripped when the caller set `include_bodies: false`. The backend's opaque per-entry `metadata` GenericJSON is deliberately dropped from the envelope in v1 — revisit via agent feedback. + +**Dual addressing (`registry` + `package_name` XOR `repo_url`).** `package_changelog` is the only pkg-intel MCP tool with dual addressing. P1 / P2 / P3 all accept only `registry` + `package_name` because their underlying backend queries (summary / vulnerabilities / dependencies) are registry-metadata APIs without repo-URL alternatives. `packageChangelog` is intrinsically repo-level — its sources are GitHub Releases, CHANGELOG.md, and HexDocs — so `repoUrl` is a peer addressing mode in the GraphQL signature, not a bolt-on. Future tool authors should not cargo-cult the asymmetry without reading this rationale. + +**Mode selection.** `from_version` triggers range mode (returns every entry in `[fromVersion, toVersion]` with no cap). Latest mode is the default, capped by `limit` (1–50, backend default 10). `from_version` + `limit` is rejected client-side with `INVALID_ARGUMENT` rather than silently routed to one mode. + +**`include_bodies` lever.** Release bodies on large packages (Kubernetes, Node) can run 10 KB+ per entry; a 100-entry range could produce a multi-hundred-KB envelope. `include_bodies: false` opts out explicitly — not silent truncation. Other fields (version / normalizedVersion / publishedAt / htmlUrl) remain so agents still get the release timeline. CLI's `--no-body` mirrors this flag and affects both terminal rendering and `--json` output. The CLI terminal separately caps each body at 10 lines by default for readability (with a `… (+N more — use --verbose …)` footer); `--verbose` uncaps that preview but does not change `--json` output. + +**`filter.*` echo.** `filter` is emitted only when the caller explicitly supplied at least one of `from_version`, `to_version`, `limit`, or `git_ref`. Backend-default `limit: 10` / `toVersion: ` is never echoed. The request builder tracks explicit-vs-defaulted via an `explicitFilterFields` set so defaults don't round-trip as caller intent. + +**Version validation.** Same rule as `package_vulnerabilities` / `package_dependencies`: tag-style `v`-prefixed inputs on `from_version` / `to_version` are rejected client-side with `INVALID_ARGUMENT`. `@` is also rejected — the `pkg changelog` family has no single-version query, and silently remapping to `to_version` would be a client-invented semantic shift. Hint text redirects callers to `--to` / `to_version`. + +**NOT_FOUND semantics.** Backend `source === null` is promoted to a typed `PackageIntelligenceChangelogSourceNotFoundError` at the service boundary, which the shared classifier routes to the `NOT_FOUND` envelope with a message naming the sources that were tried. Empty `entries.items: []` with a valid `source` is a success, not an error — "no entries in this range" is a legitimate neutral outcome. + +**Overlap with `package_summary`.** `package_summary` already surfaces a short-form `recentChanges` block (from the backend's `latestChangelogs` field on `PackageSummaryResult`). For a quick "what shipped recently" glance embedded in a package overview, use `package_summary`. For the full range-capable, body-rich, `include_bodies`-toggleable changelog with `--no-body` timeline mode and repo-URL addressing, use `package_changelog`. + +`package_changelog` shares its envelope builder with the CLI `githits pkg changelog` command via `src/shared/package-changelog-request.ts` and `src/shared/package-changelog-response.ts`. The terminal formatter is CLI-only. The parity test (`src/tools/package-changelog-parity.test.ts`) asserts `toEqual` across every service-sourced success / error fixture (happy latest, range mode, repo-URL addressing, `--no-body` / `include_bodies: false`, default bodies, empty entries, NOT_FOUND, PackageIntelligenceTargetNotFoundError, VERSION_NOT_FOUND, BACKEND_ERROR) and `toMatchObject` for builder-sourced `INVALID_ARGUMENT`. + ## Server instructions The MCP server advertises a short, cross-tool orientation via the protocol's server-level `instructions` field. This is distinct from per-tool `description` text: instructions cover rationale, workflow glue, and decisions that span multiple tools, while per-tool descriptions remain the source of truth for arguments, output shape, and tool-specific constraints. diff --git a/src/commands/mcp-instructions.test.ts b/src/commands/mcp-instructions.test.ts index 63a59789..7a6fc129 100644 --- a/src/commands/mcp-instructions.test.ts +++ b/src/commands/mcp-instructions.test.ts @@ -50,6 +50,7 @@ const KNOWN_TOOLS = [ "package_summary", "package_vulnerabilities", "package_dependencies", + "package_changelog", ] as const; function mentionedTools(instructions: string): Set { @@ -106,6 +107,7 @@ describe("buildMcpInstructions", () => { expect(instructions).not.toContain("package_summary"); expect(instructions).not.toContain("package_vulnerabilities"); expect(instructions).not.toContain("package_dependencies"); + expect(instructions).not.toContain("package_changelog"); expect(instructions).not.toContain("search_symbols"); }); @@ -123,6 +125,7 @@ describe("buildMcpInstructions", () => { expect(instructions).toContain("`package_summary`"); expect(instructions).toContain("`package_vulnerabilities`"); expect(instructions).toContain("`package_dependencies`"); + expect(instructions).toContain("`package_changelog`"); expect(instructions).toContain("`search_symbols`"); }); @@ -179,6 +182,7 @@ describe("buildMcpInstructions", () => { expect(instructions).toContain("`package_summary`"); expect(instructions).toContain("`package_vulnerabilities`"); expect(instructions).toContain("`package_dependencies`"); + expect(instructions).toContain("`package_changelog`"); expect(instructions).not.toContain("`search_symbols`"); // The decision tip references search_symbols, so it must not // appear when search_symbols isn't registered. @@ -246,6 +250,7 @@ describe("buildMcpInstructions", () => { "package_summary", "package_vulnerabilities", "package_dependencies", + "package_changelog", ]; for (const name of packageTools) { if (registered.has(name)) { diff --git a/src/commands/mcp-instructions.ts b/src/commands/mcp-instructions.ts index b6190fdf..cc5fba53 100644 --- a/src/commands/mcp-instructions.ts +++ b/src/commands/mcp-instructions.ts @@ -32,6 +32,9 @@ const PACKAGE_VULNERABILITIES_BULLET = const PACKAGE_DEPENDENCIES_BULLET = "- `package_dependencies` — direct runtime deps plus, when the backend has them, dev / peer / optional / feature groups. Pass `lifecycle` to filter groups server-side, or `include_transitive` for the full graph, conflict detection, and circular-dependency flags. Supports npm, PyPI, Hex, Crates, vcpkg, and Zig."; +const PACKAGE_CHANGELOG_BULLET = + "- `package_changelog` — release notes for a package or GitHub repo, newest-first. Default latest mode returns the 10 most recent entries with full markdown bodies; `from_version` switches to range mode between two versions. Addressable via `registry` + `package_name` or `repo_url`. Set `include_bodies: false` for a version / date / URL timeline when bodies aren't needed."; + const SEARCH_SYMBOLS_BULLET = "- `search_symbols` — text search across a dependency's source. On an INDEXING response, retry with a larger `wait_timeout_ms` (up to 60000)."; @@ -79,6 +82,7 @@ export function buildMcpInstructions(deps: Dependencies): string { bullets.push(PACKAGE_SUMMARY_BULLET); bullets.push(PACKAGE_VULNERABILITIES_BULLET); bullets.push(PACKAGE_DEPENDENCIES_BULLET); + bullets.push(PACKAGE_CHANGELOG_BULLET); } if (deps.codeNavigationService) { bullets.push(SEARCH_SYMBOLS_BULLET); diff --git a/src/commands/mcp.ts b/src/commands/mcp.ts index ab42823a..48d22790 100644 --- a/src/commands/mcp.ts +++ b/src/commands/mcp.ts @@ -6,6 +6,7 @@ import { createContainer, type Dependencies } from "../container.js"; import { dim, highlight, shouldUseColors } from "../shared/colors.js"; import { createFeedbackTool, + createPackageChangelogTool, createPackageDependenciesTool, createPackageSummaryTool, createPackageVulnerabilitiesTool, @@ -43,6 +44,7 @@ export function getMcpToolDefinitions( createPackageVulnerabilitiesTool(deps.packageIntelligenceService), ); tools.push(createPackageDependenciesTool(deps.packageIntelligenceService)); + tools.push(createPackageChangelogTool(deps.packageIntelligenceService)); } return tools; diff --git a/src/commands/pkg/changelog.test.ts b/src/commands/pkg/changelog.test.ts new file mode 100644 index 00000000..29f3633d --- /dev/null +++ b/src/commands/pkg/changelog.test.ts @@ -0,0 +1,443 @@ +import { describe, expect, it, mock, spyOn } from "bun:test"; +import { + PackageIntelligenceChangelogSourceNotFoundError, + PackageIntelligenceTargetNotFoundError, + PackageIntelligenceVersionNotFoundError, +} from "../../services/index.js"; +import { + createMockPackageIntelligenceService, + defaultChangelogReport, +} from "../../services/test-helpers.js"; +import { + type PkgChangelogCommandDependencies, + pkgChangelogAction, +} from "./changelog.js"; + +describe("pkgChangelogAction", () => { + const mcpUrl = "https://mcp.githits.com"; + + function createDeps( + overrides: Partial = {}, + ): PkgChangelogCommandDependencies { + return { + packageIntelligenceService: createMockPackageIntelligenceService(), + codeNavigationUrl: "https://pkgseer.dev", + hasValidToken: true, + mcpUrl, + ...overrides, + }; + } + + it("renders the default summary + one-liner entries via stdout.write", async () => { + const writes: string[] = []; + const writeSpy = spyOn(process.stdout, "write").mockImplementation((( + chunk: string | Uint8Array, + ) => { + writes.push( + typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk), + ); + return true; + }) as typeof process.stdout.write); + + await pkgChangelogAction("npm:express", {}, createDeps()); + + const combined = writes.join(""); + expect(combined).toContain("express · npm"); + expect(combined).toContain("source: GitHub Releases"); + expect(combined).toContain("2 entries"); + expect(combined).toContain("5.2.1"); + // Default now shows bodies (capped at 10 lines). Fixture bodies + // are well under the cap, so full content appears without the + // "… more lines" footer. + expect(combined).toContain("## Patch"); + expect(combined).toContain("- fixed a thing"); + expect(combined).not.toContain("use --verbose"); + writeSpy.mockRestore(); + }); + + it("renders the truncation footer when a body exceeds the default cap", async () => { + const writes: string[] = []; + const writeSpy = spyOn(process.stdout, "write").mockImplementation((( + chunk: string | Uint8Array, + ) => { + writes.push( + typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk), + ); + return true; + }) as typeof process.stdout.write); + + // Service returns a single entry with 25 body lines — exceeds + // the 10-line default cap. Expect the first 10 lines + a footer. + const longBody = Array.from({ length: 25 }, (_, i) => `line ${i + 1}`).join( + "\n", + ); + const longReport = { + package: { name: "big-pkg", registry: "npm", limit: 10 }, + source: "releases", + entries: [ + { + version: "1.0.0", + normalizedVersion: "1.0.0", + publishedAt: "2026-01-01T00:00:00Z", + htmlUrl: "https://example.com", + body: longBody, + }, + ], + }; + await pkgChangelogAction( + "npm:big-pkg", + {}, + createDeps({ + packageIntelligenceService: createMockPackageIntelligenceService({ + packageChangelog: mock(() => Promise.resolve(longReport)), + }), + }), + ); + + const combined = writes.join(""); + expect(combined).toContain("line 1"); + expect(combined).toContain("line 10"); + expect(combined).not.toContain("line 11"); + expect(combined).toContain("+15 more lines"); + expect(combined).toContain("use --verbose for the full body"); + writeSpy.mockRestore(); + }); + + it("expands bodies fully under --verbose (no truncation footer)", async () => { + const writes: string[] = []; + const writeSpy = spyOn(process.stdout, "write").mockImplementation((( + chunk: string | Uint8Array, + ) => { + writes.push( + typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk), + ); + return true; + }) as typeof process.stdout.write); + + // Use a body that exceeds the default cap — under --verbose it + // should render in full with no truncation footer. + const longBody = Array.from({ length: 25 }, (_, i) => `line ${i + 1}`).join( + "\n", + ); + const longReport = { + package: { name: "big-pkg", registry: "npm", limit: 10 }, + source: "releases", + entries: [ + { + version: "1.0.0", + normalizedVersion: "1.0.0", + publishedAt: "2026-01-01T00:00:00Z", + htmlUrl: "https://example.com", + body: longBody, + }, + ], + }; + await pkgChangelogAction( + "npm:big-pkg", + { verbose: true }, + createDeps({ + packageIntelligenceService: createMockPackageIntelligenceService({ + packageChangelog: mock(() => Promise.resolve(longReport)), + }), + }), + ); + + const combined = writes.join(""); + expect(combined).toContain("line 1"); + expect(combined).toContain("line 25"); + expect(combined).not.toContain("use --verbose"); + expect(combined).not.toContain("more line"); + writeSpy.mockRestore(); + }); + + it("emits the JSON envelope when --json is set", async () => { + const logSpy = spyOn(console, "log").mockImplementation(() => {}); + + await pkgChangelogAction("npm:express", { json: true }, createDeps()); + + const output = logSpy.mock.calls[0]?.[0] as string; + const payload = JSON.parse(output); + expect(payload.registry).toBe("npm"); + expect(payload.name).toBe("express"); + expect(payload.source).toBe("releases"); + expect(payload.mode).toBe("latest"); + expect(payload.entries.count).toBe(2); + expect(payload.entries.items[0].body).toBe("## Patch\n- fixed a thing"); + logSpy.mockRestore(); + }); + + it("drops body fields in JSON when --no-body is set", async () => { + const logSpy = spyOn(console, "log").mockImplementation(() => {}); + + // Commander's --no-body => body: false in the action options. + await pkgChangelogAction( + "npm:express", + { json: true, body: false }, + createDeps(), + ); + + const output = logSpy.mock.calls[0]?.[0] as string; + const payload = JSON.parse(output); + for (const item of payload.entries.items) { + expect(item.body).toBeUndefined(); + } + // Other fields preserved + expect(payload.entries.items[0].version).toBe("5.2.1"); + expect(payload.entries.items[0].publishedAt).toBe("2026-01-15T12:00:00Z"); + logSpy.mockRestore(); + }); + + it("sends spec addressing on the wire (uppercase registry, packageName)", async () => { + const packageChangelog = mock(() => + Promise.resolve(defaultChangelogReport), + ); + const writeSpy = spyOn(process.stdout, "write").mockImplementation( + (() => true) as typeof process.stdout.write, + ); + + await pkgChangelogAction( + "npm:express", + {}, + createDeps({ + packageIntelligenceService: createMockPackageIntelligenceService({ + packageChangelog, + }), + }), + ); + + const calls = packageChangelog.mock.calls as unknown as Array< + [{ registry?: string; packageName?: string; repoUrl?: string }] + >; + expect(calls[0]?.[0]?.registry).toBe("NPM"); + expect(calls[0]?.[0]?.packageName).toBe("express"); + expect(calls[0]?.[0]?.repoUrl).toBeUndefined(); + writeSpy.mockRestore(); + }); + + it("sends repo-url addressing when --repo-url is set", async () => { + const packageChangelog = mock(() => + Promise.resolve(defaultChangelogReport), + ); + const writeSpy = spyOn(process.stdout, "write").mockImplementation( + (() => true) as typeof process.stdout.write, + ); + + await pkgChangelogAction( + undefined, + { repoUrl: "https://github.com/expressjs/express" }, + createDeps({ + packageIntelligenceService: createMockPackageIntelligenceService({ + packageChangelog, + }), + }), + ); + + const calls = packageChangelog.mock.calls as unknown as Array< + [{ registry?: string; packageName?: string; repoUrl?: string }] + >; + expect(calls[0]?.[0]?.registry).toBeUndefined(); + expect(calls[0]?.[0]?.packageName).toBeUndefined(); + expect(calls[0]?.[0]?.repoUrl).toBe("https://github.com/expressjs/express"); + writeSpy.mockRestore(); + }); + + it("rejects @ with a hint pointing to --to / --from", async () => { + const errorSpy = spyOn(console, "error").mockImplementation(() => {}); + const exitSpy = spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + + try { + await pkgChangelogAction("npm:express@5.0.0", {}, createDeps()); + } catch { + /* expected */ + } + + const msg = errorSpy.mock.calls[0]?.[0] as string; + expect(msg).toContain("--to"); + expect(msg).toContain("--from"); + errorSpy.mockRestore(); + exitSpy.mockRestore(); + }); + + it("rejects --from + --limit together", async () => { + const errorSpy = spyOn(console, "error").mockImplementation(() => {}); + const exitSpy = spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + + try { + await pkgChangelogAction( + "npm:express", + { from: "4.0.0", limit: "10" }, + createDeps(), + ); + } catch { + /* expected */ + } + + const msg = errorSpy.mock.calls[0]?.[0] as string; + expect(msg).toContain("latest-mode"); + errorSpy.mockRestore(); + exitSpy.mockRestore(); + }); + + it("rejects non-numeric --limit input", async () => { + const errorSpy = spyOn(console, "error").mockImplementation(() => {}); + const exitSpy = spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + + try { + await pkgChangelogAction("npm:express", { limit: "abc" }, createDeps()); + } catch { + /* expected */ + } + + const msg = errorSpy.mock.calls[0]?.[0] as string; + expect(msg).toContain("integer"); + errorSpy.mockRestore(); + exitSpy.mockRestore(); + }); + + it("rejects --no-body + --verbose with an actionable hint", async () => { + const errorSpy = spyOn(console, "error").mockImplementation(() => {}); + const exitSpy = spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + + try { + await pkgChangelogAction( + "npm:express", + { body: false, verbose: true }, + createDeps(), + ); + } catch { + /* expected */ + } + + const msg = errorSpy.mock.calls[0]?.[0] as string; + expect(msg).toContain("--no-body"); + expect(msg).toContain("--verbose"); + expect(msg).toContain("uncaps"); + errorSpy.mockRestore(); + exitSpy.mockRestore(); + }); + + it("rejects tag-style --from versions", async () => { + const errorSpy = spyOn(console, "error").mockImplementation(() => {}); + const exitSpy = spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + + try { + await pkgChangelogAction("npm:express", { from: "v4.0.0" }, createDeps()); + } catch { + /* expected */ + } + + const msg = errorSpy.mock.calls[0]?.[0] as string; + expect(msg).toContain("git tag"); + errorSpy.mockRestore(); + exitSpy.mockRestore(); + }); + + it("routes NOT_FOUND (no changelog source) through the error envelope", async () => { + const errorSpy = spyOn(console, "error").mockImplementation(() => {}); + const exitSpy = spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + + try { + await pkgChangelogAction( + "npm:obscure-pkg", + { json: true }, + createDeps({ + packageIntelligenceService: createMockPackageIntelligenceService({ + packageChangelog: mock(() => + Promise.reject( + new PackageIntelligenceChangelogSourceNotFoundError( + "No changelog source available for npm:obscure-pkg.", + ), + ), + ), + }), + }), + ); + } catch { + /* expected */ + } + + const payload = JSON.parse(errorSpy.mock.calls[0]?.[0] as string); + expect(payload.code).toBe("NOT_FOUND"); + errorSpy.mockRestore(); + exitSpy.mockRestore(); + }); + + it("renders VERSION_NOT_FOUND with structured detail lines", async () => { + const errorSpy = spyOn(console, "error").mockImplementation(() => {}); + const exitSpy = spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + + try { + await pkgChangelogAction( + "npm:express", + { from: "99.0.0" }, + createDeps({ + packageIntelligenceService: createMockPackageIntelligenceService({ + packageChangelog: mock(() => + Promise.reject( + new PackageIntelligenceVersionNotFoundError( + "No matching version found", + "npm:express", + "99.0.0", + undefined, + ), + ), + ), + }), + }), + ); + } catch { + /* expected */ + } + + const output = errorSpy.mock.calls[0]?.[0] as string; + expect(output).toContain("No matching version found"); + expect(output).toContain("package: npm:express"); + expect(output).toContain("requested: 99.0.0"); + errorSpy.mockRestore(); + exitSpy.mockRestore(); + }); + + it("routes generic PackageIntelligenceTargetNotFoundError as NOT_FOUND", async () => { + const errorSpy = spyOn(console, "error").mockImplementation(() => {}); + const exitSpy = spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + + try { + await pkgChangelogAction( + "npm:does-not-exist", + { json: true }, + createDeps({ + packageIntelligenceService: createMockPackageIntelligenceService({ + packageChangelog: mock(() => + Promise.reject( + new PackageIntelligenceTargetNotFoundError("Package not found"), + ), + ), + }), + }), + ); + } catch { + /* expected */ + } + + const payload = JSON.parse(errorSpy.mock.calls[0]?.[0] as string); + expect(payload.code).toBe("NOT_FOUND"); + errorSpy.mockRestore(); + exitSpy.mockRestore(); + }); +}); diff --git a/src/commands/pkg/changelog.ts b/src/commands/pkg/changelog.ts new file mode 100644 index 00000000..fd6d5002 --- /dev/null +++ b/src/commands/pkg/changelog.ts @@ -0,0 +1,236 @@ +import type { Command } from "commander"; +import { createContainer } from "../../container.js"; +import type { PackageIntelligenceService } from "../../services/index.js"; +import { shouldUseColors } from "../../shared/colors.js"; +import { + InvalidPackageSpecError, + type MappedError, + mapPackageIntelligenceError, + parsePackageSpec, + requireAuth, +} from "../../shared/index.js"; +import { buildPackageChangelogParams } from "../../shared/package-changelog-request.js"; +import { + buildPackageChangelogSuccessPayload, + formatPackageChangelogTerminal, +} from "../../shared/package-changelog-response.js"; +import { toPkgseerRegistryLowercase } from "../../shared/pkgseer-registry.js"; + +export interface PkgChangelogCommandOptions { + repoUrl?: string; + gitRef?: string; + from?: string; + to?: string; + limit?: string; + verbose?: boolean; + noBody?: boolean; + // Commander's `--no-body` flag is surfaced as `body: false` in + // options; we map it to `includeBodies` in the builder. + body?: boolean; + json?: boolean; +} + +export interface PkgChangelogCommandDependencies { + packageIntelligenceService: PackageIntelligenceService | undefined; + codeNavigationUrl: string | undefined; + hasValidToken: boolean; + mcpUrl: string; +} + +/** + * Core `pkg changelog` action. Accepts either `` (same parser + * as `pkg info` / `pkg vulns` / `pkg deps`) or `--repo-url `, + * mutually exclusive. `@` is rejected at the shared + * builder boundary — use `--to ` to cap by version. + */ +export async function pkgChangelogAction( + spec: string | undefined, + options: PkgChangelogCommandOptions, + deps: PkgChangelogCommandDependencies, +): Promise { + requireAuth(deps); + + try { + if (!deps.codeNavigationUrl || !deps.packageIntelligenceService) { + throw new InvalidPackageSpecError( + "Package intelligence is not configured for this environment.", + ); + } + + const parsed = spec !== undefined ? parsePackageSpec(spec) : undefined; + const limit = resolveLimit(options); + const includeBodies = options.body !== false; + if (!includeBodies && options.verbose) { + throw new InvalidPackageSpecError( + "--no-body drops the bodies that --verbose uncaps — pass only one of the two flags.", + ); + } + + const { params, explicitFilterFields } = buildPackageChangelogParams({ + registry: parsed?.registry, + packageName: parsed?.name, + specVersion: parsed?.version, + repoUrl: options.repoUrl, + gitRef: options.gitRef, + fromVersion: options.from, + toVersion: options.to, + limit, + }); + + const report = + await deps.packageIntelligenceService.packageChangelog(params); + + const payload = buildPackageChangelogSuccessPayload(report, { + registry: params.registry + ? toPkgseerRegistryLowercase(params.registry) + : undefined, + name: params.packageName, + repoUrl: params.repoUrl, + mode: params.fromVersion ? "range" : "latest", + explicitFilterFields, + includeBodies, + fromVersion: params.fromVersion, + toVersion: params.toVersion, + limit: params.limit, + gitRef: params.gitRef, + }); + + if (options.json) { + console.log(JSON.stringify(payload)); + return; + } + + const output = formatPackageChangelogTerminal(payload, { + verbose: options.verbose ?? false, + useColors: shouldUseColors(), + }); + process.stdout.write(output); + } catch (error) { + handlePkgChangelogCommandError(error, options.json ?? false); + } +} + +function resolveLimit(options: PkgChangelogCommandOptions): number | undefined { + const raw = options.limit; + if (raw === undefined) return undefined; + if (!/^-?\d+$/.test(raw.trim())) { + throw new InvalidPackageSpecError( + `--limit expects an integer between 1 and 50. Got '${raw}'.`, + ); + } + return Number.parseInt(raw, 10); +} + +function handlePkgChangelogCommandError(error: unknown, json: boolean): never { + const mapped = mapPackageIntelligenceError(error); + + if (json) { + console.error( + JSON.stringify({ + error: mapped.message, + code: mapped.code, + retryable: mapped.retryable ?? false, + ...(mapped.details ? { details: mapped.details } : {}), + }), + ); + process.exit(1); + } + + console.error(formatChangelogTerminalError(mapped)); + process.exit(1); +} + +/** + * Mirrors `pkg vulns` / `pkg deps` — enriches VERSION_NOT_FOUND with + * the package and requested version (the shared helper populated + * these from `fromVersion` / `toVersion` when `version` wasn't set). + */ +function formatChangelogTerminalError(mapped: MappedError): string { + if (mapped.code !== "VERSION_NOT_FOUND") return mapped.message; + const detail = mapped.details ?? {}; + const pkg = typeof detail.package === "string" ? detail.package : undefined; + const requested = + typeof detail.requestedVersion === "string" + ? detail.requestedVersion + : undefined; + const lines = [mapped.message]; + if (pkg && requested) { + lines.push(` package: ${pkg}`); + lines.push(` requested: ${requested}`); + } else if (requested) { + lines.push(` requested: ${requested}`); + } + const rawAvailable = Array.isArray(detail.availableVersions) + ? detail.availableVersions + : undefined; + const available = rawAvailable + ?.map((entry) => (typeof entry?.version === "string" ? entry.version : "")) + .filter((v): v is string => v.length > 0); + if (available && available.length > 0) { + const sample = available.slice(0, 5).join(", "); + const more = available.length - 5; + const suffix = more > 0 ? `, … (+${more} more)` : ""; + lines.push(` available: ${sample}${suffix}`); + } + return lines.join("\n"); +} + +const PKG_CHANGELOG_DESCRIPTION = `Fetch recent release notes or changelog entries for a package or +repository. By default shows the ten most recent entries with the +first 10 lines of each entry's body. Use --from for a full version +range, --limit to change the latest-mode count (1-50), --verbose to +uncap the body preview, and --no-body to drop bodies entirely. + +Addressing: (registry:name) OR --repo-url . Source +(GitHub Releases, CHANGELOG.md, or HexDocs) is shown on the summary +line. + +Package spec: :. Supported registries: npm, pypi, +hex, crates, vcpkg, zig, nuget, maven, packagist. \`@\` +is NOT accepted here — use --to for "entries up to this +version".`; + +export function registerPkgChangelogCommand(pkgCommand: Command): Command { + return pkgCommand + .command("changelog") + .summary("Fetch release notes / changelog entries for a package") + .description(PKG_CHANGELOG_DESCRIPTION) + .argument( + "[spec]", + "Package spec, e.g. npm:express (mutually exclusive with --repo-url)", + ) + .option( + "--repo-url ", + "Repository URL addressing (mutually exclusive with )", + ) + .option( + "--from ", + "Start of version range (enables range mode; disables --limit)", + ) + .option("--to ", "End of range / latest-mode cap") + .option("--limit ", "Latest-mode entry count (1-50, default 10)") + .option( + "--git-ref ", + "Git branch/tag for CHANGELOG.md source (ignored for GitHub Releases / HexDocs)", + ) + .option( + "-v, --verbose", + "Uncap the markdown body preview (default cap: 10 lines per entry)", + ) + .option( + "--no-body", + "Drop body fields from entries (affects terminal + JSON)", + ) + .option("--json", "Emit the JSON envelope") + .action( + async (spec: string | undefined, options: PkgChangelogCommandOptions) => { + const deps = await createContainer(); + await pkgChangelogAction(spec, options, { + packageIntelligenceService: deps.packageIntelligenceService, + codeNavigationUrl: deps.codeNavigationUrl, + hasValidToken: deps.hasValidToken, + mcpUrl: deps.mcpUrl, + }); + }, + ); +} diff --git a/src/commands/pkg/index.ts b/src/commands/pkg/index.ts index 011a6e60..570b9fc0 100644 --- a/src/commands/pkg/index.ts +++ b/src/commands/pkg/index.ts @@ -6,6 +6,7 @@ import { getEnvApiToken, isCodeNavigationCliOverrideEnabled, } from "../../services/index.js"; +import { registerPkgChangelogCommand } from "./changelog.js"; import { registerPkgDepsCommand } from "./deps.js"; import { registerPkgInfoCommand } from "./info.js"; import { registerPkgVulnsCommand } from "./vulns.js"; @@ -75,4 +76,5 @@ export async function registerPkgCommandGroup( registerPkgInfoCommand(pkgCommand); registerPkgVulnsCommand(pkgCommand); registerPkgDepsCommand(pkgCommand); + registerPkgChangelogCommand(pkgCommand); } diff --git a/src/services/index.ts b/src/services/index.ts index 9fef26d5..0a1516ce 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -82,6 +82,9 @@ export { export { MigratingAuthStorage } from "./migrating-auth-storage.js"; export type { ChangelogEntry, + ChangelogEntryDetail, + ChangelogPackageInfo, + ChangelogReport, DependencyBundle, DependencyGroup, DependencyGroupsInfo, @@ -89,6 +92,7 @@ export type { DirectDependency, GithubRepository, GroupDependency, + PackageChangelogParams, PackageDependenciesParams, PackageIdentity, PackageIntelligenceService, @@ -109,6 +113,7 @@ export { MalformedPackageIntelligenceResponseError, PackageIntelligenceAccessError, PackageIntelligenceBackendError, + PackageIntelligenceChangelogSourceNotFoundError, PackageIntelligenceFeatureFlagRequiredError, PackageIntelligenceGraphQLError, PackageIntelligenceNetworkError, diff --git a/src/services/package-intelligence-service.ts b/src/services/package-intelligence-service.ts index 1fcce388..15653a21 100644 --- a/src/services/package-intelligence-service.ts +++ b/src/services/package-intelligence-service.ts @@ -225,6 +225,72 @@ export interface DependencyReport { dependencyGroups?: DependencyGroupsInfo; } +/** + * Inputs to `packageChangelog`. Addressing is "spec XOR repo-URL": + * either both `registry` and `packageName`, or `repoUrl` alone. + * The shared request builder enforces the XOR before reaching the + * service; the service layer trusts the contract. + */ +export interface PackageChangelogParams { + /** Uppercase GraphQL registry enum value. Required with `packageName`. */ + registry?: PkgseerRegistry; + /** Package name. Required with `registry`. */ + packageName?: string; + /** GitHub repo URL. Mutually exclusive with `registry` + `packageName`. */ + repoUrl?: string; + /** Branch or tag for CHANGELOG.md fetching. Ignored for GH Releases. */ + gitRef?: string; + /** + * Start of version range. When set, the backend returns every entry + * between `fromVersion` and `toVersion` (or latest); `limit` is + * rejected client-side in this mode. + */ + fromVersion?: string; + /** End of range / latest-mode cap. Defaults to latest on the wire. */ + toVersion?: string; + /** Latest-mode cap (1–50). Rejected client-side when `fromVersion` is set. */ + limit?: number; +} + +/** + * Package-info echo from the changelog response. Mirrors + * `ChangelogPackageInfo` in the schema — all fields nullable at the + * wire level. + */ +export interface ChangelogPackageInfo { + name?: string; + registry?: string; + repoUrl?: string; + fromVersion?: string; + toVersion?: string; + limit?: number; +} + +/** + * Full changelog entry as observed on the wire. The envelope builder + * projects this into the lean response shape; `metadata` is dropped + * from the envelope because its source-specific opaque structure + * isn't worth the token cost today. Revisit via agent feedback. + */ +export interface ChangelogEntryDetail { + version?: string; + normalizedVersion?: string; + body?: string; + htmlUrl?: string; + publishedAt?: string; + /** TODO(pkgseer-backend): surface when shape is documented. */ + metadata?: UntypedGenericJSON; +} + +export interface ChangelogReport { + /** Echo of addressing + filter as the backend saw it. */ + package?: ChangelogPackageInfo; + /** `"releases"` | `"changelog_file"` | `"hexdocs"` when resolved; null otherwise. */ + source?: string; + /** Entries, newest-first. Empty array = resolved source but nothing in range. */ + entries: ChangelogEntryDetail[]; +} + export interface PackageIntelligenceService { packageSummary(params: PackageSummaryParams): Promise; packageVulnerabilities( @@ -233,6 +299,7 @@ export interface PackageIntelligenceService { packageDependencies( params: PackageDependenciesParams, ): Promise; + packageChangelog(params: PackageChangelogParams): Promise; } // -------------------------------------------------------------------- @@ -330,6 +397,23 @@ export class MalformedPackageIntelligenceResponseError extends Error { } } +/** + * Raised when the backend confirmed the package / repo exists but + * could not resolve a changelog source for it (no GitHub Releases, + * no CHANGELOG.md, no HexDocs). Distinct from + * {@link PackageIntelligenceTargetNotFoundError} which signals the + * package itself is missing. The error-map routes this to the shared + * `NOT_FOUND` code so MCP / CLI error envelopes are consistent, but + * the distinct class lets the changelog executor attach a message + * naming the sources that were tried. + */ +export class PackageIntelligenceChangelogSourceNotFoundError extends Error { + constructor(message: string) { + super(message); + this.name = "PackageIntelligenceChangelogSourceNotFoundError"; + } +} + // -------------------------------------------------------------------- // Zod schema for the packageSummary response shape // -------------------------------------------------------------------- @@ -690,6 +774,90 @@ query PackageDependencies( } }`; +// -------------------------------------------------------------------- +// Zod schema + query for packageChangelog +// -------------------------------------------------------------------- + +const changelogPackageInfoSchema = z + .object({ + name: z.string().nullable().optional(), + registry: z.string().nullable().optional(), + repoUrl: z.string().nullable().optional(), + fromVersion: z.string().nullable().optional(), + toVersion: z.string().nullable().optional(), + limit: z.number().int().nullable().optional(), + }) + .nullable() + .optional(); + +const changelogEntryDetailSchema = z.object({ + version: z.string().nullable().optional(), + normalizedVersion: z.string().nullable().optional(), + body: z.string().nullable().optional(), + htmlUrl: z.string().nullable().optional(), + publishedAt: z.string().nullable().optional(), + // `metadata` is schema-level GenericJSON and we drop it from the + // service-returned shape today (envelope doesn't surface it). We + // still request-select it on the wire so live smoke can observe + // real shapes for a future typed surface. + metadata: z.unknown().nullable().optional(), +}); + +const changelogReportResponseSchema = z.object({ + package: changelogPackageInfoSchema, + source: z.string().nullable().optional(), + entries: z.array(changelogEntryDetailSchema).nullable().optional(), +}); + +const changelogGraphQLResponseSchema = z.object({ + data: z + .object({ + packageChangelog: changelogReportResponseSchema.nullable().optional(), + }) + .nullable() + .optional(), + errors: z.array(graphQLErrorSchema).optional(), +}); + +const PACKAGE_CHANGELOG_QUERY = ` +query PackageChangelog( + $registry: Registry + $name: String + $repoUrl: String + $gitRef: String + $fromVersion: String + $toVersion: String + $limit: Int +) { + packageChangelog( + registry: $registry + name: $name + repoUrl: $repoUrl + gitRef: $gitRef + fromVersion: $fromVersion + toVersion: $toVersion + limit: $limit + ) { + package { + name + registry + repoUrl + fromVersion + toVersion + limit + } + source + entries { + version + normalizedVersion + body + htmlUrl + publishedAt + metadata + } + } +}`; + // -------------------------------------------------------------------- // Service implementation // -------------------------------------------------------------------- @@ -1215,6 +1383,128 @@ export class PackageIntelligenceServiceImpl dependencyGroups, }; } + + async packageChangelog( + params: PackageChangelogParams, + ): Promise { + return executeWithTokenRefresh({ + getToken: () => this.tokenProvider.getToken(), + forceRefresh: () => this.tokenProvider.forceRefresh(), + shouldRefresh: (error) => error instanceof AuthenticationError, + executeWithToken: (token) => this.executePackageChangelog(token, params), + }); + } + + private async executePackageChangelog( + token: string, + params: PackageChangelogParams, + ): Promise { + let response: PkgseerGraphqlResponse; + try { + response = await postPkgseerGraphql({ + endpointUrl: this.endpointUrl, + token, + query: PACKAGE_CHANGELOG_QUERY, + variables: { + registry: params.registry, + name: params.packageName, + repoUrl: params.repoUrl, + gitRef: params.gitRef, + fromVersion: params.fromVersion, + toVersion: params.toVersion, + limit: params.limit, + }, + fetchFn: this.fetchFn, + }); + } catch (cause) { + if (cause instanceof PkgseerTransportError) { + throw new PackageIntelligenceNetworkError( + "Could not reach the package intelligence service. Check your connection or set GITHITS_CODE_NAV_URL.", + { cause }, + ); + } + throw cause; + } + + if (response.status < 200 || response.status >= 300) { + throw this.createHttpError(response); + } + + const parsed = changelogGraphQLResponseSchema.safeParse( + response.parsedBody, + ); + if (!parsed.success) { + throw new MalformedPackageIntelligenceResponseError( + "Malformed response from the package-intelligence service.", + ); + } + + if (parsed.data.errors && parsed.data.errors.length > 0) { + throw promoteGenericVersionNotFound( + this.createGraphQLError(parsed.data.errors), + params, + ); + } + + const data = parsed.data.data?.packageChangelog; + if (!data) { + throw new MalformedPackageIntelligenceResponseError( + "Empty response from the package-intelligence service.", + ); + } + + return this.normaliseChangelogReport(data, params); + } + + private normaliseChangelogReport( + data: z.infer, + params: PackageChangelogParams, + ): ChangelogReport { + // Backend returns `source: null` when no changelog source could + // be resolved for the package/repo. Distinct from `entries: []` + // which means "source resolved but produced no entries in this + // range". Promote the null-source case to a typed error at the + // service boundary so the envelope builder never has to think + // about it. + const source = data.source ?? undefined; + if (!source) { + const target = + params.repoUrl ?? + (params.registry && params.packageName + ? `${params.registry.toLowerCase()}:${params.packageName}` + : "package"); + throw new PackageIntelligenceChangelogSourceNotFoundError( + `No changelog source available for ${target} (tried GitHub Releases, CHANGELOG.md, and HexDocs).`, + ); + } + + const rawEntries = data.entries ?? []; + const entries: ChangelogEntryDetail[] = rawEntries.map((entry) => ({ + version: entry.version ?? undefined, + normalizedVersion: entry.normalizedVersion ?? undefined, + body: entry.body ?? undefined, + htmlUrl: entry.htmlUrl ?? undefined, + publishedAt: entry.publishedAt ?? undefined, + metadata: entry.metadata ?? undefined, + })); + + const packageInfo: ChangelogPackageInfo | undefined = data.package + ? { + name: data.package.name ?? undefined, + registry: data.package.registry ?? undefined, + repoUrl: data.package.repoUrl ?? undefined, + fromVersion: data.package.fromVersion ?? undefined, + toVersion: data.package.toVersion ?? undefined, + limit: data.package.limit ?? undefined, + } + : undefined; + + return { + package: packageInfo, + source, + entries, + }; + } } function parseDetail(body: string): string | undefined { diff --git a/src/services/promote-version-not-found.test.ts b/src/services/promote-version-not-found.test.ts index 0731d1af..4cb2e669 100644 --- a/src/services/promote-version-not-found.test.ts +++ b/src/services/promote-version-not-found.test.ts @@ -78,4 +78,67 @@ describe("promoteGenericVersionNotFound", () => { PackageIntelligenceVersionNotFoundError, ); }); + + it("promotes when fromVersion is set (range-mode callers)", () => { + const generic = new PackageIntelligenceBackendError( + "No matching version found", + ); + const promoted = promoteGenericVersionNotFound(generic, { + registry: "NPM", + packageName: "lodash", + fromVersion: "4.0.0", + }) as PackageIntelligenceVersionNotFoundError; + expect(promoted).toBeInstanceOf(PackageIntelligenceVersionNotFoundError); + expect(promoted.packageName).toBe("npm:lodash"); + expect(promoted.requestedVersion).toBe("4.0.0"); + }); + + it("prefers fromVersion over toVersion when both are set", () => { + const generic = new PackageIntelligenceBackendError( + "No matching version found", + ); + const promoted = promoteGenericVersionNotFound(generic, { + registry: "NPM", + packageName: "lodash", + fromVersion: "3.0.0", + toVersion: "4.0.0", + }) as PackageIntelligenceVersionNotFoundError; + expect(promoted.requestedVersion).toBe("3.0.0"); + }); + + it("promotes when only toVersion is set (latest-mode cap)", () => { + const generic = new PackageIntelligenceBackendError( + "No matching version found", + ); + const promoted = promoteGenericVersionNotFound(generic, { + registry: "NPM", + packageName: "lodash", + toVersion: "99.0.0", + }) as PackageIntelligenceVersionNotFoundError; + expect(promoted.requestedVersion).toBe("99.0.0"); + }); + + it("does not promote when no version field is set (unconstrained request)", () => { + const generic = new PackageIntelligenceBackendError( + "No matching version found", + ); + expect( + promoteGenericVersionNotFound(generic, { + registry: "NPM", + packageName: "lodash", + }), + ).toBe(generic); + }); + + it("omits details.package in repo-URL mode (no registry / packageName)", () => { + const generic = new PackageIntelligenceBackendError( + "No matching version found", + ); + const promoted = promoteGenericVersionNotFound(generic, { + fromVersion: "1.0.0", + }) as PackageIntelligenceVersionNotFoundError; + expect(promoted).toBeInstanceOf(PackageIntelligenceVersionNotFoundError); + expect(promoted.packageName).toBeUndefined(); + expect(promoted.requestedVersion).toBe("1.0.0"); + }); }); diff --git a/src/services/promote-version-not-found.ts b/src/services/promote-version-not-found.ts index 5c364179..cb7a23ed 100644 --- a/src/services/promote-version-not-found.ts +++ b/src/services/promote-version-not-found.ts @@ -2,14 +2,14 @@ * Shared "generic error → typed `VERSION_NOT_FOUND`" promoter. * * Called from versioned query executors (`packageVulnerabilities`, - * `packageDependencies`) right after `createGraphQLError`. When the - * backend has not yet been updated to emit `extensions.code = - * "VERSION_NOT_FOUND"` with structured `package` / `requested_version` - * / `available_versions` fields, it falls back to a generic - * backend error with the literal message "No matching version - * found". This helper recognises that shape and promotes it to the - * typed {@link PackageIntelligenceVersionNotFoundError} so downstream - * surfaces can render structured, actionable error details. + * `packageDependencies`, `packageChangelog`) right after + * `createGraphQLError`. When the backend has not yet been updated to + * emit `extensions.code = "VERSION_NOT_FOUND"` with structured + * `package` / `requested_version` / `available_versions` fields, it + * falls back to a generic backend error with the literal message "No + * matching version found". This helper recognises that shape and + * promotes it to the typed {@link PackageIntelligenceVersionNotFoundError} + * so downstream surfaces can render structured, actionable error details. * * TODO(pkgseer-backend): remove once the upstream resolvers all emit * the typed `extensions.code = "VERSION_NOT_FOUND"` payload. The typed @@ -23,12 +23,18 @@ * (including INTERNAL_ERROR, UPSTREAM_ERROR, TIMEOUT, …) is * respected as-is so we never swallow real backend signalling or * flip retryability. - * - Only promotes when `params.version` is set — if the caller asked - * for "latest", a "no matching version" message can only reflect - * an unrelated upstream condition, not a caller-addressable one. + * - Only promotes when at least one version field (`version`, + * `fromVersion`, `toVersion`) is set — if the caller asked for the + * unconstrained latest timeline, a "no matching version" message + * can only reflect an unrelated upstream condition. * - `details.package` is qualified with the lowercase registry prefix - * (e.g. `"npm:lodash"`) so CLI / MCP output matches the shape - * produced when the backend sends the typed code. + * (e.g. `"npm:lodash"`) when both `registry` and `packageName` are + * provided. In repo-URL addressing mode (`packageChangelog`) neither + * is available; `details.package` is omitted entirely. + * - `details.requestedVersion` preference order when multiple are + * set: `version` → `fromVersion` → `toVersion`. First non-null + * wins. Range-mode requests typically set `fromVersion`, which is + * the most likely culprit when the backend rejects the version. */ import type { PkgseerRegistry } from "../shared/pkgseer-registry.js"; @@ -39,13 +45,16 @@ import { /** * Minimal shape shared by every versioned-query params type we route - * through this helper. `registry` is the uppercase GraphQL enum value; - * we lowercase it for the qualified package name. + * through this helper. All fields optional so repo-URL-addressed + * queries (`packageChangelog`) can also flow through — the helper + * omits any detail it can't synthesize. */ export interface PromotableVersionedQueryParams { - registry: PkgseerRegistry; - packageName: string; + registry?: PkgseerRegistry; + packageName?: string; version?: string; + fromVersion?: string; + toVersion?: string; } export function promoteGenericVersionNotFound( @@ -54,13 +63,30 @@ export function promoteGenericVersionNotFound( ): Error { if (!(error instanceof PackageIntelligenceBackendError)) return error; if (error.graphqlCode !== undefined) return error; - if (!params.version) return error; + const requestedVersion = pickRequestedVersion(params); + if (!requestedVersion) return error; if (!/no matching version/i.test(error.message)) return error; - const qualifiedName = `${params.registry.toLowerCase()}:${params.packageName}`; + const qualifiedName = synthesizeQualifiedName(params); return new PackageIntelligenceVersionNotFoundError( error.message, qualifiedName, - params.version, + requestedVersion, undefined, ); } + +function pickRequestedVersion( + params: PromotableVersionedQueryParams, +): string | undefined { + if (params.version) return params.version; + if (params.fromVersion) return params.fromVersion; + if (params.toVersion) return params.toVersion; + return undefined; +} + +function synthesizeQualifiedName( + params: PromotableVersionedQueryParams, +): string | undefined { + if (!params.registry || !params.packageName) return undefined; + return `${params.registry.toLowerCase()}:${params.packageName}`; +} diff --git a/src/services/test-helpers.ts b/src/services/test-helpers.ts index 70227e30..02a8e2fb 100644 --- a/src/services/test-helpers.ts +++ b/src/services/test-helpers.ts @@ -21,6 +21,7 @@ import type { FileSystemService } from "./filesystem-service.js"; import type { GitHitsService } from "./githits-service.js"; import type { KeyringService } from "./keyring-service.js"; import type { + ChangelogReport, DependencyReport, PackageIntelligenceService, PackageSummary, @@ -492,6 +493,39 @@ export const cratesFeatureDependencyReport: DependencyReport = { }, }; +/** + * Default changelog fixture — express with two GitHub Releases entries. + * Covers the common shape: resolved `releases` source, populated body + * markdown, ISO date, and a normalisedVersion that equals the raw. + */ +export const defaultChangelogReport: ChangelogReport = { + package: { + name: "express", + registry: "npm", + repoUrl: undefined, + fromVersion: undefined, + toVersion: undefined, + limit: 10, + }, + source: "releases", + entries: [ + { + version: "5.2.1", + normalizedVersion: "5.2.1", + publishedAt: "2026-01-15T12:00:00Z", + htmlUrl: "https://github.com/expressjs/express/releases/tag/5.2.1", + body: "## Patch\n- fixed a thing", + }, + { + version: "5.2.0", + normalizedVersion: "5.2.0", + publishedAt: "2025-12-10T09:00:00Z", + htmlUrl: "https://github.com/expressjs/express/releases/tag/5.2.0", + body: "## Minor\n- added WebSocket support", + }, + ], +}; + /** * Creates a mock PackageIntelligenceService. Defaults resolve to the * fully-populated fixtures; override per-test as needed. @@ -505,6 +539,7 @@ export function createMockPackageIntelligenceService( Promise.resolve(defaultVulnerabilityReport), ), packageDependencies: mock(() => Promise.resolve(defaultDependencyReport)), + packageChangelog: mock(() => Promise.resolve(defaultChangelogReport)), ...impl, }; } diff --git a/src/shared/package-changelog-request.test.ts b/src/shared/package-changelog-request.test.ts new file mode 100644 index 00000000..917c800e --- /dev/null +++ b/src/shared/package-changelog-request.test.ts @@ -0,0 +1,213 @@ +import { describe, expect, it } from "bun:test"; +import { buildPackageChangelogParams } from "./package-changelog-request.js"; + +describe("buildPackageChangelogParams — addressing XOR", () => { + it("accepts spec-only input and produces uppercase registry", () => { + const { params, explicitFilterFields } = buildPackageChangelogParams({ + registry: "npm", + packageName: "express", + }); + expect(params.registry).toBe("NPM"); + expect(params.packageName).toBe("express"); + expect(params.repoUrl).toBeUndefined(); + expect(explicitFilterFields.size).toBe(0); + }); + + it("accepts repo-url-only input and leaves registry/name empty", () => { + const { params } = buildPackageChangelogParams({ + repoUrl: "https://github.com/expressjs/express", + }); + expect(params.repoUrl).toBe("https://github.com/expressjs/express"); + expect(params.registry).toBeUndefined(); + expect(params.packageName).toBeUndefined(); + }); + + it("rejects when both spec and repo-url are provided", () => { + expect(() => + buildPackageChangelogParams({ + registry: "npm", + packageName: "express", + repoUrl: "https://github.com/expressjs/express", + }), + ).toThrow(/not both/); + }); + + it("rejects when neither addressing form is provided", () => { + expect(() => buildPackageChangelogParams({})).toThrow(/spec/); + }); + + it("rejects a non-URL-shaped repo-url value", () => { + expect(() => buildPackageChangelogParams({ repoUrl: "not a url" })).toThrow( + /URL/, + ); + }); + + it("rejects a spec with an unknown registry", () => { + expect(() => + buildPackageChangelogParams({ + registry: "obscure", + packageName: "example", + }), + ).toThrow(/Unsupported registry/); + }); +}); + +describe("buildPackageChangelogParams — `@` rejection", () => { + it("rejects specVersion with a hint redirecting to --to / --from", () => { + expect(() => + buildPackageChangelogParams({ + registry: "npm", + packageName: "express", + specVersion: "4.18.0", + }), + ).toThrow(/--to|--from/); + }); +}); + +describe("buildPackageChangelogParams — mode mutual exclusion", () => { + it("rejects --from + --limit together", () => { + expect(() => + buildPackageChangelogParams({ + registry: "npm", + packageName: "express", + fromVersion: "4.0.0", + limit: 10, + }), + ).toThrow(/latest-mode/); + }); + + it("accepts --from alone (range mode)", () => { + const { params, explicitFilterFields } = buildPackageChangelogParams({ + registry: "npm", + packageName: "express", + fromVersion: "4.0.0", + }); + expect(params.fromVersion).toBe("4.0.0"); + expect(params.limit).toBeUndefined(); + expect(explicitFilterFields.has("fromVersion")).toBe(true); + }); + + it("accepts --limit alone (latest mode)", () => { + const { params, explicitFilterFields } = buildPackageChangelogParams({ + registry: "npm", + packageName: "express", + limit: 5, + }); + expect(params.limit).toBe(5); + expect(explicitFilterFields.has("limit")).toBe(true); + }); + + it("accepts --to in either mode", () => { + const latest = buildPackageChangelogParams({ + registry: "npm", + packageName: "express", + toVersion: "5.0.0", + }); + expect(latest.params.toVersion).toBe("5.0.0"); + expect(latest.explicitFilterFields.has("toVersion")).toBe(true); + + const range = buildPackageChangelogParams({ + registry: "npm", + packageName: "express", + fromVersion: "4.0.0", + toVersion: "5.0.0", + }); + expect(range.params.fromVersion).toBe("4.0.0"); + expect(range.params.toVersion).toBe("5.0.0"); + }); +}); + +describe("buildPackageChangelogParams — version validation", () => { + it("rejects tag-style fromVersion", () => { + expect(() => + buildPackageChangelogParams({ + registry: "npm", + packageName: "express", + fromVersion: "v4.18.0", + }), + ).toThrow(/git tag/); + }); + + it("rejects tag-style toVersion", () => { + expect(() => + buildPackageChangelogParams({ + registry: "npm", + packageName: "express", + toVersion: "V5.0.0", + }), + ).toThrow(/git tag/); + }); + + it.each([ + "5.0.0-rc.1", + "2.32.0.dev0", + "1.7.0-rc.5", + "4.0.0-alpha", + "1.0.0+build.1", + ])("accepts pre-release / build version '%s' on --from", (version) => { + const { params } = buildPackageChangelogParams({ + registry: "npm", + packageName: "express", + fromVersion: version, + }); + expect(params.fromVersion).toBe(version); + }); +}); + +describe("buildPackageChangelogParams — limit validation", () => { + it.each([0, 51, 3.5, -1])("rejects out-of-range limit %s", (limit) => { + expect(() => + buildPackageChangelogParams({ + registry: "npm", + packageName: "express", + limit, + }), + ).toThrow(/1 and 50/); + }); + + it("accepts limit at boundaries (1 and 50)", () => { + const low = buildPackageChangelogParams({ + registry: "npm", + packageName: "express", + limit: 1, + }); + expect(low.params.limit).toBe(1); + const high = buildPackageChangelogParams({ + registry: "npm", + packageName: "express", + limit: 50, + }); + expect(high.params.limit).toBe(50); + }); +}); + +describe("buildPackageChangelogParams — filter tracking", () => { + it("tracks gitRef as explicit when set", () => { + const { explicitFilterFields } = buildPackageChangelogParams({ + registry: "npm", + packageName: "express", + gitRef: "main", + }); + expect(explicitFilterFields.has("gitRef")).toBe(true); + }); + + it("treats whitespace-only gitRef as absent", () => { + const { params, explicitFilterFields } = buildPackageChangelogParams({ + registry: "npm", + packageName: "express", + gitRef: " ", + }); + expect(params.gitRef).toBeUndefined(); + expect(explicitFilterFields.has("gitRef")).toBe(false); + }); + + it("treats whitespace-only fromVersion as absent", () => { + const { params, explicitFilterFields } = buildPackageChangelogParams({ + registry: "npm", + packageName: "express", + fromVersion: " ", + }); + expect(params.fromVersion).toBeUndefined(); + expect(explicitFilterFields.has("fromVersion")).toBe(false); + }); +}); diff --git a/src/shared/package-changelog-request.ts b/src/shared/package-changelog-request.ts new file mode 100644 index 00000000..a4935bfb --- /dev/null +++ b/src/shared/package-changelog-request.ts @@ -0,0 +1,214 @@ +/** + * Shared request builder for the `package_changelog` tool. The CLI + * command and the MCP tool normalise their inputs here so the two + * surfaces cannot diverge on addressing rules, version validation, + * or mode/limit mutual exclusion. + * + * Responsibilities: + * - Enforce addressing XOR: exactly one of (a) `` (registry + + * packageName) or (b) `repoUrl`. Reject both-present, none-present, + * and malformed ``. + * - Reject tag-style versions (`v4.18.0`) on `fromVersion` / + * `toVersion` — leading `v` is a git-tag convention, not a + * canonical version. + * - Reject `@`: the `pkg changelog` family does not + * give `@version` a meaning (unlike `pkg vulns` and `pkg deps`). + * Redirect callers to `--to` / `to_version`. + * - Reject `fromVersion` + `limit` together (backend says range mode + * has no count cap; we catch it before the wire). + * - Enforce `limit` range (1–50). + * - Emit an `explicitFilterFields` set so the response envelope only + * echoes `filter.*` for caller-supplied fields, not backend + * defaults. + */ + +import type { PackageChangelogParams } from "../services/index.js"; +import { + InvalidPackageSpecError, + UnsupportedRegistryError, +} from "./package-spec.js"; +import { + isKnownPkgseerRegistryArg, + type PkgseerRegistryArg, + toPkgseerRegistry, +} from "./pkgseer-registry.js"; + +/** + * Raw inputs from either CLI or MCP, pre-normalisation. Keep every + * field optional so the builder is the single place enforcing the + * XOR + co-occurrence rules. + */ +export interface PackageChangelogRequestInput { + /** Lowercase registry surface value (`npm`, `pypi`, …). */ + registry?: string; + /** Raw package name — trimmed before validation. */ + packageName?: string; + /** GitHub repo URL. Mutex with `registry` + `packageName`. */ + repoUrl?: string; + /** Optional git branch/tag for CHANGELOG.md. */ + gitRef?: string; + /** Optional `@` captured from the spec parser. Always rejected here. */ + specVersion?: string; + /** Range-mode start version. */ + fromVersion?: string; + /** End-of-range / latest-mode cap. */ + toVersion?: string; + /** Latest-mode entry count cap. */ + limit?: number; +} + +/** Fields whose *explicit* presence the envelope echoes under `filter.*`. */ +export type ExplicitFilterField = + | "fromVersion" + | "toVersion" + | "limit" + | "gitRef"; + +export interface PackageChangelogRequestBuildResult { + params: PackageChangelogParams; + /** + * Set of filter fields the caller explicitly supplied. The envelope + * consults this set instead of `params.*` to decide whether to + * echo a field under `filter.*`, so backend defaults (latest = 10) + * don't accidentally round-trip as caller intent. + */ + explicitFilterFields: Set; +} + +export function buildPackageChangelogParams( + input: PackageChangelogRequestInput, +): PackageChangelogRequestBuildResult { + if (input.specVersion !== undefined) { + throw new InvalidPackageSpecError( + "`@` isn't supported for pkg changelog — use `--to ` for entries up to a version, or `--from ` for a full range.", + ); + } + + const addressing = resolveAddressing(input); + const gitRef = normaliseGitRef(input.gitRef); + const fromVersion = normaliseVersion(input.fromVersion, "from"); + const toVersion = normaliseVersion(input.toVersion, "to"); + const limit = normaliseLimit(input.limit); + + if (fromVersion !== undefined && limit !== undefined) { + throw new InvalidPackageSpecError( + "`--limit` / `limit` is a latest-mode input; drop `--limit` for range mode, or drop `--from` / `from_version` to cap by count instead.", + ); + } + + const explicit = new Set(); + if (fromVersion !== undefined) explicit.add("fromVersion"); + if (toVersion !== undefined) explicit.add("toVersion"); + if (limit !== undefined) explicit.add("limit"); + if (gitRef !== undefined) explicit.add("gitRef"); + + return { + params: { + ...addressing, + gitRef, + fromVersion, + toVersion, + limit, + }, + explicitFilterFields: explicit, + }; +} + +type ResolvedAddressing = + | { + registry: PackageChangelogParams["registry"]; + packageName: string; + repoUrl?: undefined; + } + | { repoUrl: string; registry?: undefined; packageName?: undefined }; + +function resolveAddressing( + input: PackageChangelogRequestInput, +): ResolvedAddressing { + const hasSpec = Boolean(input.registry || input.packageName); + const hasRepoUrl = Boolean(input.repoUrl?.trim()); + + if (hasSpec && hasRepoUrl) { + throw new InvalidPackageSpecError( + "Provide either `` (registry + name) or `--repo-url` / `repo_url`, not both.", + ); + } + if (!hasSpec && !hasRepoUrl) { + throw new InvalidPackageSpecError( + "`pkg changelog` requires a package spec (e.g. `npm:express`) or `--repo-url` / `repo_url`.", + ); + } + + if (hasRepoUrl) { + const repoUrl = (input.repoUrl as string).trim(); + if (!isUrlShape(repoUrl)) { + throw new InvalidPackageSpecError( + `'${repoUrl}' does not look like a URL. Pass a full GitHub URL (e.g. https://github.com/expressjs/express).`, + ); + } + return { repoUrl }; + } + + const packageName = input.packageName?.trim() ?? ""; + if (!packageName) { + throw new InvalidPackageSpecError("Package name is required."); + } + + const normalisedRegistryArg = input.registry?.trim().toLowerCase() ?? ""; + if (!isKnownPkgseerRegistryArg(normalisedRegistryArg)) { + throw new UnsupportedRegistryError( + `Unsupported registry '${input.registry}'. Supported: npm, pypi, hex, crates, nuget, maven, zig, vcpkg, packagist.`, + ); + } + const registry = toPkgseerRegistry( + normalisedRegistryArg as PkgseerRegistryArg, + ); + return { registry, packageName }; +} + +function normaliseGitRef(raw: string | undefined): string | undefined { + if (raw === undefined) return undefined; + const trimmed = raw.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function normaliseVersion( + raw: string | undefined, + field: "from" | "to", +): string | undefined { + if (raw === undefined) return undefined; + const trimmed = raw.trim(); + if (trimmed.length === 0) return undefined; + if (/^v[0-9]/i.test(trimmed)) { + const flag = field === "from" ? "--from" : "--to"; + throw new InvalidPackageSpecError( + `Version '${trimmed}' looks like a git tag. Use the canonical version without a leading 'v' (e.g. ${flag} ${trimmed.slice(1)}).`, + ); + } + return trimmed; +} + +function normaliseLimit(raw: number | undefined): number | undefined { + if (raw === undefined) return undefined; + if (!Number.isInteger(raw) || raw < 1 || raw > 50) { + throw new InvalidPackageSpecError( + `\`limit\` must be an integer between 1 and 50. Got ${raw}.`, + ); + } + return raw; +} + +/** + * Minimal URL-shape test. We want to reject obvious non-URLs like + * `"not a url"` client-side so agents get an actionable error instead + * of an opaque `BACKEND_ERROR`. Backend handles host-specific + * validation (GitHub-only enforcement etc.). + */ +function isUrlShape(raw: string): boolean { + try { + const parsed = new URL(raw); + return parsed.protocol === "http:" || parsed.protocol === "https:"; + } catch { + return false; + } +} diff --git a/src/shared/package-changelog-response.test.ts b/src/shared/package-changelog-response.test.ts new file mode 100644 index 00000000..56d4ee2e --- /dev/null +++ b/src/shared/package-changelog-response.test.ts @@ -0,0 +1,448 @@ +import { describe, expect, it } from "bun:test"; +import type { ChangelogReport } from "../services/index.js"; +import type { ExplicitFilterField } from "./package-changelog-request.js"; +import { + buildPackageChangelogSuccessPayload, + formatPackageChangelogTerminal, +} from "./package-changelog-response.js"; + +const baseReport: ChangelogReport = { + package: { + name: "express", + registry: "npm", + limit: 10, + }, + source: "releases", + entries: [ + { + version: "5.2.1", + normalizedVersion: "5.2.1", + publishedAt: "2026-01-15T12:00:00Z", + htmlUrl: "https://github.com/expressjs/express/releases/tag/5.2.1", + body: "## Patch\n- fixed a thing", + }, + { + version: "5.2.0", + normalizedVersion: "5.2.0", + publishedAt: "2025-12-10T09:00:00Z", + htmlUrl: "https://github.com/expressjs/express/releases/tag/5.2.0", + body: "## Minor\n- added WebSocket support", + }, + ], +}; + +const baseOptions = { + registry: "npm", + name: "express", + mode: "latest" as const, + explicitFilterFields: new Set(), + includeBodies: true, +}; + +describe("buildPackageChangelogSuccessPayload — envelope shape", () => { + it("emits the data-first envelope with entries.count computed client-side", () => { + const envelope = buildPackageChangelogSuccessPayload( + baseReport, + baseOptions, + ); + expect(envelope.registry).toBe("npm"); + expect(envelope.name).toBe("express"); + expect(envelope.source).toBe("releases"); + expect(envelope.mode).toBe("latest"); + expect(envelope.entries.count).toBe(2); + expect(envelope.entries.items.length).toBe(2); + expect(envelope.entries.count).toBe(envelope.entries.items.length); + }); + + it("preserves version/normalizedVersion/publishedAt/htmlUrl/body on each entry", () => { + const envelope = buildPackageChangelogSuccessPayload( + baseReport, + baseOptions, + ); + expect(envelope.entries.items[0]).toEqual({ + version: "5.2.1", + normalizedVersion: "5.2.1", + publishedAt: "2026-01-15T12:00:00Z", + htmlUrl: "https://github.com/expressjs/express/releases/tag/5.2.1", + body: "## Patch\n- fixed a thing", + }); + }); + + it("emits the repo-URL addressing shape when no registry/name are set", () => { + const envelope = buildPackageChangelogSuccessPayload(baseReport, { + ...baseOptions, + registry: undefined, + name: undefined, + repoUrl: "https://github.com/expressjs/express", + }); + expect(envelope.registry).toBeUndefined(); + expect(envelope.name).toBeUndefined(); + expect(envelope.repoUrl).toBe("https://github.com/expressjs/express"); + }); +}); + +describe("buildPackageChangelogSuccessPayload — version null handling", () => { + it("keeps version: null on entries missing a version", () => { + const report: ChangelogReport = { + ...baseReport, + entries: [ + { + version: undefined, + publishedAt: "2026-01-01T00:00:00Z", + htmlUrl: "https://example.com", + body: "raw changelog", + }, + ], + }; + const envelope = buildPackageChangelogSuccessPayload(report, baseOptions); + expect(envelope.entries.items[0]?.version).toBeNull(); + expect(envelope.entries.items[0]?.publishedAt).toBe("2026-01-01T00:00:00Z"); + }); + + it("strips normalizedVersion / publishedAt / htmlUrl / body when null", () => { + const report: ChangelogReport = { + ...baseReport, + entries: [ + { + version: "1.0.0", + normalizedVersion: undefined, + publishedAt: undefined, + htmlUrl: undefined, + body: undefined, + }, + ], + }; + const envelope = buildPackageChangelogSuccessPayload(report, baseOptions); + const entry = envelope.entries.items[0]; + expect(entry?.version).toBe("1.0.0"); + expect(entry?.normalizedVersion).toBeUndefined(); + expect(entry?.publishedAt).toBeUndefined(); + expect(entry?.htmlUrl).toBeUndefined(); + expect(entry?.body).toBeUndefined(); + }); + + it("preserves empty-string body (distinct from null) so agents can tell 'empty notes' from 'no notes field'", () => { + const report: ChangelogReport = { + ...baseReport, + entries: [ + { + version: "1.0.0", + body: "", + }, + ], + }; + const envelope = buildPackageChangelogSuccessPayload(report, baseOptions); + expect(envelope.entries.items[0]?.body).toBe(""); + }); + + it("throws if the service layer leaks source=null into the envelope builder (defence in depth)", () => { + const report: ChangelogReport = { + ...baseReport, + source: undefined, + }; + expect(() => + buildPackageChangelogSuccessPayload(report, baseOptions), + ).toThrow(/null source/); + }); +}); + +describe("buildPackageChangelogSuccessPayload — include_bodies lever", () => { + it("drops body fields from every entry when includeBodies is false", () => { + const envelope = buildPackageChangelogSuccessPayload(baseReport, { + ...baseOptions, + includeBodies: false, + }); + for (const entry of envelope.entries.items) { + expect(entry.body).toBeUndefined(); + } + // Other fields preserved. + expect(envelope.entries.items[0]?.version).toBe("5.2.1"); + expect(envelope.entries.items[0]?.publishedAt).toBe("2026-01-15T12:00:00Z"); + expect(envelope.entries.items[0]?.htmlUrl).toBeTruthy(); + }); +}); + +describe("buildPackageChangelogSuccessPayload — mode derivation", () => { + it("emits mode: 'range' when the caller sent a fromVersion", () => { + const envelope = buildPackageChangelogSuccessPayload(baseReport, { + ...baseOptions, + mode: "range", + fromVersion: "5.0.0", + explicitFilterFields: new Set(["fromVersion"]), + }); + expect(envelope.mode).toBe("range"); + expect(envelope.filter?.fromVersion).toBe("5.0.0"); + }); + + it("emits mode: 'latest' otherwise", () => { + const envelope = buildPackageChangelogSuccessPayload( + baseReport, + baseOptions, + ); + expect(envelope.mode).toBe("latest"); + }); +}); + +describe("buildPackageChangelogSuccessPayload — filter echo", () => { + it("emits filter only when at least one field was explicit", () => { + const noFilter = buildPackageChangelogSuccessPayload( + baseReport, + baseOptions, + ); + expect(noFilter.filter).toBeUndefined(); + + const withLimit = buildPackageChangelogSuccessPayload(baseReport, { + ...baseOptions, + limit: 5, + explicitFilterFields: new Set(["limit"]), + }); + expect(withLimit.filter?.limit).toBe(5); + expect(withLimit.filter?.fromVersion).toBeUndefined(); + expect(withLimit.filter?.toVersion).toBeUndefined(); + }); + + it("does not echo backend-default values (limit=10 from wire)", () => { + // Backend might echo limit=10 on the package info, but we don't + // mirror that — only explicit caller inputs. + const envelope = buildPackageChangelogSuccessPayload( + baseReport, + baseOptions, + ); + expect(envelope.filter).toBeUndefined(); + }); + + it("echoes gitRef when caller set it", () => { + const envelope = buildPackageChangelogSuccessPayload(baseReport, { + ...baseOptions, + gitRef: "develop", + explicitFilterFields: new Set(["gitRef"]), + }); + expect(envelope.filter?.gitRef).toBe("develop"); + }); +}); + +describe("buildPackageChangelogSuccessPayload — empty entries", () => { + it("emits entries: { count: 0, items: [] } on zero-entry response", () => { + const report: ChangelogReport = { + ...baseReport, + entries: [], + }; + const envelope = buildPackageChangelogSuccessPayload(report, baseOptions); + expect(envelope.entries.count).toBe(0); + expect(envelope.entries.items).toEqual([]); + expect(envelope.source).toBe("releases"); + }); +}); + +describe("formatPackageChangelogTerminal", () => { + it("renders a summary header + one-line entries by default", () => { + const envelope = buildPackageChangelogSuccessPayload( + baseReport, + baseOptions, + ); + const output = formatPackageChangelogTerminal(envelope, { + verbose: false, + useColors: false, + }); + expect(output).toContain("express · npm"); + expect(output).toContain("source: GitHub Releases"); + expect(output).toContain("2 entries"); + expect(output).toContain("5.2.1"); + expect(output).toContain("2026-01-15"); + expect(output).toContain("/releases/tag/5.2.1"); + // Fixture bodies are well under the 10-line cap, so they render + // in full without a truncation footer. + expect(output).toContain("## Patch"); + expect(output).toContain("- fixed a thing"); + expect(output).not.toContain("use --verbose"); + }); + + it("caps the body at 10 lines by default with a truncation footer", () => { + const longBody = Array.from({ length: 25 }, (_, i) => `line ${i + 1}`).join( + "\n", + ); + const report: ChangelogReport = { + ...baseReport, + entries: [ + { + version: "1.0.0", + publishedAt: "2026-01-01T00:00:00Z", + htmlUrl: "https://example.com", + body: longBody, + }, + ], + }; + const envelope = buildPackageChangelogSuccessPayload(report, baseOptions); + const output = formatPackageChangelogTerminal(envelope, { + verbose: false, + useColors: false, + }); + expect(output).toContain("line 1"); + expect(output).toContain("line 10"); + expect(output).not.toContain("line 11"); + expect(output).toContain("+15 more lines"); + expect(output).toContain("use --verbose for the full body"); + }); + + it("lifts the body cap under --verbose and omits the truncation footer", () => { + const longBody = Array.from({ length: 25 }, (_, i) => `line ${i + 1}`).join( + "\n", + ); + const report: ChangelogReport = { + ...baseReport, + entries: [ + { + version: "1.0.0", + publishedAt: "2026-01-01T00:00:00Z", + htmlUrl: "https://example.com", + body: longBody, + }, + ], + }; + const envelope = buildPackageChangelogSuccessPayload(report, baseOptions); + const output = formatPackageChangelogTerminal(envelope, { + verbose: true, + useColors: false, + }); + expect(output).toContain("line 1"); + expect(output).toContain("line 25"); + expect(output).not.toContain("use --verbose"); + expect(output).not.toContain("more line"); + }); + + it("uses singular wording in the truncation footer when exactly one line is hidden", () => { + const elevenLines = Array.from( + { length: 11 }, + (_, i) => `line ${i + 1}`, + ).join("\n"); + const report: ChangelogReport = { + ...baseReport, + entries: [ + { + version: "1.0.0", + publishedAt: "2026-01-01T00:00:00Z", + htmlUrl: "https://example.com", + body: elevenLines, + }, + ], + }; + const envelope = buildPackageChangelogSuccessPayload(report, baseOptions); + const output = formatPackageChangelogTerminal(envelope, { + verbose: false, + useColors: false, + }); + expect(output).toContain("+1 more line "); + expect(output).not.toContain("+1 more lines"); + }); + + it("renders '(unversioned)' for entries with no version", () => { + const report: ChangelogReport = { + ...baseReport, + entries: [ + { + version: undefined, + publishedAt: "2026-01-01T00:00:00Z", + htmlUrl: "https://example.com", + body: "raw", + }, + ], + }; + const envelope = buildPackageChangelogSuccessPayload(report, baseOptions); + const output = formatPackageChangelogTerminal(envelope, { + verbose: false, + useColors: false, + }); + expect(output).toContain("(unversioned)"); + }); + + it("renders a dash for missing publishedAt", () => { + const report: ChangelogReport = { + ...baseReport, + entries: [ + { + version: "1.0.0", + publishedAt: undefined, + htmlUrl: "https://example.com", + }, + ], + }; + const envelope = buildPackageChangelogSuccessPayload(report, baseOptions); + const output = formatPackageChangelogTerminal(envelope, { + verbose: false, + useColors: false, + }); + expect(output).toContain("—"); + }); + + it("renders 'No entries in this range.' on zero-entry success", () => { + const report: ChangelogReport = { ...baseReport, entries: [] }; + const envelope = buildPackageChangelogSuccessPayload(report, baseOptions); + const output = formatPackageChangelogTerminal(envelope, { + verbose: false, + useColors: false, + }); + expect(output).toContain("No entries in this range."); + }); + + it("labels range mode with from → to in the summary", () => { + const envelope = buildPackageChangelogSuccessPayload(baseReport, { + ...baseOptions, + mode: "range", + fromVersion: "4.0.0", + toVersion: "5.0.0", + explicitFilterFields: new Set(["fromVersion", "toVersion"]), + }); + const output = formatPackageChangelogTerminal(envelope, { + verbose: false, + useColors: false, + }); + expect(output).toContain("range 4.0.0 → 5.0.0"); + }); + + it("labels latest mode with 'latest up to X' when toVersion is set", () => { + const envelope = buildPackageChangelogSuccessPayload(baseReport, { + ...baseOptions, + toVersion: "5.2.1", + explicitFilterFields: new Set(["toVersion"]), + }); + const output = formatPackageChangelogTerminal(envelope, { + verbose: false, + useColors: false, + }); + expect(output).toContain("latest up to 5.2.1"); + }); + + it("renders '(empty release notes)' sentinel for an empty-string body under --verbose", () => { + const report: ChangelogReport = { + ...baseReport, + entries: [ + { + version: "1.0.0", + publishedAt: "2026-01-01T00:00:00Z", + htmlUrl: "https://example.com", + body: "", + }, + ], + }; + const envelope = buildPackageChangelogSuccessPayload(report, baseOptions); + const output = formatPackageChangelogTerminal(envelope, { + verbose: true, + useColors: false, + }); + expect(output).toContain("(empty release notes)"); + }); + + it("uses the repo URL as identity in repo-URL addressing", () => { + const envelope = buildPackageChangelogSuccessPayload(baseReport, { + ...baseOptions, + registry: undefined, + name: undefined, + repoUrl: "https://github.com/expressjs/express", + }); + const output = formatPackageChangelogTerminal(envelope, { + verbose: false, + useColors: false, + }); + expect(output).toContain("https://github.com/expressjs/express"); + }); +}); diff --git a/src/shared/package-changelog-response.ts b/src/shared/package-changelog-response.ts new file mode 100644 index 00000000..466751ba --- /dev/null +++ b/src/shared/package-changelog-response.ts @@ -0,0 +1,373 @@ +/** + * Hand-crafted response envelope for the `package_changelog` tool. + * Shared by CLI `--json` output and MCP `content[0].text`. Terminal + * formatter is CLI-only but reads from the same envelope shape so + * the two surfaces cannot drift. + * + * Key design commitments: + * + * - **Data-first envelope.** Every top-level key is driven by what + * the backend returned and what the caller asked for, not by + * additional caller flags. `entries` is `{count, items}` whenever + * the backend resolved a `source` (even `{count: 0, items: []}` + * for an empty range); `source === null` is promoted to a + * `NOT_FOUND` error at the service boundary and never reaches the + * envelope builder. + * - **Mode derived from request.** `mode: "range"` iff `fromVersion` + * was non-null after normalisation; `"latest"` otherwise. Lowercase + * strings matching the backend's doc-comment convention. + * - **`entries.count` computed client-side** from `items.length`. No + * backend-count field is selected on the wire, so the invariant + * `entries.count === entries.items.length` always holds. + * - **`version` kept when null**, every other per-entry nullable + * field stripped *only when null/undefined*. Present-but-empty + * values (empty-string `body`, empty `htmlUrl`) are preserved so + * agents can distinguish "backend returned no content" from + * "backend didn't return this field". Rationale: `version` is the + * primary key agents index entries by, so keeping the slot + * present (even with `null`) makes it safe to write + * `entries.items.map(e => e.version)` without guarding. Other + * fields are metadata; their absence is signal, but empty values + * are their own signal. + * - **`filter.*` emits only when caller explicitly supplied them.** + * Backend defaults (latest = 10, to = latest version) don't echo. + * The request builder produces an `explicitFilterFields` set that + * the envelope consults here. + * - **`include_bodies` lever.** When false, each entry drops its + * `body` field. Other fields (`version`, `normalizedVersion`, + * `publishedAt`, `htmlUrl`) remain so the tool still produces the + * version / date / URL timeline. + * - **`metadata` dropped from envelope.** Source-specific opaque + * JSON; live-smoke observations will inform a typed passthrough + * later. TODO(pkgseer-backend) marker in the service types. + */ + +import type { ChangelogReport } from "../services/index.js"; +import { colorize, dim } from "./colors.js"; +import type { ExplicitFilterField } from "./package-changelog-request.js"; + +/** Two backend-documented modes, kept lowercase. */ +export type ChangelogMode = "latest" | "range"; + +export interface LeanChangelogEntry { + /** Present with a possibly-null value — the primary index key. */ + version: string | null; + /** Backend-normalised version for sorting. Stripped when null. */ + normalizedVersion?: string; + /** ISO8601 date string. Stripped when null. */ + publishedAt?: string; + /** Absolute URL to release / commit / doc page. Stripped when null. */ + htmlUrl?: string; + /** Raw markdown. Stripped when null OR when include_bodies=false. */ + body?: string; +} + +export interface LeanEntriesBlock { + /** Client-computed. Matches `items.length` by construction. */ + count: number; + items: LeanChangelogEntry[]; +} + +/** + * `filter` block — present only when the caller supplied at least + * one explicit input. Each field inside emits only when it was + * caller-supplied; backend defaults never round-trip as caller + * intent. + */ +export interface LeanChangelogFilter { + fromVersion?: string; + toVersion?: string; + limit?: number; + gitRef?: string; +} + +export interface LeanChangelogEnvelope { + /** Present for spec addressing. */ + registry?: string; + /** Present for spec addressing. */ + name?: string; + /** Present for repo-URL addressing. */ + repoUrl?: string; + /** `"releases"` | `"changelog_file"` | `"hexdocs"`. Never null here + * (null is promoted to NOT_FOUND at the service boundary). */ + source: string; + /** Derived from request params. */ + mode: ChangelogMode; + entries: LeanEntriesBlock; + filter?: LeanChangelogFilter; +} + +export interface BuildChangelogPayloadOptions { + /** Caller's addressing echo; one of the two is present. */ + registry?: string; + name?: string; + repoUrl?: string; + /** Mode the caller requested. Built from `params.fromVersion`. */ + mode: ChangelogMode; + explicitFilterFields: Set; + /** When false, drop each entry's `body` field. Default: true. */ + includeBodies: boolean; + /** Caller's raw inputs, echoed under `filter.*` when explicit. */ + fromVersion?: string; + toVersion?: string; + limit?: number; + gitRef?: string; +} + +export function buildPackageChangelogSuccessPayload( + report: ChangelogReport, + options: BuildChangelogPayloadOptions, +): LeanChangelogEnvelope { + const items = report.entries.map((entry) => { + const lean: LeanChangelogEntry = { + version: entry.version ?? null, + }; + // Non-null-strip policy: present-but-null/undefined fields are + // stripped; present-but-empty values (empty-string body, empty + // URL) are preserved so agents can distinguish "backend returned + // no content" from "backend didn't return this field". The only + // mutation is `include_bodies: false`, which explicitly drops + // `body` regardless of its value. + if (entry.normalizedVersion != null) { + lean.normalizedVersion = entry.normalizedVersion; + } + if (entry.publishedAt != null) { + lean.publishedAt = entry.publishedAt; + } + if (entry.htmlUrl != null) { + lean.htmlUrl = entry.htmlUrl; + } + if (options.includeBodies && entry.body != null) { + lean.body = entry.body; + } + return lean; + }); + + if (report.source == null) { + // Defence in depth — the service promotes null-source to + // NOT_FOUND before we get here, so this path is unreachable in + // practice. Throw rather than emit an envelope that would + // violate the `source: string` type contract. + throw new Error( + "Changelog envelope builder received a null source — should have been promoted to NOT_FOUND at the service boundary.", + ); + } + + const envelope: LeanChangelogEnvelope = { + source: report.source, + mode: options.mode, + entries: { + count: items.length, + items, + }, + }; + + if (options.registry) envelope.registry = options.registry; + if (options.name) envelope.name = options.name; + if (options.repoUrl) envelope.repoUrl = options.repoUrl; + + const filter = buildFilterBlock(options); + if (filter) envelope.filter = filter; + + return envelope; +} + +function buildFilterBlock( + options: BuildChangelogPayloadOptions, +): LeanChangelogFilter | undefined { + const { explicitFilterFields } = options; + if (explicitFilterFields.size === 0) return undefined; + const filter: LeanChangelogFilter = {}; + if (explicitFilterFields.has("fromVersion") && options.fromVersion) { + filter.fromVersion = options.fromVersion; + } + if (explicitFilterFields.has("toVersion") && options.toVersion) { + filter.toVersion = options.toVersion; + } + if (explicitFilterFields.has("limit") && options.limit !== undefined) { + filter.limit = options.limit; + } + if (explicitFilterFields.has("gitRef") && options.gitRef) { + filter.gitRef = options.gitRef; + } + return Object.keys(filter).length > 0 ? filter : undefined; +} + +// -------------------------------------------------------------------- +// Terminal formatter (CLI-only). +// -------------------------------------------------------------------- + +export interface FormatChangelogTerminalOptions { + verbose?: boolean; + useColors: boolean; +} + +/** + * Default line cap applied to the body preview when `--verbose` is + * not set. Release notes run long (100+ lines on typescript / + * kubernetes); an unbounded default would dominate the terminal + * scrollback. A 10-line cap shows the first one or two sections + * plus any preamble, which is usually enough to answer "what + * shipped". `--verbose` lifts the cap; `--no-body` (which flows + * through the envelope builder as `body: undefined`) skips this + * branch entirely. + */ +const DEFAULT_BODY_PREVIEW_LINES = 10; + +/** + * Format an envelope for terminal display. The summary header leads + * with the addressing + count + source; each entry renders as + * `version date url` plus an indented body preview. The preview + * is capped at {@link DEFAULT_BODY_PREVIEW_LINES} lines by default, + * expanded fully under `--verbose`, and skipped entirely when the + * caller passed `--no-body` (bodies are absent from the envelope + * on that path). + * + * Edge cases: + * - Empty entries: summary header + "No entries in this range.". + * - Missing `publishedAt`: `—` in the date column. + * - Missing `version`: `(unversioned)`; backend newest-first order + * preserved (we don't re-sort). + * - Empty-string body: rendered with a neutral `(empty release + * notes)` sentinel so agents can tell it apart from + * `--no-body` / bodies-absent. + */ +export function formatPackageChangelogTerminal( + envelope: LeanChangelogEnvelope, + options: FormatChangelogTerminalOptions, +): string { + const lines: string[] = []; + lines.push(buildSummaryLine(envelope, options)); + lines.push(""); + + if (envelope.entries.items.length === 0) { + lines.push(dim("No entries in this range.", options.useColors)); + lines.push(""); + return lines.join("\n"); + } + + const versionWidth = computeVersionColumnWidth(envelope.entries.items); + const dateWidth = 10; // YYYY-MM-DD + + for (const entry of envelope.entries.items) { + const version = entry.version ?? "(unversioned)"; + const date = entry.publishedAt + ? formatDate(entry.publishedAt) + : dim("—", options.useColors); + const url = entry.htmlUrl + ? dim(entry.htmlUrl, options.useColors) + : dim("(no link)", options.useColors); + const padded = padColumn(version, versionWidth); + const datePadded = padColumn(date, dateWidth); + lines.push( + `${colorize(padded, "bold", options.useColors)} ${datePadded} ${url}`, + ); + if (entry.body != null) { + appendBodyLines(lines, entry.body, options); + } + lines.push(""); + } + + return lines.join("\n"); +} + +function appendBodyLines( + lines: string[], + body: string, + options: FormatChangelogTerminalOptions, +): void { + lines.push(""); + if (body.length === 0) { + lines.push(` ${dim("(empty release notes)", options.useColors)}`); + return; + } + const bodyLines = body.split("\n"); + const cap = options.verbose ? bodyLines.length : DEFAULT_BODY_PREVIEW_LINES; + const visible = bodyLines.slice(0, cap); + for (const bodyLine of visible) { + lines.push(` ${dim(bodyLine, options.useColors)}`); + } + const hidden = bodyLines.length - visible.length; + if (hidden > 0) { + lines.push( + ` ${dim(`… (+${hidden} more line${hidden === 1 ? "" : "s"} — use --verbose for the full body)`, options.useColors)}`, + ); + } +} + +function buildSummaryLine( + envelope: LeanChangelogEnvelope, + options: FormatChangelogTerminalOptions, +): string { + const identity = + envelope.registry && envelope.name + ? `${envelope.name} · ${envelope.registry}` + : (envelope.repoUrl ?? "(unknown)"); + const sourceLabel = humanizeSource(envelope.source); + const modeLabel = + envelope.mode === "range" ? rangeLabel(envelope) : latestLabel(envelope); + const countLabel = `${envelope.entries.count} ${plural("entry", "entries", envelope.entries.count)}`; + const parts = [identity, `source: ${sourceLabel}`, modeLabel, countLabel]; + return colorize(parts.join(" · "), "bold", options.useColors); +} + +function rangeLabel(envelope: LeanChangelogEnvelope): string { + const from = envelope.filter?.fromVersion ?? "earliest"; + const to = envelope.filter?.toVersion ?? "latest"; + return `range ${from} → ${to}`; +} + +function latestLabel(envelope: LeanChangelogEnvelope): string { + if (envelope.filter?.toVersion) { + return `latest up to ${envelope.filter.toVersion}`; + } + return "latest"; +} + +function humanizeSource(source: string): string { + switch (source) { + case "releases": + return "GitHub Releases"; + case "changelog_file": + return "CHANGELOG.md"; + case "hexdocs": + return "HexDocs"; + default: + return source; + } +} + +function plural(singular: string, pluralForm: string, count: number): string { + return count === 1 ? singular : pluralForm; +} + +function formatDate(iso: string): string { + // Slice YYYY-MM-DD without turning this into a full Date parsing + // problem. Backend returns ISO8601; anything shorter stays verbatim. + if (/^\d{4}-\d{2}-\d{2}/.test(iso)) return iso.slice(0, 10); + return iso; +} + +function computeVersionColumnWidth(items: LeanChangelogEntry[]): number { + let width = 0; + for (const item of items) { + const label = item.version ?? "(unversioned)"; + if (label.length > width) width = label.length; + } + return width; +} + +function padColumn(text: string, width: number): string { + const visible = stripAnsi(text); + if (visible.length >= width) return text; + return text + " ".repeat(width - visible.length); +} + +const ESC = String.fromCharCode(0x1b); +const ANSI_SGR_PATTERN = new RegExp(`${ESC}\\[[0-9;]*m`, "g"); + +function stripAnsi(text: string): string { + // Minimal ANSI CSI stripper — the terminal formatter only uses + // SGR sequences produced by `colorize` / `dim`. + return text.replace(ANSI_SGR_PATTERN, ""); +} diff --git a/src/shared/package-intelligence-error-map.ts b/src/shared/package-intelligence-error-map.ts index 25ffb6d6..546d36e4 100644 --- a/src/shared/package-intelligence-error-map.ts +++ b/src/shared/package-intelligence-error-map.ts @@ -13,6 +13,7 @@ import { MalformedPackageIntelligenceResponseError, PackageIntelligenceAccessError, PackageIntelligenceBackendError, + PackageIntelligenceChangelogSourceNotFoundError, PackageIntelligenceFeatureFlagRequiredError, PackageIntelligenceGraphQLError, PackageIntelligenceNetworkError, @@ -50,7 +51,10 @@ export function mapPackageIntelligenceError(error: unknown): MappedError { } function classify(error: unknown): MappedError { - if (error instanceof PackageIntelligenceTargetNotFoundError) { + if ( + error instanceof PackageIntelligenceTargetNotFoundError || + error instanceof PackageIntelligenceChangelogSourceNotFoundError + ) { return { code: "NOT_FOUND", message: error.message, diff --git a/src/tools/index.ts b/src/tools/index.ts index 33f07318..09548470 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -1,4 +1,5 @@ export { createFeedbackTool } from "./feedback.js"; +export { createPackageChangelogTool } from "./package-changelog.js"; export { createPackageDependenciesTool } from "./package-dependencies.js"; export { createPackageSummaryTool } from "./package-summary.js"; export { createPackageVulnerabilitiesTool } from "./package-vulnerabilities.js"; diff --git a/src/tools/package-changelog-parity.test.ts b/src/tools/package-changelog-parity.test.ts new file mode 100644 index 00000000..da2467e5 --- /dev/null +++ b/src/tools/package-changelog-parity.test.ts @@ -0,0 +1,423 @@ +// PARITY TEST — enforces rule IDs from docs/implementation/mcp-cli-parity.md: +// PARITY-JSON-KEYS CLI --json output and MCP text payload parse to +// deepEqual JSON objects for equivalent inputs. +// PARITY-ERROR-ENVELOPE Both surfaces emit { error, code, retryable, +// details? } on every error path; MCP error text is +// always valid JSON. +// +// Assertion policy (matches P1–P3 precedent): +// - Service-sourced success / error fixtures use `toEqual`: both +// surfaces route through the same request builder and envelope +// shaper, so envelopes are byte-identical. +// - `INVALID_ARGUMENT` fixtures use `toMatchObject`: CLI rejects in +// `buildPackageChangelogParams` after `parsePackageSpec`; MCP +// rejects in the same builder. Same envelope shape, surface- +// specific error text. + +import { describe, expect, it, mock, spyOn } from "bun:test"; +import { + type PkgChangelogCommandDependencies, + pkgChangelogAction, +} from "../commands/pkg/changelog.js"; +import { + type ChangelogReport, + PackageIntelligenceBackendError, + PackageIntelligenceChangelogSourceNotFoundError, + PackageIntelligenceTargetNotFoundError, + PackageIntelligenceVersionNotFoundError, +} from "../services/index.js"; +import { + createMockPackageIntelligenceService, + defaultChangelogReport, +} from "../services/test-helpers.js"; +import { createPackageChangelogTool } from "./package-changelog.js"; + +function cliDeps( + overrides: Partial = {}, +): PkgChangelogCommandDependencies { + return { + packageIntelligenceService: createMockPackageIntelligenceService(), + codeNavigationUrl: "https://pkgseer.dev", + hasValidToken: true, + mcpUrl: "https://mcp.example.com", + ...overrides, + }; +} + +async function cliJson( + spec: string | undefined, + options: Parameters[1] = {}, + deps: PkgChangelogCommandDependencies = cliDeps(), +): Promise { + const logSpy = spyOn(console, "log").mockImplementation(() => {}); + const errSpy = spyOn(console, "error").mockImplementation(() => {}); + const exitSpy = spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + try { + try { + await pkgChangelogAction(spec, { ...options, json: true }, deps); + } catch { + /* CLI error paths call process.exit — caught. */ + } + const fromLog = logSpy.mock.calls[0]?.[0] as string | undefined; + const fromErr = errSpy.mock.calls[0]?.[0] as string | undefined; + const raw = fromLog ?? fromErr; + return raw ? JSON.parse(raw) : undefined; + } finally { + logSpy.mockRestore(); + errSpy.mockRestore(); + exitSpy.mockRestore(); + } +} + +interface McpArgs { + registry?: string; + package_name?: string; + repo_url?: string; + from_version?: string; + to_version?: string; + limit?: number; + git_ref?: string; + include_bodies?: boolean; +} + +async function mcpJson( + args: McpArgs, + packageChangelogMock?: () => Promise, +): Promise<{ json: unknown; isError: boolean | undefined }> { + const service = createMockPackageIntelligenceService( + packageChangelogMock + ? { packageChangelog: packageChangelogMock as never } + : {}, + ); + const tool = createPackageChangelogTool(service); + const result = await tool.handler(args, {}); + const text = result.content[0]?.text ?? ""; + return { json: JSON.parse(text), isError: result.isError }; +} + +describe("package_changelog parity", () => { + it("PARITY-JSON-KEYS: happy latest-mode CLI === MCP", async () => { + const fn = mock(() => Promise.resolve(defaultChangelogReport)); + const cli = await cliJson( + "npm:express", + {}, + cliDeps({ + packageIntelligenceService: createMockPackageIntelligenceService({ + packageChangelog: fn as never, + }), + }), + ); + const { json, isError } = await mcpJson( + { registry: "npm", package_name: "express" }, + fn as never, + ); + expect(isError).toBeUndefined(); + expect(cli).toEqual(json); + const envelope = cli as { + registry: string; + name: string; + source: string; + mode: string; + entries: { count: number; items: unknown[] }; + }; + expect(envelope.registry).toBe("npm"); + expect(envelope.name).toBe("express"); + expect(envelope.source).toBe("releases"); + expect(envelope.mode).toBe("latest"); + expect(envelope.entries.count).toBe(2); + }); + + it("PARITY-JSON-KEYS: range mode (--from / from_version) echoes filter and flips mode", async () => { + const rangeReport: ChangelogReport = { + ...defaultChangelogReport, + entries: [defaultChangelogReport.entries[0]!], + }; + const fn = mock(() => Promise.resolve(rangeReport)); + const cli = await cliJson( + "npm:express", + { from: "5.0.0" }, + cliDeps({ + packageIntelligenceService: createMockPackageIntelligenceService({ + packageChangelog: fn as never, + }), + }), + ); + const { json } = await mcpJson( + { registry: "npm", package_name: "express", from_version: "5.0.0" }, + fn as never, + ); + expect(cli).toEqual(json); + const envelope = cli as { + mode: string; + filter?: { fromVersion?: string }; + }; + expect(envelope.mode).toBe("range"); + expect(envelope.filter?.fromVersion).toBe("5.0.0"); + }); + + it("PARITY-JSON-KEYS: repo-URL addressing (CLI --repo-url === MCP repo_url)", async () => { + const fn = mock(() => Promise.resolve(defaultChangelogReport)); + const cli = await cliJson( + undefined, + { repoUrl: "https://github.com/expressjs/express" }, + cliDeps({ + packageIntelligenceService: createMockPackageIntelligenceService({ + packageChangelog: fn as never, + }), + }), + ); + const { json } = await mcpJson( + { repo_url: "https://github.com/expressjs/express" }, + fn as never, + ); + expect(cli).toEqual(json); + const envelope = cli as { + repoUrl?: string; + registry?: string; + name?: string; + }; + expect(envelope.repoUrl).toBe("https://github.com/expressjs/express"); + expect(envelope.registry).toBeUndefined(); + expect(envelope.name).toBeUndefined(); + }); + + it("PARITY-JSON-KEYS: no-body (CLI --no-body === MCP include_bodies: false)", async () => { + const fn = mock(() => Promise.resolve(defaultChangelogReport)); + const cli = await cliJson( + "npm:express", + { body: false }, + cliDeps({ + packageIntelligenceService: createMockPackageIntelligenceService({ + packageChangelog: fn as never, + }), + }), + ); + const { json } = await mcpJson( + { registry: "npm", package_name: "express", include_bodies: false }, + fn as never, + ); + expect(cli).toEqual(json); + const envelope = cli as { + entries: { items: Array<{ body?: string }> }; + }; + for (const item of envelope.entries.items) { + expect(item.body).toBeUndefined(); + } + }); + + it("PARITY-JSON-KEYS: default includes bodies on both surfaces", async () => { + const fn = mock(() => Promise.resolve(defaultChangelogReport)); + const cli = await cliJson( + "npm:express", + {}, + cliDeps({ + packageIntelligenceService: createMockPackageIntelligenceService({ + packageChangelog: fn as never, + }), + }), + ); + const { json } = await mcpJson( + { registry: "npm", package_name: "express" }, + fn as never, + ); + expect(cli).toEqual(json); + const envelope = cli as { + entries: { items: Array<{ body?: string }> }; + }; + expect(envelope.entries.items[0]?.body).toBe("## Patch\n- fixed a thing"); + }); + + it("PARITY-JSON-KEYS: empty entries lossless on both surfaces", async () => { + const emptyReport: ChangelogReport = { + ...defaultChangelogReport, + entries: [], + }; + const fn = mock(() => Promise.resolve(emptyReport)); + const cli = await cliJson( + "npm:express", + {}, + cliDeps({ + packageIntelligenceService: createMockPackageIntelligenceService({ + packageChangelog: fn as never, + }), + }), + ); + const { json } = await mcpJson( + { registry: "npm", package_name: "express" }, + fn as never, + ); + expect(cli).toEqual(json); + const envelope = cli as { entries: { count: number; items: unknown[] } }; + expect(envelope.entries.count).toBe(0); + expect(envelope.entries.items).toEqual([]); + }); + + it("PARITY-ERROR-ENVELOPE: NOT_FOUND (no changelog source) identical on both surfaces", async () => { + const fn = mock(() => + Promise.reject( + new PackageIntelligenceChangelogSourceNotFoundError( + "No changelog source available for npm:obscure (tried GitHub Releases, CHANGELOG.md, and HexDocs).", + ), + ), + ); + const cli = await cliJson( + "npm:obscure", + {}, + cliDeps({ + packageIntelligenceService: createMockPackageIntelligenceService({ + packageChangelog: fn as never, + }), + }), + ); + const { json } = await mcpJson( + { registry: "npm", package_name: "obscure" }, + fn as never, + ); + expect(cli).toEqual(json); + expect((cli as { code: string }).code).toBe("NOT_FOUND"); + }); + + it("PARITY-ERROR-ENVELOPE: PackageIntelligenceTargetNotFoundError (package missing) identical", async () => { + const fn = mock(() => + Promise.reject( + new PackageIntelligenceTargetNotFoundError("Package not found"), + ), + ); + const cli = await cliJson( + "npm:does-not-exist", + {}, + cliDeps({ + packageIntelligenceService: createMockPackageIntelligenceService({ + packageChangelog: fn as never, + }), + }), + ); + const { json } = await mcpJson( + { registry: "npm", package_name: "does-not-exist" }, + fn as never, + ); + expect(cli).toEqual(json); + expect((cli as { code: string }).code).toBe("NOT_FOUND"); + }); + + it("PARITY-ERROR-ENVELOPE: VERSION_NOT_FOUND with structured details identical", async () => { + const fn = mock(() => + Promise.reject( + new PackageIntelligenceVersionNotFoundError( + "No matching version found", + "npm:express", + "99.0.0", + undefined, + ), + ), + ); + const cli = await cliJson( + "npm:express", + { from: "99.0.0" }, + cliDeps({ + packageIntelligenceService: createMockPackageIntelligenceService({ + packageChangelog: fn as never, + }), + }), + ); + const { json } = await mcpJson( + { registry: "npm", package_name: "express", from_version: "99.0.0" }, + fn as never, + ); + expect(cli).toEqual(json); + const envelope = cli as { + code: string; + details?: { package?: string; requestedVersion?: string }; + }; + expect(envelope.code).toBe("VERSION_NOT_FOUND"); + expect(envelope.details?.package).toBe("npm:express"); + expect(envelope.details?.requestedVersion).toBe("99.0.0"); + }); + + it("PARITY-ERROR-ENVELOPE: BACKEND_ERROR identical on both surfaces", async () => { + const fn = mock(() => + Promise.reject( + new PackageIntelligenceBackendError( + "Upstream timed out", + 504, + "UPSTREAM_ERROR", + true, + ), + ), + ); + const cli = await cliJson( + "npm:express", + {}, + cliDeps({ + packageIntelligenceService: createMockPackageIntelligenceService({ + packageChangelog: fn as never, + }), + }), + ); + const { json } = await mcpJson( + { registry: "npm", package_name: "express" }, + fn as never, + ); + expect(cli).toEqual(json); + expect((cli as { code: string }).code).toBe("BACKEND_ERROR"); + expect((cli as { retryable: boolean }).retryable).toBe(true); + }); + + it("PARITY-ERROR-ENVELOPE: INVALID_ARGUMENT for @ matches shape", async () => { + const cli = await cliJson("npm:express@5.0.0", {}); + const { json } = await mcpJson({ + registry: "npm", + package_name: "express", + // The MCP surface has no `@` channel; we test + // the equivalent rule from the other direction — a different + // builder rule (from + limit). The shape is the concern here, + // not identical text. + }); + // CLI envelope is an error; MCP hits the default service mock + // happy path, so shapes differ by design. Instead assert CLI + // matches the shared envelope contract. + expect(cli).toMatchObject({ + code: "INVALID_ARGUMENT", + error: expect.any(String), + retryable: false, + }); + // Sanity: MCP rejects from + limit with INVALID_ARGUMENT too. + const mcpReject = await mcpJson({ + registry: "npm", + package_name: "express", + from_version: "5.0.0", + limit: 10, + }); + expect(mcpReject.json).toMatchObject({ + code: "INVALID_ARGUMENT", + error: expect.any(String), + retryable: false, + }); + // Suppress unused-var warning on `json` — we don't compare it. + void json; + }); + + it("PARITY-ERROR-ENVELOPE: INVALID_ARGUMENT for from + limit matches on both surfaces", async () => { + const cli = await cliJson("npm:express", { from: "5.0.0", limit: "10" }); + const { json } = await mcpJson({ + registry: "npm", + package_name: "express", + from_version: "5.0.0", + limit: 10, + }); + // Shape parity — message text differs (CLI gets the Node error + // surface, MCP the JSON payload). toMatchObject covers both. + expect(cli).toMatchObject({ + code: "INVALID_ARGUMENT", + error: expect.stringContaining("latest-mode"), + retryable: false, + }); + expect(json).toMatchObject({ + code: "INVALID_ARGUMENT", + error: expect.stringContaining("latest-mode"), + retryable: false, + }); + }); +}); diff --git a/src/tools/package-changelog.test.ts b/src/tools/package-changelog.test.ts new file mode 100644 index 00000000..6b50a94d --- /dev/null +++ b/src/tools/package-changelog.test.ts @@ -0,0 +1,313 @@ +import { describe, expect, it, mock } from "bun:test"; +import { + PackageIntelligenceChangelogSourceNotFoundError, + PackageIntelligenceTargetNotFoundError, +} from "../services/index.js"; +import { + createMockPackageIntelligenceService, + defaultChangelogReport, +} from "../services/test-helpers.js"; +import { createPackageChangelogTool } from "./package-changelog.js"; + +function parseText(result: { content: Array<{ text: string }> }): unknown { + return JSON.parse(result.content[0]?.text ?? ""); +} + +describe("createPackageChangelogTool — metadata", () => { + it("registers the correct tool name, description, and schema keys", () => { + const tool = createPackageChangelogTool( + createMockPackageIntelligenceService(), + ); + expect(tool.name).toBe("package_changelog"); + expect(tool.description).toContain("latest mode"); + expect(tool.description).toContain("range mode"); + expect(Object.keys(tool.schema).sort()).toEqual([ + "from_version", + "git_ref", + "include_bodies", + "limit", + "package_name", + "registry", + "repo_url", + "to_version", + ]); + expect(tool.annotations?.readOnlyHint).toBe(true); + }); +}); + +describe("createPackageChangelogTool — happy path", () => { + it("normalises spec addressing and calls service.packageChangelog", async () => { + const packageChangelog = mock(() => + Promise.resolve(defaultChangelogReport), + ); + const service = createMockPackageIntelligenceService({ packageChangelog }); + const tool = createPackageChangelogTool(service); + + await tool.handler({ registry: "npm", package_name: "express" }, {}); + + const calls = packageChangelog.mock.calls as unknown as Array< + [{ registry?: string; packageName?: string; repoUrl?: string }] + >; + expect(calls[0]?.[0]?.registry).toBe("NPM"); + expect(calls[0]?.[0]?.packageName).toBe("express"); + expect(calls[0]?.[0]?.repoUrl).toBeUndefined(); + }); + + it("emits the envelope with entries.count computed client-side", async () => { + const tool = createPackageChangelogTool( + createMockPackageIntelligenceService(), + ); + const result = await tool.handler( + { registry: "npm", package_name: "express" }, + {}, + ); + expect(result.isError).toBeUndefined(); + const payload = parseText(result) as { + registry: string; + name: string; + source: string; + mode: string; + entries: { count: number; items: unknown[] }; + }; + expect(payload.registry).toBe("npm"); + expect(payload.name).toBe("express"); + expect(payload.source).toBe("releases"); + expect(payload.mode).toBe("latest"); + expect(payload.entries.count).toBe(2); + expect(payload.entries.items.length).toBe(2); + }); + + it("accepts repo_url addressing and emits repoUrl in the envelope", async () => { + const tool = createPackageChangelogTool( + createMockPackageIntelligenceService(), + ); + const result = await tool.handler( + { repo_url: "https://github.com/expressjs/express" }, + {}, + ); + const payload = parseText(result) as { + registry?: string; + name?: string; + repoUrl?: string; + }; + expect(payload.repoUrl).toBe("https://github.com/expressjs/express"); + expect(payload.registry).toBeUndefined(); + expect(payload.name).toBeUndefined(); + }); + + it("emits mode: 'range' and filter.fromVersion when from_version is set", async () => { + const tool = createPackageChangelogTool( + createMockPackageIntelligenceService(), + ); + const result = await tool.handler( + { + registry: "npm", + package_name: "express", + from_version: "5.0.0", + }, + {}, + ); + const payload = parseText(result) as { + mode: string; + filter?: { fromVersion?: string }; + }; + expect(payload.mode).toBe("range"); + expect(payload.filter?.fromVersion).toBe("5.0.0"); + }); + + it("drops body fields when include_bodies is false", async () => { + const tool = createPackageChangelogTool( + createMockPackageIntelligenceService(), + ); + const result = await tool.handler( + { + registry: "npm", + package_name: "express", + include_bodies: false, + }, + {}, + ); + const payload = parseText(result) as { + entries: { items: Array<{ body?: string }> }; + }; + for (const item of payload.entries.items) { + expect(item.body).toBeUndefined(); + } + }); + + it("keeps body fields by default", async () => { + const tool = createPackageChangelogTool( + createMockPackageIntelligenceService(), + ); + const result = await tool.handler( + { registry: "npm", package_name: "express" }, + {}, + ); + const payload = parseText(result) as { + entries: { items: Array<{ body?: string }> }; + }; + expect(payload.entries.items[0]?.body).toBeTruthy(); + }); +}); + +describe("createPackageChangelogTool — validation errors via in-handler builder", () => { + it("returns INVALID_ARGUMENT when both spec and repo_url are provided", async () => { + const tool = createPackageChangelogTool( + createMockPackageIntelligenceService(), + ); + const result = await tool.handler( + { + registry: "npm", + package_name: "express", + repo_url: "https://github.com/expressjs/express", + }, + {}, + ); + expect(result.isError).toBe(true); + const payload = parseText(result) as { code: string; error: string }; + expect(payload.code).toBe("INVALID_ARGUMENT"); + expect(payload.error).toContain("not both"); + }); + + it("returns INVALID_ARGUMENT when neither addressing form is provided", async () => { + const tool = createPackageChangelogTool( + createMockPackageIntelligenceService(), + ); + const result = await tool.handler({}, {}); + expect(result.isError).toBe(true); + const payload = parseText(result) as { code: string }; + expect(payload.code).toBe("INVALID_ARGUMENT"); + }); + + it("returns INVALID_ARGUMENT for from_version + limit", async () => { + const tool = createPackageChangelogTool( + createMockPackageIntelligenceService(), + ); + const result = await tool.handler( + { + registry: "npm", + package_name: "express", + from_version: "5.0.0", + limit: 10, + }, + {}, + ); + expect(result.isError).toBe(true); + const payload = parseText(result) as { code: string; error: string }; + expect(payload.code).toBe("INVALID_ARGUMENT"); + expect(payload.error).toContain("latest-mode"); + }); + + it("returns INVALID_ARGUMENT for tag-style from_version", async () => { + const tool = createPackageChangelogTool( + createMockPackageIntelligenceService(), + ); + const result = await tool.handler( + { + registry: "npm", + package_name: "express", + from_version: "v4.18.0", + }, + {}, + ); + expect(result.isError).toBe(true); + const payload = parseText(result) as { code: string; error: string }; + expect(payload.code).toBe("INVALID_ARGUMENT"); + expect(payload.error).toContain("git tag"); + }); + + it("returns INVALID_ARGUMENT envelope (not a raw SDK error) for out-of-range limit", async () => { + const tool = createPackageChangelogTool( + createMockPackageIntelligenceService(), + ); + // MCP schema is permissive; the shared builder enforces bounds. + // This guarantees agents always see the shared envelope rather + // than a raw Zod rejection from the SDK. + const result = await tool.handler( + { registry: "npm", package_name: "express", limit: 51 }, + {}, + ); + expect(result.isError).toBe(true); + const payload = parseText(result) as { code: string; error: string }; + expect(payload.code).toBe("INVALID_ARGUMENT"); + expect(payload.error).toContain("1 and 50"); + }); + + it("returns INVALID_ARGUMENT envelope for a non-integer limit", async () => { + const tool = createPackageChangelogTool( + createMockPackageIntelligenceService(), + ); + const result = await tool.handler( + { registry: "npm", package_name: "express", limit: 3.5 }, + {}, + ); + expect(result.isError).toBe(true); + const payload = parseText(result) as { code: string }; + expect(payload.code).toBe("INVALID_ARGUMENT"); + }); + + it("returns INVALID_ARGUMENT for a non-URL repo_url value", async () => { + const tool = createPackageChangelogTool( + createMockPackageIntelligenceService(), + ); + const result = await tool.handler({ repo_url: "not a url" }, {}); + expect(result.isError).toBe(true); + const payload = parseText(result) as { code: string; error: string }; + expect(payload.code).toBe("INVALID_ARGUMENT"); + expect(payload.error).toContain("URL"); + }); +}); + +describe("createPackageChangelogTool — service errors", () => { + it("classifies PackageIntelligenceChangelogSourceNotFoundError as NOT_FOUND", async () => { + const service = createMockPackageIntelligenceService({ + packageChangelog: mock(() => + Promise.reject( + new PackageIntelligenceChangelogSourceNotFoundError( + "No changelog source available for npm:ghost.", + ), + ), + ), + }); + const tool = createPackageChangelogTool(service); + const result = await tool.handler( + { registry: "npm", package_name: "ghost" }, + {}, + ); + expect(result.isError).toBe(true); + const payload = parseText(result) as { code: string }; + expect(payload.code).toBe("NOT_FOUND"); + }); + + it("classifies PackageIntelligenceTargetNotFoundError as NOT_FOUND (package missing)", async () => { + const service = createMockPackageIntelligenceService({ + packageChangelog: mock(() => + Promise.reject( + new PackageIntelligenceTargetNotFoundError("Package not found"), + ), + ), + }); + const tool = createPackageChangelogTool(service); + const result = await tool.handler( + { registry: "npm", package_name: "does-not-exist" }, + {}, + ); + expect(result.isError).toBe(true); + const payload = parseText(result) as { code: string }; + expect(payload.code).toBe("NOT_FOUND"); + }); + + it("classifies unexpected Error as UNKNOWN", async () => { + const service = createMockPackageIntelligenceService({ + packageChangelog: mock(() => Promise.reject(new Error("boom"))), + }); + const tool = createPackageChangelogTool(service); + const result = await tool.handler( + { registry: "npm", package_name: "express" }, + {}, + ); + expect(result.isError).toBe(true); + const payload = parseText(result) as { code: string }; + expect(payload.code).toBe("UNKNOWN"); + }); +}); diff --git a/src/tools/package-changelog.ts b/src/tools/package-changelog.ts new file mode 100644 index 00000000..d2fa528e --- /dev/null +++ b/src/tools/package-changelog.ts @@ -0,0 +1,152 @@ +import { z } from "zod"; +import type { PackageIntelligenceService } from "../services/index.js"; +import { buildPackageChangelogParams } from "../shared/package-changelog-request.js"; +import { buildPackageChangelogSuccessPayload } from "../shared/package-changelog-response.js"; +import { mapPackageIntelligenceError } from "../shared/package-intelligence-error-map.js"; +import { toPkgseerRegistryLowercase } from "../shared/pkgseer-registry.js"; +import { type ToolDefinition, textResult } from "./types.js"; + +export interface PackageChangelogArgs { + registry?: string; + package_name?: string; + repo_url?: string; + git_ref?: string; + from_version?: string; + to_version?: string; + limit?: number; + include_bodies?: boolean; +} + +/** + * Permissive schema — the shared `buildPackageChangelogParams` builder + * is the single validation path. Raw Zod errors never surface to + * agents. Matches the pattern established by P1 / P2 / P3. + * + * `package_changelog` is the first pkg-intel MCP tool with dual + * addressing (`registry` + `package_name` XOR `repo_url`). The + * underlying `packageChangelog` query is intrinsically repo-level + * (sources: GitHub Releases / CHANGELOG.md / HexDocs), so exposing + * `repo_url` isn't a bolt-on — it's a peer addressing mode on the + * schema. P1 / P2 / P3 omit it because their queries are registry- + * metadata APIs with no repo-URL alternative. + */ +const schema = { + registry: z + .string() + .optional() + .describe( + "Package registry (with `package_name`). Mutually exclusive with `repo_url`. Supported: npm, pypi, hex, crates, vcpkg, zig, nuget, maven, packagist.", + ), + package_name: z + .string() + .optional() + .describe( + "Package name (with `registry`). Scoped names ok (`@types/node`). Mutually exclusive with `repo_url`.", + ), + repo_url: z + .string() + .optional() + .describe( + "GitHub repository URL (https://…). Mutually exclusive with `registry` + `package_name`. Use when agents have a repo URL without a registry mapping.", + ), + from_version: z + .string() + .optional() + .describe( + "Start of version range. When set, the response returns every entry between `from_version` and `to_version` (or latest) with no count cap — range mode. Mutually exclusive with `limit`. Tag-style `v`-prefixed inputs are rejected.", + ), + to_version: z + .string() + .optional() + .describe( + "End of range / latest-mode cap. Works in either mode. Defaults to latest on the wire. Tag-style `v`-prefixed inputs are rejected.", + ), + limit: z + .number() + .optional() + .describe( + "Latest-mode cap on entry count (1–50, default 10). Rejected with `INVALID_ARGUMENT` when `from_version` is also set or when out of range.", + ), + git_ref: z + .string() + .optional() + .describe( + "Git branch or tag for CHANGELOG.md source (no effect on GitHub Releases or HexDocs). Defaults to the repository's default branch.", + ), + include_bodies: z + .boolean() + .optional() + .describe( + "When false, each entry in `entries.items[]` omits its `body` field. Default true. Set false when you only need the version / date / URL timeline — drops 10 KB+ per entry on large release notes.", + ), +}; + +const DESCRIPTION = + "Release notes for a package or GitHub repo, newest-first. Default " + + "latest mode returns the ten most recent entries (`limit` 1–50). " + + "With `from_version`, returns every entry in the " + + "`[from_version, to_version]` range (range mode, no count cap). " + + "Address via `registry` + `package_name` or `repo_url` (mutually " + + 'exclusive). Response: `source` (`"releases"` / `"changelog_file"` ' + + '/ `"hexdocs"`), `mode` (`"latest"` or `"range"`), ' + + "`entries: { count, items }` with full markdown bodies. Set " + + "`include_bodies: false` for a version / date / URL timeline only. " + + "Supports npm, PyPI, Hex, Crates, vcpkg, Zig, NuGet, Maven, " + + "Packagist; returns `NOT_FOUND` when a package has no changelog " + + "source."; + +export function createPackageChangelogTool( + service: PackageIntelligenceService, +): ToolDefinition { + return { + name: "package_changelog", + description: DESCRIPTION, + schema, + annotations: { readOnlyHint: true }, + handler: async (args) => { + try { + const { params, explicitFilterFields } = buildPackageChangelogParams({ + registry: args.registry, + packageName: args.package_name, + repoUrl: args.repo_url, + gitRef: args.git_ref, + fromVersion: args.from_version, + toVersion: args.to_version, + limit: args.limit, + }); + const report = await service.packageChangelog(params); + const payload = buildPackageChangelogSuccessPayload(report, { + registry: params.registry + ? toPkgseerRegistryLowercase(params.registry) + : undefined, + name: params.packageName, + repoUrl: params.repoUrl, + mode: params.fromVersion ? "range" : "latest", + explicitFilterFields, + includeBodies: args.include_bodies ?? true, + fromVersion: params.fromVersion, + toVersion: params.toVersion, + limit: params.limit, + gitRef: params.gitRef, + }); + return textResult(JSON.stringify(payload)); + } catch (error) { + const mapped = mapPackageIntelligenceError(error); + return { + content: [ + { + type: "text" as const, + text: JSON.stringify({ + error: mapped.message, + code: mapped.code, + retryable: mapped.retryable ?? false, + ...(mapped.details ? { details: mapped.details } : {}), + }), + }, + ], + isError: true, + }; + } + }, + }; +}