Skip to content

Commit d10176f

Browse files
authored
fix(errors): add ResolutionError for not-found/ambiguous resolution failures (#293)
## Summary - Fixes confusing errors like `Issue 99124558 is required.` (Sentry issue CLI-8C) — these appeared when a numeric issue ID was looked up but not found, misusing `ContextError` which is meant for omitted required values - Adds a new `ResolutionError` class for when the user *provided* a value that couldn't be resolved, with a structured format: `${resource} ${headline}.\n\nTry:\n ${hint}\n\nOr:\n - ${suggestions}` - Migrates 12 callsites across `issue/utils.ts`, `resolve-target.ts`, `event/view.ts`, and `issue/list.ts` from `ContextError` to `ResolutionError` with appropriate headlines (`not found`, `is ambiguous`, `could not be resolved`) ## Before / After **Before:** ``` Issue 99124558 is required. Try: sentry issue view <org>/99124558 ``` **After:** ``` Issue 99124558 not found. Try: sentry issue view <org>/99124558 Or: - No issue with numeric ID 99124558 found — you may not have access or it belongs to a different org - If this is a short ID suffix, try: sentry issue view <project>-99124558 ```
1 parent 3670887 commit d10176f

File tree

12 files changed

+286
-81
lines changed

12 files changed

+286
-81
lines changed

src/commands/event/view.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import {
1919
} from "../../lib/arg-parsing.js";
2020
import { openInBrowser } from "../../lib/browser.js";
2121
import { buildCommand } from "../../lib/command.js";
22-
import { ContextError } from "../../lib/errors.js";
22+
import { ContextError, ResolutionError } from "../../lib/errors.js";
2323
import { formatEventDetails, writeJson } from "../../lib/formatters/index.js";
2424
import {
2525
resolveOrgAndProject,
@@ -221,9 +221,10 @@ export async function resolveOrgAllTarget(
221221
): Promise<ResolvedEventTarget> {
222222
const resolved = await resolveEventInOrg(org, eventId);
223223
if (!resolved) {
224-
throw new ContextError(
224+
throw new ResolutionError(
225225
`Event ${eventId} in organization "${org}"`,
226-
`sentry event view ${org}/ ${eventId}`
226+
"not found",
227+
`sentry event view ${org}/<project> ${eventId}`
227228
);
228229
}
229230
return {

src/commands/issue/list.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {
3131
ApiError,
3232
AuthError,
3333
ContextError,
34+
ResolutionError,
3435
ValidationError,
3536
} from "../../lib/errors.js";
3637
import {
@@ -352,10 +353,11 @@ async function resolveTargetsFromParsedArg(
352353
const matches = await findProjectsBySlug(parsed.projectSlug);
353354

354355
if (matches.length === 0) {
355-
throw new ContextError(
356-
"Project",
357-
`No project '${parsed.projectSlug}' found in any accessible organization.\n\n` +
358-
`Try: sentry issue list <org>/${parsed.projectSlug}`
356+
throw new ResolutionError(
357+
`Project '${parsed.projectSlug}'`,
358+
"not found",
359+
`sentry issue list <org>/${parsed.projectSlug}`,
360+
["No project with this slug found in any accessible organization"]
359361
);
360362
}
361363

src/commands/issue/utils.ts

Lines changed: 41 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {
1414
import { parseIssueArg } from "../../lib/arg-parsing.js";
1515
import { getProjectByAlias } from "../../lib/db/project-aliases.js";
1616
import { detectAllDsns } from "../../lib/dsn/index.js";
17-
import { ApiError, ContextError } from "../../lib/errors.js";
17+
import { ApiError, ContextError, ResolutionError } from "../../lib/errors.js";
1818
import { getProgressMessage } from "../../lib/formatters/seer.js";
1919
import { expandToFullShortId, isShortSuffix } from "../../lib/issue-id.js";
2020
import { poll } from "../../lib/polling.js";
@@ -140,15 +140,19 @@ async function resolveProjectSearch(
140140
const projects = await findProjectsBySlug(projectSlug.toLowerCase());
141141

142142
if (projects.length === 0) {
143-
throw new ContextError(`Project '${projectSlug}' not found`, commandHint, [
144-
"No project with this slug found in any accessible organization",
145-
]);
143+
throw new ResolutionError(
144+
`Project '${projectSlug}'`,
145+
"not found",
146+
commandHint,
147+
["No project with this slug found in any accessible organization"]
148+
);
146149
}
147150

148151
if (projects.length > 1) {
149152
const orgList = projects.map((p) => p.orgSlug).join(", ");
150-
throw new ContextError(
151-
`Project '${projectSlug}' found in multiple organizations`,
153+
throw new ResolutionError(
154+
`Project '${projectSlug}'`,
155+
"is ambiguous",
152156
commandHint,
153157
[
154158
`Found in: ${orgList}`,
@@ -159,7 +163,11 @@ async function resolveProjectSearch(
159163

160164
const project = projects[0];
161165
if (!project) {
162-
throw new ContextError(`Project '${projectSlug}' not found`, commandHint);
166+
throw new ResolutionError(
167+
`Project '${projectSlug}'`,
168+
"not found",
169+
commandHint
170+
);
163171
}
164172

165173
const fullShortId = expandToFullShortId(suffix, project.slug);
@@ -181,8 +189,9 @@ async function resolveSuffixOnly(
181189
): Promise<StrictResolvedIssue> {
182190
const target = await resolveOrgAndProject({ cwd });
183191
if (!target) {
184-
throw new ContextError(
185-
`Cannot resolve issue suffix '${suffix}' without project context`,
192+
throw new ResolutionError(
193+
`Issue suffix '${suffix}'`,
194+
"could not be resolved without project context",
186195
commandHint
187196
);
188197
}
@@ -208,8 +217,9 @@ function resolveExplicitOrgSuffix(
208217
suffix: string,
209218
commandHint: string
210219
): never {
211-
throw new ContextError(
212-
`Cannot resolve suffix '${suffix}' without project context`,
220+
throw new ResolutionError(
221+
`Issue suffix '${suffix}'`,
222+
"could not be resolved without project context",
213223
commandHint,
214224
[
215225
`The format '${org}/${suffix}' requires a project to build the full issue ID.`,
@@ -243,7 +253,8 @@ export type ResolveIssueOptions = {
243253
*
244254
* @param options - Resolution options
245255
* @returns Object with org slug and full issue
246-
* @throws {ContextError} When required context cannot be resolved
256+
* @throws {ContextError} When required context (org) is missing
257+
* @throws {ResolutionError} When an issue or project could not be found or resolved
247258
*/
248259
export async function resolveIssue(
249260
options: ResolveIssueOptions
@@ -263,10 +274,15 @@ export async function resolveIssue(
263274
// Improve on the generic "Issue not found" message by including the ID
264275
// and suggesting the short-ID format, since users often confuse numeric
265276
// group IDs with short-ID suffixes.
266-
throw new ContextError(`Issue ${parsed.id}`, commandHint, [
267-
`No issue with numeric ID ${parsed.id} found — you may not have access, or it may have been deleted.`,
268-
`If this is a short ID suffix, try: sentry issue ${command} <project>-${parsed.id}`,
269-
]);
277+
throw new ResolutionError(
278+
`Issue ${parsed.id}`,
279+
"not found",
280+
commandHint,
281+
[
282+
`No issue with numeric ID ${parsed.id} found — you may not have access, or it may have been deleted.`,
283+
`If this is a short ID suffix, try: sentry issue ${command} <project>-${parsed.id}`,
284+
]
285+
);
270286
}
271287
throw err;
272288
}
@@ -286,10 +302,15 @@ export async function resolveIssue(
286302
return { org: parsed.org, issue };
287303
} catch (err) {
288304
if (err instanceof ApiError && err.status === 404) {
289-
throw new ContextError(`Issue ${parsed.numericId}`, commandHint, [
290-
`No issue with numeric ID ${parsed.numericId} found in org '${parsed.org}' — you may not have access, or it may have been deleted.`,
291-
`If this is a short ID suffix, try: sentry issue ${command} <project>-${parsed.numericId}`,
292-
]);
305+
throw new ResolutionError(
306+
`Issue ${parsed.numericId}`,
307+
"not found",
308+
commandHint,
309+
[
310+
`No issue with numeric ID ${parsed.numericId} found in org '${parsed.org}' — you may not have access, or it may have been deleted.`,
311+
`If this is a short ID suffix, try: sentry issue ${command} <project>-${parsed.numericId}`,
312+
]
313+
);
293314
}
294315
throw err;
295316
}

src/lib/errors.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,31 @@ function buildContextMessage(
163163
return lines.join("\n");
164164
}
165165

166+
/**
167+
* Build the formatted resolution error message for entities that could not be found or resolved.
168+
*
169+
* @param resource - The entity that could not be resolved (e.g., "Issue 99124558")
170+
* @param headline - Describes the failure (e.g., "not found", "is ambiguous", "could not be resolved")
171+
* @param hint - Primary usage example or suggestion
172+
* @param suggestions - Additional help bullets shown under "Or:"
173+
* @returns Formatted multi-line error message
174+
*/
175+
function buildResolutionMessage(
176+
resource: string,
177+
headline: string,
178+
hint: string,
179+
suggestions: string[]
180+
): string {
181+
const lines = [`${resource} ${headline}.`, "", "Try:", ` ${hint}`];
182+
if (suggestions.length > 0) {
183+
lines.push("", "Or:");
184+
for (const s of suggestions) {
185+
lines.push(` - ${s}`);
186+
}
187+
}
188+
return lines.join("\n");
189+
}
190+
166191
/**
167192
* Missing required context errors (org, project, etc).
168193
*
@@ -196,6 +221,43 @@ export class ContextError extends CliError {
196221
}
197222
}
198223

224+
/**
225+
* Resolution errors for entities that exist but could not be found or resolved.
226+
*
227+
* Use this when the user provided a value but it could not be matched — as
228+
* opposed to {@link ContextError}, which is for when the user omitted a
229+
* required value entirely.
230+
*
231+
* @param resource - The entity that failed to resolve (e.g., "Issue 99124558", "Project 'cli'")
232+
* @param headline - Short phrase describing the failure (e.g., "not found", "is ambiguous", "could not be resolved")
233+
* @param hint - Primary usage example or suggestion (shown under "Try:")
234+
* @param suggestions - Additional help bullets shown under "Or:" (defaults to empty)
235+
*/
236+
export class ResolutionError extends CliError {
237+
readonly resource: string;
238+
readonly headline: string;
239+
readonly hint: string;
240+
readonly suggestions: string[];
241+
242+
constructor(
243+
resource: string,
244+
headline: string,
245+
hint: string,
246+
suggestions: string[] = []
247+
) {
248+
super(buildResolutionMessage(resource, headline, hint, suggestions));
249+
this.name = "ResolutionError";
250+
this.resource = resource;
251+
this.headline = headline;
252+
this.hint = hint;
253+
this.suggestions = suggestions;
254+
}
255+
256+
override format(): string {
257+
return this.message;
258+
}
259+
}
260+
199261
/**
200262
* Input validation errors.
201263
*

src/lib/resolve-target.ts

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,12 @@ import {
3737
formatMultipleProjectsFooter,
3838
getDsnSourceDescription,
3939
} from "./dsn/index.js";
40-
import { AuthError, ContextError, ValidationError } from "./errors.js";
40+
import {
41+
AuthError,
42+
ContextError,
43+
ResolutionError,
44+
ValidationError,
45+
} from "./errors.js";
4146
import { warning } from "./formatters/colors.js";
4247
import { isAllDigits } from "./utils.js";
4348

@@ -793,11 +798,16 @@ export async function resolveProjectBySlug(
793798
): Promise<{ org: string; project: string }> {
794799
const found = await findProjectsBySlug(projectSlug);
795800
if (found.length === 0) {
796-
throw new ContextError(`Project "${projectSlug}"`, usageHint, [
797-
isAllDigits(projectSlug)
798-
? "No project with this ID was found — check the ID or use the project slug instead"
799-
: "Check that you have access to a project with this slug",
800-
]);
801+
throw new ResolutionError(
802+
`Project "${projectSlug}"`,
803+
"not found",
804+
usageHint,
805+
[
806+
isAllDigits(projectSlug)
807+
? "No project with this ID was found — check the ID or use the project slug instead"
808+
: "Check that you have access to a project with this slug",
809+
]
810+
);
801811
}
802812
if (found.length > 1) {
803813
const orgList = found.map((p) => ` ${p.orgSlug}/${p.slug}`).join("\n");
@@ -935,20 +945,23 @@ export async function resolveOrgProjectTarget(
935945
const matches = await findProjectsBySlug(parsed.projectSlug);
936946

937947
if (matches.length === 0) {
938-
throw new ContextError(
939-
"Project",
940-
`No project '${parsed.projectSlug}' found in any accessible organization.\n\n` +
941-
`Try: sentry ${commandName} <org>/${parsed.projectSlug}`
948+
throw new ResolutionError(
949+
`Project '${parsed.projectSlug}'`,
950+
"not found",
951+
`sentry ${commandName} <org>/${parsed.projectSlug}`,
952+
["No project with this slug found in any accessible organization"]
942953
);
943954
}
944955

945956
if (matches.length > 1) {
946957
const options = matches
947958
.map((m) => ` sentry ${commandName} ${m.orgSlug}/${m.slug}`)
948959
.join("\n");
949-
throw new ContextError(
950-
"Project",
951-
`Found '${parsed.projectSlug}' in ${matches.length} organizations. Please specify:\n${options}`
960+
throw new ResolutionError(
961+
`Project '${parsed.projectSlug}'`,
962+
"is ambiguous",
963+
`sentry ${commandName} <org>/${parsed.projectSlug}`,
964+
[`Found in ${matches.length} organizations. Specify one:\n${options}`]
952965
);
953966
}
954967

test/commands/event/view.test.ts

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,11 @@ import type { ProjectWithOrg } from "../../../src/lib/api-client.js";
2424
// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking
2525
import * as apiClient from "../../../src/lib/api-client.js";
2626
import { ProjectSpecificationType } from "../../../src/lib/arg-parsing.js";
27-
import { ContextError, ValidationError } from "../../../src/lib/errors.js";
27+
import {
28+
ContextError,
29+
ResolutionError,
30+
ValidationError,
31+
} from "../../../src/lib/errors.js";
2832
// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking
2933
import * as resolveTarget from "../../../src/lib/resolve-target.js";
3034
import { resolveProjectBySlug } from "../../../src/lib/resolve-target.js";
@@ -221,11 +225,11 @@ describe("resolveProjectBySlug", () => {
221225
});
222226

223227
describe("no projects found", () => {
224-
test("throws ContextError when project not found", async () => {
228+
test("throws ResolutionError when project not found", async () => {
225229
findProjectsBySlugSpy.mockResolvedValue([]);
226230

227231
await expect(resolveProjectBySlug("my-project", HINT)).rejects.toThrow(
228-
ContextError
232+
ResolutionError
229233
);
230234
});
231235

@@ -236,11 +240,15 @@ describe("resolveProjectBySlug", () => {
236240
await resolveProjectBySlug("frontend", HINT);
237241
expect.unreachable("Should have thrown");
238242
} catch (error) {
239-
expect(error).toBeInstanceOf(ContextError);
240-
expect((error as ContextError).message).toContain('Project "frontend"');
241-
expect((error as ContextError).message).toContain(
243+
expect(error).toBeInstanceOf(ResolutionError);
244+
expect((error as ResolutionError).message).toContain(
245+
'Project "frontend"'
246+
);
247+
expect((error as ResolutionError).message).toContain(
242248
"Check that you have access"
243249
);
250+
// Message says "not found", not "is required"
251+
expect((error as ResolutionError).message).toContain("not found");
244252
}
245253
});
246254
});
@@ -352,10 +360,12 @@ describe("resolveProjectBySlug", () => {
352360
await resolveProjectBySlug("7275560680", HINT);
353361
expect.unreachable("Should have thrown");
354362
} catch (error) {
355-
expect(error).toBeInstanceOf(ContextError);
356-
const message = (error as ContextError).message;
363+
expect(error).toBeInstanceOf(ResolutionError);
364+
const message = (error as ResolutionError).message;
357365
expect(message).toContain('Project "7275560680"');
358366
expect(message).toContain("No project with this ID was found");
367+
// Message says "not found", not "is required"
368+
expect(message).toContain("not found");
359369
}
360370
});
361371

@@ -544,12 +554,12 @@ describe("resolveOrgAllTarget", () => {
544554
expect(result?.prefetchedEvent?.eventID).toBe("abc123");
545555
});
546556

547-
test("throws ContextError when event not found in explicit org", async () => {
557+
test("throws ResolutionError when event not found in explicit org", async () => {
548558
resolveEventInOrgSpy.mockResolvedValue(null);
549559

550560
await expect(
551561
resolveOrgAllTarget("acme", "notfound", "/tmp")
552-
).rejects.toBeInstanceOf(ContextError);
562+
).rejects.toBeInstanceOf(ResolutionError);
553563
});
554564

555565
test("propagates errors from resolveEventInOrg", async () => {

0 commit comments

Comments
 (0)