Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 3 additions & 6 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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/52.1.31/apiv3-bundled.yaml -o ./api-v3/api-swagger.yaml --create-dirs",
"fetch-api": "curl https://artifacts.codacy.com/api/codacy-api/55.6.0/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"
Expand Down
198 changes: 198 additions & 0 deletions src/commands/issues.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -807,4 +807,202 @@ describe("issues command", () => {

mockExit.mockRestore();
});

describe("--false-positives flag", () => {
it("should pass onlyPotentialFalsePositives: true in the body", async () => {
vi.mocked(AnalysisService.searchRepositoryIssues).mockResolvedValue({
data: [],
} as any);

const program = createProgram();
await program.parseAsync([
"node", "test", "issues", "gh", "test-org", "test-repo",
"--false-positives",
]);

expect(AnalysisService.searchRepositoryIssues).toHaveBeenCalledWith(
"gh", "test-org", "test-repo", undefined, 100,
{ onlyPotentialFalsePositives: true },
);
});

it("should combine onlyPotentialFalsePositives with other filters (--patterns)", async () => {
vi.mocked(AnalysisService.searchRepositoryIssues).mockResolvedValue({
data: [],
} as any);

const program = createProgram();
await program.parseAsync([
"node", "test", "issues", "gh", "test-org", "test-repo",
"--false-positives",
"--patterns", "no-undef,sql-injection",
"--branch", "main",
]);

expect(AnalysisService.searchRepositoryIssues).toHaveBeenCalledWith(
"gh", "test-org", "test-repo", undefined, 100,
{
onlyPotentialFalsePositives: true,
patternIds: ["no-undef", "sql-injection"],
branchName: "main",
},
);
});

it("should display false positive issues in list format", async () => {
const fpIssue = {
...mockIssues[0],
falsePositiveProbability: 0.9,
falsePositiveThreshold: 0.5,
falsePositiveReason: "Common safe pattern",
};
vi.mocked(AnalysisService.searchRepositoryIssues).mockResolvedValue({
data: [fpIssue],
} as any);

const program = createProgram();
await program.parseAsync([
"node", "test", "issues", "gh", "test-org", "test-repo",
"--false-positives",
]);

const output = getAllOutput();
expect(output).toContain("Potential SQL injection vulnerability");
expect(output).toContain("Potential false positive");
});
});

describe("--bulk-ignore flag", () => {
it("should fetch all FP issues with onlyPotentialFalsePositives: true and call bulkIgnoreIssues", async () => {
vi.mocked(AnalysisService.searchRepositoryIssues).mockResolvedValue({
data: mockIssues,
} as any);
vi.mocked(AnalysisService.bulkIgnoreIssues).mockResolvedValue(undefined as any);

const program = createProgram();
await program.parseAsync([
"node", "test", "issues", "gh", "test-org", "test-repo",
"--bulk-ignore",
]);

expect(AnalysisService.searchRepositoryIssues).toHaveBeenCalledWith(
"gh", "test-org", "test-repo", undefined, 100,
{ onlyPotentialFalsePositives: true },
);
expect(AnalysisService.bulkIgnoreIssues).toHaveBeenCalledWith(
"gh", "test-org", "test-repo",
{
issueIds: [mockIssues[0].issueId, mockIssues[1].issueId],
reason: "FalsePositive",
comment: undefined,
},
);
});

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

const program = createProgram();
await program.parseAsync([
"node", "test", "issues", "gh", "test-org", "test-repo",
"--bulk-ignore",
]);

expect(AnalysisService.bulkIgnoreIssues).not.toHaveBeenCalled();
const output = getAllOutput();
expect(output).toContain("No false positive issues found");
});

it("should batch bulkIgnoreIssues calls when there are more than 100 issues", async () => {
// 150 issues across two pages
const page1 = Array.from({ length: 100 }, (_, i) => ({
...mockIssues[0],
issueId: `fp-${i}`,
resultDataId: i,
}));
const page2 = Array.from({ length: 50 }, (_, i) => ({
...mockIssues[0],
issueId: `fp-${100 + i}`,
resultDataId: 100 + i,
}));

vi.mocked(AnalysisService.searchRepositoryIssues)
.mockResolvedValueOnce({
data: page1,
pagination: { cursor: "cursor-2", limit: 100, total: 150 },
} as any)
.mockResolvedValueOnce({
data: page2,
pagination: { cursor: undefined, limit: 100, total: 150 },
} as any);
vi.mocked(AnalysisService.bulkIgnoreIssues).mockResolvedValue(undefined as any);

const program = createProgram();
await program.parseAsync([
"node", "test", "issues", "gh", "test-org", "test-repo",
"--bulk-ignore",
]);

// Should have made 2 search calls (paginated)
expect(AnalysisService.searchRepositoryIssues).toHaveBeenCalledTimes(2);
// Should have made 2 bulk-ignore calls: one with 100 IDs, one with 50 IDs
expect(AnalysisService.bulkIgnoreIssues).toHaveBeenCalledTimes(2);
expect(AnalysisService.bulkIgnoreIssues).toHaveBeenNthCalledWith(
1, "gh", "test-org", "test-repo",
expect.objectContaining({ issueIds: expect.arrayContaining([expect.stringMatching(/^fp-/)]) }),
);
const firstCallIds: string[] = (AnalysisService.bulkIgnoreIssues as ReturnType<typeof vi.fn>).mock.calls[0][3].issueIds;
expect(firstCallIds).toHaveLength(100);
const secondCallIds: string[] = (AnalysisService.bulkIgnoreIssues as ReturnType<typeof vi.fn>).mock.calls[1][3].issueIds;
expect(secondCallIds).toHaveLength(50);
});

