Skip to content

Commit 54bcedf

Browse files
alerizzoclaude
andcommitted
feat: Generalize --ignore for all issues, add --ignore-reason CF-2412
Rename --bulk-ignore to --ignore so it works for all issues (not just false positives). Make --false-positives a tri-state filter (true/false/ omit) and add --ignore-reason with the same choices as the issue command. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 095445e commit 54bcedf

4 files changed

Lines changed: 149 additions & 35 deletions

File tree

SPECS/commands/issues.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,13 @@ Both accept the same `SearchRepositoryIssuesBody` for filtering.
3333
| `--languages <languages>` | `-l` | Comma-separated language names |
3434
| `--tags <tags>` | `-t` | Comma-separated tag names |
3535
| `--authors <authors>` | `-a` | Comma-separated author emails |
36+
| `--tools <tools>` | `-T` | Comma-separated tool UUIDs or names |
37+
| `--limit <n>` | `-n` | Maximum number of issues (default: 100, max: 1000) |
3638
| `--overview` | `-O` | Show overview counts instead of list |
39+
| `--false-positives [value]` | `-F` | Filter by potential false positives (true, false, or omit) |
40+
| `--ignore` | `-I` | Ignore all issues matching current filters |
41+
| `--ignore-reason <reason>` | `-R` | Reason for ignoring (AcceptedUse, FalsePositive, NotExploitable, TestCode, ExternalCode) |
42+
| `--ignore-comment <comment>` | `-m` | Optional comment when using --ignore |
3743

3844
## Output
3945

@@ -64,4 +70,4 @@ Six count tables sorted descending by count: Category, Severity, Language, Tag,
6470

6571
## Tests
6672

67-
File: `src/commands/issues.test.ts`11 tests.
73+
File: `src/commands/issues.test.ts`39 tests.

