diff --git a/apps/cli-docs/src/fragments/commands/release.md b/apps/cli-docs/src/fragments/commands/release.md index 3ee1fee6e..ef54f842d 100644 --- a/apps/cli-docs/src/fragments/commands/release.md +++ b/apps/cli-docs/src/fragments/commands/release.md @@ -11,5 +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. +- **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` takes the name that `sentry repo list` shows. When a name only differs from Sentry's (for example in case), the CLI uses Sentry's name and prints a warning. 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 c17ed9dba..95e5c278f 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 { + correctRefRepositories, explainSetCommitsError, NO_REPO_INTEGRATIONS_MESSAGE, setCommitsAuto, @@ -526,7 +527,13 @@ export const setCommitsCommand = buildCommand({ try { release = await setCommitsWithRefs(org, version, refs); } catch (error) { - throw await explainSetCommitsError(org, version, refs, error); + // Retry once with Sentry's names when they only differ in case or + // form; otherwise explain how to fix the repository. + const corrected = await correctRefRepositories(org, refs, error); + if (!corrected) { + throw await explainSetCommitsError(org, version, refs, error); + } + release = await setCommitsWithRefs(org, version, corrected); } yield new CommandOutput(release); return; diff --git a/packages/cli/src/lib/api-client.ts b/packages/cli/src/lib/api-client.ts index b8eaa0f5e..2252b63e2 100644 --- a/packages/cli/src/lib/api-client.ts +++ b/packages/cli/src/lib/api-client.ts @@ -164,6 +164,7 @@ export { tryGetPrimaryDsn, } from "./api/projects.js"; export { + correctRefRepositories, createRelease, createReleaseDeploy, deleteRelease, diff --git a/packages/cli/src/lib/api/releases.ts b/packages/cli/src/lib/api/releases.ts index f1f19d535..4d0ab0b55 100644 --- a/packages/cli/src/lib/api/releases.ts +++ b/packages/cli/src/lib/api/releases.ts @@ -774,6 +774,79 @@ const COMMIT_REPOSITORY_FIELD = "commit.repository"; */ const INVALID_REPOSITORY_NAMES_RE = /Invalid repository names: ([^"\]]+)/; +/** + * Repository names from the server's "Invalid repository names" 400, or + * `undefined` for any other error. + */ +function invalidRepositoryNames(error: unknown): string[] | undefined { + if (!(error instanceof ApiError && error.status === 400)) { + return; + } + const invalid = error.detail?.match(INVALID_REPOSITORY_NAMES_RE)?.[1]; + return invalid?.split(",").map((n) => n.trim()); +} + +/** + * Replace `--commit` repository names that Sentry rejected with the name of + * the same repository in Sentry, when the names only differ (case, GitLab + * display names, or a name that matches the repository URL). + * + * Sentry matches `--commit` names exactly, while build tools pass what the + * host reports, such as `VERCEL_GIT_REPO_OWNER/VERCEL_GIT_REPO_SLUG`. Each + * replacement is logged as a warning so the caller can fix the name. + * + * @param orgSlug - Organization slug + * @param refs - The refs that were sent + * @param error - The error thrown by {@link setCommitsWithRefs} + * @returns The refs with Sentry's names, or `undefined` when the error is not + * an "Invalid repository names" 400 or a rejected repository has no match + */ +export async function correctRefRepositories( + orgSlug: string, + refs: CommitRef[], + error: unknown, +): Promise { + const names = invalidRepositoryNames(error); + if (!names) { + return; + } + let matches: (SentryRepository | undefined)[]; + try { + matches = await Promise.all( + refs.map(async (ref) => + names.includes(ref.repository) + ? (await findSentryRepository(orgSlug, ref.repository)).match + : undefined, + ), + ); + } catch (lookupError) { + log.debug("Could not look up repositories for --commit", lookupError); + return; + } + const corrected: CommitRef[] = []; + const replaced: { from: string; to: string }[] = []; + for (const [index, ref] of refs.entries()) { + if (!names.includes(ref.repository)) { + corrected.push(ref); + continue; + } + const match = matches[index]; + if (!match || match.name === ref.repository) { + return; + } + replaced.push({ from: ref.repository, to: match.name }); + corrected.push({ ...ref, repository: match.name }); + } + // Warn only once every rejected name has a match, so no warning claims a + // replacement that the caller never retries with. + for (const { from, to } of replaced) { + log.warn( + `Sentry organization '${orgSlug}' has no repository named '${from}'. Using '${to}', the same repository in Sentry.`, + ); + } + return corrected; +} + /** * 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 @@ -796,14 +869,10 @@ export async function explainSetCommitsError( 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) { + const names = invalidRepositoryNames(error); + if (!names) { 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 diff --git a/packages/cli/test/commands/release/set-commits.test.ts b/packages/cli/test/commands/release/set-commits.test.ts index bd673a793..9d93e4307 100644 --- a/packages/cli/test/commands/release/set-commits.test.ts +++ b/packages/cli/test/commands/release/set-commits.test.ts @@ -124,6 +124,9 @@ describe("release set-commits --commit", () => { "organizations/my-org/releases/1.0.0/", ); setCommitsWithRefsSpy.mockRejectedValue(serverError); + const correctSpy = vi + .spyOn(apiClient, "correctRefRepositories") + .mockResolvedValue(undefined); const explainSpy = vi .spyOn(apiClient, "explainSetCommitsError") .mockResolvedValue(new ValidationError("explained", "commit.repository")); @@ -151,9 +154,54 @@ describe("release set-commits --commit", () => { [{ repository: "owner/repo", commit: "abc123" }], serverError, ); + correctSpy.mockRestore(); explainSpy.mockRestore(); }); + test("retries with Sentry's repository name when only the form differs", 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 + .mockRejectedValueOnce(serverError) + .mockResolvedValueOnce(sampleRelease); + const corrected = [{ repository: "owner/repo", commit: "abc123" }]; + const correctSpy = vi + .spyOn(apiClient, "correctRefRepositories") + .mockResolvedValue(corrected); + + const { context } = createMockContext(); + const func = await setCommitsCommand.loader(); + await func.call( + context, + { + auto: false, + local: false, + clear: false, + commit: "Owner/Repo@abc123", + "initial-depth": 20, + json: true, + }, + "1.0.0", + ); + + expect(correctSpy).toHaveBeenCalledWith( + "my-org", + [{ repository: "Owner/Repo", commit: "abc123" }], + serverError, + ); + expect(setCommitsWithRefsSpy).toHaveBeenLastCalledWith( + "my-org", + "1.0.0", + corrected, + ); + correctSpy.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 e628bcd5f..170742003 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, + correctRefRepositories, explainSetCommitsError, getRelease, listReleaseDeploys, @@ -1099,6 +1100,127 @@ describe("explainSetCommitsError", () => { }); }); +describe("correctRefRepositories", () => { + const REFS = [ + { + repository: "AutonomousBusinessUnits/platform", + commit: "def456", + previousCommit: "abc123", + }, + ]; + const invalidRepoError = new ApiError( + "API request failed: 400 Bad Request", + 400, + JSON.stringify({ + refs: ["Invalid repository names: AutonomousBusinessUnits/platform"], + }), + "organizations/test-org/releases/1.0.0/", + ); + const reposResponse = (repos: unknown[]) => + mockFetch( + async () => + new Response(JSON.stringify(repos), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + const repo = (name: string) => ({ + id: name, + name, + url: `https://github.com/${name}`, + provider: { id: "integrations:github", name: "GitHub" }, + status: "active", + }); + + test("uses Sentry's name when only the case differs", async () => { + globalThis.fetch = reposResponse([ + repo("autonomousbusinessunits/platform"), + ]); + const stderr = vi.spyOn(process.stderr, "write").mockReturnValue(true); + try { + expect( + await correctRefRepositories("test-org", REFS, invalidRepoError), + ).toEqual([ + { + repository: "autonomousbusinessunits/platform", + commit: "def456", + previousCommit: "abc123", + }, + ]); + expect(stderr.mock.calls.join("")).toContain( + "Using 'autonomousbusinessunits/platform'", + ); + } finally { + stderr.mockRestore(); + } + }); + + test("keeps the error when Sentry has no matching repository", async () => { + globalThis.fetch = reposResponse([repo("AutonomousBusinessUnits/web")]); + + expect( + await correctRefRepositories("test-org", REFS, invalidRepoError), + ).toBeUndefined(); + }); + + test("warns about no replacement when a later name has no match", async () => { + const refs = [ + ...REFS, + { repository: "AutonomousBusinessUnits/missing", commit: "fed654" }, + ]; + const error = new ApiError( + "API request failed: 400 Bad Request", + 400, + JSON.stringify({ + refs: [ + "Invalid repository names: AutonomousBusinessUnits/platform,AutonomousBusinessUnits/missing", + ], + }), + "organizations/test-org/releases/1.0.0/", + ); + globalThis.fetch = reposResponse([ + repo("autonomousbusinessunits/platform"), + ]); + const stderr = vi.spyOn(process.stderr, "write").mockReturnValue(true); + try { + expect( + await correctRefRepositories("test-org", refs, error), + ).toBeUndefined(); + expect(stderr.mock.calls.join("")).not.toContain("Using '"); + } finally { + stderr.mockRestore(); + } + }); + + test("ignores other errors without looking up repositories", async () => { + const fetchMock = vi.fn(); + globalThis.fetch = fetchMock; + + expect( + await correctRefRepositories( + "test-org", + REFS, + new ApiError("API request failed: 400 Bad Request", 400, "Invalid SHA"), + ), + ).toBeUndefined(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test("keeps the error when the lookup fails", async () => { + globalThis.fetch = mockFetch( + async () => + new Response(JSON.stringify({ detail: "boom" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }), + ); + + expect( + await correctRefRepositories("test-org", REFS, invalidRepoError), + ).toBeUndefined(); + }); +}); + // ============================================================================= // setCommitsLocal // =============================================================================