Skip to content

Commit 0697de7

Browse files
fix(cli): Name rejected project slugs and release versions instead of reporting 400s (#2159)
A wrong or inaccessible project slug, or a release version Sentry rejects, comes back as a 400. The CLI used to print the raw body and report it to Sentry as a CLI bug. These 400s are now CLI errors that name the value and say what to check, and they count only on the `cli.error.silenced` metric. These are CLI-2G3 (`release create`) and CLI-344 (sourcemap assemble); on 0.49.0 nearly all of their events are these cases. Any other 400 stays a captured `ApiError`. The output below comes from binaries built from `main` and from this branch, run against a local mock server that returns Sentry's 400 bodies (checked against Sentry source). **Wrong project slug: `release create` (CLI-2G3) and `sourcemap upload` (CLI-344)** ``` # before Error: Failed to create release: 400 Bad Request {"projects":["Invalid project ids or slugs"]} Error: Failed to assemble artifact bundle: 400 Bad Request {"error":"One or more projects are invalid"} # after (both) Error: Project 'frontnd' was not found in organization 'my-org'. Try: sentry project list my-org/ Or: - Check the slug passed with --project (or the bundler plugin's `project` option) - Check that your auth token can access the project # release create only ``` **Release version Sentry rejects** ``` # before Error: Failed to create release: 400 Bad Request {"version":["Release with name .. is not allowed"]} # after Error: Sentry does not allow the release version '..'. Try: sentry release propose-version Note: A release version cannot be 'latest', '.' or '..', contain a slash, backslash, tab or newline, or be longer than 200 characters. ``` **Release already exists for projects the token can't access** ``` # before Error: Failed to create release: 400 Bad Request {"projects":["You do not have permission to one of the projects: backend"]} # after Error: Release '1.0.0' already exists in organization 'my-org' for projects your auth token cannot access: backend. Note: Use an auth token that can access those projects, or a different release version. ``` `projects: This field is required` (`release create` without `--project`) stays captured on purpose. Auto-detecting the project there is the CLI's job. <!-- junior-request-attribution:start --> via @betegon. <!-- junior-request-attribution:end --> <!-- junior-session-footer:start --> <!-- junior-conversation-id:agent%3A7c47dab6b11a9b2f157ce8a825ab2e8a --> -- [View Session in Sentry](https://sentry.sentry.io/explore/conversations/agent%3A7c47dab6b11a9b2f157ce8a825ab2e8a/?project=4510944073809921) <!-- junior-session-footer:end --> Co-authored-by: sentry-junior[bot] <264270552+sentry-junior[bot]@users.noreply.github.com> Co-authored-by: bete <28983162+betegon@users.noreply.github.com>
1 parent 0035474 commit 0697de7

7 files changed

Lines changed: 259 additions & 13 deletions

File tree

‎packages/cli/src/lib/api/chunk-upload.ts‎

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ import {
4343
record,
4444
string,
4545
} from "valibot";
46-
import { ApiError } from "../errors.js";
46+
import { ApiError, projectsNotFoundError } from "../errors.js";
4747
import { logger } from "../logger.js";
4848
import { getSdkConfig } from "../sentry-client.js";
4949
import { getOrgSdkConfig, unwrapValidatedResult } from "./infrastructure.js";
@@ -656,11 +656,32 @@ export async function assembleArtifactBundle(
656656
path: { organization_id_or_slug: org },
657657
body,
658658
});
659-
return unwrapValidatedResult(
660-
result,
661-
"Failed to assemble artifact bundle",
662-
AssembleResponseSchema,
663-
`organizations/${org}/artifactbundle/assemble/`,
659+
try {
660+
return unwrapValidatedResult(
661+
result,
662+
"Failed to assemble artifact bundle",
663+
AssembleResponseSchema,
664+
`organizations/${org}/artifactbundle/assemble/`,
665+
);
666+
} catch (error) {
667+
const projects = body.projects ?? [];
668+
if (projects.length > 0 && isInvalidProjectsError(error)) {
669+
throw projectsNotFoundError(org, projects);
670+
}
671+
throw error;
672+
}
673+
}
674+
675+
/**
676+
* The assemble endpoint's 400 when a project slug or ID is not an active
677+
* project of the org (`OrganizationArtifactBundleAssembleEndpoint` in Sentry,
678+
* CLI-344).
679+
*/
680+
function isInvalidProjectsError(error: unknown): boolean {
681+
return (
682+
error instanceof ApiError &&
683+
error.status === 400 &&
684+
(error.detail?.includes("One or more projects are invalid") ?? false)
664685
);
665686
}
666687

