Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ The bare `sonar analyze` command accepts `-p, --project <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 <key>` and `--repo <slug>` (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 <key>` and `--repo <slug>` (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`.

Expand Down
183 changes: 168 additions & 15 deletions src/cli/commands/import/_common/resolve-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
* `<slug>|<id>` 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<string> {
): Promise<ResolvedOrg> {
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(
Expand Down Expand Up @@ -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})`,
}));

Expand All @@ -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<string> {
if (opts.repo) return opts.repo;

if (opts.nonInteractive) {
): Promise<ResolvedRepo> {
if (opts.nonInteractive && !opts.repo) {
throw new InvalidOptionError(
'--repo is required in non-interactive mode',
'Pass --repo <slug> to specify a repository directly.',
Expand All @@ -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<ResolvedRepo> {
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<ResolvedRepo> {
let repos: RepositoryCollection;
try {
repos = await withSpinner('Loading repositories...', () =>
Expand All @@ -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<{
Comment thread
gitar-bot[bot] marked this conversation as resolved.
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...' });
Expand All @@ -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<DopRepository[]> {
// 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();
}
}
51 changes: 46 additions & 5 deletions src/cli/commands/import/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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');
}
Loading