From 6fb5d6e85e166c1b6c1d5abb4a6234188643813d Mon Sep 17 00:00:00 2001 From: Alejandro Rizzo Date: Mon, 13 Apr 2026 16:46:13 +0100 Subject: [PATCH 1/3] feat: add --tools filter to issues command Allow filtering issues by tool using UUIDs or human-friendly names. Resolution logic: exact match on name/shortName, then substring search on name/shortName/prefix (only when unambiguous). Ambiguous or unknown inputs produce a clear error message. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/commands/issues.test.ts | 155 +++++++++++++++++++++++++++++++++++ src/commands/issues.ts | 20 ++++- src/utils/formatting.test.ts | 91 +++++++++++++++++++- src/utils/formatting.ts | 72 ++++++++++++++++ 4 files changed, 336 insertions(+), 2 deletions(-) diff --git a/src/commands/issues.test.ts b/src/commands/issues.test.ts index e1e1868..2273190 100644 --- a/src/commands/issues.test.ts +++ b/src/commands/issues.test.ts @@ -2,8 +2,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { Command } from "commander"; import { registerIssuesCommand } from "./issues"; import { AnalysisService } from "../api/client/services/AnalysisService"; +import { ToolsService } from "../api/client/services/ToolsService"; vi.mock("../api/client/services/AnalysisService"); +vi.mock("../api/client/services/ToolsService"); vi.mock("../utils/credentials", () => ({ loadCredentials: vi.fn(() => null) })); vi.spyOn(console, "log").mockImplementation(() => {}); @@ -612,6 +614,159 @@ describe("issues command", () => { ); }); + describe("--tools filter", () => { + const mockToolList = { + data: [ + { uuid: "uuid-eslint", name: "ESLint", shortName: "eslint", prefix: "ESLint_" }, + { uuid: "uuid-eslint9", name: "ESLint 9", shortName: "eslint9", prefix: "ESLint9_" }, + { uuid: "uuid-semgrep", name: "Semgrep", shortName: "semgrep", prefix: "Semgrep_" }, + { uuid: "uuid-markdownlint", name: "Markdownlint", shortName: "markdownlint", prefix: "Markdownlint_" }, + { uuid: "uuid-remarklint", name: "Remarklint", shortName: "remarklint", prefix: "Remarklint_" }, + ], + pagination: undefined, + }; + + it("should pass a UUID directly to body.toolUuids", async () => { + vi.mocked(AnalysisService.searchRepositoryIssues).mockResolvedValue({ + data: [], + } as any); + + const program = createProgram(); + await program.parseAsync([ + "node", "test", "issues", "gh", "test-org", "test-repo", + "--tools", "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + ]); + + expect(ToolsService.listTools).not.toHaveBeenCalled(); + expect(AnalysisService.searchRepositoryIssues).toHaveBeenCalledWith( + "gh", "test-org", "test-repo", undefined, 100, + { toolUuids: ["a1b2c3d4-e5f6-7890-abcd-ef1234567890"] }, + ); + }); + + it("should resolve an exact tool name to its UUID", async () => { + vi.mocked(ToolsService.listTools).mockResolvedValue(mockToolList as any); + vi.mocked(AnalysisService.searchRepositoryIssues).mockResolvedValue({ + data: [], + } as any); + + const program = createProgram(); + await program.parseAsync([ + "node", "test", "issues", "gh", "test-org", "test-repo", + "--tools", "eslint", + ]); + + expect(ToolsService.listTools).toHaveBeenCalled(); + expect(AnalysisService.searchRepositoryIssues).toHaveBeenCalledWith( + "gh", "test-org", "test-repo", undefined, 100, + { toolUuids: ["uuid-eslint"] }, + ); + }); + + it("should resolve a shortName match to its UUID", async () => { + vi.mocked(ToolsService.listTools).mockResolvedValue(mockToolList as any); + vi.mocked(AnalysisService.searchRepositoryIssues).mockResolvedValue({ + data: [], + } as any); + + const program = createProgram(); + await program.parseAsync([ + "node", "test", "issues", "gh", "test-org", "test-repo", + "--tools", "semgrep", + ]); + + expect(AnalysisService.searchRepositoryIssues).toHaveBeenCalledWith( + "gh", "test-org", "test-repo", undefined, 100, + { toolUuids: ["uuid-semgrep"] }, + ); + }); + + it("should resolve a substring match via prefix when only one tool matches", async () => { + vi.mocked(ToolsService.listTools).mockResolvedValue(mockToolList as any); + vi.mocked(AnalysisService.searchRepositoryIssues).mockResolvedValue({ + data: [], + } as any); + + const program = createProgram(); + // "eslint9" matches shortName "eslint9" exactly + await program.parseAsync([ + "node", "test", "issues", "gh", "test-org", "test-repo", + "--tools", "eslint9", + ]); + + expect(AnalysisService.searchRepositoryIssues).toHaveBeenCalledWith( + "gh", "test-org", "test-repo", undefined, 100, + { toolUuids: ["uuid-eslint9"] }, + ); + }); + + it("should error when tool name is ambiguous", async () => { + vi.mocked(ToolsService.listTools).mockResolvedValue(mockToolList as any); + + const mockExit = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit called"); + }); + const mockStderr = vi.spyOn(console, "error").mockImplementation(() => {}); + + const program = createProgram(); + await expect( + program.parseAsync([ + "node", "test", "issues", "gh", "test-org", "test-repo", + "--tools", "mark", + ]), + ).rejects.toThrow("process.exit called"); + + expect(mockStderr).toHaveBeenCalledWith( + expect.stringContaining("ambiguous"), + ); + + mockExit.mockRestore(); + mockStderr.mockRestore(); + }); + + it("should error when tool name is not found", async () => { + vi.mocked(ToolsService.listTools).mockResolvedValue(mockToolList as any); + + const mockExit = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit called"); + }); + const mockStderr = vi.spyOn(console, "error").mockImplementation(() => {}); + + const program = createProgram(); + await expect( + program.parseAsync([ + "node", "test", "issues", "gh", "test-org", "test-repo", + "--tools", "nonexistent", + ]), + ).rejects.toThrow("process.exit called"); + + expect(mockStderr).toHaveBeenCalledWith( + expect.stringContaining('not found'), + ); + + mockExit.mockRestore(); + mockStderr.mockRestore(); + }); + + it("should handle mixed UUIDs and tool names", async () => { + vi.mocked(ToolsService.listTools).mockResolvedValue(mockToolList as any); + vi.mocked(AnalysisService.searchRepositoryIssues).mockResolvedValue({ + data: [], + } as any); + + const program = createProgram(); + await program.parseAsync([ + "node", "test", "issues", "gh", "test-org", "test-repo", + "--tools", "a1b2c3d4-e5f6-7890-abcd-ef1234567890,semgrep", + ]); + + expect(AnalysisService.searchRepositoryIssues).toHaveBeenCalledWith( + "gh", "test-org", "test-repo", undefined, 100, + { toolUuids: ["a1b2c3d4-e5f6-7890-abcd-ef1234567890", "uuid-semgrep"] }, + ); + }); + }); + it("should fail when CODACY_API_TOKEN is not set", async () => { delete process.env.CODACY_API_TOKEN; diff --git a/src/commands/issues.ts b/src/commands/issues.ts index c56c220..a1fd29d 100644 --- a/src/commands/issues.ts +++ b/src/commands/issues.ts @@ -10,8 +10,10 @@ import { printJson, printPaginationWarning, } from "../utils/output"; -import { printSection, printIssueCard } from "../utils/formatting"; +import { printSection, printIssueCard, resolveToolUuids } from "../utils/formatting"; import { AnalysisService } from "../api/client/services/AnalysisService"; +import { ToolsService } from "../api/client/services/ToolsService"; +import { Tool } from "../api/client/models/Tool"; import { CommitIssue } from "../api/client/models/CommitIssue"; import { SeverityLevel } from "../api/client/models/SeverityLevel"; import { SearchRepositoryIssuesBody } from "../api/client/models/SearchRepositoryIssuesBody"; @@ -169,6 +171,7 @@ export function registerIssuesCommand(program: Command) { .argument("", "repository name") .option("-b, --branch ", "branch name (defaults to the main branch)") .option("-p, --patterns ", "comma-separated list of pattern IDs") + .option("-T, --tools ", "comma-separated tool UUIDs or names to filter by") .option( "-s, --severities ", "comma-separated severity levels: Critical, High, Medium, Minor (or Error, Warning, Info)", @@ -189,6 +192,7 @@ Examples: $ codacy issues gh my-org my-repo $ codacy issues gh my-org my-repo --branch main --severities Critical,Medium $ codacy issues gh my-org my-repo --categories Security --overview + $ codacy issues gh my-org my-repo --tools eslint,semgrep $ codacy issues gh my-org my-repo --limit 500 $ codacy issues gh my-org my-repo --output json`, ) @@ -220,6 +224,20 @@ Examples: const author = parseCommaList(opts.authors); if (author) body.authorEmails = author; + const toolInputs = parseCommaList(opts.tools); + if (toolInputs) { + body.toolUuids = await resolveToolUuids(toolInputs, async () => { + const tools: Tool[] = []; + let cursor: string | undefined; + do { + const resp = await ToolsService.listTools(cursor, 100); + tools.push(...resp.data); + cursor = resp.pagination?.cursor; + } while (cursor); + return tools; + }); + } + const limit = Math.min(Math.max(parseInt(opts.limit, 10) || 100, 1), 1000); const spinner = ora( diff --git a/src/utils/formatting.test.ts b/src/utils/formatting.test.ts index 717a39c..4c0139a 100644 --- a/src/utils/formatting.test.ts +++ b/src/utils/formatting.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { formatAnalysisStatus } from "./formatting"; +import { formatAnalysisStatus, resolveToolUuids } from "./formatting"; // Mock ansis to return raw text for easier testing vi.mock("ansis", () => ({ @@ -92,3 +92,92 @@ describe("formatAnalysisStatus", () => { expect(result).toBe("Never"); }); }); + +describe("resolveToolUuids", () => { + const mockTools = [ + { uuid: "uuid-eslint", name: "ESLint", shortName: "eslint", prefix: "ESLint_" }, + { uuid: "uuid-eslint9", name: "ESLint 9", shortName: "eslint9", prefix: "ESLint9_" }, + { uuid: "uuid-semgrep", name: "Semgrep", shortName: "semgrep", prefix: "Semgrep_" }, + { uuid: "uuid-markdownlint", name: "Markdownlint", shortName: "markdownlint", prefix: "Markdownlint_" }, + { uuid: "uuid-remarklint", name: "Remarklint", shortName: "remarklint", prefix: "Remarklint_" }, + ] as any[]; + + const fetchTools = vi.fn(async () => mockTools); + + beforeEach(() => { + fetchTools.mockClear(); + }); + + it("should pass UUIDs through without fetching tools", async () => { + const result = await resolveToolUuids( + ["a1b2c3d4-e5f6-7890-abcd-ef1234567890"], + fetchTools, + ); + expect(result).toEqual(["a1b2c3d4-e5f6-7890-abcd-ef1234567890"]); + expect(fetchTools).not.toHaveBeenCalled(); + }); + + it("should resolve exact name match (case-insensitive)", async () => { + const result = await resolveToolUuids(["eslint"], fetchTools); + expect(result).toEqual(["uuid-eslint"]); + }); + + it("should resolve exact shortName match (case-insensitive)", async () => { + const result = await resolveToolUuids(["eslint9"], fetchTools); + expect(result).toEqual(["uuid-eslint9"]); + }); + + it("should resolve a unique substring match via name", async () => { + const result = await resolveToolUuids(["semgr"], fetchTools); + expect(result).toEqual(["uuid-semgrep"]); + }); + + it("should error on ambiguous substring match", async () => { + const mockExit = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit called"); + }); + vi.spyOn(console, "error").mockImplementation(() => {}); + + await expect(resolveToolUuids(["mark"], fetchTools)).rejects.toThrow( + "process.exit called", + ); + + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining("ambiguous"), + ); + + mockExit.mockRestore(); + (console.error as any).mockRestore(); + }); + + it("should error when tool is not found", async () => { + const mockExit = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit called"); + }); + vi.spyOn(console, "error").mockImplementation(() => {}); + + await expect(resolveToolUuids(["zzz"], fetchTools)).rejects.toThrow( + "process.exit called", + ); + + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining("not found"), + ); + + mockExit.mockRestore(); + (console.error as any).mockRestore(); + }); + + it("should handle mixed UUIDs and names, fetching tools only once", async () => { + const result = await resolveToolUuids( + ["a1b2c3d4-e5f6-7890-abcd-ef1234567890", "semgrep", "eslint"], + fetchTools, + ); + expect(result).toEqual([ + "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "uuid-semgrep", + "uuid-eslint", + ]); + expect(fetchTools).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/utils/formatting.ts b/src/utils/formatting.ts index d3c2c5b..5ebe080 100644 --- a/src/utils/formatting.ts +++ b/src/utils/formatting.ts @@ -10,6 +10,7 @@ import { Pattern } from "../api/client/models/Pattern"; import { CodeBlockLine } from "../api/client/models/CodeBlockLine"; import { CveRecord } from "./cve"; import { AnalysisTool } from "../api/client/models/AnalysisTool"; +import { Tool } from "../api/client/models/Tool"; import { formatFriendlyDate } from "./output"; export const SEVERITY_DISPLAY: Record = { @@ -536,6 +537,77 @@ export function findToolByName( return anyPrefixMatches.sort((a, b) => a.name.length - b.name.length)[0]; } +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** + * Resolve a list of tool inputs (UUIDs or name strings) to UUIDs. + * + * Resolution order for each non-UUID input: + * 1. Exact match (case-insensitive) on tool.name + * 2. Exact match (case-insensitive) on tool.shortName + * 3. Substring search (case-insensitive) on name, shortName, and prefix — only if exactly one tool matches + * + * The fetchTools callback is only called when at least one input is not a UUID. + */ +export async function resolveToolUuids( + inputs: string[], + fetchTools: () => Promise, +): Promise { + let allTools: Tool[] | undefined; + + const uuids: string[] = []; + for (const input of inputs) { + if (UUID_RE.test(input)) { + uuids.push(input); + continue; + } + + if (!allTools) { + allTools = await fetchTools(); + } + + const lower = input.toLowerCase(); + + // Exact match on name + const nameMatch = allTools.find((t) => t.name.toLowerCase() === lower); + if (nameMatch) { + uuids.push(nameMatch.uuid); + continue; + } + + // Exact match on shortName + const shortMatch = allTools.find((t) => t.shortName.toLowerCase() === lower); + if (shortMatch) { + uuids.push(shortMatch.uuid); + continue; + } + + // Substring search on name, shortName, and prefix + const matches = allTools.filter((t) => { + return ( + t.name.toLowerCase().includes(lower) || + t.shortName.toLowerCase().includes(lower) || + (t.prefix && t.prefix.toLowerCase().includes(lower)) + ); + }); + + if (matches.length === 1) { + uuids.push(matches[0].uuid); + } else if (matches.length === 0) { + console.error(ansis.red(`Error: Tool "${input}" not found.`)); + process.exit(1); + } else { + const names = matches.map((t) => t.name).join(", "); + console.error( + ansis.red(`Error: Tool "${input}" is ambiguous, matches: ${names}`), + ); + process.exit(1); + } + } + + return uuids; +} + const COVERAGE_REPORTS_WAIT_HOURS = 3; /** From 888369c96c27efe22b5a2f5a401ff7ebda6cf969 Mon Sep 17 00:00:00 2001 From: Alejandro Rizzo Date: Mon, 13 Apr 2026 16:49:28 +0100 Subject: [PATCH 2/3] add missing files --- .gitignore | 5 ++++- package.json | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 5189ffe..89dc384 100644 --- a/.gitignore +++ b/.gitignore @@ -17,4 +17,7 @@ dist/ api-v3/ # Ignore .codacy -.codacy/ \ No newline at end of file +.codacy/ + +#Ignore vscode AI rules +.github/instructions/codacy.instructions.md diff --git a/package.json b/package.json index 8f143ca..ecaf861 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,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/50.7.17/apiv3-bundled.yaml -o ./api-v3/api-swagger.yaml --create-dirs", + "fetch-api": "curl https://artifacts.codacy.com/api/codacy-api/52.1.31/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" From babb0cf899b351891fde66671cbc9775a4f66e1e Mon Sep 17 00:00:00 2001 From: Alejandro Rizzo Date: Mon, 13 Apr 2026 16:55:07 +0100 Subject: [PATCH 3/3] fix: address PR review feedback - Refactor resolveToolUuids to throw errors instead of process.exit(), letting the caller's handleError() manage the exit flow - Deduplicate resolved UUIDs via Set - Fix misleading test name and add true substring match test - Fix test spy restoration to use stored variable references Co-Authored-By: Claude Opus 4.6 (1M context) --- src/commands/issues.test.ts | 24 +++++++++++++++++++++--- src/utils/formatting.test.ts | 31 ++++++------------------------- src/utils/formatting.ts | 10 +++------- 3 files changed, 30 insertions(+), 35 deletions(-) diff --git a/src/commands/issues.test.ts b/src/commands/issues.test.ts index 2273190..8d4ca4f 100644 --- a/src/commands/issues.test.ts +++ b/src/commands/issues.test.ts @@ -681,14 +681,13 @@ describe("issues command", () => { ); }); - it("should resolve a substring match via prefix when only one tool matches", async () => { + it("should resolve an exact shortName match (eslint9)", async () => { vi.mocked(ToolsService.listTools).mockResolvedValue(mockToolList as any); vi.mocked(AnalysisService.searchRepositoryIssues).mockResolvedValue({ data: [], } as any); const program = createProgram(); - // "eslint9" matches shortName "eslint9" exactly await program.parseAsync([ "node", "test", "issues", "gh", "test-org", "test-repo", "--tools", "eslint9", @@ -700,6 +699,25 @@ describe("issues command", () => { ); }); + it("should resolve a unique substring match via prefix", async () => { + vi.mocked(ToolsService.listTools).mockResolvedValue(mockToolList as any); + vi.mocked(AnalysisService.searchRepositoryIssues).mockResolvedValue({ + data: [], + } as any); + + const program = createProgram(); + // "semgr" is not an exact name or shortName, but substring-matches only Semgrep + await program.parseAsync([ + "node", "test", "issues", "gh", "test-org", "test-repo", + "--tools", "semgr", + ]); + + expect(AnalysisService.searchRepositoryIssues).toHaveBeenCalledWith( + "gh", "test-org", "test-repo", undefined, 100, + { toolUuids: ["uuid-semgrep"] }, + ); + }); + it("should error when tool name is ambiguous", async () => { vi.mocked(ToolsService.listTools).mockResolvedValue(mockToolList as any); @@ -741,7 +759,7 @@ describe("issues command", () => { ).rejects.toThrow("process.exit called"); expect(mockStderr).toHaveBeenCalledWith( - expect.stringContaining('not found'), + expect.stringContaining("not found"), ); mockExit.mockRestore(); diff --git a/src/utils/formatting.test.ts b/src/utils/formatting.test.ts index 4c0139a..99c77b3 100644 --- a/src/utils/formatting.test.ts +++ b/src/utils/formatting.test.ts @@ -133,39 +133,20 @@ describe("resolveToolUuids", () => { }); it("should error on ambiguous substring match", async () => { - const mockExit = vi.spyOn(process, "exit").mockImplementation(() => { - throw new Error("process.exit called"); - }); - vi.spyOn(console, "error").mockImplementation(() => {}); - await expect(resolveToolUuids(["mark"], fetchTools)).rejects.toThrow( - "process.exit called", - ); - - expect(console.error).toHaveBeenCalledWith( - expect.stringContaining("ambiguous"), + /ambiguous.*Markdownlint.*Remarklint/, ); - - mockExit.mockRestore(); - (console.error as any).mockRestore(); }); it("should error when tool is not found", async () => { - const mockExit = vi.spyOn(process, "exit").mockImplementation(() => { - throw new Error("process.exit called"); - }); - vi.spyOn(console, "error").mockImplementation(() => {}); - await expect(resolveToolUuids(["zzz"], fetchTools)).rejects.toThrow( - "process.exit called", - ); - - expect(console.error).toHaveBeenCalledWith( - expect.stringContaining("not found"), + 'Tool "zzz" not found', ); + }); - mockExit.mockRestore(); - (console.error as any).mockRestore(); + it("should deduplicate resolved UUIDs", async () => { + const result = await resolveToolUuids(["eslint", "eslint"], fetchTools); + expect(result).toEqual(["uuid-eslint"]); }); it("should handle mixed UUIDs and names, fetching tools only once", async () => { diff --git a/src/utils/formatting.ts b/src/utils/formatting.ts index 5ebe080..7048a73 100644 --- a/src/utils/formatting.ts +++ b/src/utils/formatting.ts @@ -594,18 +594,14 @@ export async function resolveToolUuids( if (matches.length === 1) { uuids.push(matches[0].uuid); } else if (matches.length === 0) { - console.error(ansis.red(`Error: Tool "${input}" not found.`)); - process.exit(1); + throw new Error(`Tool "${input}" not found.`); } else { const names = matches.map((t) => t.name).join(", "); - console.error( - ansis.red(`Error: Tool "${input}" is ambiguous, matches: ${names}`), - ); - process.exit(1); + throw new Error(`Tool "${input}" is ambiguous, matches: ${names}`); } } - return uuids; + return [...new Set(uuids)]; } const COVERAGE_REPORTS_WAIT_HOURS = 3;