Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions packages/cli/src/commands/release/set-commits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
38 changes: 25 additions & 13 deletions packages/cli/src/lib/api/releases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand All @@ -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.
Expand All @@ -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(
Expand All @@ -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;
Expand All @@ -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) {
Expand Down Expand Up @@ -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(
Expand Down
8 changes: 5 additions & 3 deletions packages/cli/src/lib/api/repositories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<PaginatedResponse<SentryRepository[]>> {
const config = await getOrgSdkConfig(orgSlug);

Expand All @@ -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<SentryRepository[]>(
Expand Down
70 changes: 38 additions & 32 deletions packages/cli/test/commands/release/set-commits.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/",
),
Expand Down
33 changes: 30 additions & 3 deletions packages/cli/test/lib/api/releases.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down Expand Up @@ -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([
Expand Down Expand Up @@ -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(
Expand Down
Loading