Skip to content

Commit 6fb5d6e

Browse files
alerizzoclaude
andcommitted
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) <noreply@anthropic.com>
1 parent 62c3b1e commit 6fb5d6e

4 files changed

Lines changed: 336 additions & 2 deletions

File tree

src/commands/issues.test.ts

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
22
import { Command } from "commander";
33
import { registerIssuesCommand } from "./issues";
44
import { AnalysisService } from "../api/client/services/AnalysisService";
5+
import { ToolsService } from "../api/client/services/ToolsService";
56

67
vi.mock("../api/client/services/AnalysisService");
8+
vi.mock("../api/client/services/ToolsService");
79
vi.mock("../utils/credentials", () => ({ loadCredentials: vi.fn(() => null) }));
810
vi.spyOn(console, "log").mockImplementation(() => {});
911

@@ -612,6 +614,159 @@ describe("issues command", () => {
612614
);
613615
});
614616

617+
describe("--tools filter", () => {
618+
const mockToolList = {
619+
data: [
620+
{ uuid: "uuid-eslint", name: "ESLint", shortName: "eslint", prefix: "ESLint_" },
621+
{ uuid: "uuid-eslint9", name: "ESLint 9", shortName: "eslint9", prefix: "ESLint9_" },
622+
{ uuid: "uuid-semgrep", name: "Semgrep", shortName: "semgrep", prefix: "Semgrep_" },
623+
{ uuid: "uuid-markdownlint", name: "Markdownlint", shortName: "markdownlint", prefix: "Markdownlint_" },
624+
{ uuid: "uuid-remarklint", name: "Remarklint", shortName: "remarklint", prefix: "Remarklint_" },
625+
],
626+
pagination: undefined,
627+
};
628+
629+
it("should pass a UUID directly to body.toolUuids", async () => {
630+
vi.mocked(AnalysisService.searchRepositoryIssues).mockResolvedValue({
631+
data: [],
632+
} as any);
633+
634+
const program = createProgram();
635+
await program.parseAsync([
636+
"node", "test", "issues", "gh", "test-org", "test-repo",
637+
"--tools", "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
638+
]);
639+
640+
expect(ToolsService.listTools).not.toHaveBeenCalled();
641+
expect(AnalysisService.searchRepositoryIssues).toHaveBeenCalledWith(
642+
"gh", "test-org", "test-repo", undefined, 100,
643+
{ toolUuids: ["a1b2c3d4-e5f6-7890-abcd-ef1234567890"] },
644+
);
645+
});
646+
647+
it("should resolve an exact tool name to its UUID", async () => {
648+
vi.mocked(ToolsService.listTools).mockResolvedValue(mockToolList as any);
649+
vi.mocked(AnalysisService.searchRepositoryIssues).mockResolvedValue({
650+
data: [],
651+
} as any);
652+
653+
const program = createProgram();
654+
await program.parseAsync([
655+
"node", "test", "issues", "gh", "test-org", "test-repo",
656+
"--tools", "eslint",
657+
]);
658+
659+
expect(ToolsService.listTools).toHaveBeenCalled();
660+
expect(AnalysisService.searchRepositoryIssues).toHaveBeenCalledWith(
661+
"gh", "test-org", "test-repo", undefined, 100,
662+
{ toolUuids: ["uuid-eslint"] },
663+
);
664+
});
665+
666+
it("should resolve a shortName match to its UUID", async () => {
667+
vi.mocked(ToolsService.listTools).mockResolvedValue(mockToolList as any);
668+
vi.mocked(AnalysisService.searchRepositoryIssues).mockResolvedValue({
669+
data: [],
670+
} as any);
671+
672+
const program = createProgram();
673+
await program.parseAsync([
674+
"node", "test", "issues", "gh", "test-org", "test-repo",
675+
"--tools", "semgrep",
676+
]);
677+
678+
expect(AnalysisService.searchRepositoryIssues).toHaveBeenCalledWith(
679+
"gh", "test-org", "test-repo", undefined, 100,
680+
{ toolUuids: ["uuid-semgrep"] },
681+
);
682+
});
683+
684+
it("should resolve a substring match via prefix when only one tool matches", async () => {
685+
vi.mocked(ToolsService.listTools).mockResolvedValue(mockToolList as any);
686+
vi.mocked(AnalysisService.searchRepositoryIssues).mockResolvedValue({
687+
data: [],
688+
} as any);
689+
690+
const program = createProgram();
691+
// "eslint9" matches shortName "eslint9" exactly
692+
await program.parseAsync([
693+
"node", "test", "issues", "gh", "test-org", "test-repo",
694+
"--tools", "eslint9",
695+
]);
696+
697+
expect(AnalysisService.searchRepositoryIssues).toHaveBeenCalledWith(
698+
"gh", "test-org", "test-repo", undefined, 100,
699+
{ toolUuids: ["uuid-eslint9"] },
700+
);
701+
});
702+
703+
it("should error when tool name is ambiguous", async () => {
704+
vi.mocked(ToolsService.listTools).mockResolvedValue(mockToolList as any);
705+
706+
const mockExit = vi.spyOn(process, "exit").mockImplementation(() => {
707+
throw new Error("process.exit called");
708+
});
709+
const mockStderr = vi.spyOn(console, "error").mockImplementation(() => {});
710+
711+
const program = createProgram();
712+
await expect(
713+
program.parseAsync([
714+
"node", "test", "issues", "gh", "test-org", "test-repo",
715+
"--tools", "mark",
716+
]),
717+
).rejects.toThrow("process.exit called");
718+
719+
expect(mockStderr).toHaveBeenCalledWith(
720+
expect.stringContaining("ambiguous"),
721+
);
722+
723+
mockExit.mockRestore();
724+
mockStderr.mockRestore();
725+
});
726+
727+
it("should error when tool name is not found", async () => {
728+
vi.mocked(ToolsService.listTools).mockResolvedValue(mockToolList as any);
729+
730+
const mockExit = vi.spyOn(process, "exit").mockImplementation(() => {
731+
throw new Error("process.exit called");
732+
});
733+
const mockStderr = vi.spyOn(console, "error").mockImplementation(() => {});
734+
735+
const program = createProgram();
736+
await expect(
737+
program.parseAsync([
738+
"node", "test", "issues", "gh", "test-org", "test-repo",
739+
"--tools", "nonexistent",
740+
]),
741+
).rejects.toThrow("process.exit called");
742+
743+
expect(mockStderr).toHaveBeenCalledWith(
744+
expect.stringContaining('not found'),
745+
);
746+
747+
mockExit.mockRestore();
748+
mockStderr.mockRestore();
749+
});
750+
751+
it("should handle mixed UUIDs and tool names", async () => {
752+
vi.mocked(ToolsService.listTools).mockResolvedValue(mockToolList as any);
753+
vi.mocked(AnalysisService.searchRepositoryIssues).mockResolvedValue({
754+
data: [],
755+
} as any);
756+
757+
const program = createProgram();
758+
await program.parseAsync([
759+
"node", "test", "issues", "gh", "test-org", "test-repo",
760+
"--tools", "a1b2c3d4-e5f6-7890-abcd-ef1234567890,semgrep",
761+
]);
762+
763+
expect(AnalysisService.searchRepositoryIssues).toHaveBeenCalledWith(
764+
"gh", "test-org", "test-repo", undefined, 100,
765+
{ toolUuids: ["a1b2c3d4-e5f6-7890-abcd-ef1234567890", "uuid-semgrep"] },
766+
);
767+
});
768+
});
769+
615770
it("should fail when CODACY_API_TOKEN is not set", async () => {
616771
delete process.env.CODACY_API_TOKEN;
617772

src/commands/issues.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,10 @@ import {
1010
printJson,
1111
printPaginationWarning,
1212
} from "../utils/output";
13-
import { printSection, printIssueCard } from "../utils/formatting";
13+
import { printSection, printIssueCard, resolveToolUuids } from "../utils/formatting";
1414
import { AnalysisService } from "../api/client/services/AnalysisService";
15+
import { ToolsService } from "../api/client/services/ToolsService";
16+
import { Tool } from "../api/client/models/Tool";
1517
import { CommitIssue } from "../api/client/models/CommitIssue";
1618
import { SeverityLevel } from "../api/client/models/SeverityLevel";
1719
import { SearchRepositoryIssuesBody } from "../api/client/models/SearchRepositoryIssuesBody";
@@ -169,6 +171,7 @@ export function registerIssuesCommand(program: Command) {
169171
.argument("<repository>", "repository name")
170172
.option("-b, --branch <branch>", "branch name (defaults to the main branch)")
171173
.option("-p, --patterns <patterns>", "comma-separated list of pattern IDs")
174+
.option("-T, --tools <tools>", "comma-separated tool UUIDs or names to filter by")
172175
.option(
173176
"-s, --severities <severities>",
174177
"comma-separated severity levels: Critical, High, Medium, Minor (or Error, Warning, Info)",
@@ -189,6 +192,7 @@ Examples:
189192
$ codacy issues gh my-org my-repo
190193
$ codacy issues gh my-org my-repo --branch main --severities Critical,Medium
191194
$ codacy issues gh my-org my-repo --categories Security --overview
195+
$ codacy issues gh my-org my-repo --tools eslint,semgrep
192196
$ codacy issues gh my-org my-repo --limit 500
193197
$ codacy issues gh my-org my-repo --output json`,
194198
)
@@ -220,6 +224,20 @@ Examples:
220224
const author = parseCommaList(opts.authors);
221225
if (author) body.authorEmails = author;
222226

227+
const toolInputs = parseCommaList(opts.tools);
228+
if (toolInputs) {
229+
body.toolUuids = await resolveToolUuids(toolInputs, async () => {
230+
const tools: Tool[] = [];
231+
let cursor: string | undefined;
232+
do {
233+
const resp = await ToolsService.listTools(cursor, 100);
234+
tools.push(...resp.data);
235+
cursor = resp.pagination?.cursor;
236+
} while (cursor);
237+
return tools;
238+
});
239+
}
240+
223241
const limit = Math.min(Math.max(parseInt(opts.limit, 10) || 100, 1), 1000);
224242

225243
const spinner = ora(

src/utils/formatting.test.ts

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect, vi, beforeEach } from "vitest";
2-
import { formatAnalysisStatus } from "./formatting";
2+
import { formatAnalysisStatus, resolveToolUuids } from "./formatting";
33

44
// Mock ansis to return raw text for easier testing
55
vi.mock("ansis", () => ({
@@ -92,3 +92,92 @@ describe("formatAnalysisStatus", () => {
9292
expect(result).toBe("Never");
9393
});
9494
});
95+
96+
describe("resolveToolUuids", () => {
97+
const mockTools = [
98+
{ uuid: "uuid-eslint", name: "ESLint", shortName: "eslint", prefix: "ESLint_" },
99+
{ uuid: "uuid-eslint9", name: "ESLint 9", shortName: "eslint9", prefix: "ESLint9_" },
100+
{ uuid: "uuid-semgrep", name: "Semgrep", shortName: "semgrep", prefix: "Semgrep_" },
101+
{ uuid: "uuid-markdownlint", name: "Markdownlint", shortName: "markdownlint", prefix: "Markdownlint_" },
102+
{ uuid: "uuid-remarklint", name: "Remarklint", shortName: "remarklint", prefix: "Remarklint_" },
103+
] as any[];
104+
105+
const fetchTools = vi.fn(async () => mockTools);
106+
107+
beforeEach(() => {
108+
fetchTools.mockClear();
109+
});
110+
111+
it("should pass UUIDs through without fetching tools", async () => {
112+
const result = await resolveToolUuids(
113+
["a1b2c3d4-e5f6-7890-abcd-ef1234567890"],
114+
fetchTools,
115+
);
116+
expect(result).toEqual(["a1b2c3d4-e5f6-7890-abcd-ef1234567890"]);
117+
expect(fetchTools).not.toHaveBeenCalled();
118+
});
119+
120+
it("should resolve exact name match (case-insensitive)", async () => {
121+
const result = await resolveToolUuids(["eslint"], fetchTools);
122+
expect(result).toEqual(["uuid-eslint"]);
123+
});
124+
125+
it("should resolve exact shortName match (case-insensitive)", async () => {
126+
const result = await resolveToolUuids(["eslint9"], fetchTools);
127+
expect(result).toEqual(["uuid-eslint9"]);
128+
});
129+
130+
it("should resolve a unique substring match via name", async () => {
131+
const result = await resolveToolUuids(["semgr"], fetchTools);
132+
expect(result).toEqual(["uuid-semgrep"]);
133+
});
134+
135+
it("should error on ambiguous substring match", async () => {
136+
const mockExit = vi.spyOn(process, "exit").mockImplementation(() => {
137+
throw new Error("process.exit called");
138+
});
139+
vi.spyOn(console, "error").mockImplementation(() => {});
140+
141+
await expect(resolveToolUuids(["mark"], fetchTools)).rejects.toThrow(
142+
"process.exit called",
143+
);
144+
145+
expect(console.error).toHaveBeenCalledWith(
146+
expect.stringContaining("ambiguous"),
147+
);
148+
149+
mockExit.mockRestore();
150+
(console.error as any).mockRestore();
151+
});
152+
153+
it("should error when tool is not found", async () => {
154+
const mockExit = vi.spyOn(process, "exit").mockImplementation(() => {
155+
throw new Error("process.exit called");
156+
});
157+
vi.spyOn(console, "error").mockImplementation(() => {});
158+
159+
await expect(resolveToolUuids(["zzz"], fetchTools)).rejects.toThrow(
160+
"process.exit called",
161+
);
162+
163+
expect(console.error).toHaveBeenCalledWith(
164+
expect.stringContaining("not found"),
165+
);
166+
167+
mockExit.mockRestore();
168+
(console.error as any).mockRestore();
169+
});
170+
171+
it("should handle mixed UUIDs and names, fetching tools only once", async () => {
172+
const result = await resolveToolUuids(
173+
["a1b2c3d4-e5f6-7890-abcd-ef1234567890", "semgrep", "eslint"],
174+
fetchTools,
175+
);
176+
expect(result).toEqual([
177+
"a1b2c3d4-e5f6-7890-abcd-ef1234567890",
178+
"uuid-semgrep",
179+
"uuid-eslint",
180+
]);
181+
expect(fetchTools).toHaveBeenCalledTimes(1);
182+
});
183+
});

0 commit comments

Comments
 (0)