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
2 changes: 1 addition & 1 deletion apps/cli-docs/src/fragments/commands/release.md
Original file line number Diff line number Diff line change
Expand Up @@ -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@<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.
- **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.
9 changes: 8 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 {
correctRefRepositories,
explainSetCommitsError,
NO_REPO_INTEGRATIONS_MESSAGE,
setCommitsAuto,
Expand Down Expand Up @@ -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;
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 @@ -164,6 +164,7 @@ export {
tryGetPrimaryDsn,
} from "./api/projects.js";
export {
correctRefRepositories,
createRelease,
createReleaseDeploy,
deleteRelease,
Expand Down
81 changes: 75 additions & 6 deletions packages/cli/src/lib/api/releases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<CommitRef[] | undefined> {
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.`,
);
Comment thread
sentry-junior[bot] marked this conversation as resolved.
Resolved
}
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
Expand All @@ -796,14 +869,10 @@ export async function explainSetCommitsError(
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) {
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
Expand Down
48 changes: 48 additions & 0 deletions packages/cli/test/commands/release/set-commits.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
Expand Down Expand Up @@ -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);
Expand Down
122 changes: 122 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,
correctRefRepositories,
explainSetCommitsError,
getRelease,
listReleaseDeploys,
Expand Down Expand Up @@ -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<typeof globalThis.fetch>();
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
// =============================================================================
Expand Down
Loading