it("should forward --comment to bulkIgnoreIssues", async () => {
Comment thread
og-pixel marked this conversation as resolved.
Outdated
vi.mocked(AnalysisService.searchRepositoryIssues).mockResolvedValue({
data: [mockIssues[0]],
} as any);
vi.mocked(AnalysisService.bulkIgnoreIssues).mockResolvedValue(undefined as any);

const program = createProgram();
await program.parseAsync([
"node", "test", "issues", "gh", "test-org", "test-repo",
"--bulk-ignore",
"--comment", "Verified by security team",
]);

expect(AnalysisService.bulkIgnoreIssues).toHaveBeenCalledWith(
"gh", "test-org", "test-repo",
{
issueIds: [mockIssues[0].issueId],
reason: "FalsePositive",
comment: "Verified by security team",
},
);
});

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

const program = createProgram();
await program.parseAsync([
"node", "test", "issues", "gh", "test-org", "test-repo",
"--bulk-ignore",
"--branch", "develop",
"--patterns", "sql-injection",
]);

expect(AnalysisService.searchRepositoryIssues).toHaveBeenCalledWith(
"gh", "test-org", "test-repo", undefined, 100,
{
onlyPotentialFalsePositives: true,
branchName: "develop",
patternIds: ["sql-injection"],
},
);
});
});
});
57 changes: 57 additions & 0 deletions src/commands/issues.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ import { SearchRepositoryIssuesBody } from "../api/client/models/SearchRepositor
import { Count } from "../api/client/models/Count";
import { PatternsCount } from "../api/client/models/PatternsCount";

// API allows a maximum of 100 issue IDs per bulk-ignore call
const BULK_BATCH_SIZE = 100;

const SEVERITY_ORDER: Record<string, number> = {
Error: 0,
High: 1,
Expand Down Expand Up @@ -185,6 +188,9 @@ export function registerIssuesCommand(program: Command) {
.option("-a, --authors <authors>", "comma-separated list of author emails")
.option("-n, --limit <n>", "maximum number of issues to return (default: 100, max: 1000)", "100")
.option("-O, --overview", "show issue count totals instead of the issues list")
.option("-F, --false-positives", "only show issues that are potential false positives")
.option("-I, --bulk-ignore", "ignore all false positive issues matching the current filters")
Comment thread
og-pixel marked this conversation as resolved.
Outdated
.option("-m, --comment <comment>", "optional comment when using --bulk-ignore")
Comment thread
og-pixel marked this conversation as resolved.
Outdated
.addHelpText(
"after",
`
Expand All @@ -194,6 +200,9 @@ Examples:
$ 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 --false-positives
$ codacy issues gh my-org my-repo --bulk-ignore --branch main
$ codacy issues gh my-org my-repo --bulk-ignore --patterns security-rule --comment "Confirmed FPs"
$ codacy issues gh my-org my-repo --output json`,
)
.action(async function (
Comment thread
og-pixel marked this conversation as resolved.
Expand All @@ -207,6 +216,7 @@ Examples:
const opts = this.opts();
const format = getOutputFormat(this);
const isOverview = !!opts.overview;
const isBulkIgnore = !!opts.bulkIgnore;

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

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

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

// --bulk-ignore: fetch all FP issues (all pages) then call bulkIgnoreIssues in batches
if (isBulkIgnore) {
const fetchSpinner = ora("Fetching false positive issues...").start();
Comment thread
og-pixel marked this conversation as resolved.
Outdated
const allIssues: CommitIssue[] = [];
let cursor: string | undefined;

do {
const resp = await AnalysisService.searchRepositoryIssues(
provider,
organization,
repository,
cursor,
100,
body,
);
allIssues.push(...resp.data);
Comment thread
og-pixel marked this conversation as resolved.
Outdated
cursor = resp.pagination?.cursor;
} while (cursor);

Comment thread
og-pixel marked this conversation as resolved.
Outdated
fetchSpinner.stop();

if (allIssues.length === 0) {
console.log(ansis.green("No false positive issues found."));
return;
}

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

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

for (let i = 0; i < issueIds.length; i += BULK_BATCH_SIZE) {
await AnalysisService.bulkIgnoreIssues(provider, organization, repository, {
issueIds: issueIds.slice(i, i + BULK_BATCH_SIZE),
reason: "FalsePositive",
comment: opts.comment || undefined,
});
}

ignoreSpinner.succeed(`Ignored ${ansis.bold(String(count))} false positive issue${plural}.`);
return;
}

const spinner = ora(
isOverview ? "Fetching issues overview..." : "Fetching issues...",
).start();
Expand Down
Loading