From 5c8a10f7b4cd7c13924c3b4ae02e7fa3b99d56c0 Mon Sep 17 00:00:00 2001 From: Scott Cooper Date: Sat, 17 Oct 2026 10:51:55 -0700 Subject: [PATCH 1/2] perf(cli): Filter repositories by name in set-commits --auto setCommitsAuto walked every page of the org's repositories to find the one matching the git remote, which is hundreds of requests for orgs with thousands of repositories. Pass the remote name as the endpoint's `query` filter and keep the exact match on the client. When the filtered lookup is empty, check for any repository so the no-integration fallback still works. Co-Authored-By: Claude Opus 5.5 --- packages/cli/src/lib/api/releases.ts | 34 +++++++++++++++------- packages/cli/src/lib/api/repositories.ts | 8 +++-- packages/cli/test/lib/api/releases.test.ts | 23 +++++++++++++++ 3 files changed, 51 insertions(+), 14 deletions(-) diff --git a/packages/cli/src/lib/api/releases.ts b/packages/cli/src/lib/api/releases.ts index 73db501b8..8346b191d 100644 --- a/packages/cli/src/lib/api/releases.ts +++ b/packages/cli/src/lib/api/releases.ts @@ -402,10 +402,6 @@ 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 @@ -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. @@ -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, + 400, + 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/lib/api/releases.test.ts b/packages/cli/test/lib/api/releases.test.ts index 4d7218442..224d5d7e8 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" }, @@ -387,6 +390,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( From 1dbb6042311bcf98799c92e78f3217f1b6216611 Mon Sep 17 00:00:00 2001 From: Scott Cooper Date: Sat, 17 Oct 2026 16:53:39 -0700 Subject: [PATCH 2/2] fix(cli): Use 404 for missing repository integrations in set-commits The client-side "no repository integrations" error used status 400, which error reporting and isUserError treat as a CLI bug. Use 404 so it's classified as a user error, and match it in the set-commits fallback. Co-Authored-By: Claude Opus 5.5 --- .../cli/src/commands/release/set-commits.ts | 6 +- packages/cli/src/lib/api/releases.ts | 6 +- .../test/commands/release/set-commits.test.ts | 70 ++++++++++--------- packages/cli/test/lib/api/releases.test.ts | 10 ++- 4 files changed, 51 insertions(+), 41 deletions(-) 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 8346b191d..05d8d6869 100644 --- a/packages/cli/src/lib/api/releases.ts +++ b/packages/cli/src/lib/api/releases.ts @@ -405,7 +405,7 @@ async function getPreviousReleaseCommit( /** * 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`. @@ -432,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( @@ -499,7 +499,7 @@ export async function setCommitsAuto( const endpoint = `organizations/${orgSlug}/releases/${encodeURIComponent(version)}/`; throw new ApiError( NO_REPO_INTEGRATIONS_MESSAGE, - 400, + 404, undefined, endpoint, ); 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 224d5d7e8..1ef6ba4d6 100644 --- a/packages/cli/test/lib/api/releases.test.ts +++ b/packages/cli/test/lib/api/releases.test.ts @@ -347,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([