src/commands/AGENTS.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,19 @@ Several helpers are shared between `repository.ts` and `pull-request.ts` via `ut
131131
- **`--unignore` mode** (`-U`): calls `AnalysisService.updateIssueState` with `{ ignored: false }`; skips rendering issue details
132132
- The API uses the string UUID (`issue.issueId`), not the numeric `resultDataId`, for the `updateIssueState` call
133133

134+
## issues command (`issues.ts`)
135+
136+
- Takes `<provider>`, `<organization>`, and `<repository>` as required arguments
137+
- **List mode** (default): card-style format sorted by severity (Error > High > Warning > Info)
138+
- **Overview mode** (`-O, --overview`): six count tables — Category, Severity, Language, Tag, Pattern, Author
139+
- **Filters**: `--branch`, `--patterns`, `--tools`, `--severities`, `--categories`, `--languages`, `--tags`, `--authors`, `--limit`
140+
- **`--false-positives [value]`** (`-F`): tri-state filter — `true` (default when flag present) sends `onlyPotentialFalsePositives: true`, `false` sends `onlyPotentialFalsePositives: false`, omitted sends nothing
141+
- **`--ignore` mode** (`-I`): fetches all issues matching current filters (all pages), then calls `AnalysisService.bulkIgnoreIssues` in batches of 100
142+
- `-R, --ignore-reason`: `AcceptedUse` (default) | `FalsePositive` | `NotExploitable` | `TestCode` | `ExternalCode`
143+
- `-m, --ignore-comment`: optional free-text comment
144+
- Cannot be combined with `--overview` or `--limit`
145+
- Works with any combination of filters; use `--false-positives --ignore` to ignore only FP issues
146+
134147
## finding command (`finding.ts`)
135148

136149
- Takes `<provider>`, `<organization>`, and `<findingId>` (UUID shown on finding cards) as required arguments — **no `<repository>` argument**

src/commands/issues.test.ts

Lines changed: 101 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -849,6 +849,40 @@ describe("issues command", () => {
849849
);
850850
});
851851

852+
it("should pass onlyPotentialFalsePositives: false when --false-positives false", async () => {
853+
vi.mocked(AnalysisService.searchRepositoryIssues).mockResolvedValue({
854+
data: [],
855+
} as any);
856+
857+
const program = createProgram();
858+
await program.parseAsync([
859+
"node", "test", "issues", "gh", "test-org", "test-repo",
860+
"--false-positives", "false",
861+
]);
862+
863+
expect(AnalysisService.searchRepositoryIssues).toHaveBeenCalledWith(
864+
"gh", "test-org", "test-repo", undefined, 100,
865+
{ onlyPotentialFalsePositives: false },
866+
);
867+
});
868+
869+
it("should pass onlyPotentialFalsePositives: true when --false-positives true", async () => {
870+
vi.mocked(AnalysisService.searchRepositoryIssues).mockResolvedValue({
871+
data: [],
872+
} as any);
873+
874+
const program = createProgram();
875+
await program.parseAsync([
876+
"node", "test", "issues", "gh", "test-org", "test-repo",
877+
"--false-positives", "true",
878+
]);
879+
880+
expect(AnalysisService.searchRepositoryIssues).toHaveBeenCalledWith(
881+
"gh", "test-org", "test-repo", undefined, 100,
882+
{ onlyPotentialFalsePositives: true },
883+
);
884+
});
885+
852886
it("should display false positive issues in list format", async () => {
853887
const fpIssue = {
854888
...mockIssues[0],
@@ -872,8 +906,8 @@ describe("issues command", () => {
872906
});
873907
});
874908

875-
describe("--bulk-ignore flag", () => {
876-
it("should error when --overview is combined with --bulk-ignore", async () => {
909+
describe("--ignore flag", () => {
910+
it("should error when --overview is combined with --ignore", async () => {
877911
const mockExit = vi.spyOn(process, "exit").mockImplementation(() => {
878912
throw new Error("process.exit called");
879913
});
@@ -883,7 +917,7 @@ describe("issues command", () => {
883917
await expect(
884918
program.parseAsync([
885919
"node", "test", "issues", "gh", "test-org", "test-repo",
886-
"--bulk-ignore", "--overview",
920+
"--ignore", "--overview",
887921
]),
888922
).rejects.toThrow("process.exit called");
889923

@@ -893,7 +927,7 @@ describe("issues command", () => {
893927
mockExit.mockRestore();
894928
});
895929

896-
it("should error when --limit is explicitly combined with --bulk-ignore", async () => {
930+
it("should error when --limit is explicitly combined with --ignore", async () => {
897931
const mockExit = vi.spyOn(process, "exit").mockImplementation(() => {
898932
throw new Error("process.exit called");
899933
});
@@ -903,7 +937,7 @@ describe("issues command", () => {
903937
await expect(
904938
program.parseAsync([
905939
"node", "test", "issues", "gh", "test-org", "test-repo",
906-
"--bulk-ignore", "--limit", "10",
940+
"--ignore", "--limit", "10",
907941
]),
908942
).rejects.toThrow("process.exit called");
909943

@@ -913,7 +947,7 @@ describe("issues command", () => {
913947
mockExit.mockRestore();
914948
});
915949

916-
it("should fetch all FP issues with onlyPotentialFalsePositives: true and call bulkIgnoreIssues", async () => {
950+
it("should fetch all issues and call bulkIgnoreIssues with default reason AcceptedUse", async () => {
917951
vi.mocked(AnalysisService.searchRepositoryIssues).mockResolvedValue({
918952
data: mockIssues,
919953
} as any);
@@ -922,37 +956,37 @@ describe("issues command", () => {
922956
const program = createProgram();
923957
await program.parseAsync([
924958
"node", "test", "issues", "gh", "test-org", "test-repo",
925-
"--bulk-ignore",
959+
"--ignore",
926960
]);
927961

928962
expect(AnalysisService.searchRepositoryIssues).toHaveBeenCalledWith(
929963
"gh", "test-org", "test-repo", undefined, 100,
930-
{ onlyPotentialFalsePositives: true },
964+
{},
931965
);
932966
expect(AnalysisService.bulkIgnoreIssues).toHaveBeenCalledWith(
933967
"gh", "test-org", "test-repo",
934968
{
935969
issueIds: [mockIssues[0].issueId, mockIssues[1].issueId],
936-
reason: "FalsePositive",
970+
reason: "AcceptedUse",
937971
comment: undefined,
938972
},
939973
);
940974
});
941975

942-
it("should show 'No false positive issues found' when API returns empty list", async () => {
976+
it("should show 'No issues found' when API returns empty list", async () => {
943977
vi.mocked(AnalysisService.searchRepositoryIssues).mockResolvedValue({
944978
data: [],
945979
} as any);
946980

947981
const program = createProgram();
948982
await program.parseAsync([
949983
"node", "test", "issues", "gh", "test-org", "test-repo",
950-
"--bulk-ignore",
984+
"--ignore",
951985
]);
952986

953987
expect(AnalysisService.bulkIgnoreIssues).not.toHaveBeenCalled();
954988
const output = getAllOutput();
955-
expect(output).toContain("No false positive issues found");
989+
expect(output).toContain("No issues found matching the current filters");
956990
});
957991

958992
it("should batch bulkIgnoreIssues calls when there are more than 100 issues", async () => {
@@ -982,7 +1016,7 @@ describe("issues command", () => {
9821016
const program = createProgram();
9831017
await program.parseAsync([
9841018
"node", "test", "issues", "gh", "test-org", "test-repo",
985-
"--bulk-ignore",
1019+
"--ignore",
9861020
]);
9871021

9881022
// Should have made 2 search calls (paginated)
@@ -1008,41 +1042,90 @@ describe("issues command", () => {
10081042
const program = createProgram();
10091043
await program.parseAsync([
10101044
"node", "test", "issues", "gh", "test-org", "test-repo",
1011-
"--bulk-ignore",
1045+
"--ignore",
10121046
"--ignore-comment", "Verified by security team",
10131047
]);
10141048

10151049
expect(AnalysisService.bulkIgnoreIssues).toHaveBeenCalledWith(
10161050
"gh", "test-org", "test-repo",
10171051
{
10181052
issueIds: [mockIssues[0].issueId],
1019-
reason: "FalsePositive",
1053+
reason: "AcceptedUse",
10201054
comment: "Verified by security team",
10211055
},
10221056
);
10231057
});
10241058

1025-
it("should combine --bulk-ignore with other filters (--branch, --patterns)", async () => {
1059+
it("should combine --ignore with other filters (--branch, --patterns)", async () => {
10261060
vi.mocked(AnalysisService.searchRepositoryIssues).mockResolvedValue({
10271061
data: [],
10281062
} as any);
10291063

10301064
const program = createProgram();
10311065
await program.parseAsync([
10321066
"node", "test", "issues", "gh", "test-org", "test-repo",
1033-
"--bulk-ignore",
1067+
"--ignore",
10341068
"--branch", "develop",
10351069
"--patterns", "sql-injection",
10361070
]);
10371071

10381072
expect(AnalysisService.searchRepositoryIssues).toHaveBeenCalledWith(
10391073
"gh", "test-org", "test-repo", undefined, 100,
10401074
{
1041-
onlyPotentialFalsePositives: true,
10421075
branchName: "develop",
10431076
patternIds: ["sql-injection"],
10441077
},
10451078
);
10461079
});
1080+
1081+
it("should pass --ignore-reason to bulkIgnoreIssues", async () => {
1082+
vi.mocked(AnalysisService.searchRepositoryIssues).mockResolvedValue({
1083+
data: [mockIssues[0]],
1084+
} as any);
1085+
vi.mocked(AnalysisService.bulkIgnoreIssues).mockResolvedValue(undefined as any);
1086+
1087+
const program = createProgram();
1088+
await program.parseAsync([
1089+
"node", "test", "issues", "gh", "test-org", "test-repo",
1090+
"--ignore",
1091+
"--ignore-reason", "FalsePositive",
1092+
]);
1093+
1094+
expect(AnalysisService.bulkIgnoreIssues).toHaveBeenCalledWith(
1095+
"gh", "test-org", "test-repo",
1096+
{
1097+
issueIds: [mockIssues[0].issueId],
1098+
reason: "FalsePositive",
1099+
comment: undefined,
1100+
},
1101+
);
1102+
});
1103+
1104+
it("should combine --ignore with --false-positives to ignore only FP issues", async () => {
1105+
vi.mocked(AnalysisService.searchRepositoryIssues).mockResolvedValue({
1106+
data: [mockIssues[0]],
1107+
} as any);
1108+
vi.mocked(AnalysisService.bulkIgnoreIssues).mockResolvedValue(undefined as any);
1109+
1110+
const program = createProgram();
1111+
await program.parseAsync([
1112+
"node", "test", "issues", "gh", "test-org", "test-repo",
1113+
"--ignore",
1114+
"--false-positives",
1115+
]);
1116+
1117+
expect(AnalysisService.searchRepositoryIssues).toHaveBeenCalledWith(
1118+
"gh", "test-org", "test-repo", undefined, 100,
1119+
{ onlyPotentialFalsePositives: true },
1120+
);
1121+
expect(AnalysisService.bulkIgnoreIssues).toHaveBeenCalledWith(
1122+
"gh", "test-org", "test-repo",
1123+
{
1124+
issueIds: [mockIssues[0].issueId],
1125+
reason: "AcceptedUse",
1126+
comment: undefined,
1127+
},
1128+
);
1129+
});
10471130
});
10481131
});

src/commands/issues.ts

Lines changed: 28 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,10 @@ function normalizeCategory(input: string): string {
8080
return CATEGORY_NORMALIZE[key] ?? input;
8181
}
8282

83+
function parseBooleanOption(value: string): boolean {
84+
return value.toLowerCase() !== "false";
85+
}
86+
8387

8488
function printIssuesList(issues: CommitIssue[], total: number): void {
8589
printSection("Issues", total, "issue");
@@ -203,8 +207,8 @@ async function buildFilterBody(opts: Record<string, any>): Promise<SearchReposit
203207
const author = parseCommaList(opts.authors);
204208
if (author) body.authorEmails = author;
205209

206-
// --false-positives and --bulk-ignore both restrict the API query to FP issues only
207-
if (opts.falsePositives || opts.bulkIgnore) body.onlyPotentialFalsePositives = true;
210+
if (opts.falsePositives === true) body.onlyPotentialFalsePositives = true;
211+
else if (opts.falsePositives === false) body.onlyPotentialFalsePositives = false;
208212

209213
const toolInputs = parseCommaList(opts.tools);
210214
if (toolInputs) body.toolUuids = await resolveToolUuids(toolInputs, fetchAllTools);
@@ -221,9 +225,10 @@ async function executeBulkIgnore(
221225
organization: string,
222226
repository: string,
223227
body: SearchRepositoryIssuesBody,
228+
reason: string,
224229
comment: string | undefined,
225230
): Promise<void> {
226-
const fetchSpinner = ora("Fetching false positive issues...").start();
231+
const fetchSpinner = ora("Fetching issues...").start();
227232
const allIssues: CommitIssue[] = [];
228233
let cursor: string | undefined;
229234

@@ -243,26 +248,26 @@ async function executeBulkIgnore(
243248
fetchSpinner.stop();
244249

245250
if (allIssues.length === 0) {
246-
console.log(ansis.green("No false positive issues found."));
251+
console.log(ansis.green("No issues found matching the current filters."));
247252
return;
248253
}
249254

250255
const count = allIssues.length;
251256
const plural = count === 1 ? "" : "s";
252-
console.log(`Found ${ansis.bold(String(count))} false positive issue${plural}.`);
257+
console.log(`Found ${ansis.bold(String(count))} issue${plural}.`);
253258

254259
const ignoreSpinner = ora(`Ignoring ${count} issue${plural}...`).start();
255260
const issueIds = allIssues.map((i) => i.issueId);
256261

257262
for (let i = 0; i < issueIds.length; i += BULK_BATCH_SIZE) {
258263
await AnalysisService.bulkIgnoreIssues(provider, organization, repository, {
259264
issueIds: issueIds.slice(i, i + BULK_BATCH_SIZE),
260-
reason: "FalsePositive",
265+
reason,
261266
comment: comment || undefined,
262267
});
263268
}
264269

265-
ignoreSpinner.succeed(`Ignored ${ansis.bold(String(count))} false positive issue${plural}.`);
270+
ignoreSpinner.succeed(`Ignored ${ansis.bold(String(count))} issue${plural}.`);
266271
}
267272

268273
export function registerIssuesCommand(program: Command) {
@@ -289,9 +294,14 @@ export function registerIssuesCommand(program: Command) {
289294
.option("-a, --authors <authors>", "comma-separated list of author emails")
290295
.option("-n, --limit <n>", "maximum number of issues to return (default: 100, max: 1000)", "100")
291296
.option("-O, --overview", "show issue count totals instead of the issues list")
292-
.option("-F, --false-positives", "only show issues that are potential false positives")
293-
.option("-I, --bulk-ignore", "ignore all false positive issues matching the current filters")
294-
.option("-m, --ignore-comment <comment>", "optional comment when using --bulk-ignore")
297+
.option("-F, --false-positives [value]", "filter by potential false positives (true, false, or omit)", parseBooleanOption)
298+
.option("-I, --ignore", "ignore all issues matching the current filters")
299+
.option(
300+
"-R, --ignore-reason <reason>",
301+
"reason for ignoring (AcceptedUse|FalsePositive|NotExploitable|TestCode|ExternalCode)",
302+
"AcceptedUse",
303+
)
304+
.option("-m, --ignore-comment <comment>", "optional comment when using --ignore")
295305
.addHelpText(
296306
"after",
297307
`
@@ -302,8 +312,10 @@ Examples:
302312
$ codacy issues gh my-org my-repo --tools eslint,semgrep
303313
$ codacy issues gh my-org my-repo --limit 500
304314
$ codacy issues gh my-org my-repo --false-positives
305-
$ codacy issues gh my-org my-repo --bulk-ignore --branch main
306-
$ codacy issues gh my-org my-repo --bulk-ignore --patterns security-rule --ignore-comment "Confirmed FPs"
315+
$ codacy issues gh my-org my-repo --false-positives false
316+
$ codacy issues gh my-org my-repo --ignore --branch main
317+
$ codacy issues gh my-org my-repo --false-positives --ignore --ignore-reason FalsePositive
318+
$ codacy issues gh my-org my-repo --ignore --ignore-reason NotExploitable --ignore-comment "Reviewed"
307319
$ codacy issues gh my-org my-repo --output json`,
308320
)
309321
.action(async function (
@@ -321,14 +333,14 @@ Examples:
321333
const body = await buildFilterBody(opts);
322334
const limit = Math.min(Math.max(parseInt(opts.limit, 10) || 100, 1), 1000);
323335

324-
if (opts.bulkIgnore) {
336+
if (opts.ignore) {
325337
if (isOverview) {
326-
this.error("--overview cannot be used with --bulk-ignore; --overview is a read-only display mode");
338+
this.error("--overview cannot be used with --ignore; --overview is a read-only display mode");
327339
}
328340
if (this.getOptionValueSource("limit") === "cli") {
329-
this.error("--limit cannot be used with --bulk-ignore; the bulk-ignore path always processes all matching issues");
341+
this.error("--limit cannot be used with --ignore; the --ignore path always processes all matching issues");
330342
}
331-
await executeBulkIgnore(provider, organization, repository, body, opts.ignoreComment);
343+
await executeBulkIgnore(provider, organization, repository, body, opts.ignoreReason, opts.ignoreComment);
332344
return;
333345
}
334346

0 commit comments

Comments
 (0)