diff --git a/apps/cli-docs/src/fragments/commands/release.md b/apps/cli-docs/src/fragments/commands/release.md index 3c01998ee..3ee1fee6e 100644 --- a/apps/cli-docs/src/fragments/commands/release.md +++ b/apps/cli-docs/src/fragments/commands/release.md @@ -11,4 +11,5 @@ sequence. - **Version matching**: The release version must match the `release` value in your `Sentry.init()` call. If your SDK uses `"1.0.0"`, create the release as `sentry release create org/1.0.0` (version = `1.0.0`), **not** `sentry release create org/myapp/1.0.0`. - **The `org/` prefix is the org slug**: In `sentry release create sentry/1.0.0`, `sentry` is the org slug and `1.0.0` is the version. The `/` separates org from version — it is not part of the version string. - **`--auto` needs a git checkout or CI variables**: The `--auto` flag lists repos from the Sentry API and matches against your local `origin` remote URL. Without one (for example, Docker builds that exclude `.git`), it uses the repository and commit from the CI provider: GitHub Actions, GitLab CI, Bitbucket Pipelines, Vercel production builds, Render, Railway, CircleCI, Buildkite, Netlify, and Azure Pipelines. Otherwise, pass `--commit owner/repo@`. +- **Commits need a repository in Sentry**: `--auto` and `--commit` only work for repositories that a source code integration (GitHub, GitLab, Bitbucket, Azure DevOps) added to your Sentry org. `--commit` needs the name exactly as `sentry repo list` shows it. If your org has no repositories, connect an integration in Sentry under **Settings → Integrations**; the CLI cannot install integrations yet. Until then, `--local` reads commits from local git history. - **Default mode tries `--auto` first**: When neither `--auto` nor `--local` is specified, `set-commits` tries auto-discovery first and falls back to local git history if the integration isn't configured. diff --git a/packages/cli/src/commands/release/set-commits.ts b/packages/cli/src/commands/release/set-commits.ts index e9837c72a..c17ed9dba 100644 --- a/packages/cli/src/commands/release/set-commits.ts +++ b/packages/cli/src/commands/release/set-commits.ts @@ -7,6 +7,7 @@ import { inlineCode, text } from "@sentry/toolkit-core/markdown"; import type { SentryContext } from "../../context.js"; import { + explainSetCommitsError, NO_REPO_INTEGRATIONS_MESSAGE, setCommitsAuto, setCommitsLocal, @@ -521,7 +522,12 @@ export const setCommitsCommand = buildCommand({ // Explicit --commit mode: parse REPO@SHA or REPO@PREV..SHA pairs as refs if (flags.commit) { const refs = parseCommitRefs(flags.commit); - const release = await setCommitsWithRefs(org, version, refs); + let release: SentryRelease; + try { + release = await setCommitsWithRefs(org, version, refs); + } catch (error) { + throw await explainSetCommitsError(org, version, refs, error); + } yield new CommandOutput(release); return; } diff --git a/packages/cli/src/lib/api-client.ts b/packages/cli/src/lib/api-client.ts index f652704a2..b8eaa0f5e 100644 --- a/packages/cli/src/lib/api-client.ts +++ b/packages/cli/src/lib/api-client.ts @@ -167,6 +167,7 @@ export { createRelease, createReleaseDeploy, deleteRelease, + explainSetCommitsError, getRelease, type ListReleasesOptions, listProjectEnvironments, diff --git a/packages/cli/src/lib/api/releases.ts b/packages/cli/src/lib/api/releases.ts index 8cc1c003c..f1f19d535 100644 --- a/packages/cli/src/lib/api/releases.ts +++ b/packages/cli/src/lib/api/releases.ts @@ -739,11 +739,7 @@ type CommitRef = { export async function setCommitsWithRefs( orgSlug: string, version: string, - refs: Array<{ - repository: string; - commit: string; - previousCommit?: string; - }>, + refs: CommitRef[], ): Promise { const encodedOrg = encodeApiPathSegment(orgSlug); if (encodedOrg === null) { @@ -765,6 +761,106 @@ export async function setCommitsWithRefs( return data; } +/** + * Field of the {@link ValidationError} for a `--commit` repository the org + * doesn't have, so this user configuration error can be told apart (CLI-2SK). + */ +const COMMIT_REPOSITORY_FIELD = "commit.repository"; + +/** + * The server rejects refs whose repository the org doesn't have with a 400 + * whose detail is `{"refs":["Invalid repository names: a,b"]}` (Sentry + * `Release.set_refs`, which matches names exactly). + */ +const INVALID_REPOSITORY_NAMES_RE = /Invalid repository names: ([^"\]]+)/; + +/** + * Turn the server's "Invalid repository names" 400 from {@link setCommitsWithRefs} + * into an error that says how to fix it: connect a source code integration + * when the org has no repositories, use the exact Sentry name when the + * repository exists under a different name (case, GitLab display names), or + * add the repository in Sentry otherwise. + * + * Any other error is returned unchanged. Repository lookups are best-effort: + * when they fail, the error still links to the repository settings. + * + * @param orgSlug - Organization slug + * @param version - Release version + * @param refs - The refs that were sent + * @param error - The error thrown by {@link setCommitsWithRefs} + * @returns The error to throw + */ +export async function explainSetCommitsError( + orgSlug: string, + version: string, + refs: CommitRef[], + error: unknown, +): Promise { + if (!(error instanceof ApiError && error.status === 400)) { + return error; + } + const invalid = error.detail?.match(INVALID_REPOSITORY_NAMES_RE)?.[1]; + if (!invalid) { + return error; + } + const names = invalid.split(",").map((n) => n.trim()); + const ref = refs.find((r) => names.includes(r.repository)) ?? refs[0]; + const repository = ref?.repository ?? names[0] ?? ""; + const commit = ref?.previousCommit + ? `${ref.previousCommit}..${ref.commit}` + : (ref?.commit ?? ""); + const target = `${orgSlug}/${version}`; + const commitExample = (name: string) => + `sentry release set-commits ${target} --commit '${name}@${commit}'`; + const headline = `Sentry organization '${orgSlug}' has no repository named '${repository}'.`; + + let match: SentryRepository | undefined; + let candidates: SentryRepository[] = []; + let hasRepositories: boolean | undefined; + try { + const found = await findSentryRepository(orgSlug, repository); + match = found.match; + candidates = found.candidates; + hasRepositories = + found.foundAnyRepos || (await orgHasRepositories(orgSlug)); + } catch (lookupError) { + log.debug( + "Could not look up repositories for the --commit error", + lookupError, + ); + } + + if (match) { + return validationError( + `${headline} It has '${match.name}'. --commit needs the repository name exactly as Sentry shows it.`, + [commitExample(match.name)], + COMMIT_REPOSITORY_FIELD, + ); + } + if (hasRepositories === false) { + return validationError( + `${headline} It has no repositories yet, so it cannot associate commits.`, + [`sentry integration list ${orgSlug}/`], + COMMIT_REPOSITORY_FIELD, + connectIntegrationHint(orgSlug), + ); + } + const suggestions = [...new Set(candidates.map((r) => r.name))].slice( + 0, + MAX_REPO_SUGGESTIONS, + ); + const didYouMean = + suggestions.length > 0 + ? ` It has similarly named repositories: ${suggestions.join(", ")}.` + : ""; + return validationError( + `${headline}${didYouMean}`, + [...suggestions.map(commitExample), `sentry repo list ${orgSlug}/`], + COMMIT_REPOSITORY_FIELD, + `Add the repository to Sentry through your source code integration: ${buildRepositorySettingsUrl(orgSlug)}`, + ); +} + /** * Set commits on a release using explicit commit data. * diff --git a/packages/cli/test/commands/release/set-commits.test.ts b/packages/cli/test/commands/release/set-commits.test.ts index 05633ab74..bd673a793 100644 --- a/packages/cli/test/commands/release/set-commits.test.ts +++ b/packages/cli/test/commands/release/set-commits.test.ts @@ -115,6 +115,45 @@ describe("release set-commits --commit", () => { ]); }); + test("explains a repository the org does not have", async () => { + resolveOrgSpy.mockResolvedValue({ org: "my-org" }); + const serverError = new ApiError( + "API request failed: 400 Bad Request", + 400, + JSON.stringify({ refs: ["Invalid repository names: owner/repo"] }), + "organizations/my-org/releases/1.0.0/", + ); + setCommitsWithRefsSpy.mockRejectedValue(serverError); + const explainSpy = vi + .spyOn(apiClient, "explainSetCommitsError") + .mockResolvedValue(new ValidationError("explained", "commit.repository")); + + const { context } = createMockContext(); + const func = await setCommitsCommand.loader(); + await expect( + func.call( + context, + { + auto: false, + local: false, + clear: false, + commit: "owner/repo@abc123", + "initial-depth": 20, + json: true, + }, + "1.0.0", + ), + ).rejects.toThrow("explained"); + + expect(explainSpy).toHaveBeenCalledWith( + "my-org", + "1.0.0", + [{ repository: "owner/repo", commit: "abc123" }], + serverError, + ); + explainSpy.mockRestore(); + }); + test("parses REPO@PREV..SHA range", async () => { resolveOrgSpy.mockResolvedValue({ org: "my-org" }); setCommitsWithRefsSpy.mockResolvedValue(sampleRelease); diff --git a/packages/cli/test/lib/api/releases.test.ts b/packages/cli/test/lib/api/releases.test.ts index 53e51c6ed..e628bcd5f 100644 --- a/packages/cli/test/lib/api/releases.test.ts +++ b/packages/cli/test/lib/api/releases.test.ts @@ -45,6 +45,7 @@ const { createRelease, createReleaseDeploy, deleteRelease, + explainSetCommitsError, getRelease, listReleaseDeploys, listReleasesPaginated, @@ -963,6 +964,141 @@ describe("setCommitsWithRefs", () => { }); }); +describe("explainSetCommitsError", () => { + const REFS = [ + { + repository: "AutonomousBusinessUnits/platform", + commit: "def456", + previousCommit: "abc123", + }, + ]; + /** The server's 400 for a ref whose repository the org doesn't have. */ + const invalidRepoError = (name: string) => + new ApiError( + "API request failed: 400 Bad Request", + 400, + JSON.stringify({ refs: [`Invalid repository names: ${name}`] }), + "organizations/test-org/releases/1.0.0/", + ); + const repo = (name: string, url?: string) => ({ + id: name, + name, + url, + provider: { id: "integrations:github", name: "GitHub" }, + status: "active", + }); + /** Answer every repository search with `repos`. */ + const reposResponse = (repos: unknown[]) => + mockFetch( + async () => + new Response(JSON.stringify(repos), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + + test.each([ + new ApiError("API request failed: 400 Bad Request", 400, "Invalid SHA"), + new ApiError("API request failed: 500", 500, "Invalid repository names: x"), + new Error("boom"), + ])("returns other errors unchanged (%s)", async (error) => { + const fetchMock = vi.fn(); + globalThis.fetch = fetchMock; + + expect(await explainSetCommitsError("test-org", "1.0.0", REFS, error)).toBe( + error, + ); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test("names the repository Sentry has when only the case differs", async () => { + globalThis.fetch = reposResponse([ + repo( + "autonomousbusinessunits/platform", + "https://github.com/autonomousbusinessunits/platform", + ), + ]); + + const error = await explainSetCommitsError( + "test-org", + "1.0.0", + REFS, + invalidRepoError("AutonomousBusinessUnits/platform"), + ); + + expect(error).toMatchObject({ + name: "ValidationError", + field: "commit.repository", + }); + const { message } = error as Error; + expect(message).toContain( + "has no repository named 'AutonomousBusinessUnits/platform'", + ); + expect(message).toContain( + "sentry release set-commits test-org/1.0.0 --commit 'autonomousbusinessunits/platform@abc123..def456'", + ); + }); + + test("links the integration directory when the org has no repositories", async () => { + globalThis.fetch = reposResponse([]); + + const error = await explainSetCommitsError( + "test-org", + "1.0.0", + REFS, + invalidRepoError("AutonomousBusinessUnits/platform"), + ); + + expect(error).toMatchObject({ field: "commit.repository" }); + const { message } = error as Error; + expect(message).toContain("It has no repositories yet"); + expect(message).toMatch( + /\/settings\/integrations\/\?category=source%20code%20management/, + ); + }); + + test("suggests similar repositories and links the repository settings", async () => { + globalThis.fetch = reposResponse([repo("AutonomousBusinessUnits/web")]); + + const error = await explainSetCommitsError( + "test-org", + "1.0.0", + REFS, + invalidRepoError("AutonomousBusinessUnits/platform"), + ); + + const { message } = error as Error; + expect(message).toContain( + "It has similarly named repositories: AutonomousBusinessUnits/web.", + ); + expect(message).toContain( + "--commit 'AutonomousBusinessUnits/web@abc123..def456'", + ); + expect(message).toContain("sentry repo list test-org/"); + expect(message).toMatch(/\/settings\/repos\//); + }); + + test("still links the repository settings when the lookup fails", async () => { + globalThis.fetch = mockFetch( + async () => + new Response(JSON.stringify({ detail: "boom" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }), + ); + + const error = await explainSetCommitsError( + "test-org", + "1.0.0", + REFS, + invalidRepoError("AutonomousBusinessUnits/platform"), + ); + + expect(error).toMatchObject({ field: "commit.repository" }); + expect((error as Error).message).toMatch(/\/settings\/repos\//); + }); +}); + // ============================================================================= // setCommitsLocal // =============================================================================