Skip to content
Merged
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
1 change: 1 addition & 0 deletions apps/cli-docs/src/fragments/commands/release.md
Original file line number Diff line number Diff line change
Expand Up @@ -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@<sha>`.
- **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.
8 changes: 7 additions & 1 deletion packages/cli/src/commands/release/set-commits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/lib/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ export {
createRelease,
createReleaseDeploy,
deleteRelease,
explainSetCommitsError,
getRelease,
type ListReleasesOptions,
listProjectEnvironments,
Expand Down
106 changes: 101 additions & 5 deletions packages/cli/src/lib/api/releases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SentryRelease> {
const encodedOrg = encodeApiPathSegment(orgSlug);
if (encodedOrg === null) {
Expand All @@ -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<unknown> {
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 ?? "<commit-sha>");
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.
*
Expand Down
39 changes: 39 additions & 0 deletions packages/cli/test/commands/release/set-commits.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
136 changes: 136 additions & 0 deletions packages/cli/test/lib/api/releases.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ const {
createRelease,
createReleaseDeploy,
deleteRelease,
explainSetCommitsError,
getRelease,
listReleaseDeploys,
listReleasesPaginated,
Expand Down Expand Up @@ -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<typeof globalThis.fetch>();
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
// =============================================================================
Expand Down
Loading