Skip to content

Commit 87edb94

Browse files
committed
feat: Add filter and bulk-ignore for FP CF-2412
1 parent df6a92d commit 87edb94

4 files changed

Lines changed: 259 additions & 7 deletions

File tree

package-lock.json

Lines changed: 3 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

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/52.1.31/apiv3-bundled.yaml -o ./api-v3/api-swagger.yaml --create-dirs",
26+
"fetch-api": "curl https://artifacts.codacy.com/api/codacy-api/55.6.0/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: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -807,4 +807,202 @@ describe("issues command", () => {
807807

808808
mockExit.mockRestore();
809809
});
810+
811+
describe("--false-positives flag", () => {
812+
it("should pass onlyPotentialFalsePositives: true in the body", async () => {
813+
vi.mocked(AnalysisService.searchRepositoryIssues).mockResolvedValue({
814+
data: [],
815+
} as any);
816+
817+
const program = createProgram();
818+
await program.parseAsync([
819+
"node", "test", "issues", "gh", "test-org", "test-repo",
820+
"--false-positives",
821+
]);
822+
823+
expect(AnalysisService.searchRepositoryIssues).toHaveBeenCalledWith(
824+
"gh", "test-org", "test-repo", undefined, 100,
825+
{ onlyPotentialFalsePositives: true },
826+
);
827+
});
828+
829+
it("should combine onlyPotentialFalsePositives with other filters (--patterns)", async () => {
830+
vi.mocked(AnalysisService.searchRepositoryIssues).mockResolvedValue({
831+
data: [],
832+
} as any);
833+
834+
const program = createProgram();
835+
await program.parseAsync([
836+
"node", "test", "issues", "gh", "test-org", "test-repo",
837+
"--false-positives",
838+
"--patterns", "no-undef,sql-injection",
839+
"--branch", "main",
840+
]);
841+
842+
expect(AnalysisService.searchRepositoryIssues).toHaveBeenCalledWith(
843+
"gh", "test-org", "test-repo", undefined, 100,
844+
{
845+
onlyPotentialFalsePositives: true,
846+
patternIds: ["no-undef", "sql-injection"],
847+
branchName: "main",
848+
},
849+
);
850+
});
851+
852+
it("should display false positive issues in list format", async () => {
853+
const fpIssue = {
854+
...mockIssues[0],
855+
falsePositiveProbability: 0.9,
856+
falsePositiveThreshold: 0.5,
857+
falsePositiveReason: "Common safe pattern",
858+
};
859+
vi.mocked(AnalysisService.searchRepositoryIssues).mockResolvedValue({
860+
data: [fpIssue],
861+
} as any);
862+
863+
const program = createProgram();
864+
await program.parseAsync([
865+
"node", "test", "issues", "gh", "test-org", "test-repo",
866+
"--false-positives",
867+
]);
868+
869+
const output = getAllOutput();
870+
expect(output).toContain("Potential SQL injection vulnerability");
871+
expect(output).toContain("Potential false positive");
872+
});
873+
});
874+
875+
describe("--bulk-ignore flag", () => {
876+
it("should fetch all FP issues with onlyPotentialFalsePositives: true and call bulkIgnoreIssues", async () => {
877+
vi.mocked(AnalysisService.searchRepositoryIssues).mockResolvedValue({
878+
data: mockIssues,
879+
} as any);
880+
vi.mocked(AnalysisService.bulkIgnoreIssues).mockResolvedValue(undefined as any);
881+
882+
const program = createProgram();
883+
await program.parseAsync([
884+
"node", "test", "issues", "gh", "test-org", "test-repo",
885+
"--bulk-ignore",
886+
]);
887+
888+
expect(AnalysisService.searchRepositoryIssues).toHaveBeenCalledWith(
889+
"gh", "test-org", "test-repo", undefined, 100,
890+
{ onlyPotentialFalsePositives: true },
891+
);
892+
expect(AnalysisService.bulkIgnoreIssues).toHaveBeenCalledWith(
893+
"gh", "test-org", "test-repo",
894+
{
895+
issueIds: [mockIssues[0].issueId, mockIssues[1].issueId],
896+
reason: "FalsePositive",
897+
comment: undefined,
898+
},
899+
);
900+
});
901+
902+
it("should show 'No false positive issues found' when API returns empty list", async () => {
903+
vi.mocked(AnalysisService.searchRepositoryIssues).mockResolvedValue({
904+
data: [],
905+
} as any);
906+
907+
const program = createProgram();
908+
await program.parseAsync([
909+
"node", "test", "issues", "gh", "test-org", "test-repo",
910+
"--bulk-ignore",
911+
]);
912+
913+
expect(AnalysisService.bulkIgnoreIssues).not.toHaveBeenCalled();
914+
const output = getAllOutput();
915+
expect(output).toContain("No false positive issues found");
916+
});
917+
918+
it("should batch bulkIgnoreIssues calls when there are more than 100 issues", async () => {
919+
// 150 issues across two pages
920+
const page1 = Array.from({ length: 100 }, (_, i) => ({
921+
...mockIssues[0],
922+
issueId: `fp-${i}`,
923+
resultDataId: i,
924+
}));
925+
const page2 = Array.from({ length: 50 }, (_, i) => ({
926+
...mockIssues[0],
927+
issueId: `fp-${100 + i}`,
928+
resultDataId: 100 + i,
929+
}));
930+
931+
vi.mocked(AnalysisService.searchRepositoryIssues)
932+
.mockResolvedValueOnce({
933+
data: page1,
934+
pagination: { cursor: "cursor-2", limit: 100, total: 150 },
935+
} as any)
936+
.mockResolvedValueOnce({
937+
data: page2,
938+
pagination: { cursor: undefined, limit: 100, total: 150 },
939+
} as any);
940+
vi.mocked(AnalysisService.bulkIgnoreIssues).mockResolvedValue(undefined as any);
941+
942+
const program = createProgram();
943+
await program.parseAsync([
944+
"node", "test", "issues", "gh", "test-org", "test-repo",
945+
"--bulk-ignore",
946+
]);
947+
948+
// Should have made 2 search calls (paginated)
949+
expect(AnalysisService.searchRepositoryIssues).toHaveBeenCalledTimes(2);
950+
// Should have made 2 bulk-ignore calls: one with 100 IDs, one with 50 IDs
951+
expect(AnalysisService.bulkIgnoreIssues).toHaveBeenCalledTimes(2);
952+
expect(AnalysisService.bulkIgnoreIssues).toHaveBeenNthCalledWith(
953+
1, "gh", "test-org", "test-repo",
954+
expect.objectContaining({ issueIds: expect.arrayContaining([expect.stringMatching(/^fp-/)]) }),
955+
);
956+
const firstCallIds: string[] = (AnalysisService.bulkIgnoreIssues as ReturnType<typeof vi.fn>).mock.calls[0][3].issueIds;
957+
expect(firstCallIds).toHaveLength(100);
958+
const secondCallIds: string[] = (AnalysisService.bulkIgnoreIssues as ReturnType<typeof vi.fn>).mock.calls[1][3].issueIds;
959+
expect(secondCallIds).toHaveLength(50);
960+
});
961+
962+
it("should forward --comment to bulkIgnoreIssues", async () => {
963+
vi.mocked(AnalysisService.searchRepositoryIssues).mockResolvedValue({
964+
data: [mockIssues[0]],
965+
} as any);
966+
vi.mocked(AnalysisService.bulkIgnoreIssues).mockResolvedValue(undefined as any);
967+
968+
const program = createProgram();
969+
await program.parseAsync([
970+
"node", "test", "issues", "gh", "test-org", "test-repo",
971+
"--bulk-ignore",
972+
"--comment", "Verified by security team",
973+
]);
974+
975+
expect(AnalysisService.bulkIgnoreIssues).toHaveBeenCalledWith(
976+
"gh", "test-org", "test-repo",
977+
{
978+
issueIds: [mockIssues[0].issueId],
979+
reason: "FalsePositive",
980+
comment: "Verified by security team",
981+
},
982+
);
983+
});
984+
985+
it("should combine --bulk-ignore with other filters (--branch, --patterns)", async () => {
986+
vi.mocked(AnalysisService.searchRepositoryIssues).mockResolvedValue({
987+
data: [],
988+
} as any);
989+
990+
const program = createProgram();
991+
await program.parseAsync([
992+
"node", "test", "issues", "gh", "test-org", "test-repo",
993+
"--bulk-ignore",
994+
"--branch", "develop",
995+
"--patterns", "sql-injection",
996+
]);
997+
998+
expect(AnalysisService.searchRepositoryIssues).toHaveBeenCalledWith(
999+
"gh", "test-org", "test-repo", undefined, 100,
1000+
{
1001+
onlyPotentialFalsePositives: true,
1002+
branchName: "develop",
1003+
patternIds: ["sql-injection"],
1004+
},
1005+
);
1006+
});
1007+
});
8101008
});

