From 8e830a799000bfc0e1911ac0875b7a5fe63eef54 Mon Sep 17 00:00:00 2001 From: Kevin Silva Date: Tue, 7 Jul 2026 13:48:24 +0200 Subject: [PATCH] GROW-213 Provision SonarQube project in sonar import command Wires up the final step of sonar import: after org/repo selection, create the bound SonarQube project via provision_projects, resolving the org's DevOps platform to format the installation key and enforcing the org's public/private project visibility rules. Co-Authored-By: Claude Sonnet 5 --- CLAUDE.md | 2 +- .../import/_common/resolve-options.ts | 183 ++++++++- src/cli/commands/import/index.ts | 51 ++- src/sonarqube/client.ts | 91 +++++ .../harness/fake-sonarqube-server.ts | 72 ++++ tests/integration/specs/import/import.test.ts | 367 +++++++++++++++++- 6 files changed, 738 insertions(+), 28 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9c0c5c314..321094aeb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,7 +44,7 @@ The bare `sonar analyze` command accepts `-p, --project ` for the agent `sonar analyze dependency-risks` pre-scans discovered manifest files for secrets (via `sonar-secrets`) before the SCA scan and aborts if any are found. In the `analyze` path the secrets binary is a hard prerequisite (install failure aborts the run); the git pre-commit hook path fails open (skips/warns). See `dependency-risk-helpers/manifest-secrets-guard.ts`. -`sonar import` (hidden while in development) imports a repository from a connected DevOps platform into SonarQube. The provider is determined internally from the selected DevOps platform config, not from a user-facing argument. Accepts `--org ` and `--repo ` (both prompted interactively via paginated select prompts when omitted) plus `--non-interactive`. Implementation lives in `src/cli/commands/import/`, with org/repo resolution in `_common/resolve-options.ts`. The command will become visible (with `rootHelp({ category: 'data' })`) once DevOps platform config selection and the import API call are wired up. +`sonar import` (hidden while in development) imports a repository from a connected DevOps platform into SonarQube by provisioning a bound project. Accepts `--org ` and `--repo ` (prompted interactively when omitted) plus `--non-interactive`. Implementation lives in `src/cli/commands/import/`. Declarative integration registry helpers live in `src/cli/commands/integrate/_common/registry/index.ts`. New integration descriptors should use that public entrypoint for dependency/resource factories, operations, and registry validation. Command handlers should keep command-specific validation, prompts, and target resolution thin, then delegate feature selection, generic install messages, dependency/resource application, and state recording to `src/cli/commands/integrate/_common/installer.ts`. diff --git a/src/cli/commands/import/_common/resolve-options.ts b/src/cli/commands/import/_common/resolve-options.ts index 14c91ee2a..3eda74978 100644 --- a/src/cli/commands/import/_common/resolve-options.ts +++ b/src/cli/commands/import/_common/resolve-options.ts @@ -18,23 +18,81 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import { type SonarQubeClient } from '../../../../sonarqube/client'; +import { type DopRepository, type SonarQubeClient } from '../../../../sonarqube/client'; import { selectPrompt, withSpinner } from '../../../../ui'; import { CommandFailedError, InvalidOptionError } from '../../_common/error'; import { OrganizationCollection } from './organization-collection'; import { RepositoryCollection } from './repository-collection'; +/** ALM key used by GitHub-bound organizations, per `Organization.alm.key`. */ +const GITHUB_ALM_KEY = 'github'; + +export interface OnlyPrivateProjects { + enabled: boolean; + available: boolean; +} + +export interface ResolvedOrg { + key: string; + /** Undefined when resolved via `--org`, since that skips fetching the `Organization`. */ + almKey?: string; + /** + * `Organization.onlyPrivateProjects.enabled`. Undefined when resolved via `--org`, since + * that skips fetching the `Organization`. Callers should default this to `false` (no + * restriction) so the `OnlyPrivateProjects` they build is never left undefined; the + * `available` half comes from a separate billing entitlement check, not from here. + */ + onlyPrivateProjectsEnabled?: boolean; +} + +export interface ResolvedRepo { + slug: string; + /** ALM-specific identifier expected by `provision_projects`' `installationKeys` param. */ + installationKey: string; +} + +/** + * Format a DOP repository's unique identifier the way `provision_projects` expects it: + * `|` for GitHub, plain `id` for every other DevOps platform. + */ +function computeInstallationKey( + repo: { id: string; slug: string }, + almKey: string | undefined, +): string { + return almKey === GITHUB_ALM_KEY ? `${repo.slug}|${repo.id}` : repo.id; +} + +/** + * Whether a repo's visibility is selectable under the org's `onlyPrivateProjects` setting. + * Unavailable means public-only, available+enabled means private-only, available+disabled + * allows both. + */ +function isRepoSelectable( + repo: { private: boolean }, + onlyPrivateProjects: OnlyPrivateProjects, +): boolean { + if (!onlyPrivateProjects.available) return !repo.private; + if (onlyPrivateProjects.enabled) return repo.private; + return true; +} + +/** e.g. `my-org/repo - private`. */ +function formatRepoLabel(repo: { slug: string; private: boolean }): string { + const visibility = repo.private ? 'private' : 'public'; + return `${repo.slug} - ${visibility}`; +} + export async function resolveOrg( client: SonarQubeClient, opts: { org?: string; nonInteractive?: boolean }, -): Promise { +): Promise { if (!client.isCloud) { throw new CommandFailedError('sonar import is only supported on SonarQube Cloud.', { remediationHint: "Run 'sonar auth login' and connect to SonarQube Cloud, then retry.", }); } - if (opts.org) return opts.org; + if (opts.org) return { key: opts.org }; if (opts.nonInteractive) { throw new InvalidOptionError( @@ -68,9 +126,13 @@ export async function resolveOrg( // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition while (true) { - const options: Array<{ value: string | typeof LOAD_MORE; label: string }> = + const options: Array<{ value: ResolvedOrg | typeof LOAD_MORE; label: string }> = eligible.visible.map((org) => ({ - value: org.key, + value: { + key: org.key, + almKey: org.alm?.key, + onlyPrivateProjectsEnabled: org.onlyPrivateProjects?.enabled, + }, label: `${org.name} (${org.key})`, })); @@ -96,11 +158,11 @@ export async function resolveOrg( export async function resolveRepo( client: SonarQubeClient, orgKey: string, + almKey: string | undefined, + onlyPrivateProjects: OnlyPrivateProjects, opts: { repo?: string; nonInteractive?: boolean }, -): Promise { - if (opts.repo) return opts.repo; - - if (opts.nonInteractive) { +): Promise { + if (opts.nonInteractive && !opts.repo) { throw new InvalidOptionError( '--repo is required in non-interactive mode', 'Pass --repo to specify a repository directly.', @@ -114,6 +176,41 @@ export async function resolveRepo( }); } + if (opts.repo) { + return resolveRepoBySlug(client, organizationId, almKey, onlyPrivateProjects, opts.repo); + } + + return promptForRepo(client, organizationId, almKey, onlyPrivateProjects); +} + +async function resolveRepoBySlug( + client: SonarQubeClient, + organizationId: string, + almKey: string | undefined, + onlyPrivateProjects: OnlyPrivateProjects, + slug: string, +): Promise { + const repo = await findRepoBySlug(client, organizationId, slug); + if (!repo) { + throw new CommandFailedError( + `Repository '${slug}' not found in the selected organization's DevOps platform.`, + { remediationHint: 'Check that the repository slug is correct and visible to the org.' }, + ); + } + if (!isRepoSelectable(repo, onlyPrivateProjects)) { + throw new CommandFailedError( + `Repository '${slug}' is ${repo.private ? 'private' : 'public'}, which isn't allowed by this organization's project visibility settings.`, + ); + } + return { slug: repo.slug, installationKey: computeInstallationKey(repo, almKey) }; +} + +async function promptForRepo( + client: SonarQubeClient, + organizationId: string, + almKey: string | undefined, + onlyPrivateProjects: OnlyPrivateProjects, +): Promise { let repos: RepositoryCollection; try { repos = await withSpinner('Loading repositories...', () => @@ -139,12 +236,15 @@ export async function resolveRepo( // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition while (true) { - const options: Array<{ value: string | typeof LOAD_MORE; label: string }> = repos.visible.map( - (repo) => ({ - value: repo.slug, - label: repo.importedInCurrentOrg ? `${repo.slug} (already imported)` : repo.slug, - }), - ); + const selectable = await loadNextSelectablePage(repos, onlyPrivateProjects); + + const options: Array<{ + value: ResolvedRepo | typeof LOAD_MORE; + label: string; + }> = selectable.map((repo) => ({ + value: { slug: repo.slug, installationKey: computeInstallationKey(repo, almKey) }, + label: formatRepoLabel(repo), + })); if (repos.hasMore) { options.push({ value: LOAD_MORE, label: 'Load more...' }); @@ -164,3 +264,56 @@ export async function resolveRepo( return choice; } } + +/** + * Advances `repos` past pages with nothing selectable (already imported, or excluded by + * the org's visibility settings), returning the selectable repos on the first page that has + * any. Throws once the collection is exhausted without finding one. + */ +async function loadNextSelectablePage( + repos: RepositoryCollection, + onlyPrivateProjects: OnlyPrivateProjects, +): Promise { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + while (true) { + const importable = repos.visible.filter((repo) => !repo.importedInCurrentOrg); + // `selectPrompt` has no concept of disabled options, so repos that don't match the + // org's visibility settings must be filtered out entirely rather than merely flagged. + const selectable = importable.filter((repo) => isRepoSelectable(repo, onlyPrivateProjects)); + + if (selectable.length > 0) return selectable; + + if (!repos.hasMore) { + throw new CommandFailedError( + importable.length === 0 + ? 'All repositories for the selected organization have already been imported into SonarQube.' + : "No repositories match this organization's project visibility settings.", + ); + } + + await repos.loadMore(); + } +} + +/** + * Search all server pages of an organization's DOP repositories for an exact `slug` match. + * Used for the `--repo` fast path, which still needs the repo's `id` to compute the + * `installationKey` for provisioning even though it skips the interactive select prompt. + */ +async function findRepoBySlug( + client: SonarQubeClient, + organizationId: string, + slug: string, +): Promise<{ id: string; slug: string; private: boolean } | undefined> { + const repos = await RepositoryCollection.create((pageIndex, pageSize) => + client.fetchDopRepositoriesPage(organizationId, pageIndex, pageSize), + ); + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + while (true) { + const match = repos.visible.find((repo) => repo.slug === slug); + if (match) return match; + if (!repos.hasMore) return undefined; + await repos.loadMore(); + } +} diff --git a/src/cli/commands/import/index.ts b/src/cli/commands/import/index.ts index 91b7f923f..9c7b81f06 100644 --- a/src/cli/commands/import/index.ts +++ b/src/cli/commands/import/index.ts @@ -20,8 +20,9 @@ import type { ResolvedAuth } from '../../../lib/auth-resolver'; import { SonarQubeClient } from '../../../sonarqube/client'; -import { info, intro, outro } from '../../../ui'; -import { resolveOrg, resolveRepo } from './_common/resolve-options'; +import { info, intro, outro, withSpinner } from '../../../ui'; +import { CommandFailedError } from '../_common/error'; +import { type OnlyPrivateProjects, resolveOrg, resolveRepo } from './_common/resolve-options'; import type { ImportOptions } from './_common/types'; export { type ImportOptions } from './_common/types'; @@ -31,11 +32,51 @@ export async function importHandler(options: ImportOptions, auth: ResolvedAuth): intro('Import repository', 'SonarQube'); - const orgKey = await resolveOrg(client, options); + const { + key: orgKey, + almKey: resolvedAlmKey, + onlyPrivateProjectsEnabled, + } = await resolveOrg(client, options); + info(`Organization: ${orgKey}`); - const repoSlug = await resolveRepo(client, orgKey, options); + const almKey = resolvedAlmKey ?? (await client.getOrganizationAlmKey(orgKey)); + const onlyPrivateProjects: OnlyPrivateProjects = { + enabled: onlyPrivateProjectsEnabled ?? false, + available: await client.hasPrivateProjectsEntitlement(orgKey), + }; + + const { slug: repoSlug, installationKey } = await resolveRepo( + client, + orgKey, + almKey, + onlyPrivateProjects, + options, + ); + info(`Repository: ${repoSlug}`); + let result; + try { + result = await withSpinner('Creating SonarQube project...', () => + client.provisionProject(orgKey, installationKey), + ); + } catch (err) { + throw new CommandFailedError( + `Failed to create project: ${err instanceof Error ? err.message : String(err)}`, + { + remediationHint: + 'Check that the repository is not already imported and that you have permission to create projects in this organization.', + }, + ); + } + + if (result.projects.length === 0) { + throw new CommandFailedError( + 'provision_projects returned no project — the repository may already be bound, or the ' + + 'installation key was rejected by the server.', + ); + } + const project = result.projects[0]; - outro('Repository selected', 'success'); + outro(`Project created: ${project.projectKey}`, 'success'); } diff --git a/src/sonarqube/client.ts b/src/sonarqube/client.ts index fa47aa855..cff65302d 100644 --- a/src/sonarqube/client.ts +++ b/src/sonarqube/client.ts @@ -62,6 +62,7 @@ export interface Organization { name: string; alm?: { key: string }; actions?: { admin: boolean }; + onlyPrivateProjects?: { enabled: boolean }; } export interface DopRepository { @@ -74,6 +75,10 @@ export interface DopRepository { importedInCurrentOrg: boolean; } +export interface ProvisionedProject { + projectKey: string; +} + export class SonarQubeClient { private readonly serverURL: string; private readonly token: string; @@ -290,6 +295,29 @@ export class SonarQubeClient { await this.raiseForStatus(response, 'POST'); } + /** + * Like `postForm`, but parses and returns the JSON response body instead of + * discarding it. Used for legacy endpoints that are + * form-encoded on the request side but return a JSON body. + */ + private async postFormJson(endpoint: string, params: Record): Promise { + const url = `${this.serverURL}${endpoint}`; + const response = await fetchGuarded( + url, + buildFetchInit( + 'POST', + this.commonHeaders('form'), + POST_REQUEST_TIMEOUT_MS, + new URLSearchParams(params).toString(), + buildFetchNetworkOptions(url), + ), + ); + + await this.raiseForStatus(response, 'POST'); + + return (await response.json()) as T; + } + /** * Revoke a user token on the server by its name. * @@ -480,6 +508,30 @@ export class SonarQubeClient { return this.checkCagEntitlement(uuid); } + /** + * Check whether an organization is entitled to a specific billing feature via + * `GET /billing/entitlements` (SonarQube Cloud only, region-specific API host). + */ + async checkBillingEntitlement(organizationUuid: string, entitlement: string): Promise { + try { + const endpoint = '/billing/entitlements'; + const result = await this.get<{ entitlements: Array<{ allowedFeatures: string[] }> }>( + endpoint, + { resourceId: organizationUuid, resourceType: 'organization' }, + resolveFromEndpoint(this.serverURL, endpoint), + ); + return result.entitlements.some((e) => e.allowedFeatures.includes(entitlement)); + } catch { + return false; + } + } + + async hasPrivateProjectsEntitlement(organizationKey: string): Promise { + const uuid = await this.getOrganizationId(organizationKey); + if (!uuid) return false; + return this.checkBillingEntitlement(uuid, 'privateProjects'); + } + async checkAiRemediationEntitlement( orgKey: string, ): Promise<{ status: 'not_eligible' | 'not_enabled' | 'ok' | 'unknown' }> { @@ -569,6 +621,45 @@ export class SonarQubeClient { return { repositories: result.repositories, total: result.page.total }; } + /** + * Create (provision) a SonarQube project bound to a single DevOps platform repository via the + * legacy `POST /api/alm_integration/provision_projects` endpoint (SonarQube Cloud only). This + * has not migrated to the newer `dop-translation` family yet. + * + * `installationKey` must already be in the ALM-specific format the server expects (e.g. + * `|` for GitHub, plain `id` for other platforms). + */ + async provisionProject( + organization: string, + installationKey: string, + ): Promise<{ projects: ProvisionedProject[] }> { + return await this.postFormJson<{ projects: ProvisionedProject[] }>( + '/api/alm_integration/provision_projects', + { organization, installationKeys: installationKey }, + ); + } + + /** + * ALM-type lookup via `GET /dop-translation/organization-bindings` (SonarQube Cloud only, + * region-specific API host), keyed by the org's **legacy** id (not `uuidV4`). Used to format + * `provision_projects`' `installationKeys` param correctly for the org's connected DevOps + * platform. + */ + async getOrganizationAlmKey(organizationKey: string): Promise { + const organizationId = await this.getOrganizationLegacyId(organizationKey); + if (!organizationId) return undefined; + + try { + const endpoint = '/dop-translation/organization-bindings'; + const result = await this.get<{ + organizationBindings: Array<{ devOpsPlatform: string }>; + }>(endpoint, { organizationId }, resolveFromEndpoint(this.serverURL, endpoint)); + return result.organizationBindings[0]?.devOpsPlatform; + } catch { + return undefined; + } + } + /** * Fetch project-scoped settings via `/api/settings/values`. The `component` * query param scopes the values to a specific project; without it the API diff --git a/tests/integration/harness/fake-sonarqube-server.ts b/tests/integration/harness/fake-sonarqube-server.ts index e89794b35..e25eefd60 100644 --- a/tests/integration/harness/fake-sonarqube-server.ts +++ b/tests/integration/harness/fake-sonarqube-server.ts @@ -133,6 +133,8 @@ export class FakeSonarQubeServerBuilder { { uuid: string; eligible: boolean; enabled: boolean } > = new Map(); private readonly cagEntitlementOrgs: Map = new Map(); + /** Keyed by org UUID (`resourceId`), for `GET /billing/entitlements`. */ + private readonly privateProjectsEntitlements: Map = new Map(); private validToken?: string; private systemStatusCode = 200; private systemVersion = '9.9.0.00001'; @@ -155,6 +157,8 @@ export class FakeSonarQubeServerBuilder { private orgsLookupReturnsEmpty = false; private orgsLookupErrorCode?: number; private serverMode: 'MQR' | 'STANDARD' = 'STANDARD'; + private provisionProjectsStatusCode?: number; + private provisionProjectsStatusBody?: string; withMode(mode: 'MQR' | 'STANDARD'): this { this.serverMode = mode; @@ -293,6 +297,26 @@ export class FakeSonarQubeServerBuilder { return this; } + /** + * Configure GET /billing/entitlements?resourceId=&resourceType=organization for an + * org's `uuidV4` (defaults to `-uuid-v4`, matching `/organizations/organizations`' + * default when no CAG/SQAA entitlement overrides it). + */ + withPrivateProjectsEntitlement(orgKey: string, allowed: boolean, uuid?: string): this { + this.privateProjectsEntitlements.set(uuid ?? `${orgKey}-uuid-v4`, allowed); + return this; + } + + /** + * Force POST /api/alm_integration/provision_projects to fail with the given HTTP status + * code, simulating a provisioning error (e.g. repo already imported, permission denied). + */ + withProvisionProjectsError(statusCode: number, body?: string): this { + this.provisionProjectsStatusCode = statusCode; + this.provisionProjectsStatusBody = body; + return this; + } + /** * Configure the response of the SCA availability endpoints * (`/sca/feature-enabled` for cloud, `/api/v2/sca/feature-enabled` for on-premise). @@ -332,6 +356,7 @@ export class FakeSonarQubeServerBuilder { sqaaPayloadLimit, sqaaEntitlementOrgs, cagEntitlementOrgs, + privateProjectsEntitlements, scaEnabled, cagEntitlementStatusCode, cagEntitlementStatusBody, @@ -342,6 +367,8 @@ export class FakeSonarQubeServerBuilder { remediationAgentEntitlement, orgsLookupReturnsEmpty, orgsLookupErrorCode, + provisionProjectsStatusCode, + provisionProjectsStatusBody, } = this; const memberOrganizationsTotal = rawMemberOrganizationsTotal ?? memberOrganizations.length; const requests: RecordedRequest[] = []; @@ -664,6 +691,51 @@ export class FakeSonarQubeServerBuilder { ); } + if (path === '/dop-translation/organization-bindings') { + // `organizationId` here is the org's legacy id, which `/organizations/organizations` + // defaults to the org key itself in this fake server (see above), so matching on + // `key` mirrors real lookup-by-legacy-id behavior for these tests. + const org = memberOrganizations.find((o) => o.key === query.organizationId); + const organizationBindings = org?.alm ? [{ devOpsPlatform: org.alm.key }] : []; + return new Response( + JSON.stringify({ + organizationBindings, + page: { pageIndex: 1, pageSize: 50, total: organizationBindings.length }, + }), + { headers: { 'Content-Type': 'application/json' } }, + ); + } + + if (path === '/billing/entitlements' && req.method === 'GET') { + const allowed = privateProjectsEntitlements.get(query.resourceId ?? '') ?? false; + return new Response( + JSON.stringify({ + entitlements: allowed ? [{ allowedFeatures: ['privateProjects'] }] : [], + }), + { headers: { 'Content-Type': 'application/json' } }, + ); + } + + if (path === '/api/alm_integration/provision_projects' && req.method === 'POST') { + if (provisionProjectsStatusCode !== undefined) { + return new Response( + provisionProjectsStatusBody ?? + JSON.stringify({ errors: [{ msg: 'Provisioning failed' }] }), + { + status: provisionProjectsStatusCode, + headers: { 'Content-Type': 'application/json' }, + }, + ); + } + const params = new URLSearchParams(body ?? ''); + const organization = params.get('organization') ?? ''; + const installationKeys = params.get('installationKeys') ?? ''; + const projectKey = `${organization}_${installationKeys}`.replace(/[^a-zA-Z0-9_-]/g, '_'); + return new Response(JSON.stringify({ projects: [{ projectKey }] }), { + headers: { 'Content-Type': 'application/json' }, + }); + } + if (path === '/api/settings/values' && req.method === 'GET') { const component = query.component; if (component && !projects.has(component)) { diff --git a/tests/integration/specs/import/import.test.ts b/tests/integration/specs/import/import.test.ts index c3039112e..4b0ce4c22 100644 --- a/tests/integration/specs/import/import.test.ts +++ b/tests/integration/specs/import/import.test.ts @@ -30,6 +30,12 @@ const GITHUB_ALM = { personal: false, membersSync: false, }; +const GITLAB_ALM = { + key: 'gitlab', + url: 'https://gitlab.com/my-org', + personal: false, + membersSync: false, +}; const ADMIN_ACTIONS = { admin: true }; // `/organizations/organizations` defaults an org's legacy ID to its key @@ -127,7 +133,9 @@ describe('sonar import', () => { expect(result.exitCode).toBe(0); expect(result.stdout).toContain('org-configured'); - expect(result.stdout).not.toContain('org-empty'); + // org-empty is filtered out of the select prompt's options, even though it still + // appears in the raw `/api/organizations/search` response the debug trace logs. + expect(result.stdout).not.toContain('Empty Org (org-empty)'); }, { timeout: 15000 }, ); @@ -168,6 +176,9 @@ describe('sonar import', () => { { key: 'org-one', name: 'Organization One', alm: GITHUB_ALM, actions: ADMIN_ACTIONS }, { key: 'org-two', name: 'Organization Two', alm: GITHUB_ALM, actions: ADMIN_ACTIONS }, ]) + .withDopRepositories('org-two', [ + { id: 'repo-1', name: 'some-repo', slug: 'org-two/some-repo' }, + ]) .start(); const serverUrl = server.baseUrl(); harness.withAuth(serverUrl, 'test-token'); @@ -205,7 +216,13 @@ describe('sonar import', () => { it( 'succeeds with --non-interactive when --org and --repo are provided', async () => { - const server = await harness.newFakeServer().withAuthToken('test-token').start(); + const server = await harness + .newFakeServer() + .withAuthToken('test-token') + .withDopRepositories('my-org', [ + { id: 'repo-1', name: 'some-repo', slug: 'my-org/some-repo' }, + ]) + .start(); const serverUrl = server.baseUrl(); harness.withAuth(serverUrl, 'test-token'); @@ -387,12 +404,15 @@ describe('sonar import', () => { ); it( - 'uses --repo flag and skips the repo lookup entirely', + 'uses --repo flag directly, skipping the select prompt but still resolving its installation key', async () => { const server = await harness .newFakeServer() .withAuthToken('test-token') - .withDopRepositories('my-org', SAMPLE_REPOS) + .withDopRepositories('my-org', [ + ...SAMPLE_REPOS, + { id: 'repo-2', name: 'other-repo', slug: 'kevinmlsilva/other-repo' }, + ]) .start(); const serverUrl = server.baseUrl(); harness.withAuth(serverUrl, 'test-token'); @@ -404,7 +424,8 @@ describe('sonar import', () => { expect(result.exitCode).toBe(0); expect(result.stdout).toContain('kevinmlsilva/other-repo'); const recorded = server.getRecordedRequests(); - expect(recorded.some((r) => r.path === '/dop-translation/dop-repositories')).toBe(false); + const repoRequests = recorded.filter((r) => r.path === '/dop-translation/dop-repositories'); + expect(repoRequests).toHaveLength(1); }, { timeout: 15000 }, ); @@ -446,13 +467,14 @@ describe('sonar import', () => { ); it( - 'marks repos already imported into the current org', + 'excludes repos already imported into the current org from the select list', async () => { const server = await harness .newFakeServer() .withAuthToken('test-token') .withDopRepositories('my-org', [ { id: 'repo-1', name: 'repo', slug: 'kevinmlsilva/repo', importedInCurrentOrg: true }, + { id: 'repo-2', name: 'other-repo', slug: 'kevinmlsilva/other-repo' }, ]) .start(); const serverUrl = server.baseUrl(); @@ -464,7 +486,32 @@ describe('sonar import', () => { }); expect(result.exitCode).toBe(0); - expect(result.stdout).toContain('kevinmlsilva/repo (already imported)'); + expect(result.stdout).not.toContain('kevinmlsilva/repo -'); + expect(result.stdout).toContain('kevinmlsilva/other-repo - public'); + }, + { timeout: 15000 }, + ); + + it( + 'exits with code 1 when every repository is already imported', + async () => { + const server = await harness + .newFakeServer() + .withAuthToken('test-token') + .withDopRepositories('my-org', [ + { id: 'repo-1', name: 'repo', slug: 'kevinmlsilva/repo', importedInCurrentOrg: true }, + ]) + .start(); + const serverUrl = server.baseUrl(); + harness.withAuth(serverUrl, 'test-token'); + + const result = await harness.run('import --org my-org', { + extraEnv: { SONARQUBE_CLI_SONARCLOUD_URL: serverUrl }, + }); + + expect(result.exitCode).toBe(1); + const output = result.stdout + result.stderr; + expect(output).toContain('already been imported into SonarQube'); }, { timeout: 15000 }, ); @@ -532,4 +579,310 @@ describe('sonar import', () => { { timeout: 15000 }, ); }); + + describe('project provisioning', () => { + it( + 'creates the project and prints its key after selecting org and repo interactively', + async () => { + const server = await harness + .newFakeServer() + .withAuthToken('test-token') + .withOrganizations([ + { key: 'my-org', name: 'My Organization', alm: GITHUB_ALM, actions: ADMIN_ACTIONS }, + ]) + .withDopRepositories('my-org', SAMPLE_REPOS) + .start(); + const serverUrl = server.baseUrl(); + harness.withAuth(serverUrl, 'test-token'); + + const result = await harness.run('import', { + stdinChunks: ['\r', '\r'], // enter → selects the only org, enter → selects the only repo + extraEnv: { SONARQUBE_CLI_SONARCLOUD_URL: serverUrl }, + }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('Project created:'); + const recorded = server.getRecordedRequests(); + const provisionRequest = recorded.find( + (r) => r.path === '/api/alm_integration/provision_projects', + ); + const params = new URLSearchParams(provisionRequest?.body ?? ''); + expect(params.get('organization')).toBe('my-org'); + // GitHub installation keys are formatted as `|`. + expect(params.get('installationKeys')).toBe('kevinmlsilva/repo|repo-1'); + }, + { timeout: 15000 }, + ); + + it( + 'sends the plain repo id as installationKeys for non-GitHub organizations', + async () => { + const server = await harness + .newFakeServer() + .withAuthToken('test-token') + .withOrganizations([ + { key: 'my-org', name: 'My Organization', alm: GITLAB_ALM, actions: ADMIN_ACTIONS }, + ]) + .withDopRepositories('my-org', SAMPLE_REPOS) + .start(); + const serverUrl = server.baseUrl(); + harness.withAuth(serverUrl, 'test-token'); + + const result = await harness.run('import', { + stdinChunks: ['\r', '\r'], + extraEnv: { SONARQUBE_CLI_SONARCLOUD_URL: serverUrl }, + }); + + expect(result.exitCode).toBe(0); + const recorded = server.getRecordedRequests(); + const provisionRequest = recorded.find( + (r) => r.path === '/api/alm_integration/provision_projects', + ); + const params = new URLSearchParams(provisionRequest?.body ?? ''); + expect(params.get('installationKeys')).toBe('repo-1'); + }, + { timeout: 15000 }, + ); + + it( + 'looks up the ALM type via organization-bindings when --org is passed directly', + async () => { + const server = await harness + .newFakeServer() + .withAuthToken('test-token') + .withOrganizations([ + { key: 'my-org', name: 'My Organization', alm: GITHUB_ALM, actions: ADMIN_ACTIONS }, + ]) + .withDopRepositories('my-org', SAMPLE_REPOS) + .start(); + const serverUrl = server.baseUrl(); + harness.withAuth(serverUrl, 'test-token'); + + const result = await harness.run('import --org my-org', { + stdin: '\r', // enter → selects the only repo + extraEnv: { SONARQUBE_CLI_SONARCLOUD_URL: serverUrl }, + }); + + expect(result.exitCode).toBe(0); + const recorded = server.getRecordedRequests(); + expect(recorded.some((r) => r.path === '/dop-translation/organization-bindings')).toBe( + true, + ); + const provisionRequest = recorded.find( + (r) => r.path === '/api/alm_integration/provision_projects', + ); + const params = new URLSearchParams(provisionRequest?.body ?? ''); + expect(params.get('installationKeys')).toBe('kevinmlsilva/repo|repo-1'); + }, + { timeout: 15000 }, + ); + + it( + 'exits with code 1 when provisioning fails', + async () => { + const server = await harness + .newFakeServer() + .withAuthToken('test-token') + .withDopRepositories('my-org', SAMPLE_REPOS) + .withProvisionProjectsError( + 400, + JSON.stringify({ errors: [{ msg: 'Repository already imported' }] }), + ) + .start(); + const serverUrl = server.baseUrl(); + harness.withAuth(serverUrl, 'test-token'); + + const result = await harness.run('import --org my-org', { + stdin: '\r', + extraEnv: { SONARQUBE_CLI_SONARCLOUD_URL: serverUrl }, + }); + + expect(result.exitCode).toBe(1); + const output = result.stdout + result.stderr; + expect(output).toContain('Failed to create project'); + }, + { timeout: 15000 }, + ); + + it( + 'exits with code 1 when --repo does not match any repository in the DevOps platform', + async () => { + const server = await harness + .newFakeServer() + .withAuthToken('test-token') + .withDopRepositories('my-org', SAMPLE_REPOS) + .start(); + const serverUrl = server.baseUrl(); + harness.withAuth(serverUrl, 'test-token'); + + const result = await harness.run('import --org my-org --repo kevinmlsilva/does-not-exist', { + extraEnv: { SONARQUBE_CLI_SONARCLOUD_URL: serverUrl }, + }); + + expect(result.exitCode).toBe(1); + const output = result.stdout + result.stderr; + expect(output).toContain("not found in the selected organization's DevOps platform"); + }, + { timeout: 15000 }, + ); + + it( + 'rejects --repo for a public repo when onlyPrivateProjects is unavailable', + async () => { + const server = await harness + .newFakeServer() + .withAuthToken('test-token') + .withOrganizations([ + { + key: 'my-org', + name: 'My Organization', + alm: GITHUB_ALM, + actions: ADMIN_ACTIONS, + onlyPrivateProjects: { enabled: false }, + }, + ]) + .withDopRepositories('my-org', [ + { id: 'repo-1', name: 'repo', slug: 'kevinmlsilva/repo', private: true }, + ]) + .start(); + const serverUrl = server.baseUrl(); + harness.withAuth(serverUrl, 'test-token'); + + const result = await harness.run('import --repo kevinmlsilva/repo', { + stdin: '\r', // enter → selects the only org + extraEnv: { SONARQUBE_CLI_SONARCLOUD_URL: serverUrl }, + }); + + expect(result.exitCode).toBe(1); + const output = result.stdout + result.stderr; + expect(output).toContain( + "isn't allowed by this organization's project visibility settings", + ); + }, + { timeout: 15000 }, + ); + + it( + 'allows --repo for a private repo when onlyPrivateProjects requires private-only', + async () => { + const server = await harness + .newFakeServer() + .withAuthToken('test-token') + .withOrganizations([ + { + key: 'my-org', + name: 'My Organization', + alm: GITHUB_ALM, + actions: ADMIN_ACTIONS, + onlyPrivateProjects: { enabled: true }, + }, + ]) + .withPrivateProjectsEntitlement('my-org', true) + .withDopRepositories('my-org', [ + { id: 'repo-1', name: 'repo', slug: 'kevinmlsilva/repo', private: true }, + ]) + .start(); + const serverUrl = server.baseUrl(); + harness.withAuth(serverUrl, 'test-token'); + + const result = await harness.run('import --repo kevinmlsilva/repo', { + stdin: '\r', // enter → selects the only org + extraEnv: { SONARQUBE_CLI_SONARCLOUD_URL: serverUrl }, + }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('Project created:'); + }, + { timeout: 15000 }, + ); + + it( + 'labels a private repo in the select prompt', + async () => { + const server = await harness + .newFakeServer() + .withAuthToken('test-token') + .withPrivateProjectsEntitlement('my-org', true) + .withDopRepositories('my-org', [ + { id: 'repo-1', name: 'repo', slug: 'kevinmlsilva/repo', private: true }, + ]) + .start(); + const serverUrl = server.baseUrl(); + harness.withAuth(serverUrl, 'test-token'); + + const result = await harness.run('import --org my-org', { + stdin: '\r', // enter → selects the only repo + extraEnv: { SONARQUBE_CLI_SONARCLOUD_URL: serverUrl }, + }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('kevinmlsilva/repo - private'); + }, + { timeout: 15000 }, + ); + + it( + 'filters out repos that fail visibility rules from the interactive select prompt', + async () => { + const server = await harness + .newFakeServer() + .withAuthToken('test-token') + .withDopRepositories('my-org', [ + { + id: 'repo-1', + name: 'private-repo', + slug: 'kevinmlsilva/private-repo', + private: true, + }, + { id: 'repo-2', name: 'public-repo', slug: 'kevinmlsilva/public-repo', private: false }, + ]) + .start(); + const serverUrl = server.baseUrl(); + harness.withAuth(serverUrl, 'test-token'); + + // No private-projects entitlement configured → org is public-only, so the private + // repo must be dropped from the list rather than merely shown as disabled. + const result = await harness.run('import --org my-org', { + stdin: '\r', // enter → selects the only remaining (public) repo + extraEnv: { SONARQUBE_CLI_SONARCLOUD_URL: serverUrl }, + }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('kevinmlsilva/public-repo'); + expect(result.stdout).not.toContain('kevinmlsilva/private-repo'); + }, + { timeout: 15000 }, + ); + + it( + 'exits with code 1 when no repositories match visibility settings', + async () => { + const server = await harness + .newFakeServer() + .withAuthToken('test-token') + .withDopRepositories('my-org', [ + { + id: 'repo-1', + name: 'private-repo', + slug: 'kevinmlsilva/private-repo', + private: true, + }, + ]) + .start(); + const serverUrl = server.baseUrl(); + harness.withAuth(serverUrl, 'test-token'); + + const result = await harness.run('import --org my-org', { + extraEnv: { SONARQUBE_CLI_SONARCLOUD_URL: serverUrl }, + }); + + expect(result.exitCode).toBe(1); + const output = result.stdout + result.stderr; + expect(output).toContain( + "No repositories match this organization's project visibility settings.", + ); + }, + { timeout: 15000 }, + ); + }); });