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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 57 additions & 11 deletions packages/cli/src/lib/api/releases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,12 @@ import type {
} from "../../types/index.js";
import { getCiCheckout } from "../ci.js";
import { getEnv } from "../env.js";
import { ApiError, ValidationError, validationError } from "../errors.js";
import {
ApiError,
buildValidationMessage,
ValidationError,
validationError,
} from "../errors.js";
import {
getHeadCommit,
getRepositoryName,
Expand All @@ -32,7 +37,10 @@ import {
} from "../git.js";
import { logger } from "../logger.js";
import { resolveOrgRegion } from "../region.js";
import { buildRepositorySettingsUrl } from "../sentry-web-urls.js";
import {
buildRepositorySettingsUrl,
buildSourceCodeIntegrationsUrl,
} from "../sentry-web-urls.js";
import {
API_MAX_PER_PAGE,
apiRequestToRegion,
Expand Down Expand Up @@ -613,6 +621,44 @@ function noMatchingRepositoryError(
);
}

/** How an org with no repositories gets one: connect a source code integration. */
function connectIntegrationHint(orgSlug: string): string {
return `Connect GitHub, GitLab, Bitbucket, or Azure DevOps to Sentry and add the repository: ${buildSourceCodeIntegrationsUrl(orgSlug)}`;
}

/** Whether the org has at least one repository from a source code integration. */
async function orgHasRepositories(orgSlug: string): Promise<boolean> {
const page = await listRepositoriesPaginated(orgSlug, { perPage: 1 });
return page.data.length > 0;
}

/**
* Build the error for an org with no repositories at all. Nothing on the
* command line can fix that, so the error links to the integration directory,
* where the org connects GitHub, GitLab, Bitbucket, or Azure DevOps.
*
* Stays an {@link ApiError} with {@link NO_REPO_INTEGRATIONS_MESSAGE} so the
* default mode can recognize it and fall back to local git history.
*/
function noRepoIntegrationError(
orgSlug: string,
version: string,
cwd?: string,
): ApiError {
const detail = buildValidationMessage(connectIntegrationHint(orgSlug), [
`sentry integration list ${orgSlug}/`,
...(isInsideGitWorkTree(cwd)
? [`sentry release set-commits ${orgSlug}/${version} --local`]
: []),
]);
return new ApiError(
NO_REPO_INTEGRATIONS_MESSAGE,
404,
detail,
`organizations/${orgSlug}/repos/`,
);
}

/**
* Set commits on a release using auto-discovery mode.
*
Expand Down Expand Up @@ -664,21 +710,21 @@ export async function setCommitsAuto(
// The name filter returns nothing for an org whose repositories all have
// other names, so only report a missing integration when the org has none.
if (!foundAnyRepos) {
const anyRepo = await listRepositoriesPaginated(orgSlug, { perPage: 1 });
if (anyRepo.data.length === 0) {
const endpoint = `organizations/${orgSlug}/releases/${encodeURIComponent(version)}/`;
throw new ApiError(
NO_REPO_INTEGRATIONS_MESSAGE,
404,
undefined,
endpoint,
);
if (!(await orgHasRepositories(orgSlug))) {
throw noRepoIntegrationError(orgSlug, version, cwd);
}
}

throw noMatchingRepositoryError(orgSlug, version, checkout, candidates, cwd);
}

/** A `--commit` ref: repository plus commit or commit range. */
type CommitRef = {
repository: string;
commit: string;
previousCommit?: string;
};

/**
* Set commits on a release using explicit refs (repository + commit range).
*
Expand Down
15 changes: 15 additions & 0 deletions packages/cli/src/lib/sentry-urls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,21 @@ export function buildRepositorySettingsUrl(orgSlug: string): string {
return `${getSentryBaseUrl()}/settings/${orgSlug}/repos/`;
}

/**
* Build URL to the organization's integration directory, filtered to source
* code integrations (GitHub, GitLab, Bitbucket, Azure DevOps). Connecting one
* is how an org gets repositories that releases can associate commits with.
*
* @param orgSlug - Organization slug
* @returns Full URL to the source code integrations directory
*/
export function buildSourceCodeIntegrationsUrl(orgSlug: string): string {
const base = isSaaS()
? `${getOrgBaseUrl(orgSlug)}/settings/integrations/`
: `${getSentryBaseUrl()}/settings/${orgSlug}/integrations/`;
return `${base}?category=source%20code%20management`;
}

/**
* Build URL to billing page with optional product filter.
*
Expand Down
5 changes: 5 additions & 0 deletions packages/cli/src/lib/sentry-web-urls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
buildReplayUrl as buildReplayUrlPure,
buildRepositorySettingsUrl as buildRepositorySettingsUrlPure,
buildSeerSettingsUrl as buildSeerSettingsUrlPure,
buildSourceCodeIntegrationsUrl as buildSourceCodeIntegrationsUrlPure,
buildTraceUrl as buildTraceUrlPure,
getOrgBaseUrl as getOrgBaseUrlPure,
getSentryBaseUrl as getSentryBaseUrlPure,
Expand Down Expand Up @@ -75,6 +76,10 @@ export function buildRepositorySettingsUrl(orgSlug: string): string {
return render(() => buildRepositorySettingsUrlPure(orgSlug));
}

export function buildSourceCodeIntegrationsUrl(orgSlug: string): string {
return render(() => buildSourceCodeIntegrationsUrlPure(orgSlug));
}

export function buildBillingUrl(orgSlug: string, product?: string): string {
return render(() => buildBillingUrlPure(orgSlug, product));
}
Expand Down
23 changes: 22 additions & 1 deletion packages/cli/test/lib/api/releases.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ import { CONFIG_DIR_ENV_VAR } from "../../../src/lib/db/index.js";
import { setOrgRegion } from "../../../src/lib/db/regions.js";
import { withEnv } from "../../../src/lib/env.js";
import { classifySilenced } from "../../../src/lib/error-reporting.js";
import { EXIT, isUserError } from "../../../src/lib/errors.js";
import { ApiError, EXIT, isUserError } from "../../../src/lib/errors.js";
import type { SentryDeploy, SentryRelease } from "../../../src/types/index.js";
import { mockFetch, useTestConfigDir } from "../../helpers.js";

Expand Down Expand Up @@ -417,9 +417,30 @@ describe("setCommitsAuto", () => {
name: "ApiError",
status: 404,
message: expect.stringMatching(/No repository integrations/),
endpoint: "organizations/test-org/repos/",
detail: expect.stringMatching(
/Connect GitHub, GitLab, Bitbucket, or Azure DevOps.*\/settings\/integrations\/\?category=source%20code%20management[\s\S]*--local/,
),
});
});

test("leaves --local out of the no-integration error outside a git checkout", async () => {
mockIsInsideGitWorkTree.mockReturnValue(false);
globalThis.fetch = mockFetch(
async () =>
new Response(JSON.stringify([]), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);

const error = await setCommitsAuto("test-org", "1.0.0", "/tmp").catch(
(e: unknown) => e,
);
expect(error).toBeInstanceOf(ApiError);
expect((error as ApiError).detail).not.toContain("--local");
});

test.each([
{ body: { ok: true }, status: 200 },
{ body: "unexpected response", status: 202 },
Expand Down
15 changes: 15 additions & 0 deletions packages/cli/test/lib/sentry-urls.property.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
buildProjectIssuesUrl,
buildProjectUrl,
buildRepositorySettingsUrl,
buildSourceCodeIntegrationsUrl,
buildSeerSettingsUrl,
buildTraceUrl,
getOrgBaseUrl,
Expand Down Expand Up @@ -440,6 +441,20 @@ describe("buildRepositorySettingsUrl properties", () => {
});
});

describe("buildSourceCodeIntegrationsUrl properties", () => {
test("output is the integration directory filtered to source code", async () => {
fcAssert(
property(slugArb, (orgSlug) => {
const result = buildSourceCodeIntegrationsUrl(orgSlug);
expect(result).toMatch(
/\/settings\/(?:[^/]+\/)?integrations\/\?category=source%20code%20management$/,
);
}),
{ numRuns: DEFAULT_NUM_RUNS },
);
});
});

describe("buildBillingUrl properties", () => {
test("without product, output has no query string", async () => {
fcAssert(
Expand Down
Loading