diff --git a/packages/cli/src/commands/release/set-commits.ts b/packages/cli/src/commands/release/set-commits.ts index d89ac603f..c9c74f020 100644 --- a/packages/cli/src/commands/release/set-commits.ts +++ b/packages/cli/src/commands/release/set-commits.ts @@ -177,13 +177,13 @@ async function setCommitsDefault( } catch (error) { // Only fall back to local git when the org genuinely has no repository // integration. setCommitsAuto internally calls setCommitsWithRefs, which can - // return a server 400 for unrelated reasons (invalid refs, bad release - // state) — those must propagate, not be masked as "no integration" (which + // return a server 400 or 404 for unrelated reasons (invalid refs, missing + // release) — those must propagate, not be masked as "no integration" (which // would also poison the cache). Match on the exact client-side message since // the API exposes no stable error code for this case. if ( error instanceof ApiError && - error.status === 400 && + error.status === 404 && error.message === NO_REPO_INTEGRATIONS_MESSAGE ) { cacheNoRepoIntegration(org); diff --git a/packages/cli/src/lib/api/releases.ts b/packages/cli/src/lib/api/releases.ts index 73db501b8..05d8d6869 100644 --- a/packages/cli/src/lib/api/releases.ts +++ b/packages/cli/src/lib/api/releases.ts @@ -402,14 +402,10 @@ async function getPreviousReleaseCommit( } } -/** - * Set commits on a release using auto-discovery mode. - * - * Lists the org's repositories from the Sentry API, matches against the /** * Message of the client-side {@link ApiError} thrown by {@link setCommitsAuto} * when the org has no repository integrations. Exported so callers can - * distinguish this specific no-repo-integration case from unrelated 400s + * distinguish this specific no-repo-integration case from unrelated API errors * returned by the server (e.g. invalid commit refs), which must not be masked * as "no integration". Matched on `message` (not `detail`) because this error is * constructed client-side with `detail: undefined`. @@ -418,9 +414,16 @@ export const NO_REPO_INTEGRATIONS_MESSAGE = "No repository integrations configured for this organization."; /** - * local git remote URL to find the corresponding Sentry repo, then sends - * a refs payload with the HEAD commit SHA. This is the equivalent of the - * reference sentry-cli's `--auto` mode. + * Set commits on a release using auto-discovery mode. + * + * Looks up the org's repositories by the name from the local git remote URL to + * find the corresponding Sentry repo, then sends a refs payload with the HEAD + * commit SHA. This is the equivalent of the reference sentry-cli's `--auto` + * mode. + * + * The lookup asks the server to filter by name instead of walking every page, + * since orgs can have tens of thousands of repositories. Servers that ignore + * the filter return every repository, which the exact name match still handles. * * Requires a GitHub/GitLab/Bitbucket integration configured in Sentry * AND a local git repository whose origin remote matches a Sentry repo. @@ -429,7 +432,7 @@ export const NO_REPO_INTEGRATIONS_MESSAGE = * @param version - Release version * @param cwd - Working directory to discover git remote and HEAD from * @returns Updated release detail with commit count - * @throws {ApiError} When the org has no repository integrations (400) + * @throws {ApiError} When the org has no repository integrations (404) * @throws {ValidationError} When local git remote is missing or doesn't match any Sentry repo */ export async function setCommitsAuto( @@ -450,8 +453,6 @@ export async function setCommitsAuto( ); } - // Paginate through org repos to find one matching the local git remote. - // Stops as soon as a match is found to avoid unnecessary API calls. const localRepoLower = localRepo.toLowerCase(); let cursor: string | undefined; let foundAnyRepos = false; @@ -460,6 +461,7 @@ export async function setCommitsAuto( const result = await listRepositoriesPaginated(orgSlug, { cursor, perPage: API_MAX_PER_PAGE, + query: localRepo, }); if (result.data.length > 0) { @@ -489,9 +491,19 @@ export async function setCommitsAuto( cursor = result.nextCursor; } + // 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 endpoint = `organizations/${orgSlug}/releases/${encodeURIComponent(version)}/`; - throw new ApiError(NO_REPO_INTEGRATIONS_MESSAGE, 400, undefined, endpoint); + 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, + ); + } } throw new ValidationError( diff --git a/packages/cli/src/lib/api/repositories.ts b/packages/cli/src/lib/api/repositories.ts index d344e8167..3565095fa 100644 --- a/packages/cli/src/lib/api/repositories.ts +++ b/packages/cli/src/lib/api/repositories.ts @@ -51,13 +51,14 @@ export async function listRepositories( * Returns a single page of results with cursor metadata. * * @param orgSlug - Organization slug - * @param options - Pagination options + * @param options - Pagination options, plus `query` to keep only repositories + * whose name contains it (case-insensitive, matched by the server) * @returns Single page of repositories with cursor metadata * @throws {ApiError} When the response is not an array of repositories */ export async function listRepositoriesPaginated( orgSlug: string, - options: { cursor?: string; perPage?: number } = {}, + options: { cursor?: string; perPage?: number; query?: string } = {}, ): Promise> { const config = await getOrgSdkConfig(orgSlug); @@ -67,7 +68,8 @@ export async function listRepositoriesPaginated( query: { cursor: options.cursor, per_page: options.perPage ?? 25, - } as { cursor?: string; per_page?: number }, + query: options.query, + }, }); const paginated = unwrapPaginatedResult( diff --git a/packages/cli/test/commands/release/set-commits.test.ts b/packages/cli/test/commands/release/set-commits.test.ts index 0b9752599..c808e0cfb 100644 --- a/packages/cli/test/commands/release/set-commits.test.ts +++ b/packages/cli/test/commands/release/set-commits.test.ts @@ -308,43 +308,49 @@ describe("release set-commits (default mode)", () => { resolveOrgSpy.mockRestore(); }); - test("propagates unrelated 400 errors from setCommitsAuto", async () => { - resolveOrgSpy.mockResolvedValue({ org: "my-org" }); - setCommitsAutoSpy.mockRejectedValue( - new ApiError("Invalid commit SHA.", 400, undefined, "releases/1.0.0/"), - ); - - const repoRoot = new URL("../../..", import.meta.url).pathname.replace( - /\/$/, - "", - ); - const { context } = createMockContext(repoRoot); - const func = await setCommitsCommand.loader(); - await expect( - func.call( - context, - { - auto: false, - local: false, - clear: false, - commit: undefined, - "initial-depth": 20, - json: true, - }, - "1.0.0", - ), - ).rejects.toThrow("Invalid commit SHA."); - - expect(setCommitsAutoSpy).toHaveBeenCalled(); - expect(setCommitsLocalSpy).not.toHaveBeenCalled(); - }); + test.each([ + { status: 400, message: "Invalid commit SHA." }, + { status: 404, message: "The requested resource does not exist" }, + ])( + "propagates unrelated $status errors from setCommitsAuto", + async ({ status, message }) => { + resolveOrgSpy.mockResolvedValue({ org: "my-org" }); + setCommitsAutoSpy.mockRejectedValue( + new ApiError(message, status, undefined, "releases/1.0.0/"), + ); + + const repoRoot = new URL("../../..", import.meta.url).pathname.replace( + /\/$/, + "", + ); + const { context } = createMockContext(repoRoot); + const func = await setCommitsCommand.loader(); + await expect( + func.call( + context, + { + auto: false, + local: false, + clear: false, + commit: undefined, + "initial-depth": 20, + json: true, + }, + "1.0.0", + ), + ).rejects.toThrow(message); + + expect(setCommitsAutoSpy).toHaveBeenCalled(); + expect(setCommitsLocalSpy).not.toHaveBeenCalled(); + }, + ); - test("falls back to local only on the no-repo-integration 400", async () => { + test("falls back to local only on the no-repo-integration 404", async () => { resolveOrgSpy.mockResolvedValue({ org: "my-org" }); setCommitsAutoSpy.mockRejectedValue( new ApiError( NO_REPO_INTEGRATIONS_MESSAGE, - 400, + 404, undefined, "releases/1.0.0/", ), diff --git a/packages/cli/test/lib/api/releases.test.ts b/packages/cli/test/lib/api/releases.test.ts index 4d7218442..1ef6ba4d6 100644 --- a/packages/cli/test/lib/api/releases.test.ts +++ b/packages/cli/test/lib/api/releases.test.ts @@ -290,6 +290,9 @@ describe("setCommitsAuto", () => { // List org repositories (SDK uses /repos/ endpoint) if (req.url.includes("/repos/")) { expect(req.method).toBe("GET"); + const params = new URL(req.url).searchParams; + expect(params.get("query")).toBe("getsentry/cli"); + expect(params.get("per_page")).toBe("100"); return new Response(JSON.stringify([SAMPLE_REPO]), { status: 200, headers: { "Content-Type": "application/json" }, @@ -344,9 +347,13 @@ describe("setCommitsAuto", () => { }), ); - await expect(setCommitsAuto("test-org", "1.0.0", "/tmp")).rejects.toThrow( - /No repository integrations/, - ); + await expect( + setCommitsAuto("test-org", "1.0.0", "/tmp"), + ).rejects.toMatchObject({ + name: "ApiError", + status: 404, + message: expect.stringMatching(/No repository integrations/), + }); }); test.each([ @@ -387,6 +394,26 @@ describe("setCommitsAuto", () => { }, ); + test("throws ValidationError when the name filter matches nothing but the org has repos", async () => { + const otherRepo = { ...SAMPLE_REPO, name: "getsentry/sentry" }; + const repoQueries: (string | null)[] = []; + + globalThis.fetch = mockFetch(async (input, init) => { + const params = new URL(new Request(input!, init).url).searchParams; + repoQueries.push(params.get("query")); + const repos = params.get("query") ? [] : [otherRepo]; + return new Response(JSON.stringify(repos), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + await expect(setCommitsAuto("test-org", "1.0.0", "/tmp")).rejects.toThrow( + /No Sentry repository matching/, + ); + expect(repoQueries).toEqual(["getsentry/cli", null]); + }); + test("throws ValidationError when no repo matches local remote", async () => { const otherRepo = { ...SAMPLE_REPO, name: "getsentry/sentry" }; globalThis.fetch = mockFetch(