Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
14 changes: 12 additions & 2 deletions package-lock.json

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

5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@codacy/codacy-cloud-cli",
"version": "1.0.4",
"version": "1.0.5",
"description": "A command-line tool to interact with Codacy Cloud from your terminal",
"homepage": "https://www.codacy.com",
"repository": {
Expand Down Expand Up @@ -40,9 +40,10 @@
"author": "Codacy <support@codacy.com> (https://www.codacy.com)",
"license": "ISC",
"engines": {
"node": ">=18"
"node": ">=20"
},
"dependencies": {
"@codacy/tooling": "0.1.0",
"ansis": "4.0.0",
"cli-table3": "^0.6.3",
"commander": "14.0.0",
Expand Down
111 changes: 111 additions & 0 deletions src/commands/findings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -500,6 +500,117 @@ describe("findings command", () => {
);
});

it("should pass a custom limit <= 100 directly to the API", async () => {
vi.mocked(SecurityService.searchSecurityItems).mockResolvedValue({
data: [],
} as any);

const program = createProgram();
await program.parseAsync([
"node",
"test",
"findings",
"gh",
"test-org",
"test-repo",
"--limit",
"50",
]);

expect(SecurityService.searchSecurityItems).toHaveBeenCalledWith(
"gh",
"test-org",
undefined,
50,
"Status",
"asc",
{
repositories: ["test-repo"],
statuses: ["Overdue", "OnTrack", "DueSoon"],
},
);
});

it("should paginate when limit > 100", async () => {
const page1 = Array.from({ length: 100 }, (_, i) => ({
id: `finding-${i}`,
title: `Finding ${i}`,
priority: "Medium",
status: "OnTrack",
securityCategory: "Other",
scanType: "SAST",
dueAt: "2024-06-01T00:00:00Z",
}));
const page2 = Array.from({ length: 50 }, (_, i) => ({
id: `finding-${100 + i}`,
title: `Finding ${100 + i}`,
priority: "Medium",
status: "OnTrack",
securityCategory: "Other",
scanType: "SAST",
dueAt: "2024-06-01T00:00:00Z",
}));

vi.mocked(SecurityService.searchSecurityItems)
.mockResolvedValueOnce({
data: page1,
pagination: { cursor: "cursor-2", limit: 100, total: 300 },
} as any)
.mockResolvedValueOnce({
data: page2,
pagination: { cursor: undefined, limit: 100, total: 300 },
} as any);

const program = createProgram();
await program.parseAsync([
"node",
"test",
"findings",
"gh",
"test-org",
"test-repo",
"--limit",
"200",
]);

expect(SecurityService.searchSecurityItems).toHaveBeenCalledTimes(2);
expect(SecurityService.searchSecurityItems).toHaveBeenNthCalledWith(
1, "gh", "test-org", undefined, 100, "Status", "asc",
{ repositories: ["test-repo"], statuses: ["Overdue", "OnTrack", "DueSoon"] },
);
expect(SecurityService.searchSecurityItems).toHaveBeenNthCalledWith(
2, "gh", "test-org", "cursor-2", 100, "Status", "asc",
{ repositories: ["test-repo"], statuses: ["Overdue", "OnTrack", "DueSoon"] },
);

const output = getAllOutput();
expect(output).toContain("Findings — Found 300 findings");
});

it("should cap limit at 1000", async () => {
vi.mocked(SecurityService.searchSecurityItems).mockResolvedValue({
data: [],
} as any);

const program = createProgram();
await program.parseAsync([
"node",
"test",
"findings",
"gh",
"test-org",
"test-repo",
"--limit",
"5000",
]);

// Should use pageSize 100 (min of 1000, 100)
expect(SecurityService.searchSecurityItems).toHaveBeenCalledWith(
"gh", "test-org", undefined, 100, "Status", "asc",
{ repositories: ["test-repo"], statuses: ["Overdue", "OnTrack", "DueSoon"] },
);
});

it("should fail when CODACY_API_TOKEN is not set", async () => {
delete process.env.CODACY_API_TOKEN;

Expand Down
49 changes: 33 additions & 16 deletions src/commands/findings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@ export function registerFindingsCommand(program: Command) {
"-T, --scan-types <types>",
"comma-separated scan types (case-insensitive): SAST, Secrets, SCA, CICD, IaC, DAST, PenTesting, License, CSPM",
)
.option("-n, --limit <n>", "maximum number of findings to return (default: 100, max: 1000)", "100")
.option("-d, --dast-targets <urls>", "comma-separated DAST target URLs")
.addHelpText(
"after",
Expand All @@ -185,6 +186,7 @@ Examples:
$ codacy findings gh my-org
$ codacy findings gh my-org --severities Critical,High
$ codacy findings gh my-org my-repo --statuses Overdue,DueSoon
$ codacy findings gh my-org my-repo --limit 500
$ codacy findings gh my-org my-repo --output json`,
)
.action(async function (
Expand Down Expand Up @@ -214,25 +216,38 @@ Examples:
const dastTargets = parseCommaList(opts.dastTargets);
if (dastTargets) body.dastTargetUrls = dastTargets;

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

const spinner = ora(
repository
? "Fetching findings..."
: "Fetching organization findings...",
).start();

const response = await SecurityService.searchSecurityItems(
provider,
organization,
undefined,
100,
"Status", // actually sorting by due date
"asc",
body,
);
spinner.stop();
const pageSize = Math.min(limit, 100);
let items: SrmItem[] = [];
let cursor: string | undefined;
let total: number | undefined;

const items = response.data;
const total = response.pagination?.total ?? items.length;
do {
const response = await SecurityService.searchSecurityItems(
provider,
organization,
cursor,
pageSize,
"Status", // actually sorting by due date
"asc",
body,
);
items = items.concat(response.data);
total ??= response.pagination?.total;
cursor = response.pagination?.cursor;
} while (cursor && items.length < limit);

// Trim to exact limit
if (items.length > limit) items = items.slice(0, limit);
total ??= items.length;
spinner.stop();

if (format === "json") {
printJson({
Expand Down Expand Up @@ -261,10 +276,12 @@ Examples:

// Show repository column only when browsing org-wide (no repo filter)
printFindingsList(items, total, !repository);
printPaginationWarning(
response.pagination,
"Use --severities or --statuses to filter findings.",
);
if (total > items.length) {
printPaginationWarning(
{ cursor: "more", limit: items.length },
"Use --limit <n> (max 1000) to fetch more, or --severities, --statuses to filter.",
);
}
} catch (err) {
handleError(err);
}
Expand Down
120 changes: 117 additions & 3 deletions src/commands/issues.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -470,9 +470,9 @@ describe("issues command", () => {
"test-repo",
]);

expect(console.log).toHaveBeenCalledWith(
expect.stringContaining('"Potential SQL injection vulnerability"'),
);
const jsonOutput = (console.log as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(jsonOutput).toContain('"Potential SQL injection vulnerability"');
expect(jsonOutput).toContain('"sql-injection"');
});

it("should output JSON for overview when --overview --output json is specified", async () => {
Expand All @@ -498,6 +498,120 @@ describe("issues command", () => {
);
});

it("should pass a custom limit <= 100 directly to the API", async () => {
vi.mocked(AnalysisService.searchRepositoryIssues).mockResolvedValue({
data: [],
} as any);

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

expect(AnalysisService.searchRepositoryIssues).toHaveBeenCalledWith(
"gh",
"test-org",
"test-repo",
undefined,
50,
{},
);
});

it("should paginate when limit > 100", async () => {
const page1Issues = Array.from({ length: 100 }, (_, i) => ({
issueId: `issue-${i}`,
resultDataId: i,
filePath: `file-${i}.ts`,
fileId: i,
patternInfo: { id: "p1", category: "Style", severityLevel: "Warning", level: "Warning" },
toolInfo: { uuid: "t1", name: "Tool" },
lineNumber: 1,
message: `Issue ${i}`,
language: "TypeScript",
lineText: "x",
falsePositiveThreshold: 0.5,
}));
const page2Issues = Array.from({ length: 50 }, (_, i) => ({
issueId: `issue-${100 + i}`,
resultDataId: 100 + i,
filePath: `file-${100 + i}.ts`,
fileId: 100 + i,
patternInfo: { id: "p1", category: "Style", severityLevel: "Warning", level: "Warning" },
toolInfo: { uuid: "t1", name: "Tool" },
lineNumber: 1,
message: `Issue ${100 + i}`,
language: "TypeScript",
lineText: "x",
falsePositiveThreshold: 0.5,
}));

vi.mocked(AnalysisService.searchRepositoryIssues)
.mockResolvedValueOnce({
data: page1Issues,
pagination: { cursor: "cursor-2", limit: 100, total: 250 },
} as any)
.mockResolvedValueOnce({
data: page2Issues,
pagination: { cursor: undefined, limit: 100, total: 250 },
} as any);

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

expect(AnalysisService.searchRepositoryIssues).toHaveBeenCalledTimes(2);
// First call: no cursor
expect(AnalysisService.searchRepositoryIssues).toHaveBeenNthCalledWith(
1, "gh", "test-org", "test-repo", undefined, 100, {},
);
// Second call: with cursor from first response
expect(AnalysisService.searchRepositoryIssues).toHaveBeenNthCalledWith(
2, "gh", "test-org", "test-repo", "cursor-2", 100, {},
);

const output = getAllOutput();
expect(output).toContain("Issues — Found 250 issues");
});

it("should cap limit at 1000", async () => {
vi.mocked(AnalysisService.searchRepositoryIssues).mockResolvedValue({
data: [],
} as any);

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

// Should use pageSize 100 (min of 1000, 100)
expect(AnalysisService.searchRepositoryIssues).toHaveBeenCalledWith(
"gh", "test-org", "test-repo", undefined, 100, {},
);
});

it("should fail when CODACY_API_TOKEN is not set", async () => {
delete process.env.CODACY_API_TOKEN;

Expand Down
Loading
Loading