diff --git a/.changeset/confirm-bulk-ignore.md b/.changeset/confirm-bulk-ignore.md new file mode 100644 index 0000000..01bae0f --- /dev/null +++ b/.changeset/confirm-bulk-ignore.md @@ -0,0 +1,10 @@ +--- +"@codacy/codacy-cloud-cli": minor +--- + +`codacy issues --ignore` now asks for confirmation before bulk-ignoring. It +prints how many issues match the current filters and only proceeds when you +answer `y`, guarding against a mistyped or too-broad filter ignoring far more +issues than intended. Pass `--skip-confirmation` (`-y`) to bypass the prompt in +CI or scripts; in a non-interactive shell without that flag the command aborts +without ignoring anything. diff --git a/.changeset/list-ignored-issues.md b/.changeset/list-ignored-issues.md new file mode 100644 index 0000000..7e79f21 --- /dev/null +++ b/.changeset/list-ignored-issues.md @@ -0,0 +1,13 @@ +--- +"@codacy/codacy-cloud-cli": minor +--- + +Add `codacy issues --ignored` (`-i`) to list issues that were marked as ignored +on Codacy. Without the flag, `codacy issues` behaves exactly as before; pass +`--ignored` to see the ignored ones instead. The +ignored listing accepts all the same filters as the normal search (`--branch`, +`--severities`, `--categories`, `--tools`, `--patterns`, `--languages`, `--tags`, +`--authors`, `--limit`, and `--false-positives`), and each ignored issue shows +who ignored it, when, the reason, and any comment. It cannot be combined with +`--overview` or `--ignore`. `--output json` emits an `ignoredIssues` array. +Unignoring individual issues stays with `codacy issue --unignore`. diff --git a/.gemini/styleguide.md b/.gemini/styleguide.md index 07fe7d2..4d9ed8f 100644 --- a/.gemini/styleguide.md +++ b/.gemini/styleguide.md @@ -30,6 +30,14 @@ concerns. versions (no `^`/`~`) for reproducibility and to avoid dependency-confusion risk. Flagging an unpinned range is correct; suggesting a range is not. +## CLI output / rendering +- `src/utils/formatting.ts` has two issue-card renderers that differ **on + purpose**: `printIgnoredIssueCard` omits the "Potential false positive" + warning that `printIssueCard` shows, because an ignored issue already surfaces + its ignore reason (usually `FalsePositive`) on the metadata line. Don't flag + the missing warning as a parity gap. The shared header/message/file-line block + is factored into `printIssueCardBody`; the trailing sections diverge by design. + ## Generated files - `package-lock.json` and everything under `src/api/client/**` are generated. Complexity, duplication, and size findings on these are false positives. diff --git a/SPECS/README.md b/SPECS/README.md index 737a7df..89e0f01 100644 --- a/SPECS/README.md +++ b/SPECS/README.md @@ -74,4 +74,6 @@ _No pending tasks._ All commands implemented. | 2026-06-24 | `findings` and `finding` now surface the vulnerable dependency's import chain from the new `dependencyChains` field: Direct (`Update to `) vs Transitive (` (Fixed in )`), with the middle collapsed to `... N more ...` for 4+ packages. List shows the first chain + `... and X more`; detail shows all chains aligned under a single label. New helpers in `utils/formatting.ts` (`formatDependencyChain`, `formatDependencyChainsLine`, `formatDependencyChainsBlock`); `dependencyChains` added to both JSON projections (17 new tests, 390 total) | | 2026-06-30 | npm-style "update available" notice via `update-notifier@5`: one-time stderr hint when a newer version is published, gated to `--output table` (suppressed for `json`, when piped, in CI, under `npx`). Non-blocking daily background check; never auto-updates. New `src/version.ts` (single source of name/version) + `src/utils/update-check.ts` (`maybeNotifyUpdate`); `preAction` hook + `--no-update-notifier` flag wired in `index.ts`. Opt-outs: `CODACY_DISABLE_UPDATE_CHECK`, `NO_UPDATE_NOTIFIER`, `--no-update-notifier`. `package.json` `overrides` pin transitive `got@^11.8.6`/`package-json@^7` (CVE-2022-33987) (7 new tests, 409 total) | | 2026-07-07 | `ls` and `directories` commands: browse a repository's folders/files with quality metrics (Grade/Issues/Complexity/Duplication/Coverage). Auto-detect provider/org/repo and the cwd-relative path; `--path`/`--branch` options; `directories --plus-children` shows one extra level as a `└─` tree (header adds `, M subdirectories`). `--sort`/`--direction` (server-side; in `ls`, directories and files sorted independently), and `ls --search ` (files only; folds the path into the search as `/%` and shows full paths). Duplication uses `numberOfClones`; Complexity uses `complexity` (hotspots). Both fetch **all** pages (no pagination warning) via new `src/utils/repo-tree.ts` (path resolution + `resolveSort`/`resolveDirection` + `fetchAllDirectories`/`fetchAllFiles`). Row markers `▸` folder / dim `·` file (no emojis). Promoted `formatGrade` to `utils/formatting.ts` (now also colors E red) and added `formatCountCell`/`formatCoverageCell` (48 new tests, 457 total) | +| 2026-07-16 | `issues --ignored` (`-i`): new read-only mode listing issues marked as ignored on Codacy, via the dedicated `searchRepositoryIgnoredIssues` endpoint. Boolean flag modeled on `--false-positives` for consistency (not a `--state ` selector). Reuses `buildFilterBody` (all existing filters + `--false-positives` pass through) and the paginate-to-`--limit` loop; errors when combined with `--overview`/`--ignore`. New `printIgnoredIssueCard` in `utils/formatting.ts` renders the ignore metadata line (`Ignored as by · ` + optional comment) and the string `issueId` (ignored issues have no numeric `resultDataId`). Omitting the flag keeps existing behavior unchanged (10 new tests, 475 total) | | 2026-07-08 | `issues --overview` noise suggestions tuned to stop firing on low-volume repos: added a `NOISE_MIN_TOTAL` (200) floor on the repo's total issues that suppresses the whole "reduce noise" section below it, and a `NOISE_MIN_PATTERN` (100) absolute floor on each pattern's own count (AND-gated with the relative rules) so a long tail of tiny patterns can't drag the median down and make a ~9-issue pattern look noisy — the total floor is kept above the per-pattern floor so it isn't dead code; the ≥10% share rule now only applies with ≥11 distinct patterns (`NOISE_MIN_PATTERNS_FOR_SHARE` — an even split only drops below 10% once N > 10, so 8–10 balanced patterns would otherwise all be flagged); and the ≥3× multiple rule (`NOISE_MEDIAN_MULTIPLE`) now measures against the **median** (via new `medianOf()`) instead of the mean, so a single huge pattern can no longer inflate the baseline and mask smaller disproportionate patterns (5 new tests, 465 total) | +| 2026-07-17 | `issues --ignore` now confirms before bulk-ignoring: `executeBulkIgnore` prints the match count then prompts via shared `confirmAction` (`utils/prompt.ts`), proceeding only on `y`. New `--skip-confirmation` (`-y`) bypasses the prompt for CI/scripts (same short flag as `tools --import --skip-approval`); non-TTY without the flag aborts rather than ignoring by accident. Confirmation runs after the fetch (count is shown) but before any `bulkIgnoreIssues` call (3 new tests, 478 total) | diff --git a/SPECS/commands/issues.md b/SPECS/commands/issues.md index 413e052..01ec62e 100644 --- a/SPECS/commands/issues.md +++ b/SPECS/commands/issues.md @@ -18,6 +18,7 @@ codacy is gh my-org my-repo --output json ## API Endpoints - [`searchRepositoryIssues`](https://api.codacy.com/api/api-docs#searchrepositoryissues) — `AnalysisService.searchRepositoryIssues(provider, org, repo, cursor, limit, body)` +- [`searchRepositoryIgnoredIssues`](https://api.codacy.com/api/api-docs#searchrepositoryignoredissues) — `AnalysisService.searchRepositoryIgnoredIssues(provider, org, repo, cursor, limit, body)` (only when `--ignored` is given; accepts the same `SearchRepositoryIssuesBody`) - [`issuesOverview`](https://api.codacy.com/api/api-docs#issuesoverview) — `AnalysisService.issuesOverview(provider, org, repo, body)` (only when `--overview` is given) - [`listTools`](https://api.codacy.com/api/api-docs#listtools) — `ToolsService.listTools(cursor, limit)` (only when `--overview` surfaces noisy patterns, to map each pattern's `prefix` to its owning tool) - [`listRepositoryTools`](https://api.codacy.com/api/api-docs#listrepositorytools) — `AnalysisService.listRepositoryTools(provider, org, repo)` (only when `--overview` surfaces noisy patterns, to detect config-file-driven tools) @@ -39,10 +40,12 @@ codacy is gh my-org my-repo --output json | `--tools ` | `-T` | Comma-separated tool UUIDs or names | | `--limit ` | `-n` | Maximum number of issues (default: 100, max: 1000) | | `--overview` | `-O` | Show overview counts instead of list | +| `--ignored` | `-i` | List issues marked as ignored instead of active ones | | `--false-positives [value]` | `-F` | Filter by potential false positives (true, false, or omit) | | `--ignore` | `-I` | Ignore all issues matching current filters | | `--ignore-reason ` | `-R` | Reason for ignoring (AcceptedUse, FalsePositive, NotExploitable, TestCode, ExternalCode) | | `--ignore-comment ` | `-m` | Optional comment when using --ignore | +| `--skip-confirmation` | `-y` | Skip the confirmation prompt when using --ignore (for CI/scripts) | ## Output @@ -128,6 +131,50 @@ calls only run when at least one noisy pattern exists. `--output json` is unaffected (raw counts only — no relabeling or suggestions). +### Ignored mode (`--ignored`) + +Lists issues that were marked as ignored on Codacy, via the dedicated +`searchRepositoryIgnoredIssues` endpoint. `--ignored` is a boolean flag (like +`--false-positives`); without it, `codacy issues …` lists active issues as +before, and passing `--ignored` switches to the ignored listing. + +The endpoint accepts the same filter body as the active-issues search, so every +filter (`--branch`, `--patterns`, `--tools`, `--severities`, `--categories`, +`--languages`, `--tags`, `--authors`, `--limit`) and `--false-positives` apply. +It cannot be combined with `--overview` (no ignored-issues overview exists) or +`--ignore` (those issues are already ignored) — both error out. + +Output is a card list sorted by severity, like the default list mode, plus an +ignore-metadata line per issue: + +``` +Critical | Security Injection +Potential SQL injection vulnerability + +src/auth.ts:20 +db.query(`SELECT * FROM users WHERE id = ${id}`); + +Ignored as FalsePositive by Jane Dev · 2026-06-01 +Comment: Reviewed, not exploitable +``` + +Ignored issues carry the string `issueId` (there is no numeric `resultDataId`), +and the `Comment:` line is shown only when a comment was recorded. Unignoring is +not part of this mode — use `codacy issue --unignore` (there is no +bulk-unignore endpoint). `--output json` emits `{ ignoredIssues: [...] }` +projected to the shown fields. + +### Bulk ignore mode (`--ignore`) + +Fetches every issue matching the current filters (all pages) and marks them all +as ignored via `bulkIgnoreIssues` (batched at 100 IDs per call). Because this is +destructive and applies to *all* matching issues, it prompts for confirmation +after showing the count (`Ignore all N matching issues? …`) and only proceeds on +an explicit `y`. `--skip-confirmation` (`-y`) bypasses the prompt for CI/scripts; +in a non-interactive shell without that flag the prompt cannot be answered, so +the command aborts without ignoring anything (rather than ignoring by accident). +Cannot be combined with `--overview` or `--limit`. + ## Tests -File: `src/commands/issues.test.ts` — 46 tests. +File: `src/commands/issues.test.ts` — 64 tests. diff --git a/src/commands/AGENTS.md b/src/commands/AGENTS.md index b7bb67a..6288333 100644 --- a/src/commands/AGENTS.md +++ b/src/commands/AGENTS.md @@ -265,8 +265,15 @@ Keeps the two command handlers thin: they only supply the API-specific callbacks - **`--ignore` mode** (`-I`): fetches all issues matching current filters (all pages), then calls `AnalysisService.bulkIgnoreIssues` in batches of 100 - `-R, --ignore-reason`: `AcceptedUse` (default) | `FalsePositive` | `NotExploitable` | `TestCode` | `ExternalCode` - `-m, --ignore-comment`: optional free-text comment + - **Confirmation** (`-y, --skip-confirmation`): the ignore is destructive and applies to *all* matching issues, so `executeBulkIgnore` prompts via `confirmAction` (shared `utils/prompt.ts`) after printing the count, and only proceeds on an explicit `y`. `--skip-confirmation` skips the prompt for CI/scripts. `confirmAction` returns `false` on a non-TTY, so a non-interactive run without the flag aborts rather than ignoring by accident. Same `-y` short flag as `tools --import`'s `--skip-approval`. Confirmation happens *after* fetching (so the count is shown) but *before* any `bulkIgnoreIssues` call - Cannot be combined with `--overview` or `--limit` - Works with any combination of filters; use `--false-positives --ignore` to ignore only FP issues +- **`--ignored` mode** (`-i`): boolean flag that lists ignored issues instead of active ones. Modeled as a boolean flag for consistency with the other boolean filter, `--false-positives` (not a `--state ` selector). `-i` is the natural short flag; note it is distinct from `-I/--ignore` (the bulk-ignore *action*) — `-i` lists already-ignored issues, `-I` ignores matching active ones. Omitting the flag keeps the existing active-issues behavior unchanged + - `--ignored` calls `AnalysisService.searchRepositoryIgnoredIssues` (dedicated `.../ignoredIssues/search` endpoint), reusing `buildFilterBody` — every base filter passes through unchanged — and the same paginate-to-`--limit` loop + pagination warning as list mode + - Guards: errors when combined with `--overview` (no ignored-issues overview endpoint) or `--ignore` (those issues are already ignored). `--false-positives` is intentionally **allowed** — it's part of the shared filter body, so "ignored issues that are potential false positives" is a valid query + - Rendered via `printIgnoredIssueCard` (in `utils/formatting.ts`): a variant of `printIssueCard` because `IgnoredIssue` has no numeric `resultDataId` (shows the string `issueId` instead) and carries ignore metadata — the trailing `Ignored as by · ` line plus an optional `Comment:` line + - View-only: unignoring stays with `issue --unignore` (there is no bulk-unignore endpoint; it would be N per-issue `updateIssueState` calls — a possible fast-follow since `IgnoredIssue` carries the string `issueId`) + - JSON: `{ ignoredIssues: [...] }`, each item projected via `pickDeep` (`issueId`, `reason`, `comment`, `ignoredByName`, `ignoredTimestamp`, `patternInfo.*`, `filePath`, `lineNumber`, `lineText`, `falsePositive*`) ## finding command (`finding.ts`) diff --git a/src/commands/issues.test.ts b/src/commands/issues.test.ts index f3896d0..f3e337d 100644 --- a/src/commands/issues.test.ts +++ b/src/commands/issues.test.ts @@ -3,6 +3,7 @@ import { Command } from "commander"; import { registerIssuesCommand } from "./issues"; import { AnalysisService } from "../api/client/services/AnalysisService"; import { ToolsService } from "../api/client/services/ToolsService"; +import * as prompt from "../utils/prompt"; vi.mock("../api/client/services/AnalysisService"); vi.mock("../api/client/services/ToolsService"); @@ -91,6 +92,51 @@ const mockOverview = { }, }; +const mockIgnoredIssues = [ + { + issueId: "ign-uuid-1", + reason: "FalsePositive", + comment: "Reviewed, not exploitable", + ignoredByName: "Jane Dev", + ignoredTimestamp: "2026-06-01T10:00:00Z", + filePath: "src/auth.ts", + patternInfo: { + id: "sql-injection", + title: "SQL Injection", + category: "Security", + subCategory: "Injection", + severityLevel: "Error", + level: "Error", + }, + toolInfo: { uuid: "tool-1", name: "Semgrep" }, + lineNumber: 20, + message: "Potential SQL injection vulnerability", + language: "TypeScript", + lineText: " db.query(`SELECT * FROM users WHERE id = ${id}`);", + falsePositiveThreshold: 0.3, + }, + { + issueId: "ign-uuid-2", + reason: "AcceptedUse", + ignoredByName: "John Ops", + ignoredTimestamp: "2026-05-20T12:00:00Z", + filePath: "src/utils.ts", + patternInfo: { + id: "no-unused", + title: "no unused variables", + category: "Code Style", + severityLevel: "Warning", + level: "Warning", + }, + toolInfo: { uuid: "tool-1", name: "ESLint" }, + lineNumber: 5, + message: "Unused variable 'helper'", + language: "TypeScript", + lineText: " const helper = 42;", + falsePositiveThreshold: 0.5, + }, +]; + function getAllOutput(): string { return (console.log as ReturnType).mock.calls .map((c) => c[0]) @@ -1332,6 +1378,13 @@ describe("issues command", () => { }); describe("--ignore flag", () => { + beforeEach(() => { + // Bulk-ignore now confirms first. Default the prompt to "yes" so the + // behavioral tests below exercise the ignore path; the confirmation tests + // further down override this to assert on the prompt itself. + vi.spyOn(prompt, "confirmAction").mockResolvedValue(true); + }); + it("should error when --overview is combined with --ignore", async () => { const mockExit = vi.spyOn(process, "exit").mockImplementation(() => { throw new Error("process.exit called"); @@ -1552,6 +1605,59 @@ describe("issues command", () => { }, ); }); + + it("prompts for confirmation (with the count) and ignores when confirmed", async () => { + vi.spyOn(prompt, "confirmAction").mockResolvedValue(true); + vi.mocked(AnalysisService.searchRepositoryIssues).mockResolvedValue({ + data: mockIssues, + } as any); + vi.mocked(AnalysisService.bulkIgnoreIssues).mockResolvedValue(undefined as any); + + const program = createProgram(); + await program.parseAsync([ + "node", "test", "issues", "gh", "test-org", "test-repo", + "--ignore", + ]); + + expect(prompt.confirmAction).toHaveBeenCalledTimes(1); + // The prompt must state how many issues will be ignored. + expect((prompt.confirmAction as any).mock.calls[0][0]).toContain("2"); + expect(AnalysisService.bulkIgnoreIssues).toHaveBeenCalled(); + }); + + it("aborts without ignoring when the user declines the confirmation", async () => { + vi.spyOn(prompt, "confirmAction").mockResolvedValue(false); + vi.mocked(AnalysisService.searchRepositoryIssues).mockResolvedValue({ + data: mockIssues, + } as any); + + const program = createProgram(); + await program.parseAsync([ + "node", "test", "issues", "gh", "test-org", "test-repo", + "--ignore", + ]); + + expect(prompt.confirmAction).toHaveBeenCalledTimes(1); + expect(AnalysisService.bulkIgnoreIssues).not.toHaveBeenCalled(); + expect(getAllOutput()).toContain("Aborted"); + }); + + it("skips the confirmation prompt with --skip-confirmation", async () => { + vi.spyOn(prompt, "confirmAction").mockResolvedValue(true); + vi.mocked(AnalysisService.searchRepositoryIssues).mockResolvedValue({ + data: mockIssues, + } as any); + vi.mocked(AnalysisService.bulkIgnoreIssues).mockResolvedValue(undefined as any); + + const program = createProgram(); + await program.parseAsync([ + "node", "test", "issues", "gh", "test-org", "test-repo", + "--ignore", "--skip-confirmation", + ]); + + expect(prompt.confirmAction).not.toHaveBeenCalled(); + expect(AnalysisService.bulkIgnoreIssues).toHaveBeenCalled(); + }); }); describe("auto-detect from git remote", () => { @@ -1598,4 +1704,212 @@ describe("issues command", () => { ); }); }); + + describe("--ignored", () => { + it("lists ignored issues with ignore metadata and comment", async () => { + vi.mocked( + AnalysisService.searchRepositoryIgnoredIssues, + ).mockResolvedValue({ + data: mockIgnoredIssues, + pagination: undefined, + } as any); + + const program = createProgram(); + await program.parseAsync([ + "node", "test", "issues", "gh", "test-org", "test-repo", + "--ignored", + ]); + + expect( + AnalysisService.searchRepositoryIgnoredIssues, + ).toHaveBeenCalledWith("gh", "test-org", "test-repo", undefined, 100, {}); + // The active-issue endpoint must not be touched. + expect(AnalysisService.searchRepositoryIssues).not.toHaveBeenCalled(); + + const output = getAllOutput(); + expect(output).toContain("Ignored Issues"); + expect(output).toContain("Potential SQL injection vulnerability"); + expect(output).toContain("ign-uuid-1"); + expect(output).toContain("Ignored as FalsePositive by Jane Dev"); + expect(output).toContain("2026-06-01"); + expect(output).toContain("Comment: Reviewed, not exploitable"); + }); + + it("omits the comment line when an ignored issue has no comment", async () => { + vi.mocked( + AnalysisService.searchRepositoryIgnoredIssues, + ).mockResolvedValue({ + data: [mockIgnoredIssues[1]], + pagination: undefined, + } as any); + + const program = createProgram(); + await program.parseAsync([ + "node", "test", "issues", "gh", "test-org", "test-repo", + "--ignored", + ]); + + const output = getAllOutput(); + expect(output).toContain("Ignored as AcceptedUse by John Ops"); + expect(output).not.toContain("Comment:"); + }); + + it("shows an empty message when there are no ignored issues", async () => { + vi.mocked( + AnalysisService.searchRepositoryIgnoredIssues, + ).mockResolvedValue({ data: [], pagination: undefined } as any); + + const program = createProgram(); + await program.parseAsync([ + "node", "test", "issues", "gh", "test-org", "test-repo", + "--ignored", + ]); + + expect(getAllOutput()).toContain("No ignored issues found."); + }); + + it("passes filters through to the ignored-issues endpoint", async () => { + vi.mocked( + AnalysisService.searchRepositoryIgnoredIssues, + ).mockResolvedValue({ data: [], pagination: undefined } as any); + + const program = createProgram(); + await program.parseAsync([ + "node", "test", "issues", "gh", "test-org", "test-repo", + "--ignored", + "--severities", "Critical", + "--categories", "Security", + ]); + + expect( + AnalysisService.searchRepositoryIgnoredIssues, + ).toHaveBeenCalledWith("gh", "test-org", "test-repo", undefined, 100, { + levels: ["Error"], + categories: ["Security"], + }); + }); + + it("allows --false-positives together with --ignored", async () => { + vi.mocked( + AnalysisService.searchRepositoryIgnoredIssues, + ).mockResolvedValue({ data: [], pagination: undefined } as any); + + const program = createProgram(); + await program.parseAsync([ + "node", "test", "issues", "gh", "test-org", "test-repo", + "--ignored", "--false-positives", + ]); + + expect( + AnalysisService.searchRepositoryIgnoredIssues, + ).toHaveBeenCalledWith("gh", "test-org", "test-repo", undefined, 100, { + potentialFalsePositives: true, + }); + }); + + it("paginates across pages and warns when more remain", async () => { + vi.mocked(AnalysisService.searchRepositoryIgnoredIssues) + .mockResolvedValueOnce({ + data: [mockIgnoredIssues[0]], + pagination: { cursor: "cursor-2", limit: 100, total: 250 }, + } as any) + .mockResolvedValueOnce({ + data: [mockIgnoredIssues[1]], + pagination: { cursor: undefined, limit: 100, total: 250 }, + } as any); + + const program = createProgram(); + await program.parseAsync([ + "node", "test", "issues", "gh", "test-org", "test-repo", + "--ignored", "--limit", "200", + ]); + + expect( + AnalysisService.searchRepositoryIgnoredIssues, + ).toHaveBeenCalledTimes(2); + expect(getAllOutput()).toContain("Ignored Issues"); + }); + + it("emits JSON with only the projected fields", async () => { + vi.mocked( + AnalysisService.searchRepositoryIgnoredIssues, + ).mockResolvedValue({ + data: [mockIgnoredIssues[0]], + pagination: undefined, + } as any); + + const program = createProgram(); + await program.parseAsync([ + "node", "test", "issues", "gh", "test-org", "test-repo", + "--ignored", "--output", "json", + ]); + + const output = getAllOutput(); + const parsed = JSON.parse(output); + expect(parsed.ignoredIssues).toHaveLength(1); + const item = parsed.ignoredIssues[0]; + expect(item.issueId).toBe("ign-uuid-1"); + expect(item.reason).toBe("FalsePositive"); + expect(item.ignoredByName).toBe("Jane Dev"); + expect(item.ignoredTimestamp).toBe("2026-06-01T10:00:00Z"); + expect(item.patternInfo.category).toBe("Security"); + // Fields not shown in the card must be stripped. + expect(item.toolInfo).toBeUndefined(); + expect(item.language).toBeUndefined(); + }); + + it("errors when --ignored is combined with --overview", async () => { + const mockExit = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit called"); + }); + vi.spyOn(console, "error").mockImplementation(() => {}); + + const program = createProgram(); + await expect( + program.parseAsync([ + "node", "test", "issues", "gh", "test-org", "test-repo", + "--ignored", "--overview", + ]), + ).rejects.toThrow("process.exit called"); + + expect(mockExit).toHaveBeenCalledWith(1); + mockExit.mockRestore(); + }); + + it("errors when --ignored is combined with --ignore", async () => { + const mockExit = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit called"); + }); + vi.spyOn(console, "error").mockImplementation(() => {}); + + const program = createProgram(); + await expect( + program.parseAsync([ + "node", "test", "issues", "gh", "test-org", "test-repo", + "--ignored", "--ignore", + ]), + ).rejects.toThrow("process.exit called"); + + expect(mockExit).toHaveBeenCalledWith(1); + expect(AnalysisService.bulkIgnoreIssues).not.toHaveBeenCalled(); + mockExit.mockRestore(); + }); + + it("default (no --ignored) still uses the active-issues endpoint", async () => { + vi.mocked(AnalysisService.searchRepositoryIssues).mockResolvedValue({ + data: [], + pagination: undefined, + } as any); + + const program = createProgram(); + await program.parseAsync([ + "node", "test", "issues", "gh", "test-org", "test-repo", + ]); + + expect(AnalysisService.searchRepositoryIssues).toHaveBeenCalled(); + expect( + AnalysisService.searchRepositoryIgnoredIssues, + ).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/commands/issues.ts b/src/commands/issues.ts index a7b50ec..53bb027 100644 --- a/src/commands/issues.ts +++ b/src/commands/issues.ts @@ -4,6 +4,7 @@ import ansis from "ansis"; import { checkApiToken } from "../utils/auth"; import { handleError } from "../utils/error"; import { resolveRepoArgs } from "../utils/resolve-repo-args"; +import { confirmAction } from "../utils/prompt"; import { createTable, getOutputFormat, @@ -14,6 +15,7 @@ import { import { printSection, printIssueCard, + printIgnoredIssueCard, resolveToolUuids, formatCount, } from "../utils/formatting"; @@ -22,6 +24,7 @@ import { ToolsService } from "../api/client/services/ToolsService"; import { Tool } from "../api/client/models/Tool"; import { AnalysisTool } from "../api/client/models/AnalysisTool"; import { CommitIssue } from "../api/client/models/CommitIssue"; +import { IgnoredIssue } from "../api/client/models/IgnoredIssue"; import { SeverityLevel } from "../api/client/models/SeverityLevel"; import { SearchRepositoryIssuesBody } from "../api/client/models/SearchRepositoryIssuesBody"; import { Count } from "../api/client/models/Count"; @@ -152,6 +155,22 @@ function printIssuesList(issues: CommitIssue[], total: number): void { } } +function printIgnoredIssuesList(issues: IgnoredIssue[], total: number): void { + printSection("Ignored Issues", total, "issue"); + if (issues.length === 0) { + console.log(ansis.dim(" No ignored issues found.")); + return; + } + const sorted = [...issues].sort((a, b) => { + const aOrder = SEVERITY_ORDER[a.patternInfo.severityLevel] ?? 99; + const bOrder = SEVERITY_ORDER[b.patternInfo.severityLevel] ?? 99; + return aOrder - bOrder; + }); + for (const issue of sorted) { + printIgnoredIssueCard(issue); + } +} + function printCountTable(title: string, counts: Count[]): void { if (counts.length === 0) return; const sorted = [...counts].sort((a, b) => b.total - a.total); @@ -508,6 +527,7 @@ async function executeBulkIgnore( repository: string, body: SearchRepositoryIssuesBody, reason: string, + skipConfirmation: boolean, comment?: string, ): Promise { const fetchSpinner = ora("Fetching issues...").start(); @@ -538,6 +558,25 @@ async function executeBulkIgnore( const plural = count === 1 ? "" : "s"; console.log(`Found ${ansis.bold(String(count))} issue${plural}.`); + // Bulk-ignore is destructive and applies to every matching issue, so confirm + // before proceeding — this guards against a mistyped or too-broad filter. + // --skip-confirmation (-y) bypasses the prompt for CI/scripts; in a + // non-interactive shell without that flag, confirmAction returns false and we + // abort rather than ignore by accident. + if (!skipConfirmation) { + const confirmed = await confirmAction( + `Ignore all ${count} matching issue${plural}? This marks them as ignored on Codacy.`, + ); + if (!confirmed) { + console.log( + ansis.dim( + "Aborted — no issues were ignored. Pass --skip-confirmation (-y) to bypass this prompt in CI or scripts.", + ), + ); + return; + } + } + const ignoreSpinner = ora(`Ignoring ${count} issue${plural}...`).start(); const issueIds = allIssues.map((i) => i.issueId); @@ -595,6 +634,10 @@ export function registerIssuesCommand(program: Command) { "-O, --overview", "show issue count totals instead of the issues list", ) + .option( + "-i, --ignored", + "list issues that were marked as ignored instead of active ones", + ) .option( "-F, --false-positives [value]", "filter by potential false positives (true, false, or omit)", @@ -610,6 +653,10 @@ export function registerIssuesCommand(program: Command) { "-m, --ignore-comment ", "optional comment when using --ignore", ) + .option( + "-y, --skip-confirmation", + "skip the confirmation prompt when using --ignore (for CI/scripts)", + ) .addHelpText( "after", ` @@ -622,9 +669,12 @@ Examples: $ codacy issues gh my-org my-repo --limit 500 $ codacy issues gh my-org my-repo --false-positives $ codacy issues gh my-org my-repo --false-positives false + $ codacy issues gh my-org my-repo --ignored + $ codacy issues gh my-org my-repo --ignored --severities Critical $ codacy issues gh my-org my-repo --ignore --branch main $ codacy issues gh my-org my-repo --false-positives --ignore --ignore-reason FalsePositive $ codacy issues gh my-org my-repo --ignore --ignore-reason NotExploitable --ignore-comment "Reviewed" + $ codacy issues gh my-org my-repo --ignore --skip-confirmation # no prompt (CI/scripts) $ codacy issues gh my-org my-repo --output json`, ) .action(async function ( @@ -644,6 +694,7 @@ Examples: const opts = this.opts(); const format = getOutputFormat(this); const isOverview = !!opts.overview; + const listIgnored = !!opts.ignored; const body = await buildFilterBody(opts); const limit = Math.min( @@ -651,6 +702,84 @@ Examples: 1000, ); + // Ignored issues are served by a dedicated endpoint that accepts the same + // filter body. It's a read-only listing, so the mutating/aggregating modes + // don't apply. --false-positives is intentionally NOT blocked: it's part of + // the shared filter body, so "ignored issues that are potential false + // positives" is a legitimate query. + if (listIgnored) { + if (isOverview) { + this.error( + "--overview cannot be used with --ignored; there is no ignored-issues overview", + ); + } + if (opts.ignore) { + this.error( + "--ignore cannot be used with --ignored; those issues are already ignored (unignore via `codacy issue --unignore`)", + ); + } + + const spinner = ora("Fetching ignored issues...").start(); + const pageSize = Math.min(limit, 100); + let ignored: IgnoredIssue[] = []; + let cursor: string | undefined; + let total: number | undefined; + + do { + const resp = + await AnalysisService.searchRepositoryIgnoredIssues( + provider, + organization, + repository, + cursor, + pageSize, + body, + ); + ignored = ignored.concat(resp.data); + total ??= resp.pagination?.total; + cursor = resp.pagination?.cursor; + } while (cursor && ignored.length < limit); + + if (ignored.length > limit) ignored = ignored.slice(0, limit); + total ??= ignored.length; + spinner.stop(); + + if (format === "json") { + printJson({ + ignoredIssues: ignored.map((issue: any) => + pickDeep(issue, [ + "issueId", + "reason", + "comment", + "ignoredByName", + "ignoredTimestamp", + "patternInfo.id", + "patternInfo.severityLevel", + "patternInfo.category", + "patternInfo.subCategory", + "message", + "filePath", + "lineNumber", + "lineText", + "falsePositiveProbability", + "falsePositiveThreshold", + "falsePositiveReason", + ]), + ), + }); + return; + } + + printIgnoredIssuesList(ignored, total); + if (total > ignored.length) { + printPaginationWarning( + { cursor: "more", limit: ignored.length }, + "Use --limit (max 1000) to fetch more, or --severities, --categories, --languages to filter.", + ); + } + return; + } + if (opts.ignore) { if (isOverview) { this.error( @@ -668,6 +797,7 @@ Examples: repository, body, opts.ignoreReason, + !!opts.skipConfirmation, opts.ignoreComment, ); return; diff --git a/src/utils/formatting.ts b/src/utils/formatting.ts index f2cc113..7003387 100644 --- a/src/utils/formatting.ts +++ b/src/utils/formatting.ts @@ -5,6 +5,7 @@ import { format as dateFnsFormat, parseISO, isValid, differenceInHours } from "d import { PullRequestWithAnalysis } from "../api/client/models/PullRequestWithAnalysis"; import { AnalysisResultReason } from "../api/client/models/AnalysisResultReason"; import { CommitIssue } from "../api/client/models/CommitIssue"; +import { IgnoredIssue } from "../api/client/models/IgnoredIssue"; import { SeverityLevel } from "../api/client/models/SeverityLevel"; import { Pattern } from "../api/client/models/Pattern"; import { ConfiguredPattern } from "../api/client/models/ConfiguredPattern"; @@ -179,37 +180,66 @@ export function formatDependencyChainsBlock( * The issue ID (resultDataId) is appended at the end of the first line in a * very dim color so it doesn't draw attention but is easy to copy. */ -export function printIssueCard( - issue: CommitIssue, - options?: { isPotential?: boolean }, -): void { - const pattern = issue.patternInfo; - const separator = ansis.dim("─".repeat(40)); +const CARD_SEPARATOR = ansis.dim("─".repeat(40)); +/** + * Render the part of an issue card shared by active and ignored issues: the + * `Severity | Category SubCategory? | POTENTIAL? ` header, the message, + * and the `file:line` + line-content block. Callers append their own trailing + * section (false-positive warning / ignore metadata) and the separator. + * + * `idText` is the already-formatted id string — active cards pass `#`, + * ignored cards pass the string `issueId` (which has no numeric resultDataId). + */ +function printIssueCardBody(fields: { + severityLevel: SeverityLevel; + category: string; + subCategory?: string; + idText: string; + message: string; + filePath: string; + lineNumber?: number; + lineText?: string; + isPotential?: boolean; +}): void { console.log(); - // Line 1: Severity | Category SubCategory? | POTENTIAL? - const severity = colorSeverity(pattern.severityLevel); - const subCat = pattern.subCategory ? ` ${pattern.subCategory}` : ""; - const potentialTag = options?.isPotential + const severity = colorSeverity(fields.severityLevel); + const subCat = fields.subCategory ? ` ${fields.subCategory}` : ""; + const potentialTag = fields.isPotential ? ` ${ansis.dim("|")} ${ansis.dim("POTENTIAL")}` : ""; - const id = ansis.hex("#555555")(`#${issue.resultDataId}`); + const id = ansis.hex("#555555")(fields.idText); console.log( - `${severity} ${ansis.dim("|")} ${pattern.category}${subCat}${potentialTag} ${id}`, + `${severity} ${ansis.dim("|")} ${fields.category}${subCat}${potentialTag} ${id}`, ); - // Issue message - console.log(issue.message); + console.log(fields.message); console.log(); - // File path : line number - console.log(ansis.dim(`${issue.filePath}:${issue.lineNumber}`)); - - // Line content (trimmed) - if (issue.lineText) { - console.log(ansis.dim(issue.lineText.trim())); + console.log(ansis.dim(`${fields.filePath}:${fields.lineNumber}`)); + if (fields.lineText) { + console.log(ansis.dim(fields.lineText.trim())); } +} + +export function printIssueCard( + issue: CommitIssue, + options?: { isPotential?: boolean }, +): void { + const pattern = issue.patternInfo; + + printIssueCardBody({ + severityLevel: pattern.severityLevel, + category: pattern.category, + subCategory: pattern.subCategory, + idText: `#${issue.resultDataId}`, + message: issue.message, + filePath: issue.filePath, + lineNumber: issue.lineNumber, + lineText: issue.lineText, + isPotential: options?.isPotential, + }); // False positive detection if ( @@ -222,7 +252,44 @@ export function printIssueCard( } console.log(); - console.log(separator); + console.log(CARD_SEPARATOR); +} + +/** + * Card renderer for an ignored issue. Reuses `printIssueCardBody` for the shared + * layout, then appends the ignore metadata line (reason / who / when) and an + * optional comment. `IgnoredIssue` has no numeric `resultDataId`, so the header + * shows the string `issueId`. + */ +export function printIgnoredIssueCard(issue: IgnoredIssue): void { + const pattern = issue.patternInfo; + + printIssueCardBody({ + severityLevel: pattern.severityLevel, + category: pattern.category, + subCategory: pattern.subCategory, + idText: issue.issueId, + message: issue.message, + filePath: issue.filePath, + lineNumber: issue.lineNumber, + lineText: issue.lineText, + }); + + // Ignore metadata: "Ignored as by · " + console.log(); + const reason = issue.reason ? ` as ${issue.reason}` : ""; + const by = issue.ignoredByName ? ` by ${issue.ignoredByName}` : ""; + const parts = [`Ignored${reason}${by}`]; + if (issue.ignoredTimestamp) { + parts.push(formatFriendlyDate(issue.ignoredTimestamp)); + } + console.log(ansis.dim(parts.join(" · "))); + if (issue.comment) { + console.log(ansis.dim(`Comment: ${issue.comment}`)); + } + + console.log(); + console.log(CARD_SEPARATOR); } export type GateStatusMap = {