From 207c4e8df6450f26d9a67660a36937711ef48db0 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Tue, 3 Nov 2026 19:59:54 +0000 Subject: [PATCH] fix(cli): Say how to connect an integration when set-commits --auto finds no repos When the org has no repositories in Sentry, `release set-commits --auto` only said "No repository integrations configured for this organization." (about 27% of CLI-2SK). The error now links the org's source code integrations page, suggests `sentry integration list /`, and suggests `--local` when run inside a git checkout. It stays an ApiError with the same message and 404 status, so default mode still falls back to local history and error reporting still silences it. Co-Authored-By: Claude --- packages/cli/src/lib/api/releases.ts | 68 ++++++++++++++++--- packages/cli/src/lib/sentry-urls.ts | 15 ++++ packages/cli/src/lib/sentry-web-urls.ts | 5 ++ packages/cli/test/lib/api/releases.test.ts | 23 ++++++- .../cli/test/lib/sentry-urls.property.test.ts | 15 ++++ 5 files changed, 114 insertions(+), 12 deletions(-) diff --git a/packages/cli/src/lib/api/releases.ts b/packages/cli/src/lib/api/releases.ts index 5f477df42..8cc1c003c 100644 --- a/packages/cli/src/lib/api/releases.ts +++ b/packages/cli/src/lib/api/releases.ts @@ -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, @@ -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, @@ -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 { + 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. * @@ -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). * diff --git a/packages/cli/src/lib/sentry-urls.ts b/packages/cli/src/lib/sentry-urls.ts index 184d92d0a..9816e7348 100644 --- a/packages/cli/src/lib/sentry-urls.ts +++ b/packages/cli/src/lib/sentry-urls.ts @@ -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. * diff --git a/packages/cli/src/lib/sentry-web-urls.ts b/packages/cli/src/lib/sentry-web-urls.ts index 70026853a..4e15d6b1e 100644 --- a/packages/cli/src/lib/sentry-web-urls.ts +++ b/packages/cli/src/lib/sentry-web-urls.ts @@ -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, @@ -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)); } diff --git a/packages/cli/test/lib/api/releases.test.ts b/packages/cli/test/lib/api/releases.test.ts index 77071c25f..53e51c6ed 100644 --- a/packages/cli/test/lib/api/releases.test.ts +++ b/packages/cli/test/lib/api/releases.test.ts @@ -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"; @@ -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 }, diff --git a/packages/cli/test/lib/sentry-urls.property.test.ts b/packages/cli/test/lib/sentry-urls.property.test.ts index c2f584edd..8d26cc96f 100644 --- a/packages/cli/test/lib/sentry-urls.property.test.ts +++ b/packages/cli/test/lib/sentry-urls.property.test.ts @@ -25,6 +25,7 @@ import { buildProjectIssuesUrl, buildProjectUrl, buildRepositorySettingsUrl, + buildSourceCodeIntegrationsUrl, buildSeerSettingsUrl, buildTraceUrl, getOrgBaseUrl, @@ -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(