‎packages/cli/src/lib/api/releases.ts‎

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import { getEnv } from "../env.js";
2828
import {
2929
ApiError,
3030
buildValidationMessage,
31+
projectsNotFoundError,
3132
ValidationError,
3233
validationError,
3334
} from "../errors.js";
@@ -268,7 +269,68 @@ export async function createRelease(
268269
if (result.data) {
269270
return result.data as SentryRelease;
270271
}
271-
return unwrapResult<SentryRelease>(result, "Failed to create release");
272+
try {
273+
return unwrapResult<SentryRelease>(result, "Failed to create release");
274+
} catch (error) {
275+
throw explainCreateReleaseError(orgSlug, body, error);
276+
}
277+
}
278+
279+
/**
280+
* 400 details from `OrganizationReleasesEndpoint.post` in Sentry:
281+
* `{"projects":["Invalid project ids or slugs"]}` when a project does not
282+
* exist or the auth token cannot access it, and
283+
* `{"projects":["You do not have permission to one of the projects: a, b"]}`
284+
* when the release already exists for projects the token cannot access.
285+
*/
286+
const INVALID_RELEASE_PROJECTS = "Invalid project ids or slugs";
287+
const RELEASE_PROJECT_PERMISSION_RE =
288+
/You do not have permission to one of the projects: ([^"\]]*)/;
289+
290+
/**
291+
* The release serializer's 400 for a version it rejects, such as `latest` or
292+
* a name with a `/` (`Release.is_valid_version`) or one over 200 characters.
293+
*/
294+
const RELEASE_VERSION_ERROR_RE = /"version":\[/;
295+
296+
/**
297+
* Turn the server's 400s for a project or version the user passed to
298+
* {@link createRelease} into errors that name the value and how to fix it
299+
* (CLI-2G3). Any other error, including other 400s, is returned unchanged.
300+
*/
301+
function explainCreateReleaseError(
302+
orgSlug: string,
303+
body: { version: string; projects?: string[] },
304+
error: unknown,
305+
): unknown {
306+
if (!(error instanceof ApiError && error.status === 400 && error.detail)) {
307+
return error;
308+
}
309+
const { detail } = error;
310+
const projects = body.projects ?? [];
311+
if (detail.includes(INVALID_RELEASE_PROJECTS) && projects.length > 0) {
312+
return projectsNotFoundError(orgSlug, projects, [
313+
"Check that your auth token can access the project",
314+
]);
315+
}
316+
const denied = detail.match(RELEASE_PROJECT_PERMISSION_RE)?.[1];
317+
if (denied !== undefined) {
318+
return validationError(
319+
`Release '${body.version}' already exists in organization '${orgSlug}' for projects your auth token cannot access: ${denied}.`,
320+
[],
321+
"release.projects",
322+
"Use an auth token that can access those projects, or a different release version.",
323+
);
324+
}
325+
if (RELEASE_VERSION_ERROR_RE.test(detail)) {
326+
return validationError(
327+
`Sentry does not allow the release version '${body.version}'.`,
328+
[sentry`release propose-version`],
329+
"release.version",
330+
"A release version cannot be 'latest', '.' or '..', contain a slash, backslash, tab or newline, or be longer than 200 characters.",
331+
);
332+
}
333+
return error;
272334
}
273335

274336
/**

‎packages/cli/src/lib/error-reporting.ts‎

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,17 @@ export function classifySilenced(error: unknown): SilenceReason | null {
149149
) {
150150
return "user_validation";
151151
}
152+
// A ValidationError with field "release.version" or "release.projects"
153+
// means Sentry rejected the version the user passed to `release create`
154+
// (`latest`, a name with a `/`), or the release already exists for projects
155+
// the auth token cannot access (CLI-2G3). The user sees which value to
156+
// change; the metric keeps the volume, split by `validation_field`.
157+
if (
158+
error instanceof ValidationError &&
159+
(error.field === "release.version" || error.field === "release.projects")
160+
) {
161+
return "user_validation";
162+
}
152163
// A ValidationError with field "input" means the --input file path the user
153164
// supplied does not exist on disk. Pure user-input noise, not a CLI bug —
154165
// the user sees a clear "File not found" message (CLI-1JY).
@@ -188,10 +199,11 @@ export function classifySilenced(error: unknown): SilenceReason | null {
188199
return "process_exit";
189200
}
190201
// A 400 (Bad Request) signals a malformed request the CLI built — a code
191-
// defect — so it is always captured. A user's unparseable `--query` is NOT a
192-
// 400 here: it is converted to a ValidationError at the command boundary
193-
// (toSearchQueryError) so the user gets an actionable message and the bug
194-
// signal for CLI-authored bad queries is preserved (CLI-FA).
202+
// defect — so it is always captured. 400s caused by user input are
203+
// converted to CLI errors where the request is made, so the user gets an
204+
// actionable message and the bug signal for the rest is preserved: an
205+
// unparseable `--query` (toSearchQueryError, CLI-FA), or a project slug or
206+
// release version Sentry rejects (CLI-2G3, CLI-344).
195207
return null;
196208
}
197209

‎packages/cli/src/lib/errors.ts‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -575,6 +575,33 @@ export class ResolutionError extends CliError {
575575
}
576576
}
577577

578+
/**
579+
* {@link ResolutionError} for project slugs that Sentry rejected for an org
580+
* without saying which one (release create, artifact bundle assemble).
581+
*
582+
* @param org - Organization slug
583+
* @param projects - The project slugs that were sent
584+
* @param extraSuggestions - More bullets shown under "Or:"
585+
*/
586+
export function projectsNotFoundError(
587+
org: string,
588+
projects: string[],
589+
extraSuggestions: string[] = [],
590+
): ResolutionError {
591+
const quoted = projects.map((p) => `'${p}'`).join(", ");
592+
return new ResolutionError(
593+
projects.length === 1
594+
? `Project ${quoted}`
595+
: `One of the projects ${quoted}`,
596+
`was not found in organization '${org}'`,
597+
sentry`project list ${org}/`,
598+
[
599+
"Check the slug passed with --project (or the bundler plugin's `project` option)",
600+
...extraSuggestions,
601+
],
602+
);
603+
}
604+
578605
/**
579606
* Build a {@link ValidationError} message with Try: examples and optional Note: section.
580607
*

‎packages/cli/test/lib/api/chunk-upload.test.ts‎

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import {
2020
} from "../../../src/lib/api/chunk-upload.js";
2121
import { setAuthToken } from "../../../src/lib/db/auth.js";
2222
import { setOrgRegion } from "../../../src/lib/db/regions.js";
23-
import { ApiError } from "../../../src/lib/errors.js";
23+
import { ApiError, ResolutionError } from "../../../src/lib/errors.js";
2424
import { mockFetch, useTestConfigDir } from "../../helpers.js";
2525

2626
useTestConfigDir("api-chunk-upload-");
@@ -155,6 +155,38 @@ describe("assemble requests", () => {
155155
]);
156156
});
157157

158+
test("assembleArtifactBundle names the project when the org has no such project (CLI-344)", async () => {
159+
respondWith({ error: "One or more projects are invalid" }, 400);
160+
161+
const error = await assembleArtifactBundle("my-org", {
162+
checksum: "abc",
163+
chunks: ["c1"],
164+
projects: ["my-projct"],
165+
}).catch((e: unknown) => e);
166+
167+
expect(error).toBeInstanceOf(ResolutionError);
168+
const { message } = error as ResolutionError;
169+
expect(message).toContain(
170+
"Project 'my-projct' was not found in organization 'my-org'.",
171+
);
172+
expect(message).toContain("sentry project list my-org/");
173+
});
174+
175+
test("assembleArtifactBundle keeps other 400s as ApiError", async () => {
176+
respondWith(
177+
{ error: "You need to specify a release together with a dist" },
178+
400,
179+
);
180+
181+
await expect(
182+
assembleArtifactBundle("my-org", {
183+
checksum: "abc",
184+
chunks: ["c1"],
185+
projects: ["my-project"],
186+
}),
187+
).rejects.toBeInstanceOf(ApiError);
188+
});
189+
158190
test("assembleArtifactBundle rejects an unknown assemble state", async () => {
159191
respondWith({ state: "bogus", missingChunks: [] });
160192

‎packages/cli/test/lib/api/releases.test.ts‎

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,13 @@ import { CONFIG_DIR_ENV_VAR } from "../../../src/lib/db/index.js";
6060
import { setOrgRegion } from "../../../src/lib/db/regions.js";
6161
import { withEnv } from "../../../src/lib/env.js";
6262
import { classifySilenced } from "../../../src/lib/error-reporting.js";
63-
import { ApiError, EXIT, isUserError } from "../../../src/lib/errors.js";
63+
import {
64+
ApiError,
65+
EXIT,
66+
isUserError,
67+
ResolutionError,
68+
ValidationError,
69+
} from "../../../src/lib/errors.js";
6470
import type { SentryDeploy, SentryRelease } from "../../../src/types/index.js";
6571
import { mockFetch, useTestConfigDir } from "../../helpers.js";
6672

@@ -174,6 +180,83 @@ describe("createRelease", () => {
174180
expect(release.version).toBe("1.0.0");
175181
expect(release.projects).toHaveLength(1);
176182
});
183+
184+
/** Answer the create request with a 400 and `body`. */
185+
const reject400 = (body: unknown) => {
186+
globalThis.fetch = mockFetch(
187+
async () =>
188+
new Response(JSON.stringify(body), {
189+
status: 400,
190+
headers: { "Content-Type": "application/json" },
191+
}),
192+
);
193+
};
194+
195+
test("names the projects Sentry rejected (CLI-2G3)", async () => {
196+
reject400({ projects: ["Invalid project ids or slugs"] });
197+
198+
const error = await createRelease("test-org", {
199+
version: "1.0.0",
200+
projects: ["web", "api"],
201+
}).catch((e: unknown) => e);
202+
203+
expect(error).toBeInstanceOf(ResolutionError);
204+
const { message } = error as ResolutionError;
205+
expect(message).toContain(
206+
"One of the projects 'web', 'api' was not found in organization 'test-org'.",
207+
);
208+
expect(message).toContain("sentry project list test-org/");
209+
expect(classifySilenced(error)).toBe("user_input_error");
210+
});
211+
212+
test("explains a release that exists for projects the token cannot access", async () => {
213+
reject400({
214+
projects: ["You do not have permission to one of the projects: web"],
215+
});
216+
217+
const error = await createRelease("test-org", {
218+
version: "1.0.0",
219+
projects: ["api"],
220+
}).catch((e: unknown) => e);
221+
222+
expect(error).toMatchObject({
223+
name: "ValidationError",
224+
field: "release.projects",
225+
});
226+
expect((error as ValidationError).message).toContain(
227+
"Release '1.0.0' already exists in organization 'test-org' for projects your auth token cannot access: web.",
228+
);
229+
expect(classifySilenced(error)).toBe("user_validation");
230+
});
231+
232+
test("names a release version Sentry rejects", async () => {
233+
reject400({ version: ["Release with name latest is not allowed"] });
234+
235+
const error = await createRelease("test-org", {
236+
version: "latest",
237+
projects: ["web"],
238+
}).catch((e: unknown) => e);
239+
240+
expect(error).toMatchObject({
241+
name: "ValidationError",
242+
field: "release.version",
243+
});
244+
expect((error as ValidationError).message).toContain(
245+
"Sentry does not allow the release version 'latest'.",
246+
);
247+
expect(classifySilenced(error)).toBe("user_validation");
248+
});
249+
250+
test("keeps other 400s as ApiError so they are captured", async () => {
251+
reject400({ projects: ["This field is required."] });
252+
253+
const error = await createRelease("test-org", { version: "1.0.0" }).catch(
254+
(e: unknown) => e,
255+
);
256+
257+
expect(error).toBeInstanceOf(ApiError);
258+
expect(classifySilenced(error)).toBeNull();
259+
});
177260
});
178261

179262
// =============================================================================

‎packages/cli/test/lib/error-reporting.test.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -387,6 +387,15 @@ describe("classifySilenced", () => {
387387
},
388388
);
389389

390+
test.each(["release.version", "release.projects"])(
391+
"silences release create ValidationError with field '%s' (CLI-2G3)",
392+
(field) => {
393+
expect(classifySilenced(new ValidationError("rejected", field))).toBe(
394+
"user_validation",
395+
);
396+
},
397+
);
398+
390399
test("silences ResolutionError (user provided a value that wasn't found)", () => {
391400
expect(
392401
classifySilenced(

0 commit comments

Comments
 (0)