diff --git a/package-lock.json b/package-lock.json index 19cf6a4..e5d4a0a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,15 @@ { "name": "@codacy/codacy-cloud-cli", - "version": "1.0.0", + "version": "1.0.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@codacy/codacy-cloud-cli", - "version": "1.0.0", + "version": "1.0.4", "license": "ISC", "dependencies": { + "@codacy/tooling": "0.1.0", "ansis": "4.0.0", "cli-table3": "^0.6.3", "commander": "14.0.0", @@ -82,6 +83,15 @@ "node": ">=16" } }, + "node_modules/@codacy/tooling": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@codacy/tooling/-/tooling-0.1.0.tgz", + "integrity": "sha512-Q6Dx1AR39tDT6Oc2PmZ9b0RfzbqmYB4/+TMbT1JaNB7ZTAUhjaLSVD/JtwkNHDoWPKaKdqfaMbBmjXoram3FJg==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@colors/colors": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", diff --git a/package.json b/package.json index 8d7cb4c..8f143ca 100644 --- a/package.json +++ b/package.json @@ -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": { @@ -40,9 +40,10 @@ "author": "Codacy (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", diff --git a/src/commands/findings.test.ts b/src/commands/findings.test.ts index d27afb9..e72b567 100644 --- a/src/commands/findings.test.ts +++ b/src/commands/findings.test.ts @@ -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; diff --git a/src/commands/findings.ts b/src/commands/findings.ts index ff3f676..46a7371 100644 --- a/src/commands/findings.ts +++ b/src/commands/findings.ts @@ -176,6 +176,7 @@ export function registerFindingsCommand(program: Command) { "-T, --scan-types ", "comma-separated scan types (case-insensitive): SAST, Secrets, SCA, CICD, IaC, DAST, PenTesting, License, CSPM", ) + .option("-n, --limit ", "maximum number of findings to return (default: 100, max: 1000)", "100") .option("-d, --dast-targets ", "comma-separated DAST target URLs") .addHelpText( "after", @@ -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 ( @@ -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({ @@ -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 (max 1000) to fetch more, or --severities, --statuses to filter.", + ); + } } catch (err) { handleError(err); } diff --git a/src/commands/issues.test.ts b/src/commands/issues.test.ts index fd504a4..e1e1868 100644 --- a/src/commands/issues.test.ts +++ b/src/commands/issues.test.ts @@ -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).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 () => { @@ -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; diff --git a/src/commands/issues.ts b/src/commands/issues.ts index a0be09a..c56c220 100644 --- a/src/commands/issues.ts +++ b/src/commands/issues.ts @@ -180,6 +180,7 @@ export function registerIssuesCommand(program: Command) { .option("-l, --languages ", "comma-separated list of language names") .option("-t, --tags ", "comma-separated list of tag names") .option("-a, --authors ", "comma-separated list of author emails") + .option("-n, --limit ", "maximum number of issues to return (default: 100, max: 1000)", "100") .option("-O, --overview", "show issue count totals instead of the issues list") .addHelpText( "after", @@ -188,6 +189,7 @@ Examples: $ codacy issues gh my-org my-repo $ codacy issues gh my-org my-repo --branch main --severities Critical,Medium $ codacy issues gh my-org my-repo --categories Security --overview + $ codacy issues gh my-org my-repo --limit 500 $ codacy issues gh my-org my-repo --output json`, ) .action(async function ( @@ -218,6 +220,8 @@ Examples: const author = parseCommaList(opts.authors); if (author) body.authorEmails = author; + const limit = Math.min(Math.max(parseInt(opts.limit, 10) || 100, 1), 1000); + const spinner = ora( isOverview ? "Fetching issues overview..." : "Fetching issues...", ).start(); @@ -254,21 +258,33 @@ Examples: authors: counts.authors, }); } else { - const issuesResponse = await AnalysisService.searchRepositoryIssues( - provider, - organization, - repository, - undefined, - 100, - body, - ); - spinner.stop(); + const pageSize = Math.min(limit, 100); + let issues: CommitIssue[] = []; + let cursor: string | undefined; + let total: number | undefined; + + do { + const issuesResponse = await AnalysisService.searchRepositoryIssues( + provider, + organization, + repository, + cursor, + pageSize, + body, + ); + issues = issues.concat(issuesResponse.data); + total ??= issuesResponse.pagination?.total; + cursor = issuesResponse.pagination?.cursor; + } while (cursor && issues.length < limit); - const issues = issuesResponse.data; - const total = issuesResponse.pagination?.total ?? issues.length; + // Trim to exact limit + if (issues.length > limit) issues = issues.slice(0, limit); + total ??= issues.length; + spinner.stop(); if (format === "json") { printJson({ issues: issues.map((issue: any) => pickDeep(issue, [ + "patternInfo.id", "patternInfo.severityLevel", "patternInfo.category", "patternInfo.subCategory", @@ -285,10 +301,12 @@ Examples: } printIssuesList(issues, total); - printPaginationWarning( - issuesResponse.pagination, - "Use --severities, --categories, or --languages to filter issues.", - ); + if (total > issues.length) { + printPaginationWarning( + { cursor: "more", limit: issues.length }, + "Use --limit (max 1000) to fetch more, or --severities, --categories, --languages to filter.", + ); + } } } catch (err) { handleError(err); diff --git a/src/commands/repository.test.ts b/src/commands/repository.test.ts index 0af6497..d193bd2 100644 --- a/src/commands/repository.test.ts +++ b/src/commands/repository.test.ts @@ -3,9 +3,11 @@ import { Command } from "commander"; import { registerRepositoryCommand } from "./repository"; import { AnalysisService } from "../api/client/services/AnalysisService"; import { RepositoryService } from "../api/client/services/RepositoryService"; +import { CodingStandardsService } from "../api/client/services/CodingStandardsService"; vi.mock("../api/client/services/AnalysisService"); vi.mock("../api/client/services/RepositoryService"); +vi.mock("../api/client/services/CodingStandardsService"); vi.mock("../utils/credentials", () => ({ loadCredentials: vi.fn(() => null) })); vi.spyOn(console, "log").mockImplementation(() => {}); @@ -661,6 +663,84 @@ describe("repository command", () => { expect(allOutput).toContain("head123"); }); + // ─── Standards display ────────────────────────────────────────────── + + it("should display coding standard IDs alongside names", async () => { + const dataWithMultipleStandards = { + ...mockRepoData, + repository: { + ...mockRepoData.repository, + standards: [ + { id: 100, name: "Security" }, + { id: 200, name: "OWASP10" }, + ], + }, + }; + + vi.mocked(AnalysisService.getRepositoryWithAnalysis).mockResolvedValue({ + data: dataWithMultipleStandards as any, + }); + vi.mocked(AnalysisService.listRepositoryPullRequests).mockResolvedValue({ + data: [] as any, + }); + vi.mocked(AnalysisService.issuesOverview).mockResolvedValue({ + data: { counts: { categories: [], levels: [], languages: [], tags: [], patterns: [], authors: [] } }, + }); + + const program = createProgram(); + await program.parseAsync([ + "node", "test", "repository", "gh", "test-org", "test-repo", + ]); + + const allOutput = (console.log as ReturnType).mock.calls + .map((c) => c[0]) + .join("\n"); + expect(allOutput).toContain("Security (#100)"); + expect(allOutput).toContain("OWASP10 (#200)"); + }); + + // ─── Link / Unlink standard ──────────────────────────────────────── + + it("should link a coding standard with --link-standard", async () => { + vi.mocked(CodingStandardsService.applyCodingStandardToRepositories).mockResolvedValue({} as any); + + const program = createProgram(); + await program.parseAsync([ + "node", "test", "repository", "gh", "test-org", "test-repo", "--link-standard", "12345", + ]); + + expect(CodingStandardsService.applyCodingStandardToRepositories).toHaveBeenCalledWith( + "gh", "test-org", 12345, { link: ["test-repo"], unlink: [] }, + ); + + const allOutput = (console.log as ReturnType).mock.calls + .map((c) => c[0]) + .join("\n"); + expect(allOutput).toContain("#12345"); + expect(allOutput).toContain("linked"); + expect(allOutput).toContain("test-repo"); + }); + + it("should unlink a coding standard with --unlink-standard", async () => { + vi.mocked(CodingStandardsService.applyCodingStandardToRepositories).mockResolvedValue({} as any); + + const program = createProgram(); + await program.parseAsync([ + "node", "test", "repository", "gh", "test-org", "test-repo", "--unlink-standard", "67890", + ]); + + expect(CodingStandardsService.applyCodingStandardToRepositories).toHaveBeenCalledWith( + "gh", "test-org", 67890, { link: [], unlink: ["test-repo"] }, + ); + + const allOutput = (console.log as ReturnType).mock.calls + .map((c) => c[0]) + .join("\n"); + expect(allOutput).toContain("#67890"); + expect(allOutput).toContain("unlinked"); + expect(allOutput).toContain("test-repo"); + }); + it("should filter JSON output with pickDeep", async () => { vi.mocked(AnalysisService.getRepositoryWithAnalysis).mockResolvedValue({ data: mockRepoData as any, diff --git a/src/commands/repository.ts b/src/commands/repository.ts index b40e56a..c087252 100644 --- a/src/commands/repository.ts +++ b/src/commands/repository.ts @@ -26,6 +26,7 @@ import { } from "../utils/formatting"; import { AnalysisService } from "../api/client/services/AnalysisService"; import { RepositoryService } from "../api/client/services/RepositoryService"; +import { CodingStandardsService } from "../api/client/services/CodingStandardsService"; import { RepositoryWithAnalysis } from "../api/client/models/RepositoryWithAnalysis"; import { PullRequestWithAnalysis } from "../api/client/models/PullRequestWithAnalysis"; import { Commit } from "../api/client/models/Commit"; @@ -82,7 +83,7 @@ function printSetup(data: RepositoryWithAnalysis): void { table.push({ "Coding Standards": repo.standards.length > 0 - ? repo.standards.map((s) => s.name).join(", ") + ? repo.standards.map((s) => `${s.name} (#${s.id})`).join(", ") : ansis.dim("None"), }); table.push({ @@ -220,6 +221,8 @@ export function registerRepositoryCommand(program: Command) { .option("-f, --follow", "follow this repository on Codacy") .option("-u, --unfollow", "unfollow this repository on Codacy") .option("-R, --reanalyze", "request reanalysis of the HEAD commit") + .option("-L, --link-standard ", "link a coding standard to this repository (by standard ID)") + .option("-K, --unlink-standard ", "unlink a coding standard from this repository (by standard ID)") .addHelpText( "after", ` @@ -230,7 +233,9 @@ Examples: $ codacy-cloud-cli repository gh my-org my-repo --remove $ codacy-cloud-cli repository gh my-org my-repo --follow $ codacy-cloud-cli repository gh my-org my-repo --unfollow - $ codacy-cloud-cli repository gh my-org my-repo --reanalyze`, + $ codacy-cloud-cli repository gh my-org my-repo --reanalyze + $ codacy-cloud-cli repository gh my-org my-repo --link-standard 12345 + $ codacy-cloud-cli repository gh my-org my-repo --unlink-standard 12345`, ) .action(async function ( this: Command, @@ -338,6 +343,38 @@ Examples: return; } + // ── Action: link-standard ───────────────────────────────────────── + if (opts.linkStandard) { + const spinner = ora(`Linking coding standard #${opts.linkStandard} to ${repository}...`).start(); + await CodingStandardsService.applyCodingStandardToRepositories( + provider, + organization, + Number(opts.linkStandard), + { link: [repository], unlink: [] }, + ); + spinner.stop(); + console.log( + `${ansis.green("✓")} Coding standard #${opts.linkStandard} linked to ${ansis.bold(repository)}.`, + ); + return; + } + + // ── Action: unlink-standard ─────────────────────────────────────── + if (opts.unlinkStandard) { + const spinner = ora(`Unlinking coding standard #${opts.unlinkStandard} from ${repository}...`).start(); + await CodingStandardsService.applyCodingStandardToRepositories( + provider, + organization, + Number(opts.unlinkStandard), + { link: [], unlink: [repository] }, + ); + spinner.stop(); + console.log( + `${ansis.green("✓")} Coding standard #${opts.unlinkStandard} unlinked from ${ansis.bold(repository)}.`, + ); + return; + } + // ── Default: dashboard view ────────────────────────────────────── const format = getOutputFormat(this); const spinner = ora("Fetching repository details...").start(); diff --git a/src/commands/tools.test.ts b/src/commands/tools.test.ts index 00e744d..5a779a7 100644 --- a/src/commands/tools.test.ts +++ b/src/commands/tools.test.ts @@ -1,9 +1,16 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { Command } from "commander"; import { registerToolsCommand } from "./tools"; +import * as fs from "fs"; import { AnalysisService } from "../api/client/services/AnalysisService"; +import { ToolsService } from "../api/client/services/ToolsService"; +import { CodingStandardsService } from "../api/client/services/CodingStandardsService"; +import * as importConfig from "../utils/import-config"; +import * as prompt from "../utils/prompt"; vi.mock("../api/client/services/AnalysisService"); +vi.mock("../api/client/services/CodingStandardsService"); +vi.mock("../api/client/services/ToolsService"); vi.mock("../utils/credentials", () => ({ loadCredentials: vi.fn(() => null) })); vi.spyOn(console, "log").mockImplementation(() => {}); vi.spyOn(console, "error").mockImplementation(() => {}); @@ -235,4 +242,172 @@ describe("tools command", () => { mockExit.mockRestore(); }); + + // ─── Import mode ────────────────────────────────────────────────────── + + describe("--import", () => { + const configContent = JSON.stringify({ + version: 1, + metadata: { + repositoryId: null, + repositoryName: null, + createdAt: "2025-01-01", + updatedAt: "2025-01-01", + languages: ["TypeScript"], + }, + tools: [ + { + toolId: "ESLint", + patterns: [{ patternId: "no-unused-vars" }], + }, + ], + }); + + const tmpConfigPath = "/tmp/test-import-config.json"; + + beforeEach(() => { + fs.writeFileSync(tmpConfigPath, configContent); + vi.mocked(AnalysisService.updateRepositoryToolPatterns).mockResolvedValue(undefined as any); + vi.mocked(AnalysisService.configureTool).mockResolvedValue(undefined as any); + vi.spyOn(importConfig, "fetchAllTools").mockResolvedValue([ + { + uuid: "uuid-eslint", + name: "ESLint", + shortName: "eslint", + prefix: "ESLint_", + version: "1.0", + needsCompilation: false, + configurationFilenames: [], + dockerImage: "docker/eslint", + languages: ["TypeScript"], + clientSide: false, + standalone: false, + enabledByDefault: false, + configurable: true, + }, + ] as any); + vi.mocked(AnalysisService.getRepositoryWithAnalysis).mockResolvedValue({ + data: { + repository: { + provider: "gh", + owner: "test-org", + name: "test-repo", + standards: [], + languages: [], + problems: [], + }, + }, + } as any); + }); + + afterEach(() => { + if (fs.existsSync(tmpConfigPath)) fs.unlinkSync(tmpConfigPath); + }); + + it("should import config with --skip-approval", async () => { + const program = createProgram(); + await program.parseAsync([ + "node", "test", "tools", "gh", "test-org", "test-repo", + "--import", tmpConfigPath, "-y", + ]); + + const output = getAllOutput(); + expect(output).toContain("imported successfully"); + }); + + it("should cancel import when user declines confirmation", async () => { + vi.spyOn(prompt, "confirmAction").mockResolvedValue(false); + + const program = createProgram(); + await program.parseAsync([ + "node", "test", "tools", "gh", "test-org", "test-repo", + "--import", tmpConfigPath, + ]); + + const output = getAllOutput(); + expect(output).toContain("cancelled"); + expect(AnalysisService.configureTool).not.toHaveBeenCalled(); + }); + + it("should warn about coding standards", async () => { + vi.mocked(AnalysisService.getRepositoryWithAnalysis).mockResolvedValue({ + data: { + repository: { + provider: "gh", + owner: "test-org", + name: "test-repo", + standards: [{ id: 1, name: "Security" }], + languages: [], + problems: [], + }, + }, + } as any); + + vi.spyOn(prompt, "confirmAction").mockResolvedValue(false); + + const program = createProgram(); + await program.parseAsync([ + "node", "test", "tools", "gh", "test-org", "test-repo", + "--import", tmpConfigPath, + ]); + + const output = getAllOutput(); + expect(output).toContain("Security"); + expect(output).toContain("coding standard"); + }); + + it("should unlink coding standards with --force", async () => { + vi.mocked(CodingStandardsService.applyCodingStandardToRepositories).mockResolvedValue({} as any); + vi.mocked(AnalysisService.getRepositoryWithAnalysis).mockResolvedValue({ + data: { + repository: { + provider: "gh", + owner: "test-org", + name: "test-repo", + standards: [ + { id: 100, name: "Security" }, + { id: 200, name: "OWASP10" }, + ], + languages: [], + problems: [], + }, + }, + } as any); + + const program = createProgram(); + await program.parseAsync([ + "node", "test", "tools", "gh", "test-org", "test-repo", + "--import", tmpConfigPath, "--force", "-y", + ]); + + // Should unlink both standards + expect(CodingStandardsService.applyCodingStandardToRepositories).toHaveBeenCalledWith( + "gh", "test-org", 100, { link: [], unlink: ["test-repo"] }, + ); + expect(CodingStandardsService.applyCodingStandardToRepositories).toHaveBeenCalledWith( + "gh", "test-org", 200, { link: [], unlink: ["test-repo"] }, + ); + + const output = getAllOutput(); + expect(output).toContain("will stop following"); + expect(output).toContain("Security"); + expect(output).toContain("OWASP10"); + expect(output).toContain("imported successfully"); + }); + + it("should report errors for failing tools", async () => { + vi.mocked(AnalysisService.configureTool).mockRejectedValue( + new Error("Conflict"), + ); + + const program = createProgram(); + await program.parseAsync([ + "node", "test", "tools", "gh", "test-org", "test-repo", + "--import", tmpConfigPath, "-y", + ]); + + const output = getAllOutput(); + expect(output).toContain("error"); + }); + }); }); diff --git a/src/commands/tools.ts b/src/commands/tools.ts index 92353bd..55272b3 100644 --- a/src/commands/tools.ts +++ b/src/commands/tools.ts @@ -1,3 +1,4 @@ +import * as path from "path"; import { Command } from "commander"; import ora from "ora"; import ansis from "ansis"; @@ -6,6 +7,14 @@ import { handleError } from "../utils/error"; import { createTable, getOutputFormat, pickDeep, printJson } from "../utils/output"; import { AnalysisService } from "../api/client/services/AnalysisService"; import { AnalysisTool } from "../api/client/models/AnalysisTool"; +import { + readConfigFile, + fetchAllTools, + buildImportPreview, + printImportPreview, + executeImport, +} from "../utils/import-config"; +import { confirmAction } from "../utils/prompt"; function configFileStatus(tool: AnalysisTool): string { if (tool.settings.usesConfigurationFile) return "Applied"; @@ -48,12 +57,19 @@ export function registerToolsCommand(program: Command) { .argument("", "git provider (gh, gl, or bb)") .argument("", "organization name") .argument("", "repository name") + .option("--import [path]", "import tool configuration from a file (default: .codacy/codacy.config.json)") + .option("-y, --skip-approval", "skip confirmation prompt during import") + .option("--force", "unlink all coding standards before importing") .addHelpText( "after", ` Examples: $ codacy-cloud-cli tools gh my-org my-repo - $ codacy-cloud-cli tools gh my-org my-repo --output json`, + $ codacy-cloud-cli tools gh my-org my-repo --output json + $ codacy-cloud-cli tools gh my-org my-repo --import + $ codacy-cloud-cli tools gh my-org my-repo --import ./custom-config.json + $ codacy-cloud-cli tools gh my-org my-repo --import -y + $ codacy-cloud-cli tools gh my-org my-repo --import --force -y`, ) .action(async function ( this: Command, @@ -63,6 +79,101 @@ Examples: ) { try { checkApiToken(); + const opts = this.opts(); + + // ── Mode: import ──────────────────────────────────────────────── + if (opts.import !== undefined) { + const configPath = + typeof opts.import === "string" + ? opts.import + : ".codacy/codacy.config.json"; + const resolvedPath = path.resolve(configPath); + + const spinner = ora("Reading configuration...").start(); + + // Read config file + const config = readConfigFile(resolvedPath); + + // Fetch current state in parallel + const [repoToolsResponse, allTools, repoResponse] = + await Promise.all([ + AnalysisService.listRepositoryTools( + provider, + organization, + repository, + ), + fetchAllTools(), + AnalysisService.getRepositoryWithAnalysis( + provider, + organization, + repository, + ), + ]); + + spinner.stop(); + + // Build and display preview + const preview = buildImportPreview( + config, + repoToolsResponse.data, + allTools, + repoResponse.data.repository.standards, + resolvedPath, + ); + + printImportPreview(preview, repository, Boolean(opts.force)); + + // Confirm + if (!opts.skipApproval) { + const confirmed = await confirmAction( + "\nDo you wish to proceed?", + ); + if (!confirmed) { + console.log("Import cancelled."); + return; + } + } + + console.log(); + const execSpinner = ora("Applying configuration...").start(); + const result = await executeImport( + provider, + organization, + repository, + preview, + config, + allTools, + execSpinner, + Boolean(opts.force), + ); + + execSpinner.stop(); + + if (result.failed.length === 0) { + console.log( + `${ansis.green("✓")} Configuration imported successfully.`, + ); + } else { + console.log( + ansis.yellow( + `Import completed with ${result.failed.length} error(s):`, + ), + ); + for (const f of result.failed) { + console.log(ansis.red(` ✗ ${f.tool}: ${f.error}`)); + } + if (result.succeeded.length > 0) { + console.log( + ansis.green( + ` ✓ ${result.succeeded.length} tool(s) configured successfully.`, + ), + ); + } + } + return; + } + + // ── Default: list tools ───────────────────────────────────────── const format = getOutputFormat(this); const spinner = ora("Fetching tools...").start(); diff --git a/src/types/codacy-config.ts b/src/types/codacy-config.ts new file mode 100644 index 0000000..857ffcf --- /dev/null +++ b/src/types/codacy-config.ts @@ -0,0 +1,5 @@ +export type { + CodacyConfig, + CodacyToolConfig, + CodacyPatternConfig, +} from "@codacy/tooling"; diff --git a/src/utils/import-config.test.ts b/src/utils/import-config.test.ts new file mode 100644 index 0000000..e3344ba --- /dev/null +++ b/src/utils/import-config.test.ts @@ -0,0 +1,469 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import * as fs from "fs"; +import { + readConfigFile, + resolveToolId, + buildImportPreview, + executeImport, +} from "./import-config"; +import { AnalysisService } from "../api/client/services/AnalysisService"; +import { CodingStandardsService } from "../api/client/services/CodingStandardsService"; +import { Tool } from "../api/client/models/Tool"; +import { AnalysisTool } from "../api/client/models/AnalysisTool"; +import { CodacyConfig } from "../types/codacy-config"; + +vi.mock("../api/client/services/AnalysisService"); +vi.mock("../api/client/services/ToolsService"); +vi.mock("../api/client/services/CodingStandardsService"); + +// ─── Test fixtures ──────────────────────────────────────────────────── + +function makeTool(overrides: Partial & { uuid: string; name: string; shortName: string }): Tool { + return { + version: "1.0", + documentationUrl: "", + sourceCodeUrl: "", + needsCompilation: false, + configurationFilenames: [], + dockerImage: "docker/image", + languages: ["TypeScript"], + clientSide: false, + standalone: false, + enabledByDefault: false, + configurable: true, + ...overrides, + } as Tool; +} + +function makeRepoTool(uuid: string, name: string, isEnabled: boolean): AnalysisTool { + return { + uuid, + name, + isClientSide: false, + settings: { + isEnabled, + enabledBy: [], + hasConfigurationFile: false, + usesConfigurationFile: false, + followsStandard: false, + isCustom: false, + }, + } as AnalysisTool; +} + +const eslintTool = makeTool({ uuid: "uuid-eslint", name: "ESLint", shortName: "eslint", prefix: "ESLint_" }); +const pylintTool = makeTool({ uuid: "uuid-pylint", name: "Pylint", shortName: "pylint", prefix: "Pylint_" }); +const checkovTool = makeTool({ uuid: "uuid-checkov", name: "Checkov", shortName: "checkov" }); +const remarklintTool = makeTool({ uuid: "uuid-remarklint", name: "Remarklint", shortName: "remarklint", prefix: "remarklint_" }); + +const allTools: Tool[] = [eslintTool, pylintTool, checkovTool, remarklintTool]; + +// ─── readConfigFile ─────────────────────────────────────────────────── + +describe("readConfigFile", () => { + it("should parse a valid config file", () => { + const config: CodacyConfig = { + version: 1, + metadata: { + repositoryId: null, + repositoryName: null, + createdAt: "2025-01-01", + updatedAt: "2025-01-01", + languages: ["TypeScript"], + }, + tools: [ + { + toolId: "ESLint", + patterns: [{ patternId: "no-unused-vars" }], + }, + ], + }; + const tmpPath = "/tmp/test-codacy-config.json"; + fs.writeFileSync(tmpPath, JSON.stringify(config)); + const result = readConfigFile(tmpPath); + expect(result.version).toBe(1); + expect(result.tools).toHaveLength(1); + fs.unlinkSync(tmpPath); + }); + + it("should throw for missing file", () => { + expect(() => readConfigFile("/tmp/nonexistent.json")).toThrow("not found"); + }); + + it("should throw for invalid JSON", () => { + const tmpPath = "/tmp/test-invalid.json"; + fs.writeFileSync(tmpPath, "not json {{{"); + expect(() => readConfigFile(tmpPath)).toThrow("Invalid JSON"); + fs.unlinkSync(tmpPath); + }); + + it("should throw for missing required fields", () => { + const tmpPath = "/tmp/test-missing-fields.json"; + fs.writeFileSync(tmpPath, JSON.stringify({ foo: "bar" })); + expect(() => readConfigFile(tmpPath)).toThrow("missing"); + fs.unlinkSync(tmpPath); + }); + + it("should throw when a tool entry is missing toolId", () => { + const tmpPath = "/tmp/test-no-toolid.json"; + fs.writeFileSync(tmpPath, JSON.stringify({ + version: 1, + tools: [{ patterns: [] }], + })); + expect(() => readConfigFile(tmpPath)).toThrow("tools[0] is missing a valid 'toolId'"); + fs.unlinkSync(tmpPath); + }); + + it("should default patterns to empty array when missing", () => { + const tmpPath = "/tmp/test-no-patterns.json"; + fs.writeFileSync(tmpPath, JSON.stringify({ + version: 1, + tools: [{ toolId: "eslint" }], + })); + const result = readConfigFile(tmpPath); + expect(result.tools[0].patterns).toEqual([]); + fs.unlinkSync(tmpPath); + }); +}); + +// ─── resolveToolId ──────────────────────────────────────────────────── + +describe("resolveToolId", () => { + it("should match by prefix without trailing underscore (case-insensitive)", () => { + const result = resolveToolId("ESLint", allTools); + expect(result?.uuid).toBe("uuid-eslint"); + }); + + it("should match by prefix case-insensitively", () => { + const result = resolveToolId("eslint", allTools); + expect(result?.uuid).toBe("uuid-eslint"); + }); + + it("should fall back to shortName when prefix doesn't match", () => { + const result = resolveToolId("checkov", allTools); + expect(result?.uuid).toBe("uuid-checkov"); + }); + + it("should prefer prefix over shortName", () => { + // remarklint has both prefix "remarklint_" and shortName "remarklint" + const result = resolveToolId("remarklint", allTools); + expect(result?.uuid).toBe("uuid-remarklint"); + }); + + it("should return undefined for unresolvable tool", () => { + const result = resolveToolId("nonexistent", allTools); + expect(result).toBeUndefined(); + }); +}); + +// ─── buildImportPreview ─────────────────────────────────────────────── + +describe("buildImportPreview", () => { + it("should categorize tools correctly", () => { + const repoTools: AnalysisTool[] = [ + makeRepoTool("uuid-eslint", "ESLint", true), + makeRepoTool("uuid-checkov", "Checkov", true), + makeRepoTool("uuid-pylint", "Pylint", false), + ]; + + const config: CodacyConfig = { + version: 1, + metadata: { + repositoryId: null, + repositoryName: null, + createdAt: "2025-01-01", + updatedAt: "2025-01-01", + languages: [], + }, + tools: [ + { toolId: "ESLint", patterns: [{ patternId: "p1" }] }, + { toolId: "Pylint", patterns: [{ patternId: "p2" }, { patternId: "p3" }] }, + ], + }; + + const preview = buildImportPreview(config, repoTools, allTools, [], "/test/path"); + + // ESLint is enabled and in config → reconfigure + expect(preview.toolsToReconfigure).toHaveLength(1); + expect(preview.toolsToReconfigure[0].tool.name).toBe("ESLint"); + + // Pylint is disabled and in config → enable + expect(preview.toolsToEnable).toHaveLength(1); + expect(preview.toolsToEnable[0].tool.name).toBe("Pylint"); + + // Checkov is enabled but NOT in config → disable + expect(preview.toolsToDisable).toHaveLength(1); + expect(preview.toolsToDisable[0].name).toBe("Checkov"); + + expect(preview.totalPatterns).toBe(3); + expect(preview.unresolvedTools).toHaveLength(0); + }); + + it("should report unresolved tools", () => { + const config: CodacyConfig = { + version: 1, + metadata: { + repositoryId: null, + repositoryName: null, + createdAt: "2025-01-01", + updatedAt: "2025-01-01", + languages: [], + }, + tools: [ + { toolId: "nonexistent_tool", patterns: [] }, + ], + }; + + const preview = buildImportPreview(config, [], allTools, [], "/test/path"); + expect(preview.unresolvedTools).toEqual(["nonexistent_tool"]); + }); + + it("should include standards in preview", () => { + const standards = [{ id: 1, name: "Security" }, { id: 2, name: "OWASP" }]; + const config: CodacyConfig = { + version: 1, + metadata: { + repositoryId: null, + repositoryName: null, + createdAt: "2025-01-01", + updatedAt: "2025-01-01", + languages: [], + }, + tools: [], + }; + + const preview = buildImportPreview(config, [], allTools, standards, "/test/path"); + expect(preview.standards).toHaveLength(2); + }); +}); + +// ─── executeImport ──────────────────────────────────────────────────── + +describe("executeImport", () => { + const mockSpinner = { + text: "", + start: vi.fn().mockReturnThis(), + stop: vi.fn(), + succeed: vi.fn(), + fail: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should configure tools from config and disable tools not in config", async () => { + vi.mocked(AnalysisService.updateRepositoryToolPatterns).mockResolvedValue(undefined as any); + vi.mocked(AnalysisService.configureTool).mockResolvedValue(undefined as any); + + const config: CodacyConfig = { + version: 1, + metadata: { + repositoryId: null, + repositoryName: null, + createdAt: "2025-01-01", + updatedAt: "2025-01-01", + languages: [], + }, + tools: [ + { + toolId: "ESLint", + patterns: [ + { patternId: "no-unused-vars", parameters: { severity: "error" } }, + { patternId: "no-console" }, + ], + }, + ], + }; + + const preview = buildImportPreview( + config, + [ + makeRepoTool("uuid-eslint", "ESLint", true), + makeRepoTool("uuid-checkov", "Checkov", true), + ], + allTools, + [], + "/test/path", + ); + + const result = await executeImport( + "gh", "test-org", "test-repo", + preview, config, allTools, + mockSpinner as any, + ); + + // Should disable all ESLint patterns first + expect(AnalysisService.updateRepositoryToolPatterns).toHaveBeenCalledWith( + "gh", "test-org", "test-repo", "uuid-eslint", { enabled: false }, + ); + + // Should configure ESLint with new patterns + expect(AnalysisService.configureTool).toHaveBeenCalledWith( + "gh", "test-org", "test-repo", "uuid-eslint", + { + enabled: true, + useConfigurationFile: false, + patterns: [ + { + id: "no-unused-vars", + enabled: true, + parameters: [{ name: "severity", value: "error" }], + }, + { + id: "no-console", + enabled: true, + parameters: undefined, + }, + ], + }, + ); + + // Should disable Checkov (not in config) + expect(AnalysisService.configureTool).toHaveBeenCalledWith( + "gh", "test-org", "test-repo", "uuid-checkov", + { enabled: false }, + ); + + expect(result.succeeded).toContain("ESLint"); + expect(result.succeeded).toContain("Checkov (disabled)"); + expect(result.failed).toHaveLength(0); + }); + + it("should pass useConfigurationFile when specified", async () => { + vi.mocked(AnalysisService.updateRepositoryToolPatterns).mockResolvedValue(undefined as any); + vi.mocked(AnalysisService.configureTool).mockResolvedValue(undefined as any); + + const config: CodacyConfig = { + version: 1, + metadata: { + repositoryId: null, + repositoryName: null, + createdAt: "2025-01-01", + updatedAt: "2025-01-01", + languages: [], + }, + tools: [ + { + toolId: "ESLint", + useLocalConfigurationFile: true, + patterns: [], + }, + ], + }; + + const preview = buildImportPreview(config, [], allTools, [], "/test/path"); + + await executeImport( + "gh", "test-org", "test-repo", + preview, config, allTools, + mockSpinner as any, + ); + + // When no patterns, should still enable with useConfigurationFile + expect(AnalysisService.configureTool).toHaveBeenCalledWith( + "gh", "test-org", "test-repo", "uuid-eslint", + { enabled: true, useConfigurationFile: true }, + ); + }); + + it("should unlink coding standards when force is true", async () => { + vi.mocked(AnalysisService.updateRepositoryToolPatterns).mockResolvedValue(undefined as any); + vi.mocked(AnalysisService.configureTool).mockResolvedValue(undefined as any); + vi.mocked(CodingStandardsService.applyCodingStandardToRepositories).mockResolvedValue({} as any); + + const config: CodacyConfig = { + version: 1, + metadata: { + repositoryId: null, + repositoryName: null, + createdAt: "2025-01-01", + updatedAt: "2025-01-01", + languages: [], + }, + tools: [], + }; + + const standards = [{ id: 100, name: "Security" }, { id: 200, name: "OWASP" }]; + const preview = buildImportPreview(config, [], allTools, standards, "/test/path"); + + const result = await executeImport( + "gh", "test-org", "test-repo", + preview, config, allTools, + mockSpinner as any, + true, + ); + + expect(CodingStandardsService.applyCodingStandardToRepositories).toHaveBeenCalledWith( + "gh", "test-org", 100, { link: [], unlink: ["test-repo"] }, + ); + expect(CodingStandardsService.applyCodingStandardToRepositories).toHaveBeenCalledWith( + "gh", "test-org", 200, { link: [], unlink: ["test-repo"] }, + ); + expect(result.failed).toHaveLength(0); + }); + + it("should not unlink coding standards when force is false", async () => { + vi.mocked(AnalysisService.updateRepositoryToolPatterns).mockResolvedValue(undefined as any); + vi.mocked(AnalysisService.configureTool).mockResolvedValue(undefined as any); + + const config: CodacyConfig = { + version: 1, + metadata: { + repositoryId: null, + repositoryName: null, + createdAt: "2025-01-01", + updatedAt: "2025-01-01", + languages: [], + }, + tools: [], + }; + + const standards = [{ id: 100, name: "Security" }]; + const preview = buildImportPreview(config, [], allTools, standards, "/test/path"); + + await executeImport( + "gh", "test-org", "test-repo", + preview, config, allTools, + mockSpinner as any, + false, + ); + + expect(CodingStandardsService.applyCodingStandardToRepositories).not.toHaveBeenCalled(); + }); + + it("should continue on error and report failures", async () => { + vi.mocked(AnalysisService.updateRepositoryToolPatterns).mockResolvedValue(undefined as any); + vi.mocked(AnalysisService.configureTool) + .mockRejectedValueOnce(new Error("Conflict: managed by standard")) + .mockResolvedValue(undefined as any); + + const config: CodacyConfig = { + version: 1, + metadata: { + repositoryId: null, + repositoryName: null, + createdAt: "2025-01-01", + updatedAt: "2025-01-01", + languages: [], + }, + tools: [ + { toolId: "ESLint", patterns: [{ patternId: "p1" }] }, + { toolId: "Pylint", patterns: [{ patternId: "p2" }] }, + ], + }; + + const preview = buildImportPreview(config, [], allTools, [], "/test/path"); + + const result = await executeImport( + "gh", "test-org", "test-repo", + preview, config, allTools, + mockSpinner as any, + ); + + expect(result.failed).toHaveLength(1); + expect(result.failed[0].tool).toBe("ESLint"); + expect(result.failed[0].error).toContain("Conflict"); + expect(result.succeeded).toContain("Pylint"); + }); +}); diff --git a/src/utils/import-config.ts b/src/utils/import-config.ts new file mode 100644 index 0000000..cc47f6b --- /dev/null +++ b/src/utils/import-config.ts @@ -0,0 +1,347 @@ +import * as fs from "fs"; +import ansis from "ansis"; +import pluralize from "pluralize"; +import { CodacyConfig, CodacyToolConfig } from "../types/codacy-config"; +import { Tool } from "../api/client/models/Tool"; +import { AnalysisTool } from "../api/client/models/AnalysisTool"; +import { CodingStandardInfo } from "../api/client/models/CodingStandardInfo"; +import { ConfigurePattern } from "../api/client/models/ConfigurePattern"; +import { AnalysisService } from "../api/client/services/AnalysisService"; +import { ToolsService } from "../api/client/services/ToolsService"; +import { CodingStandardsService } from "../api/client/services/CodingStandardsService"; +import type ora from "ora"; + +export interface ResolvedTool { + configTool: CodacyToolConfig; + tool: Tool; + repoTool?: AnalysisTool; +} + +export interface ImportPreview { + toolsToDisable: AnalysisTool[]; + toolsToEnable: ResolvedTool[]; + toolsToReconfigure: ResolvedTool[]; + unresolvedTools: string[]; + totalPatterns: number; + standards: CodingStandardInfo[]; + configPath: string; +} + +export function readConfigFile(filePath: string): CodacyConfig { + if (!fs.existsSync(filePath)) { + throw new Error(`Configuration file not found: ${filePath}`); + } + const raw = fs.readFileSync(filePath, "utf-8"); + try { + const config = JSON.parse(raw) as CodacyConfig; + if (!config.version || !Array.isArray(config.tools)) { + throw new Error("Invalid configuration file: missing 'version' or 'tools' fields."); + } + for (let i = 0; i < config.tools.length; i++) { + const tool = config.tools[i]; + if (!tool || typeof tool !== "object") { + throw new Error(`Invalid configuration file: tools[${i}] must be an object.`); + } + if (typeof tool.toolId !== "string" || tool.toolId.trim() === "") { + throw new Error(`Invalid configuration file: tools[${i}] is missing a valid 'toolId'.`); + } + if (!Array.isArray(tool.patterns)) { + tool.patterns = []; + } + } + return config; + } catch (err) { + if (err instanceof SyntaxError) { + throw new Error(`Invalid JSON in configuration file: ${filePath}`); + } + throw err; + } +} + +export function resolveToolId( + toolId: string, + allTools: Tool[], +): Tool | undefined { + const id = toolId.toLowerCase(); + + // Match by prefix (strip trailing _ before comparing) + const byPrefix = allTools.find( + (t) => t.prefix && t.prefix.replace(/_$/, "").toLowerCase() === id, + ); + if (byPrefix) return byPrefix; + + // Fall back to shortName + return allTools.find((t) => t.shortName.toLowerCase() === id); +} + +export async function fetchAllTools(): Promise { + const all: Tool[] = []; + let cursor: string | undefined; + do { + const response = await ToolsService.listTools(cursor, 100); + all.push(...response.data); + cursor = response.pagination?.cursor; + } while (cursor); + return all; +} + +export function buildImportPreview( + config: CodacyConfig, + repoTools: AnalysisTool[], + allTools: Tool[], + standards: CodingStandardInfo[], + configPath: string, +): ImportPreview { + const resolved: ResolvedTool[] = []; + const unresolvedTools: string[] = []; + + for (const configTool of config.tools) { + const tool = resolveToolId(configTool.toolId, allTools); + if (!tool) { + unresolvedTools.push(configTool.toolId); + continue; + } + const repoTool = repoTools.find((rt) => rt.uuid === tool.uuid); + resolved.push({ configTool, tool, repoTool }); + } + + // Tools in the config that need enabling (currently disabled or not present) + const toolsToEnable = resolved.filter( + (r) => !r.repoTool || !r.repoTool.settings.isEnabled, + ); + + // Tools in the config that are already enabled (need reconfiguration) + const toolsToReconfigure = resolved.filter( + (r) => r.repoTool && r.repoTool.settings.isEnabled, + ); + + // Repo tools that are currently enabled but NOT in the config → disable + const resolvedUuids = new Set(resolved.map((r) => r.tool.uuid)); + const toolsToDisable = repoTools.filter( + (rt) => rt.settings.isEnabled && !resolvedUuids.has(rt.uuid), + ); + + const totalPatterns = config.tools.reduce( + (sum, t) => sum + (Array.isArray(t.patterns) ? t.patterns.length : 0), + 0, + ); + + return { + toolsToDisable, + toolsToEnable, + toolsToReconfigure, + unresolvedTools, + totalPatterns, + standards, + configPath, + }; +} + +export function printImportPreview( + preview: ImportPreview, + repoName: string, + force: boolean, +): void { + console.log(); + + // Standards + if (preview.standards.length > 0) { + const names = preview.standards.map((s) => s.name).join(", "); + if (force) { + console.log( + `${repoName} will stop following ${preview.standards.length} ${pluralize("coding standard", preview.standards.length)}: ${names}`, + ); + } else { + console.log( + ansis.yellow( + `⚠ ${repoName} follows ${preview.standards.length} ${pluralize("coding standard", preview.standards.length)}: ${names}`, + ), + ); + console.log( + ansis.yellow( + " Standards may override tool configuration. Use --force to unlink them, or --unlink-standard to remove them manually.", + ), + ); + } + console.log(); + } + + // Unresolved tools warning + if (preview.unresolvedTools.length > 0) { + console.log( + ansis.yellow( + `⚠ ${preview.unresolvedTools.length} ${pluralize("tool", preview.unresolvedTools.length)} in the config could not be matched: ${preview.unresolvedTools.join(", ")}`, + ), + ); + console.log(); + } + + // Tools to disable + if (preview.toolsToDisable.length > 0) { + const names = preview.toolsToDisable.map((t) => t.name).join(", "); + console.log( + `${preview.toolsToDisable.length} ${pluralize("tool", preview.toolsToDisable.length)} will be disabled: ${names}`, + ); + } + + // Tools to enable + if (preview.toolsToEnable.length > 0) { + const names = preview.toolsToEnable.map((r) => r.tool.name).join(", "); + console.log( + `${preview.toolsToEnable.length} ${pluralize("tool", preview.toolsToEnable.length)} will be enabled: ${names}`, + ); + } + + // Tools to reconfigure + if (preview.toolsToReconfigure.length > 0) { + const names = preview.toolsToReconfigure.map((r) => r.tool.name).join(", "); + console.log( + `${preview.toolsToReconfigure.length} ${pluralize("tool", preview.toolsToReconfigure.length)} will be reconfigured: ${names}`, + ); + } + + console.log(); + console.log( + `All existing patterns in configured tools will be replaced with the patterns in ${ansis.bold(preview.configPath)}.`, + ); + console.log(); + console.log( + `${ansis.bold(String(preview.totalPatterns))} ${pluralize("pattern", preview.totalPatterns)} will be enabled.`, + ); +} + +function chunk(arr: T[], size: number): T[][] { + const chunks: T[][] = []; + for (let i = 0; i < arr.length; i += size) { + chunks.push(arr.slice(i, i + size)); + } + return chunks; +} + +function buildConfigurePatterns( + toolConfig: CodacyToolConfig, +): ConfigurePattern[] { + return toolConfig.patterns.map((p) => ({ + id: p.patternId, + enabled: true, + parameters: p.parameters + ? Object.entries(p.parameters).map(([name, value]) => ({ + name, + value: String(value), + })) + : undefined, + })); +} + +export async function executeImport( + provider: string, + organization: string, + repository: string, + preview: ImportPreview, + config: CodacyConfig, + allTools: Tool[], + spinner: ReturnType, + force: boolean = false, +): Promise<{ succeeded: string[]; failed: { tool: string; error: string }[] }> { + const succeeded: string[] = []; + const failed: { tool: string; error: string }[] = []; + + // Unlink coding standards when --force is used + if (force) { + for (const standard of preview.standards) { + spinner.text = `Unlinking coding standard "${standard.name}"...`; + try { + await CodingStandardsService.applyCodingStandardToRepositories( + provider, + organization, + standard.id, + { link: [], unlink: [repository] }, + ); + } catch (err) { + failed.push({ + tool: `Standard: ${standard.name}`, + error: err instanceof Error ? err.message : String(err), + }); + } + } + } + + // Configure each tool from the config file + const allResolved = [...preview.toolsToEnable, ...preview.toolsToReconfigure]; + for (const resolved of allResolved) { + spinner.text = `Configuring ${resolved.tool.name}...`; + try { + // Disable all existing patterns first + await AnalysisService.updateRepositoryToolPatterns( + provider, + organization, + repository, + resolved.tool.uuid, + { enabled: false }, + ); + + // Build patterns and batch + const patterns = buildConfigurePatterns(resolved.configTool); + const batches = chunk(patterns, 1000); + + for (const batch of batches) { + await AnalysisService.configureTool( + provider, + organization, + repository, + resolved.tool.uuid, + { + enabled: true, + useConfigurationFile: + resolved.configTool.useLocalConfigurationFile ?? false, + patterns: batch, + }, + ); + } + + // If no patterns, still enable the tool with the config file setting + if (batches.length === 0) { + await AnalysisService.configureTool( + provider, + organization, + repository, + resolved.tool.uuid, + { + enabled: true, + useConfigurationFile: + resolved.configTool.useLocalConfigurationFile ?? false, + }, + ); + } + + succeeded.push(resolved.tool.name); + } catch (err) { + failed.push({ + tool: resolved.tool.name, + error: err instanceof Error ? err.message : String(err), + }); + } + } + + // Disable tools not in config + for (const tool of preview.toolsToDisable) { + spinner.text = `Disabling ${tool.name}...`; + try { + await AnalysisService.configureTool( + provider, + organization, + repository, + tool.uuid, + { enabled: false }, + ); + succeeded.push(`${tool.name} (disabled)`); + } catch (err) { + failed.push({ + tool: tool.name, + error: err instanceof Error ? err.message : String(err), + }); + } + } + + return { succeeded, failed }; +} diff --git a/src/utils/prompt.ts b/src/utils/prompt.ts new file mode 100644 index 0000000..1f6c21b --- /dev/null +++ b/src/utils/prompt.ts @@ -0,0 +1,18 @@ +import * as readline from "readline"; + +export function confirmAction(message: string): Promise { + return new Promise((resolve) => { + if (!process.stdin.isTTY) { + resolve(false); + return; + } + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + rl.question(`${message} (y/N) `, (answer) => { + rl.close(); + resolve(answer.trim().toLowerCase() === "y"); + }); + }); +}