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" diff --git a/src/commands/issues.test.ts b/src/commands/issues.test.ts index e1e1868..8d4ca4f 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,177 @@ 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 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(); + 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 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); + + 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..99c77b3 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,73 @@ 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 () => { + await expect(resolveToolUuids(["mark"], fetchTools)).rejects.toThrow( + /ambiguous.*Markdownlint.*Remarklint/, + ); + }); + + it("should error when tool is not found", async () => { + await expect(resolveToolUuids(["zzz"], fetchTools)).rejects.toThrow( + 'Tool "zzz" not found', + ); + }); + + 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 () => { + 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..7048a73 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,73 @@ 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) { + throw new Error(`Tool "${input}" not found.`); + } else { + const names = matches.map((t) => t.name).join(", "); + throw new Error(`Tool "${input}" is ambiguous, matches: ${names}`); + } + } + + return [...new Set(uuids)]; +} + const COVERAGE_REPORTS_WAIT_HOURS = 3; /**