Skip to content

Commit df6a92d

Browse files
alerizzoclaude
andauthored
feat: add --tools filter to issues command (#4)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 62c3b1e commit df6a92d

File tree

6 files changed

+336
-4
lines changed

6 files changed

+336
-4
lines changed

.gitignore

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,7 @@ dist/
1717
api-v3/
1818

1919
# Ignore .codacy
20-
.codacy/
20+
.codacy/
21+
22+
#Ignore vscode AI rules
23+
.github/instructions/codacy.instructions.md

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
"prepublishOnly": "npm run update-api && npm run build",
2424
"start": "npx ts-node src/index.ts",
2525
"start:dist": "node dist/index.js",
26-
"fetch-api": "curl https://artifacts.codacy.com/api/codacy-api/50.7.17/apiv3-bundled.yaml -o ./api-v3/api-swagger.yaml --create-dirs",
26+
"fetch-api": "curl https://artifacts.codacy.com/api/codacy-api/52.1.31/apiv3-bundled.yaml -o ./api-v3/api-swagger.yaml --create-dirs",
2727
"generate-api": "rm -rf ./src/api/client && openapi --input ./api-v3/api-swagger.yaml --output ./src/api/client --useUnionTypes --indent 2 --client fetch",
2828
"update-api": "npm run fetch-api && npm run generate-api",
2929
"check-types": "tsc --noEmit"

src/commands/issues.test.ts

Lines changed: 173 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,177 @@ 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 an exact shortName match (eslint9)", 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+
await program.parseAsync([
692+
"node", "test", "issues", "gh", "test-org", "test-repo",
693+
"--tools", "eslint9",
694+
]);
695+
696+
expect(AnalysisService.searchRepositoryIssues).toHaveBeenCalledWith(
697+
"gh", "test-org", "test-repo", undefined, 100,
698+
{ toolUuids: ["uuid-eslint9"] },
699+
);
700+
});
701+
702+
it("should resolve a unique substring match via prefix", async () => {
703+
vi.mocked(ToolsService.listTools).mockResolvedValue(mockToolList as any);
704+
vi.mocked(AnalysisService.searchRepositoryIssues).mockResolvedValue({
705+
data: [],
706+
} as any);
707+
708+
const program = createProgram();
709+
// "semgr" is not an exact name or shortName, but substring-matches only Semgrep
710+
await program.parseAsync([
711+
"node", "test", "issues", "gh", "test-org", "test-repo",
712+
"--tools", "semgr",
713+
]);
714+
715+
expect(AnalysisService.searchRepositoryIssues).toHaveBeenCalledWith(
716+
"gh", "test-org", "test-repo", undefined, 100,
717+
{ toolUuids: ["uuid-semgrep"] },
718+
);
719+
});
720+
721+
it("should error when tool name is ambiguous", async () => {
722+
vi.mocked(ToolsService.listTools).mockResolvedValue(mockToolList as any);
723+
724+
const mockExit = vi.spyOn(process, "exit").mockImplementation(() => {
725+
throw new Error("process.exit called");
726+
});
727+
const mockStderr = vi.spyOn(console, "error").mockImplementation(() => {});
728+
729+
const program = createProgram();
730+
await expect(
731+
program.parseAsync([
732+
"node", "test", "issues", "gh", "test-org", "test-repo",
733+
"--tools", "mark",
734+
]),
735+
).rejects.toThrow("process.exit called");
736+
737+
expect(mockStderr).toHaveBeenCalledWith(
738+
expect.stringContaining("ambiguous"),
739+
);
740+
741+
mockExit.mockRestore();
742+
mockStderr.mockRestore();
743+
});
744+
745+
it("should error when tool name is not found", async () => {
746+
vi.mocked(ToolsService.listTools).mockResolvedValue(mockToolList as any);
747+
748+
const mockExit = vi.spyOn(process, "exit").mockImplementation(() => {
749+
throw new Error("process.exit called");
750+
});
751+
const mockStderr = vi.spyOn(console, "error").mockImplementation(() => {});
752+
753+
const program = createProgram();
754+
await expect(
755+
program.parseAsync([
756+
"node", "test", "issues", "gh", "test-org", "test-repo",
757+
"--tools", "nonexistent",
758+
]),
759+
).rejects.toThrow("process.exit called");
760+
761+
expect(mockStderr).toHaveBeenCalledWith(
762+
expect.stringContaining("not found"),
763+
);
764+
765+
mockExit.mockRestore();
766+
mockStderr.mockRestore();
767+
});
768+
769+
it("should handle mixed UUIDs and tool names", async () => {
770+
vi.mocked(ToolsService.listTools).mockResolvedValue(mockToolList as any);
771+
vi.mocked(AnalysisService.searchRepositoryIssues).mockResolvedValue({
772+
data: [],
773+
} as any);
774+
775+
const program = createProgram();
776+
await program.parseAsync([
777+
"node", "test", "issues", "gh", "test-org", "test-repo",
778+
"--tools", "a1b2c3d4-e5f6-7890-abcd-ef1234567890,semgrep",
779+
]);
780+
781+
expect(AnalysisService.searchRepositoryIssues).toHaveBeenCalledWith(
782+
"gh", "test-org", "test-repo", undefined, 100,
783+
{ toolUuids: ["a1b2c3d4-e5f6-7890-abcd-ef1234567890", "uuid-semgrep"] },
784+
);
785+
});
786+
});
787+
615788
it("should fail when CODACY_API_TOKEN is not set", async () => {
616789
delete process.env.CODACY_API_TOKEN;
617790

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: 71 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,73 @@ 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+
await expect(resolveToolUuids(["mark"], fetchTools)).rejects.toThrow(
137+
/ambiguous.*Markdownlint.*Remarklint/,
138+
);
139+
});
140+
141+
it("should error when tool is not found", async () => {
142+
await expect(resolveToolUuids(["zzz"], fetchTools)).rejects.toThrow(
143+
'Tool "zzz" not found',
144+
);
145+
});
146+
147+
it("should deduplicate resolved UUIDs", async () => {
148+
const result = await resolveToolUuids(["eslint", "eslint"], fetchTools);
149+
expect(result).toEqual(["uuid-eslint"]);
150+
});
151+
152+
it("should handle mixed UUIDs and names, fetching tools only once", async () => {
153+
const result = await resolveToolUuids(
154+
["a1b2c3d4-e5f6-7890-abcd-ef1234567890", "semgrep", "eslint"],
155+
fetchTools,
156+
);
157+
expect(result).toEqual([
158+
"a1b2c3d4-e5f6-7890-abcd-ef1234567890",
159+
"uuid-semgrep",
160+
"uuid-eslint",
161+
]);
162+
expect(fetchTools).toHaveBeenCalledTimes(1);
163+
});
164+
});

0 commit comments

Comments
 (0)