diff --git a/.changeset/issue-fixed-version.md b/.changeset/issue-fixed-version.md new file mode 100644 index 0000000..6128b25 --- /dev/null +++ b/.changeset/issue-fixed-version.md @@ -0,0 +1,5 @@ +--- +"@codacy/codacy-cloud-cli": minor +--- + +Show the fixed version on SCA issues in `codacy issue`, `codacy issues` and `codacy pull-request --issue`: a direct dependency now reads `Direct - Update to ` and a transitive one ends with `(Fixed in )`, matching `codacy finding`/`codacy findings`. `--output json` gains `fixedVersion` on the issue payload. diff --git a/SPECS/README.md b/SPECS/README.md index b4eb236..d7b5e8a 100644 --- a/SPECS/README.md +++ b/SPECS/README.md @@ -48,6 +48,7 @@ This is the single source of truth for all project tasks and specs. | Date | What was done | |---|---| +| 2026-09-30 | (OD-590, OD-784) `fixedVersion` surfaced on `issue`/`issues`/`pull-request --issue`, now that `CommitIssue` carries it (first in API `57.7.9`; counterpart of `codacy-spa` #3145). **API bump: pinned `57.6.4` → `57.7.10`** — a diff of both bundled specs shows only `CommitIssue.fixedVersion` added, the organization webhook operations retagged `organization` → `webhooks` (the CLI uses none of them) and a `hasWebhooksEnabled` field on an organization schema; no operation signatures shifted, so `SPECS/repository-tokens.md`'s whitelist needed no changes. `57.7.9` and `57.7.10` bundle identical specs. `printIssueCard` (list) and `printIssueCodeContext` (detail, shared with `pull-request --issue` and `finding`'s linked-issue block) now pass `issue.fixedVersion` to the existing `formatDependencyChainsLine`/`formatDependencyChainsBlock`, so a direct dependency reads `Direct - Update to ` and a transitive one ends `(Fixed in )`, as on `finding`/`findings`. The param `finding` passes (`SrmItem.fixedVersion`) still wins when non-empty. An empty array (no fix available) renders as before — no version text — rather than adding a "no fix" phrase the other commands don't use. `fixedVersion` added to all three JSON projections (issue.test.ts 24→28, issues.test.ts 76→78, pull-request.test.ts +2) | | 2026-09-23 | (OD-748) `images` shows a **Tags** column (per-image `tagCount`) and an `Image tags: X of Y used` line under the header, read from `listOrganizationImages`' new `usage` object (OD-724, API 57.6.4 — `fetch-api` bumped from 57.4.17). Still one request per page. The line turns red at the cap, where new tags are rejected. Exact figures, not `formatCount`. `--output json` stays an array and gains `tagCount` only; `usage` is not in the JSON, since adding it would change the top-level shape The `image --delete --keep-latest` budget warning now reads that same `usage.limit` from the image-count request it already made, in place of the hardcoded `DEFAULT_ORG_TAG_CAP` (1,000), and drops its "this CLI cannot read the value in force" disclaimer Both commands tolerate a response without `usage`/`tagCount` (an API behind the client), and the exact-figure formatter is now shared as `formatExactCount` (5 new tests, 784 total) | | 2026-09-22 | (OD-710) **Fix: path parameters are now escaped per segment.** Every `image` subcommand 404'd against a namespaced image name — `codacy/codacy-website`, the shape of all seven images in `gh/codacy` — because the generated client falls back to `encodeURI` when `OpenAPI.ENCODE_PATH` is unset, and `encodeURI` leaves `/` intact by design: it encodes whole URLs, not the segments they are built from. The value expanded into two segments and hit a route that does not exist; verified against the API, where the raw slash returns 404 and `%2F` returns 200. `src/utils/api-path.ts` exports `encodePathSegment` (`encodeURIComponent`) and `src/index.ts` installs it beside `OpenAPI.BASE` — the generated client is untouched, so `npm run update-api` cannot undo it. The encoder is global, which is correct rather than incidental: `{branchName}` and `{filePath}` carry slashes for the same reason, though no shipped command sends either as a path parameter today, so nothing else changes shape. Confirmed end to end against `gh/codacy` after the fix: `images` lists 7, `image codacy/codacy-website` lists 84 tags, `--delete --keep-latest 10 --dry-run` reports 74 of 84 (5 new tests, 767 total) | | 2026-02-17 | Project setup: Vitest, `--output json`, `src/index.ts` cleaned up | diff --git a/SPECS/commands/issue.md b/SPECS/commands/issue.md index 396b0b7..97e3a7b 100644 --- a/SPECS/commands/issue.md +++ b/SPECS/commands/issue.md @@ -85,15 +85,16 @@ is gated on `cve` for `finding`. **Dependency import chains block** — shown right after the CVE block whenever `issue.dependencyChains` is present (SCA issues), reusing `formatDependencyChainsBlock` from `finding`/`findings` — see -`SPECS/commands/finding.md` for the format. `CommitIssue` has no `affectedVersion`/`fixedVersion`, so -there's no version segment to drop, and (from `issue`/`issues` directly, without a fixed version to -pass in) a direct dependency renders as a bare `Direct - Update ` with no target version — the -one case where this differs from `finding`'s SrmItem-backed rendering. Shared via -`printIssueCodeContext`, so also applies to the `pull-request --issue` detail view, which -likewise has no fixed version to pass. Only `finding`'s detail view passes its -`SrmItem.fixedVersion` through (see `SPECS/commands/finding.md`'s "Dependency import chains" -section), so only there does the block show it. +`SPECS/commands/finding.md` for the format. `CommitIssue` has no `affectedVersion`, so there's no +version segment to drop. It carries `fixedVersion` (`string[]`, API 57.7.9+; empty when no fix is +available), which the block uses: a direct dependency renders `Direct - Update to ` +and a transitive one ends `(Fixed in )`. With an empty or absent `fixedVersion` a direct +dependency renders as a bare `Direct - Update `. Shared via `printIssueCodeContext`, so also +applies to the `pull-request --issue` detail view. `finding`'s detail view passes its +`SrmItem.fixedVersion` through as an override (see `SPECS/commands/finding.md`'s "Dependency +import chains" section); when it passes none, the issue's own `fixedVersion` is used. +`--output json` includes `issue.fixedVersion`. ## Tests -File: `src/commands/issue.test.ts` — 24 tests (20 + 4 for the dependency chains block). +File: `src/commands/issue.test.ts` — 28 tests (20 + 8 for the dependency chains block). diff --git a/SPECS/commands/issues.md b/SPECS/commands/issues.md index 3ed7d2a..08c006e 100644 --- a/SPECS/commands/issues.md +++ b/SPECS/commands/issues.md @@ -72,8 +72,10 @@ Severity colors: Error=red, High=orange, Warning=yellow, Info=blue. The dependency-chain line is shown when `issue.dependencyChains` is present (SCA issues), reusing `formatDependencyChainsLine` from `finding`/`findings` — see `SPECS/commands/findings.md` -for the format. `--output json` includes the full `dependencyChains` array, no truncation. Not -shown for ignored issues (`IgnoredIssue` has no `dependencyChains` field). +for the format, passing `issue.fixedVersion` so the line carries the target version +(`Direct - Update to ` / `(Fixed in )`). `--output json` includes the full +`dependencyChains` array, no truncation, plus `fixedVersion`. Not shown for ignored issues +(`IgnoredIssue` has no `dependencyChains` or `fixedVersion` field). The "Vulnerable functions" line is shown when `issue.advisoryInformation` is present (SCA issues linked to an OSV advisory), listing up to 3 function names with a "(+N more)" suffix diff --git a/SPECS/commands/pull-request.md b/SPECS/commands/pull-request.md index cc4aaa3..a5164b4 100644 --- a/SPECS/commands/pull-request.md +++ b/SPECS/commands/pull-request.md @@ -53,9 +53,9 @@ Default-mode issue cards (and `--issue` cards) also show the compact "Vulnerable line — see `issues.md` for the format, shared via `printIssueCard`. Dependency chains (OD-449): `--issue` detail renders the dependency import chains block when -`issue.dependencyChains` is present (see `issue.md`; no fixed version, so a direct dependency -shows as a bare `Direct - Update `), and issue cards show the compact first-chain line -(see `issues.md`). `--issue --output json` includes `issue.dependencyChains`. +`issue.dependencyChains` is present (see `issue.md`; the block carries `issue.fixedVersion` when +the API sends one), and issue cards show the compact first-chain line (see `issues.md`). +`--issue --output json` includes `issue.dependencyChains` and `issue.fixedVersion`. ## `--diff` mode diff --git a/package.json b/package.json index bc6fd93..7894a8e 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "prepublishOnly": "npm run update-api && npm run build", "start": "npx ts-node src/index.ts", "start:dist": "node dist/index.js", - "fetch-api": "curl https://artifacts.codacy.com/api/codacy-api/57.6.4/apiv3-bundled.yaml -o ./api-v3/api-swagger.yaml --create-dirs", + "fetch-api": "curl https://artifacts.codacy.com/api/codacy-api/57.7.10/apiv3-bundled.yaml -o ./api-v3/api-swagger.yaml --create-dirs", "generate-api": "rm -rf ./src/api/client && openapi --input ./api-v3/api-swagger.yaml --output ./src/api/client --useUnionTypes --indent 2 --client fetch", "update-api": "npm run fetch-api && npm run generate-api", "check-types": "tsc --noEmit" diff --git a/src/commands/AGENTS.md b/src/commands/AGENTS.md index c750751..4efacf8 100644 --- a/src/commands/AGENTS.md +++ b/src/commands/AGENTS.md @@ -390,7 +390,7 @@ Keeps the two command handlers thin: they only supply the API-specific callbacks - `-R, --ignore-reason`: `AcceptedUse` (default) | `FalsePositive` | `NotExploitable` | `TestCode` | `ExternalCode` - `-m, --ignore-comment`: optional free-text comment - **`--unignore` mode** (`-U`): calls `SecurityService.unignoreSecurityItem`; skips rendering finding details -- **Dependency import chains** (SCA findings): when `item.dependencyChains` (`string[][]`) is present, both `finding` (detail) and `findings` (list) render the vulnerable dependency's import path. A chain with a single package is a **direct** dependency (`Direct - Update to `); 2+ packages is **transitive** (`Transitive - (Fixed in )`). Chains with **4+ packages** collapse the middle to ` → ... N more ... → ` (N = length − 2). The list shows only the first chain + `... and X more`; the detail lists **all** chains with the Direct/Transitive label shown once and continuation lines indented so the `-` aligns. When chains are present, the redundant `AffectedVersion → FixedVersion` segment is dropped from the status line. Mixed direct/transitive chains (rare) take their label from the first chain. Rendering lives in `formatDependencyChainsLine` / `formatDependencyChainsBlock` (see Shared Formatting Utilities). `CommitIssue.dependencyChains` (added OD-449, same shape) reuses these same helpers for `issue`/`issues`/`pull-request --issue` — no per-command variant. Unlike `SrmItem`, `CommitIssue` has no `affectedVersion`/`fixedVersion`; `issue`/`issues` calling `formatDependencyChainsLine`/`Block` with no `fixedVersion` arg is why a direct dependency there renders as a bare `Direct - Update `. **`finding`'s own item-level chain block is gated `hasChains && !issue?.dependencyChains?.length`** (not `!issue` like the CVE/advisory blocks — a linked issue without chains would otherwise print no chains at all) — when the linked Codacy issue has chains, `printIssueCodeContext` renders `issue.dependencyChains` instead, and `finding.ts` passes `item.fixedVersion` into `printIssueCodeContext`'s `dependencyChainsFixedVersion` param so that merged block doesn't lose the fixed version. Dropping the guard duplicates the whole block for any Codacy-source SCA finding with a linked issue that also carries chains — this shipped briefly in OD-449 and was fixed same-PR. +- **Dependency import chains** (SCA findings): when `item.dependencyChains` (`string[][]`) is present, both `finding` (detail) and `findings` (list) render the vulnerable dependency's import path. A chain with a single package is a **direct** dependency (`Direct - Update to `); 2+ packages is **transitive** (`Transitive - (Fixed in )`). Chains with **4+ packages** collapse the middle to ` → ... N more ... → ` (N = length − 2). The list shows only the first chain + `... and X more`; the detail lists **all** chains with the Direct/Transitive label shown once and continuation lines indented so the `-` aligns. When chains are present, the redundant `AffectedVersion → FixedVersion` segment is dropped from the status line. Mixed direct/transitive chains (rare) take their label from the first chain. Rendering lives in `formatDependencyChainsLine` / `formatDependencyChainsBlock` (see Shared Formatting Utilities). `CommitIssue.dependencyChains` (added OD-449, same shape) reuses these same helpers for `issue`/`issues`/`pull-request --issue` — no per-command variant. Unlike `SrmItem`, `CommitIssue` has no `affectedVersion`, but it does carry `fixedVersion` (`string[]`, API 57.7.9+, empty = no fix available), which `printIssueCard` and `printIssueCodeContext` pass to the helpers — so a direct dependency reads `Direct - Update to `, and only falls back to a bare `Direct - Update ` when `fixedVersion` is empty or absent. **`finding`'s own item-level chain block is gated `hasChains && !issue?.dependencyChains?.length`** (not `!issue` like the CVE/advisory blocks — a linked issue without chains would otherwise print no chains at all) — when the linked Codacy issue has chains, `printIssueCodeContext` renders `issue.dependencyChains` instead, and `finding.ts` passes `item.fixedVersion` into `printIssueCodeContext`'s `dependencyChainsFixedVersion` param, which takes precedence over `issue.fixedVersion` (used when the param is empty or absent). Dropping the guard duplicates the whole block for any Codacy-source SCA finding with a linked issue that also carries chains — this shipped briefly in OD-449 and was fixed same-PR. - **Vulnerable functions** (`item.advisoryInformation`, both `finding` and `findings`): `findings` (list) shows the compact `Vulnerable functions: fn1, fn2 (+N more)` line via `summarizeFunctions` (same helper `issue`/`pull-request`'s card view uses). `finding` (detail) shows the full `printAdvisoryBlock` — but **only when there is no linked Codacy issue**; when there is one, `printIssueCodeContext` already renders the equivalent block from `issue.advisoryInformation`, so `finding` skips its own to avoid a duplicate. This is what makes vulnerable functions visible for SCA/dependency findings (and any other non-Codacy-source finding), which have no linked issue to borrow the block from at all. ## pull-request command (`pull-request.ts`) diff --git a/src/commands/issue.test.ts b/src/commands/issue.test.ts index 12a77b4..64ef883 100644 --- a/src/commands/issue.test.ts +++ b/src/commands/issue.test.ts @@ -390,7 +390,7 @@ describe("issue command", () => { expect(output).toContain("Transitive - root-app → lodash → vulnerable-pkg"); }); - it("should show a direct dependency as 'Update ' with no target version (CommitIssue has no fixedVersion)", async () => { + it("should show a direct dependency as 'Update ' with no target version when fixedVersion is absent", async () => { vi.mocked(AnalysisService.getIssue).mockResolvedValue({ data: { ...mockIssue, dependencyChains: [["vulnerable-pkg"]] }, } as any); @@ -404,6 +404,68 @@ describe("issue command", () => { expect(output).toContain("Direct - Update vulnerable-pkg"); }); + it("should show the fixed version on a direct dependency", async () => { + vi.mocked(AnalysisService.getIssue).mockResolvedValue({ + data: { ...mockIssue, dependencyChains: [["vulnerable-pkg"]], fixedVersion: ["1.0.1", "2.0.0"] }, + } as any); + + const program = createProgram(); + await program.parseAsync([ + "node", "test", "issue", "gh", "test-org", "test-repo", "42", + ]); + + expect(getAllOutput()).toContain("Direct - Update vulnerable-pkg to 1.0.1, 2.0.0"); + }); + + it("should show the fixed version on a transitive dependency", async () => { + vi.mocked(AnalysisService.getIssue).mockResolvedValue({ + data: { + ...mockIssue, + dependencyChains: [["root-app", "lodash", "vulnerable-pkg"]], + fixedVersion: ["1.0.1"], + }, + } as any); + + const program = createProgram(); + await program.parseAsync([ + "node", "test", "issue", "gh", "test-org", "test-repo", "42", + ]); + + expect(getAllOutput()).toContain( + "Transitive - root-app → lodash → vulnerable-pkg (Fixed in 1.0.1)", + ); + }); + + it("should not show a fixed version when fixedVersion is empty (no fix available)", async () => { + vi.mocked(AnalysisService.getIssue).mockResolvedValue({ + data: { ...mockIssue, dependencyChains: [["vulnerable-pkg"]], fixedVersion: [] }, + } as any); + + const program = createProgram(); + await program.parseAsync([ + "node", "test", "issue", "gh", "test-org", "test-repo", "42", + ]); + + const output = getAllOutput(); + expect(output).toContain("Direct - Update vulnerable-pkg"); + expect(output).not.toContain("Update vulnerable-pkg to"); + }); + + it("should include fixedVersion in JSON output", async () => { + vi.mocked(AnalysisService.getIssue).mockResolvedValue({ + data: { ...mockIssue, dependencyChains: [["vulnerable-pkg"]], fixedVersion: ["1.0.1"] }, + } as any); + + const program = createProgram(); + await program.parseAsync([ + "node", "test", "--output", "json", "issue", "gh", "test-org", "test-repo", "42", + ]); + + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('"fixedVersion"'), + ); + }); + it("should not show dependency chain block when dependencyChains is absent", async () => { const program = createProgram(); await program.parseAsync([ diff --git a/src/commands/issue.ts b/src/commands/issue.ts index 6b8c451..1f2dc11 100644 --- a/src/commands/issue.ts +++ b/src/commands/issue.ts @@ -132,6 +132,7 @@ Examples: "issue.advisoryInformation.vulnerableFunctions", "issue.advisoryInformation.publishedAt", "issue.dependencyChains", + "issue.fixedVersion", // Pattern "pattern.id", "pattern.title", diff --git a/src/commands/issues.test.ts b/src/commands/issues.test.ts index 9f292b7..80fb02e 100644 --- a/src/commands/issues.test.ts +++ b/src/commands/issues.test.ts @@ -1743,6 +1743,46 @@ describe("issues command", () => { expect(output).toContain("Transitive - root-app → lodash → vulnerable-pkg"); }); + it("should show the fixed version on the card when present", async () => { + vi.mocked(AnalysisService.searchRepositoryIssues).mockResolvedValue({ + data: [ + { + ...mockIssues[0], + dependencyChains: [["root-app", "lodash", "vulnerable-pkg"]], + fixedVersion: ["1.0.1"], + }, + ], + } as any); + + const program = createProgram(); + await program.parseAsync(["node", "test", "issues", "gh", "test-org", "test-repo"]); + + expect(getAllOutput()).toContain( + "Transitive - root-app → lodash → vulnerable-pkg (Fixed in 1.0.1)", + ); + }); + + it("should include fixedVersion in JSON output", async () => { + vi.mocked(AnalysisService.searchRepositoryIssues).mockResolvedValue({ + data: [ + { + ...mockIssues[0], + dependencyChains: [["vulnerable-pkg"]], + fixedVersion: ["1.0.1"], + }, + ], + } as any); + + const program = createProgram(); + await program.parseAsync([ + "node", "test", "--output", "json", "issues", "gh", "test-org", "test-repo", + ]); + + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('"fixedVersion"'), + ); + }); + it("should not show a dependency chain line when absent", async () => { vi.mocked(AnalysisService.searchRepositoryIssues).mockResolvedValue({ data: mockIssues, diff --git a/src/commands/issues.ts b/src/commands/issues.ts index 4a6ddc3..f5af328 100644 --- a/src/commands/issues.ts +++ b/src/commands/issues.ts @@ -943,6 +943,7 @@ Examples: "advisoryInformation.vulnerableFunctions", "advisoryInformation.publishedAt", "dependencyChains", + "fixedVersion", ]), ), }); diff --git a/src/commands/pull-request.test.ts b/src/commands/pull-request.test.ts index 50e4a3b..13eef19 100644 --- a/src/commands/pull-request.test.ts +++ b/src/commands/pull-request.test.ts @@ -1129,6 +1129,80 @@ describe("pull-request command", () => { expect.stringContaining('"dependencyChains"'), ); }); + + it("should show the fixed version in the --issue dependency chain block", async () => { + vi.mocked(AnalysisService.listPullRequestIssues) + .mockResolvedValueOnce({ + data: [ + { + ...mockNewIssues.data[2], + commitIssue: { + ...mockNewIssues.data[2].commitIssue, + dependencyChains: [["root-app", "lodash", "vulnerable-pkg"]], + fixedVersion: ["1.0.1"], + }, + }, + ], + pagination: undefined, + } as any) + .mockResolvedValueOnce({ + data: mockPotentialIssues.data, + pagination: undefined, + } as any); + vi.mocked(ToolsService.getPattern).mockResolvedValue({ + data: mockPattern, + } as any); + vi.mocked(FileService.getFileContent).mockResolvedValue({ + data: mockFileLines, + } as any); + + const program = createProgram(); + await program.parseAsync([ + "node", "test", "pull-request", "gh", "test-org", "test-repo", "42", + "--issue", "3", + ]); + + expect(getAllOutput()).toContain( + "Transitive - root-app → lodash → vulnerable-pkg (Fixed in 1.0.1)", + ); + }); + + it("should include fixedVersion in --issue JSON output", async () => { + vi.mocked(AnalysisService.listPullRequestIssues) + .mockResolvedValueOnce({ + data: [ + { + ...mockNewIssues.data[2], + commitIssue: { + ...mockNewIssues.data[2].commitIssue, + dependencyChains: [["root-app", "lodash", "vulnerable-pkg"]], + fixedVersion: ["1.0.1"], + }, + }, + ], + pagination: undefined, + } as any) + .mockResolvedValueOnce({ + data: mockPotentialIssues.data, + pagination: undefined, + } as any); + vi.mocked(ToolsService.getPattern).mockResolvedValue({ + data: mockPattern, + } as any); + vi.mocked(FileService.getFileContent).mockResolvedValue({ + data: mockFileLines, + } as any); + + const program = createProgram(); + await program.parseAsync([ + "node", "test", "--output", "json", "pull-request", "gh", "test-org", "test-repo", "42", + "--issue", "3", + ]); + + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('"fixedVersion"'), + ); + }); }); // ─── Diff Coverage Summary ───────────────────────────────────────────── diff --git a/src/commands/pull-request.ts b/src/commands/pull-request.ts index ea9de92..ce77589 100644 --- a/src/commands/pull-request.ts +++ b/src/commands/pull-request.ts @@ -1097,6 +1097,7 @@ Examples: "issue.advisoryInformation.vulnerableFunctions", "issue.advisoryInformation.publishedAt", "issue.dependencyChains", + "issue.fixedVersion", // Pattern "pattern.id", "pattern.title", diff --git a/src/utils/formatting.ts b/src/utils/formatting.ts index 5a60588..efab1b8 100644 --- a/src/utils/formatting.ts +++ b/src/utils/formatting.ts @@ -267,7 +267,10 @@ export function printIssueCard( // Dependency import chain (SCA issues with dependencyChains) if (issue.dependencyChains?.length) { - const chainLine = formatDependencyChainsLine(issue.dependencyChains); + const chainLine = formatDependencyChainsLine( + issue.dependencyChains, + issue.fixedVersion, + ); if (chainLine) { console.log(); console.log(ansis.dim(chainLine)); @@ -927,8 +930,8 @@ export function printFileContext( * and pattern documentation. * Extracted so it can be reused by both the `issue` command and Codacy-source `finding` details. * When `cveData` is provided it is injected between the code block and the pattern docs. - * `dependencyChainsFixedVersion` lets `finding` pass the linked SrmItem's `fixedVersion` - * (CommitIssue itself carries no fixed-version field) so the merged chain block still shows it. + * `dependencyChainsFixedVersion` lets `finding` pass the linked SrmItem's `fixedVersion`; + * it takes precedence over `issue.fixedVersion`, which the chain block falls back to. */ export function printIssueCodeContext( issue: CommitIssue, @@ -974,7 +977,9 @@ export function printIssueCodeContext( if (issue.dependencyChains?.length) { const chainBlock = formatDependencyChainsBlock( issue.dependencyChains, - dependencyChainsFixedVersion, + dependencyChainsFixedVersion?.length + ? dependencyChainsFixedVersion + : issue.fixedVersion, ); if (chainBlock) { console.log();