src/commands/issues.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ import { SearchRepositoryIssuesBody } from "../api/client/models/SearchRepositor
2020
import { Count } from "../api/client/models/Count";
2121
import { PatternsCount } from "../api/client/models/PatternsCount";
2222

23+
// API allows a maximum of 100 issue IDs per bulk-ignore call
24+
const BULK_BATCH_SIZE = 100;
25+
2326
const SEVERITY_ORDER: Record<string, number> = {
2427
Error: 0,
2528
High: 1,
@@ -185,6 +188,9 @@ export function registerIssuesCommand(program: Command) {
185188
.option("-a, --authors <authors>", "comma-separated list of author emails")
186189
.option("-n, --limit <n>", "maximum number of issues to return (default: 100, max: 1000)", "100")
187190
.option("-O, --overview", "show issue count totals instead of the issues list")
191+
.option("-F, --false-positives", "only show issues that are potential false positives")
192+
.option("-I, --bulk-ignore", "ignore all false positive issues matching the current filters")
193+
.option("-m, --comment <comment>", "optional comment when using --bulk-ignore")
188194
.addHelpText(
189195
"after",
190196
`
@@ -194,6 +200,9 @@ Examples:
194200
$ codacy issues gh my-org my-repo --categories Security --overview
195201
$ codacy issues gh my-org my-repo --tools eslint,semgrep
196202
$ codacy issues gh my-org my-repo --limit 500
203+
$ codacy issues gh my-org my-repo --false-positives
204+
$ codacy issues gh my-org my-repo --bulk-ignore --branch main
205+
$ codacy issues gh my-org my-repo --bulk-ignore --patterns security-rule --comment "Confirmed FPs"
197206
$ codacy issues gh my-org my-repo --output json`,
198207
)
199208
.action(async function (
@@ -207,6 +216,7 @@ Examples:
207216
const opts = this.opts();
208217
const format = getOutputFormat(this);
209218
const isOverview = !!opts.overview;
219+
const isBulkIgnore = !!opts.bulkIgnore;
210220

211221
// Build the shared filter body from CLI options
212222
const body: SearchRepositoryIssuesBody = {};
@@ -223,6 +233,8 @@ Examples:
223233
if (tags) body.tags = tags;
224234
const author = parseCommaList(opts.authors);
225235
if (author) body.authorEmails = author;
236+
// --false-positives and --bulk-ignore both restrict the API query to FP issues only
237+
if (opts.falsePositives || isBulkIgnore) body.onlyPotentialFalsePositives = true;
226238

227239
const toolInputs = parseCommaList(opts.tools);
228240
if (toolInputs) {
@@ -240,6 +252,51 @@ Examples:
240252

241253
const limit = Math.min(Math.max(parseInt(opts.limit, 10) || 100, 1), 1000);
242254

255+
// --bulk-ignore: fetch all FP issues (all pages) then call bulkIgnoreIssues in batches
256+
if (isBulkIgnore) {
257+
const fetchSpinner = ora("Fetching false positive issues...").start();
258+
const allIssues: CommitIssue[] = [];
259+
let cursor: string | undefined;
260+
261+
do {
262+
const resp = await AnalysisService.searchRepositoryIssues(
263+
provider,
264+
organization,
265+
repository,
266+
cursor,
267+
100,
268+
body,
269+
);
270+
allIssues.push(...resp.data);
271+
cursor = resp.pagination?.cursor;
272+
} while (cursor);
273+
274+
fetchSpinner.stop();
275+
276+
if (allIssues.length === 0) {
277+
console.log(ansis.green("No false positive issues found."));
278+
return;
279+
}
280+
281+
const count = allIssues.length;
282+
const plural = count === 1 ? "" : "s";
283+
console.log(`Found ${ansis.bold(String(count))} false positive issue${plural}.`);
284+
285+
const ignoreSpinner = ora(`Ignoring ${count} issue${plural}...`).start();
286+
const issueIds = allIssues.map((i) => i.issueId);
287+
288+
for (let i = 0; i < issueIds.length; i += BULK_BATCH_SIZE) {
289+
await AnalysisService.bulkIgnoreIssues(provider, organization, repository, {
290+
issueIds: issueIds.slice(i, i + BULK_BATCH_SIZE),
291+
reason: "FalsePositive",
292+
comment: opts.comment || undefined,
293+
});
294+
}
295+
296+
ignoreSpinner.succeed(`Ignored ${ansis.bold(String(count))} false positive issue${plural}.`);
297+
return;
298+
}
299+
243300
const spinner = ora(
244301
isOverview ? "Fetching issues overview..." : "Fetching issues...",
245302
).start();

0 commit comments

Comments
 (0)