diff --git a/.changeset/skip-standard-locked-import.md b/.changeset/skip-standard-locked-import.md new file mode 100644 index 0000000..6bc331c --- /dev/null +++ b/.changeset/skip-standard-locked-import.md @@ -0,0 +1,5 @@ +--- +"@codacy/codacy-cloud-cli": patch +--- + +`tools --import` no longer sends disables for tools and patterns enforced by a coding standard — it drops them from the import set up front (using `enabledBy` from the tools/patterns listings) instead of letting the server reject each one with a 409. These are now reported in a new `skipped[]` list, shown in the preview, the summary line, and `--output json`. diff --git a/SPECS/README.md b/SPECS/README.md index 31469e4..60282fb 100644 --- a/SPECS/README.md +++ b/SPECS/README.md @@ -85,4 +85,5 @@ _No pending tasks._ All commands implemented. | 2026-07-28 | (OD-296, findings side) `SrmItem` gained its own `advisoryInformation` field server-side (bumped pinned API `57.3.0` → `57.3.9`), closing the gap noted on 2026-07-24. `findings` (list) now shows the same compact "Vulnerable functions: fn1, fn2 (+N more)" line as `issues`, via the newly-exported `summarizeFunctions`. `finding` (detail) shows the full `printAdvisoryBlock` — but only when there's no linked Codacy issue, since `printIssueCodeContext` already renders the equivalent block from `issue.advisoryInformation` in that case; this is what makes vulnerable functions visible for SCA/dependency findings (and any other non-Codacy-source finding) that have no linked issue to borrow it from at all. Added to both commands' JSON `pickDeep` whitelists (6 new tests, 494 total) | | 2026-07-28 | (OD-378) New `pull-requests` (`prs`) command — the plural counterpart to `pull-request`, listing PRs for a repository with the same analysis-gated table columns as `repository`'s "Open Pull Requests" section (reuses `buildGateStatus`/`formatStandards`/`formatPrIssues`/`formatPrCoverage`/`formatDelta`). `--search-text`/`-q` and `--branch`/`-b` map to the API's `textQuery`/`targetBranch` params added in OD-376; the classification param (`search`, Merged vs. last-updated) is deliberately not exposed — different axis, out of scope. `[provider] [org] [repo]` auto-detect via `resolveRepoArgs`, paginate-to-`--limit` loop matching `findings`. Registered in `src/index.ts` (10 new tests, 516 total) | | 2026-07-30 | (OD-378, review follow-up) `pull-requests` table polish + a real data bug. **Bug:** Complexity rendered as "no data" on every PR because the API omits the flat top-level `deltaComplexity` and only returns `quality.deltaComplexity` (while still sending a top-level `deltaClonesCount`) — new shared `prQualityMetric(pr, key)` in `utils/formatting.ts` reads the nested `quality` value first and falls back to the flat field; also applied to `repository`'s Open PR table and `pull-request`'s Analysis section, which had the same bug. **Layout:** `✓` moved to the first column; metric order now matches `repositories` (issues → complexity → duplication → coverage); the Coverage column is dropped entirely when no listed PR has a coverage value (new `hasAnyPrCoverage()` — repos without coverage return `diffCoverage.cause` and no numbers on any PR); missing metric values now render as a dim `-` instead of `N/A` in `formatDelta`/`formatPrCoverage`/`formatPrIssues`, matching `formatStandards`/`formatCountCell`/`formatCoverageCell`; and a zero issue count renders as a bare `0` rather than `+0`/`-0` (`-0` read as a negative), matching what `pull-request`'s Files table and `formatDelta` already did. **JSON:** added `quality.resultReasons`/`coverage.resultReasons` (Codacy review suggestion — they drive the per-metric gate coloring, so consumers need them to see which gates passed/failed) plus the `quality.*` metric mirrors the table actually renders (23 new tests, 544 total) | +| 2026-09-03 | `tools --import` now skips standard-enforced tools/patterns instead of trying (and failing) to disable them: `buildImportPreview` drops locked tools from the disable set and fetches enabled patterns per reconfigured tool, reporting each as an `ImportSkip` in a new `skipped[]` (printed in the preview's coding-standards warning, and in `--output json`); `--force` bypasses all of this since standards are unlinked first. | | 2026-08-11 | (OD-489) Repository (project) token support. New `--repository-token ` on every command (plus `CODACY_PROJECT_TOKEN`), sent as the `project-token` header; account tokens keep `api-token`. `src/utils/auth.ts` rewritten around a `RemoteAuth` discriminated union carrying both kind and source, replacing `checkApiToken()` with `resolveAuth(this)` / `resolveAccountAuth(this, why)` / `requireAccountToken(...)` / `fetchIfAccountToken(...)`. Precedence matches `codacy-analysis` exactly — flag > `CODACY_PROJECT_TOKEN` > `CODACY_API_TOKEN` > stored login — so `vitest.config.mts` now blanks `CODACY_PROJECT_TOKEN` (it outranks the account token and is exported job-wide by the coverage reporter, so tests would otherwise depend on the developer's shell). Codacy whitelists only 13 operations for repository tokens, so `tool`/`patterns`/`pattern` work unchanged, `issues` (incl. `--overview`) and `tools --import` work, and the 9 account-only commands plus `repository`'s 6 management flags, `issues --ignore`/`--ignored`, and `tools --import --force` (only when standards exist) **fail fast before any request** with a message naming the operation, the reason, and where the token came from. `repository`'s dashboard skips the two non-whitelisted calls: the table keeps the "Open Pull Requests" header with an explanatory line, and JSON keeps `pullRequests: []` (so `jq '.pullRequests[]'` still works) plus an additive `unavailable: ["pullRequests"]` — under an account token the payload is byte-identical. Also added the long-missing `.catch()` on the PR call so an account token lacking PR access degrades instead of losing the whole dashboard, and fixed `login`'s 401 message, which told repository-token users their token was "invalid" when it is rejected by `/user` by design. New `SPECS/repository-tokens.md` (whitelist + matrix, re-verify on every `npm run update-api`) and `SPECS/missing-endpoints.md` (ranked gaps for follow-up Linear tasks) (40 new tests, 606 total) | diff --git a/SPECS/repository-tokens.md b/SPECS/repository-tokens.md index 1527f33..d935885 100644 --- a/SPECS/repository-tokens.md +++ b/SPECS/repository-tokens.md @@ -52,7 +52,7 @@ else it is rejected as if no token had been sent. | operationId | Method | Used by this CLI | |---|---|---| | `listRepositoryTools` | GET | `tools`, `tool`, `patterns`, `pattern`, `issues -O` | -| `listRepositoryToolPatterns` | GET | `patterns`, `pattern`, `issues -O` | +| `listRepositoryToolPatterns` | GET | `patterns`, `pattern`, `issues -O`, `tools --import` | | `getRepositoryWithAnalysis` | GET | `repository`, `tools --import` | | `issuesOverview` | POST | `repository`, `issues -O` | | `searchRepositoryIssues` | POST | `issues` | diff --git a/src/commands/AGENTS.md b/src/commands/AGENTS.md index 3152905..0e6d8fd 100644 --- a/src/commands/AGENTS.md +++ b/src/commands/AGENTS.md @@ -245,6 +245,16 @@ match. Modify-mode refusals (`pattern --enable/--disable/--parameter`, and `patterns --enable-all/--disable-all`) print the reason and `process.exit(1)`; info/list displays print a notice and exit 0. +`tools --import` applies the same coding-standard awareness instead of refusing: +the bulk pattern reset (`updateRepositoryToolPatterns({enabled: false})`) and +`configureTool` silently leave standard-enforced tools/patterns enabled +server-side, so `buildImportPreview` drops locked tools from the disable set +and fetches enabled patterns per reconfigured tool, reporting each as an +`ImportSkip` in `preview.skipped` (shown in the preview and in `--output json`) +rather than re-enabling them. `--force` bypasses all of it — it unlinks +standards first in `executeImport`, so nothing is locked by the time tools are +reconfigured. + ## Reanalyze and wait (`utils/reanalyze-wait.ts`) Shared by the `repository` and `pull-request` `--reanalyze-and-wait` (`-w`) modes. diff --git a/src/commands/tools.test.ts b/src/commands/tools.test.ts index a7ce1c4..3a7924c 100644 --- a/src/commands/tools.test.ts +++ b/src/commands/tools.test.ts @@ -276,6 +276,10 @@ describe("tools command", () => { fs.writeFileSync(tmpConfigPath, configContent); vi.mocked(AnalysisService.updateRepositoryToolPatterns).mockResolvedValue(undefined as any); vi.mocked(AnalysisService.configureTool).mockResolvedValue(undefined as any); + vi.mocked(AnalysisService.listRepositoryToolPatterns).mockResolvedValue({ + data: [], + pagination: undefined, + } as any); vi.spyOn(importConfig, "fetchAllTools").mockResolvedValue([ { uuid: "uuid-eslint", @@ -417,6 +421,86 @@ describe("tools command", () => { const output = getAllOutput(); expect(output).toContain("error"); }); + + it("should print only the JSON object to stdout with --output json", async () => { + const program = createProgram(); + await program.parseAsync([ + "node", "test", "--output", "json", "tools", "gh", "test-org", "test-repo", + "--import", tmpConfigPath, "-y", + ]); + + const calls = (console.log as ReturnType).mock.calls; + expect(calls).toHaveLength(1); + const parsed = JSON.parse(calls[0][0] as string); + expect(parsed).toHaveProperty("succeeded"); + expect(parsed).toHaveProperty("failed"); + expect(parsed).toHaveProperty("skipped"); + }); + + // ── Standard-enforced skips ───────────────────────────────────────── + + /** A tool enabled by a coding standard, not present in the config file. */ + function mockStandardLockedCheckov() { + vi.mocked(AnalysisService.listRepositoryTools).mockResolvedValue({ + data: [ + { + uuid: "uuid-checkov", + name: "Checkov", + isClientSide: false, + settings: { + isEnabled: true, + followsStandard: true, + isCustom: false, + hasConfigurationFile: false, + usesConfigurationFile: false, + enabledBy: [{ id: 1, name: "Security" }], + }, + }, + ], + pagination: undefined, + } as any); + vi.spyOn(importConfig, "fetchAllTools").mockResolvedValue([ + { uuid: "uuid-eslint", name: "ESLint", shortName: "eslint", prefix: "ESLint_", languages: [], clientSide: false, standalone: false, configurable: true }, + { uuid: "uuid-checkov", name: "Checkov", shortName: "checkov", languages: [], clientSide: false, standalone: false, configurable: true }, + ] as any); + vi.spyOn(importConfig, "getLocalSupportedToolIds").mockResolvedValue(["ESLint", "checkov"]); + } + + it("should print a skipped block in the preview and count it in the summary", async () => { + mockStandardLockedCheckov(); + + const program = createProgram(); + await program.parseAsync([ + "node", "test", "tools", "gh", "test-org", "test-repo", + "--import", tmpConfigPath, "-y", + ]); + + const output = getAllOutput(); + expect(output).toContain("Skipped (enforced by coding standard):"); + expect(output).toContain("Checkov (Security)"); + expect(output).toContain("1 skipped."); + // Never sent a disable for the standard-locked tool. + expect(AnalysisService.configureTool).not.toHaveBeenCalledWith( + "gh", "test-org", "test-repo", "uuid-checkov", { enabled: false }, + ); + }); + + it("should include skipped in --output json", async () => { + mockStandardLockedCheckov(); + + const program = createProgram(); + await program.parseAsync([ + "node", "test", "--output", "json", "tools", "gh", "test-org", "test-repo", + "--import", tmpConfigPath, "-y", + ]); + + const calls = (console.log as ReturnType).mock.calls; + const jsonCall = calls.map((c) => c[0]).find((c) => typeof c === "string" && c.trim().startsWith("{")); + const parsed = JSON.parse(jsonCall as string); + expect(parsed.skipped).toEqual([ + { tool: "Checkov", standards: ["Security"], reason: "enforced by coding standard" }, + ]); + }); }); describe("auto-detect from git remote", () => { @@ -453,6 +537,10 @@ describe("tools command", () => { undefined as any, ); vi.mocked(AnalysisService.configureTool).mockResolvedValue(undefined as any); + vi.mocked(AnalysisService.listRepositoryToolPatterns).mockResolvedValue({ + data: [], + pagination: undefined, + } as any); vi.spyOn(importConfig, "fetchAllTools").mockResolvedValue([ { uuid: "uuid-eslint", diff --git a/src/commands/tools.ts b/src/commands/tools.ts index 7c7f52a..e5673ac 100644 --- a/src/commands/tools.ts +++ b/src/commands/tools.ts @@ -188,21 +188,31 @@ Examples: spinner.stop(); + const isJson = getOutputFormat(this) === "json"; + // Build and display preview - const preview = buildImportPreview( + const preview = await buildImportPreview( + provider, + organization, + repository, config, repoToolsResponse.data, allTools, repoResponse.data.repository.standards, resolvedPath, localToolIds, + Boolean(opts.force), ); guardForceUnlink(auth, preview.standards.length, Boolean(opts.force)); - printImportPreview(preview, repository, Boolean(opts.force), { - canUnlinkStandards: auth.kind === "account-token", - }); + printImportPreview( + preview, + repository, + Boolean(opts.force), + { canUnlinkStandards: auth.kind === "account-token" }, + isJson ? console.error : console.log, + ); // Confirm if (!opts.skipApproval) { @@ -215,7 +225,7 @@ Examples: } } - console.log(); + if (!isJson) console.log(); const execSpinner = ora("Applying configuration...").start(); const result = await executeImport( provider, @@ -230,6 +240,11 @@ Examples: execSpinner.stop(); + if (isJson) { + printJson(result); + return; + } + if (result.failed.length === 0) { console.log( `${ansis.green("✓")} Configuration imported successfully.`, @@ -249,6 +264,9 @@ Examples: ); } } + if (result.skipped.length > 0) { + console.log(ansis.dim(` ${result.skipped.length} skipped.`)); + } return; } diff --git a/src/utils/import-config.test.ts b/src/utils/import-config.test.ts index 799d3c6..ccee07e 100644 --- a/src/utils/import-config.test.ts +++ b/src/utils/import-config.test.ts @@ -16,6 +16,15 @@ vi.mock("../api/client/services/AnalysisService"); vi.mock("../api/client/services/ToolsService"); vi.mock("../api/client/services/CodingStandardsService"); +// Default: no currently-enabled patterns, so buildImportPreview's per-tool pattern fetch is a no-op unless a test overrides it. +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(AnalysisService.listRepositoryToolPatterns).mockResolvedValue({ + data: [], + pagination: undefined, + } as any); +}); + // ─── Test fixtures ──────────────────────────────────────────────────── function makeTool(overrides: Partial & { uuid: string; name: string; shortName: string }): Tool { @@ -35,14 +44,19 @@ function makeTool(overrides: Partial & { uuid: string; name: string; short } as Tool; } -function makeRepoTool(uuid: string, name: string, isEnabled: boolean): AnalysisTool { +function makeRepoTool( + uuid: string, + name: string, + isEnabled: boolean, + enabledBy: { id: number; name: string }[] = [], +): AnalysisTool { return { uuid, name, isClientSide: false, settings: { isEnabled, - enabledBy: [], + enabledBy, hasConfigurationFile: false, usesConfigurationFile: false, followsStandard: false, @@ -51,6 +65,19 @@ function makeRepoTool(uuid: string, name: string, isEnabled: boolean): AnalysisT } as AnalysisTool; } +function makeConfiguredPattern( + id: string, + enabledBy: { id: number; name: string }[] = [], +): any { + return { + patternDefinition: { id }, + enabled: true, + isCustom: false, + parameters: [], + enabledBy, + }; +} + 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" }); @@ -159,7 +186,7 @@ describe("resolveToolId", () => { // ─── buildImportPreview ─────────────────────────────────────────────── describe("buildImportPreview", () => { - it("should categorize tools correctly with local CLI info", () => { + it("should categorize tools correctly with local CLI info", async () => { const repoTools: AnalysisTool[] = [ makeRepoTool("uuid-eslint", "ESLint", true), makeRepoTool("uuid-checkov", "Checkov", true), @@ -182,7 +209,7 @@ describe("buildImportPreview", () => { }; const localToolIds = ["ESLint", "Pylint", "checkov", "remarklint"]; - const preview = buildImportPreview(config, repoTools, allTools, [], "/test/path", localToolIds); + const preview = await buildImportPreview("gh", "org", "repo", config, repoTools, allTools, [], "/test/path", localToolIds); // ESLint is enabled and in config → reconfigure expect(preview.toolsToReconfigure).toHaveLength(1); @@ -202,7 +229,7 @@ describe("buildImportPreview", () => { expect(preview.unresolvedTools).toHaveLength(0); }); - it("should leave cloud-only tools unchanged", () => { + it("should leave cloud-only tools unchanged", async () => { const sonarSharpTool = makeTool({ uuid: "uuid-sonarsharp", name: "SonarSharp", shortName: "sonarsharp", prefix: "SonarSharp_" }); const extendedAllTools = [...allTools, sonarSharpTool]; @@ -228,7 +255,7 @@ describe("buildImportPreview", () => { // Local CLI supports ESLint and Checkov but NOT SonarSharp const localToolIds = ["ESLint", "checkov"]; - const preview = buildImportPreview(config, repoTools, extendedAllTools, [], "/test/path", localToolIds); + const preview = await buildImportPreview("gh", "org", "repo", config, repoTools, extendedAllTools, [], "/test/path", localToolIds); // ESLint is in config → reconfigure expect(preview.toolsToReconfigure).toHaveLength(1); @@ -243,7 +270,7 @@ describe("buildImportPreview", () => { expect(preview.cloudOnlyTools[0].name).toBe("SonarSharp"); }); - it("should not disable any tools when local CLI is unavailable", () => { + it("should not disable any tools when local CLI is unavailable", async () => { const repoTools: AnalysisTool[] = [ makeRepoTool("uuid-eslint", "ESLint", true), makeRepoTool("uuid-checkov", "Checkov", true), @@ -264,7 +291,7 @@ describe("buildImportPreview", () => { }; // null = local CLI not available - const preview = buildImportPreview(config, repoTools, allTools, [], "/test/path", null); + const preview = await buildImportPreview("gh", "org", "repo", config, repoTools, allTools, [], "/test/path", null); expect(preview.toolsToDisable).toHaveLength(0); expect(preview.cloudOnlyTools).toHaveLength(0); @@ -272,7 +299,7 @@ describe("buildImportPreview", () => { expect(preview.toolsToReconfigure).toHaveLength(1); }); - it("should report unresolved tools", () => { + it("should report unresolved tools", async () => { const config: CodacyConfig = { version: 1, metadata: { @@ -287,11 +314,11 @@ describe("buildImportPreview", () => { ], }; - const preview = buildImportPreview(config, [], allTools, [], "/test/path"); + const preview = await buildImportPreview("gh", "org", "repo", config, [], allTools, [], "/test/path"); expect(preview.unresolvedTools).toEqual(["nonexistent_tool"]); }); - it("should include standards in preview", () => { + it("should include standards in preview", async () => { const standards = [{ id: 1, name: "Security" }, { id: 2, name: "OWASP" }]; const config: CodacyConfig = { version: 1, @@ -305,9 +332,105 @@ describe("buildImportPreview", () => { tools: [], }; - const preview = buildImportPreview(config, [], allTools, standards, "/test/path"); + const preview = await buildImportPreview("gh", "org", "repo", config, [], allTools, standards, "/test/path"); expect(preview.standards).toHaveLength(2); }); + + // ── Standard-enforced skips ─────────────────────────────────────────── + + it("should drop a standard-enforced tool from the disable set and report it as skipped", async () => { + const repoTools: AnalysisTool[] = [ + makeRepoTool("uuid-checkov", "Checkov", true, [{ id: 1, name: "Security" }]), + ]; + const config: CodacyConfig = { + version: 1, + metadata: { repositoryId: null, repositoryName: null, createdAt: "", updatedAt: "", languages: [] }, + tools: [], + }; + + const preview = await buildImportPreview( + "gh", "org", "repo", config, repoTools, allTools, [], "/test/path", ["checkov"], + ); + + expect(preview.toolsToDisable).toHaveLength(0); + expect(preview.skipped).toEqual([ + { tool: "Checkov", standards: ["Security"], reason: "enforced by coding standard" }, + ]); + }); + + it("should keep a standard-enforced tool in the disable set and skip nothing when force is true", async () => { + const repoTools: AnalysisTool[] = [ + makeRepoTool("uuid-checkov", "Checkov", true, [{ id: 1, name: "Security" }]), + ]; + const config: CodacyConfig = { + version: 1, + metadata: { repositoryId: null, repositoryName: null, createdAt: "", updatedAt: "", languages: [] }, + tools: [], + }; + + const preview = await buildImportPreview( + "gh", "org", "repo", config, repoTools, allTools, [], "/test/path", ["checkov"], true, + ); + + expect(preview.toolsToDisable).toHaveLength(1); + expect(preview.toolsToDisable[0].name).toBe("Checkov"); + expect(preview.skipped).toEqual([]); + expect(AnalysisService.listRepositoryToolPatterns).not.toHaveBeenCalled(); + }); + + it("should keep a standard-enforced pattern enabled and report it as skipped", async () => { + vi.mocked(AnalysisService.listRepositoryToolPatterns).mockResolvedValue({ + data: [ + makeConfiguredPattern("p-locked", [{ id: 2, name: "OWASP" }]), + makeConfiguredPattern("p-unlocked"), + ], + pagination: undefined, + } as any); + + const repoTools: AnalysisTool[] = [makeRepoTool("uuid-eslint", "ESLint", true)]; + const config: CodacyConfig = { + version: 1, + metadata: { repositoryId: null, repositoryName: null, createdAt: "", updatedAt: "", languages: [] }, + tools: [{ toolId: "ESLint", patterns: [{ patternId: "p1" }] }], + }; + + const preview = await buildImportPreview("gh", "org", "repo", config, repoTools, allTools, [], "/test/path"); + + expect(AnalysisService.listRepositoryToolPatterns).toHaveBeenCalledWith( + "gh", "org", "repo", "uuid-eslint", + undefined, undefined, undefined, undefined, undefined, + true, undefined, undefined, undefined, undefined, 100, + ); + expect(preview.skipped).toEqual([ + { tool: "ESLint", patternId: "p-locked", standards: ["OWASP"], reason: "enforced by coding standard" }, + ]); + }); + + it("should not fetch patterns for tools that are enabled, not reconfigured", async () => { + const repoTools: AnalysisTool[] = [makeRepoTool("uuid-pylint", "Pylint", false)]; + const config: CodacyConfig = { + version: 1, + metadata: { repositoryId: null, repositoryName: null, createdAt: "", updatedAt: "", languages: [] }, + tools: [{ toolId: "Pylint", patterns: [{ patternId: "p1" }] }], + }; + + await buildImportPreview("gh", "org", "repo", config, repoTools, allTools, [], "/test/path"); + + expect(AnalysisService.listRepositoryToolPatterns).not.toHaveBeenCalled(); + }); + + it("should not fetch patterns for a reconfigured tool using a local configuration file", async () => { + const repoTools: AnalysisTool[] = [makeRepoTool("uuid-eslint", "ESLint", true)]; + const config: CodacyConfig = { + version: 1, + metadata: { repositoryId: null, repositoryName: null, createdAt: "", updatedAt: "", languages: [] }, + tools: [{ toolId: "ESLint", useLocalConfigurationFile: true, patterns: [] }], + }; + + await buildImportPreview("gh", "org", "repo", config, repoTools, allTools, [], "/test/path"); + + expect(AnalysisService.listRepositoryToolPatterns).not.toHaveBeenCalled(); + }); }); // ─── executeImport ──────────────────────────────────────────────────── @@ -349,7 +472,8 @@ describe("executeImport", () => { ], }; - const preview = buildImportPreview( + const preview = await buildImportPreview( + "gh", "org", "repo", config, [ makeRepoTool("uuid-eslint", "ESLint", true), @@ -402,6 +526,44 @@ describe("executeImport", () => { expect(result.succeeded).toContain("ESLint"); expect(result.succeeded).toContain("Checkov (disabled)"); expect(result.failed).toHaveLength(0); + expect(result.skipped).toEqual([]); + }); + + it("should not re-enable locked patterns, but still pass skipped through", 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: "", updatedAt: "", languages: [] }, + tools: [{ toolId: "ESLint", patterns: [{ patternId: "p1" }] }], + }; + + const preview = await buildImportPreview("gh", "org", "repo", config, [ + makeRepoTool("uuid-eslint", "ESLint", true), + ], allTools, [], "/test/path"); + // Simulate what buildImportPreview computes when a pattern fetch finds a locked one. + preview.skipped = [ + { tool: "ESLint", patternId: "p-locked", standards: ["OWASP"], reason: "enforced by coding standard" }, + ]; + + const result = await executeImport( + "gh", "test-org", "test-repo", + preview, config, allTools, + mockSpinner as any, + ); + + expect(AnalysisService.configureTool).toHaveBeenCalledWith( + "gh", "test-org", "test-repo", "uuid-eslint", + { + enabled: true, + useConfigurationFile: false, + patterns: [ + { id: "p1", enabled: true, parameters: undefined }, + ], + }, + ); + expect(result.skipped).toEqual(preview.skipped); }); it("should skip pattern reset and use config file mode when useLocalConfigurationFile is true", async () => { @@ -426,7 +588,7 @@ describe("executeImport", () => { ], }; - const preview = buildImportPreview(config, [], allTools, [], "/test/path"); + const preview = await buildImportPreview("gh", "org", "repo", config, [], allTools, [], "/test/path"); await executeImport( "gh", "test-org", "test-repo", @@ -462,7 +624,7 @@ describe("executeImport", () => { }; const standards = [{ id: 100, name: "Security" }, { id: 200, name: "OWASP" }]; - const preview = buildImportPreview(config, [], allTools, standards, "/test/path"); + const preview = await buildImportPreview("gh", "org", "repo", config, [], allTools, standards, "/test/path"); const result = await executeImport( "gh", "test-org", "test-repo", @@ -497,7 +659,7 @@ describe("executeImport", () => { }; const standards = [{ id: 100, name: "Security" }]; - const preview = buildImportPreview(config, [], allTools, standards, "/test/path"); + const preview = await buildImportPreview("gh", "org", "repo", config, [], allTools, standards, "/test/path"); await executeImport( "gh", "test-org", "test-repo", @@ -530,7 +692,7 @@ describe("executeImport", () => { ], }; - const preview = buildImportPreview(config, [], allTools, [], "/test/path"); + const preview = await buildImportPreview("gh", "org", "repo", config, [], allTools, [], "/test/path"); const result = await executeImport( "gh", "test-org", "test-repo", @@ -562,7 +724,7 @@ describe("executeImport", () => { tools: [{ toolId: "ESLint", patterns: [{ patternId: "p1" }] }], }; - const preview = buildImportPreview(config, [], allTools, [], "/test/path"); + const preview = await buildImportPreview("gh", "org", "repo", config, [], allTools, [], "/test/path"); const result = await executeImport("gh", "org", "repo", preview, config, allTools, mockSpinner as any); expect(result.failed[0].status).toBe(409); @@ -587,7 +749,7 @@ describe("executeImport", () => { tools: [{ toolId: "ESLint", patterns: [{ patternId: "p1" }] }], }; - const preview = buildImportPreview(config, [], allTools, [], "/test/path"); + const preview = await buildImportPreview("gh", "org", "repo", config, [], allTools, [], "/test/path"); const result = await executeImport("gh", "org", "repo", preview, config, allTools, mockSpinner as any); expect(result.failed[0].status).toBe(400); @@ -611,7 +773,7 @@ describe("executeImport", () => { tools: [{ toolId: "ESLint", patterns: [{ patternId: "p1" }] }], }; - const preview = buildImportPreview(config, [], allTools, [], "/test/path"); + const preview = await buildImportPreview("gh", "org", "repo", config, [], allTools, [], "/test/path"); const result = await executeImport("gh", "org", "repo", preview, config, allTools, mockSpinner as any); expect(result.failed[0].status).toBe(500); diff --git a/src/utils/import-config.ts b/src/utils/import-config.ts index 939088b..97ee014 100644 --- a/src/utils/import-config.ts +++ b/src/utils/import-config.ts @@ -8,10 +8,12 @@ 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 { ConfiguredPattern } from "../api/client/models/ConfiguredPattern"; import { AnalysisService } from "../api/client/services/AnalysisService"; import { ToolsService } from "../api/client/services/ToolsService"; import { CodingStandardsService } from "../api/client/services/CodingStandardsService"; import { ApiError } from "../api/client/core/ApiError"; +import { patternEnforcedBy } from "./formatting"; import type ora from "ora"; const execAsync = promisify(exec); @@ -22,6 +24,13 @@ export interface ResolvedTool { repoTool?: AnalysisTool; } +export interface ImportSkip { + tool: string; + patternId?: string; + standards: string[]; + reason: string; +} + export interface ImportPreview { toolsToDisable: AnalysisTool[]; toolsToEnable: ResolvedTool[]; @@ -32,6 +41,7 @@ export interface ImportPreview { totalPatterns: number; standards: CodingStandardInfo[]; configPath: string; + skipped: ImportSkip[]; } export interface ImportFailure { @@ -135,6 +145,38 @@ export async function fetchAllTools(): Promise { return all; } +async function fetchEnabledToolPatterns( + provider: string, + organization: string, + repository: string, + toolUuid: string, +): Promise { + const all: ConfiguredPattern[] = []; + let cursor: string | undefined; + do { + const response = await AnalysisService.listRepositoryToolPatterns( + provider, + organization, + repository, + toolUuid, + undefined, // languages + undefined, // categories + undefined, // severityLevels + undefined, // tags + undefined, // search + true, // enabled + undefined, // recommended + undefined, // sort + undefined, // direction + cursor, + 100, + ); + all.push(...response.data); + cursor = response.pagination?.cursor; + } while (cursor); + return all; +} + export async function getLocalSupportedToolIds(): Promise { try { const { stdout } = await execAsync("codacy-analysis info -f json", { @@ -150,14 +192,18 @@ export async function getLocalSupportedToolIds(): Promise { } } -export function buildImportPreview( +export async function buildImportPreview( + provider: string, + organization: string, + repository: string, config: CodacyConfig, repoTools: AnalysisTool[], allTools: Tool[], standards: CodingStandardInfo[], configPath: string, localToolIds?: string[] | null, -): ImportPreview { + force: boolean = false, +): Promise { const resolved: ResolvedTool[] = []; const unresolvedTools: string[] = []; @@ -206,6 +252,47 @@ export function buildImportPreview( cloudOnlyTools = []; } + const skipped: ImportSkip[] = []; + + // --force unlinks standards in executeImport before any disables, so enabledBy is stale here + if (!force) { + // server returns 409 for standard-enforced tool disables; skip client-side + const lockedToolsToDisable = toolsToDisable.filter((t) => t.settings.enabledBy.length > 0); + toolsToDisable = toolsToDisable.filter((t) => t.settings.enabledBy.length === 0); + for (const t of lockedToolsToDisable) { + skipped.push({ + tool: t.name, + standards: t.settings.enabledBy.map((s) => s.name), + reason: "enforced by coding standard", + }); + } + + // config-file-driven tools never touch patterns, so skip the fetch for them + for (const r of toolsToReconfigure) { + if (r.configTool.useLocalConfigurationFile) continue; + + const configuredPatternIds = new Set(r.configTool.patterns.map((p) => p.patternId)); + const currentlyEnabled = await fetchEnabledToolPatterns( + provider, + organization, + repository, + r.tool.uuid, + ); + // bulk reset leaves standard-enforced patterns enabled server-side; report them only + const locked = currentlyEnabled.filter( + (cp) => patternEnforcedBy(cp).length > 0 && !configuredPatternIds.has(cp.patternDefinition.id), + ); + for (const cp of locked) { + skipped.push({ + tool: r.tool.name, + patternId: cp.patternDefinition.id, + standards: patternEnforcedBy(cp), + reason: "enforced by coding standard", + }); + } + } + } + const totalPatterns = config.tools.reduce( (sum, t) => sum + (Array.isArray(t.patterns) ? t.patterns.length : 0), 0, @@ -221,9 +308,25 @@ export function buildImportPreview( totalPatterns, standards, configPath, + skipped, }; } +const MAX_PREVIEW_SKIP_LINES = 5; + +// Shared by both places skipped tools/patterns are listed (standards block and standalone fallback). +function printSkippedLines(skipped: ImportSkip[], log: (...args: unknown[]) => void): void { + const shown = skipped.slice(0, MAX_PREVIEW_SKIP_LINES); + for (const s of shown) { + const target = s.patternId ? `${s.tool}:${s.patternId}` : s.tool; + log(` ${target} (${s.standards.join(", ")})`); + } + const remaining = skipped.length - shown.length; + if (remaining > 0) { + log(` ... and ${remaining} more`); + } +} + export function printImportPreview( preview: ImportPreview, repoName: string, @@ -235,58 +338,65 @@ export function printImportPreview( * hint has to point somewhere the user can actually go. */ options: { canUnlinkStandards?: boolean } = {}, + log: (...args: unknown[]) => void = console.log, ): void { const canUnlinkStandards = options.canUnlinkStandards ?? true; - console.log(); + log(); // Standards if (preview.standards.length > 0) { const names = preview.standards.map((s) => s.name).join(", "); if (force) { - console.log( + log( `${repoName} will stop following ${preview.standards.length} ${pluralize("coding standard", preview.standards.length)}: ${names}`, ); } else { - console.log( + log( ansis.yellow( `⚠ ${repoName} follows ${preview.standards.length} ${pluralize("coding standard", preview.standards.length)}: ${names}`, ), ); - console.log( + log( ansis.yellow( canUnlinkStandards ? " Standards may override tool configuration. Use --force to unlink them, or --unlink-standard to remove them manually." : " Standards may override tool configuration. They can't be unlinked with a repository token — unlink them in Codacy (Repository > Settings > Coding standards), or re-run with an account API token.", ), ); + printSkippedLines(preview.skipped, log); } - console.log(); + log(); + } else if (preview.skipped.length > 0) { + // Should not happen (skipped is only populated alongside standards), but keep it safe. + log(ansis.dim("Skipped (enforced by coding standard):")); + printSkippedLines(preview.skipped, log); + log(); } // Local CLI availability warning if (!preview.localCliAvailable) { - console.log( + log( ansis.yellow( "⚠ Could not query codacy-analysis CLI. No tools will be disabled — only tools in the config will be enabled/reconfigured.", ), ); - console.log(); + log(); } // Unresolved tools warning if (preview.unresolvedTools.length > 0) { - console.log( + log( ansis.yellow( `⚠ ${preview.unresolvedTools.length} ${pluralize("tool", preview.unresolvedTools.length)} in the config could not be matched: ${preview.unresolvedTools.join(", ")}`, ), ); - console.log(); + log(); } // Cloud-only tools (unchanged) if (preview.cloudOnlyTools.length > 0) { const names = preview.cloudOnlyTools.map((t) => t.name).join(", "); - console.log( + log( ansis.dim( `${preview.cloudOnlyTools.length} cloud-only ${pluralize("tool", preview.cloudOnlyTools.length)} unchanged: ${names}`, ), @@ -296,7 +406,7 @@ export function printImportPreview( // Tools to disable if (preview.toolsToDisable.length > 0) { const names = preview.toolsToDisable.map((t) => t.name).join(", "); - console.log( + log( `${preview.toolsToDisable.length} ${pluralize("tool", preview.toolsToDisable.length)} will be disabled: ${names}`, ); } @@ -304,7 +414,7 @@ export function printImportPreview( // Tools to enable if (preview.toolsToEnable.length > 0) { const names = preview.toolsToEnable.map((r) => r.tool.name).join(", "); - console.log( + log( `${preview.toolsToEnable.length} ${pluralize("tool", preview.toolsToEnable.length)} will be enabled: ${names}`, ); } @@ -312,7 +422,7 @@ export function printImportPreview( // Tools to reconfigure if (preview.toolsToReconfigure.length > 0) { const names = preview.toolsToReconfigure.map((r) => r.tool.name).join(", "); - console.log( + log( `${preview.toolsToReconfigure.length} ${pluralize("tool", preview.toolsToReconfigure.length)} will be reconfigured: ${names}`, ); } @@ -328,18 +438,18 @@ export function printImportPreview( (r) => !r.configTool.useLocalConfigurationFile, ); - console.log(); + log(); if (patternTools.length > 0) { - console.log( + log( `Existing patterns in ${patternTools.length} ${pluralize("tool", patternTools.length)} will be replaced with the patterns in ${ansis.bold(preview.configPath)}.`, ); - console.log( + log( `${ansis.bold(String(preview.totalPatterns))} ${pluralize("pattern", preview.totalPatterns)} will be enabled.`, ); } if (configFileTools.length > 0) { const names = configFileTools.map((r) => r.tool.name).join(", "); - console.log( + log( `${configFileTools.length} ${pluralize("tool", configFileTools.length)} will use their local configuration file: ${names}`, ); } @@ -377,7 +487,7 @@ export async function executeImport( allTools: Tool[], spinner: ReturnType, force: boolean = false, -): Promise<{ succeeded: string[]; failed: ImportFailure[] }> { +): Promise<{ succeeded: string[]; failed: ImportFailure[]; skipped: ImportSkip[] }> { const succeeded: string[] = []; const failed: ImportFailure[] = []; @@ -482,5 +592,5 @@ export async function executeImport( } } - return { succeeded, failed }; + return { succeeded, failed, skipped: preview.skipped }; }