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
10 changes: 6 additions & 4 deletions src/commands/issue/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1145,10 +1145,12 @@ async function handleResolvedTargets(

if (targets.length === 0) {
if (skippedSelfHosted) {
throw new ContextError("Organization and project", USAGE_HINT, [
`Found ${skippedSelfHosted} DSN(s) that could not be resolved`,
"You may not have access to these projects, or you can specify the target explicitly",
]);
throw new ContextError(
"Organization and project",
USAGE_HINT,
undefined,
`Found ${skippedSelfHosted} DSN(s) that could not be resolved — you may not have access to these projects`
);
}
throw new ContextError("Organization and project", USAGE_HINT);
}
Expand Down
11 changes: 6 additions & 5 deletions src/commands/project/view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,12 @@ const USAGE_HINT = "sentry project view <org>/<project>";
*/
function buildContextError(skippedSelfHosted?: number): ContextError {
if (skippedSelfHosted) {
return new ContextError("Organization and project", USAGE_HINT, [
"Run from a directory with a Sentry-configured project",
"Set SENTRY_ORG and SENTRY_PROJECT (or SENTRY_DSN) environment variables",
`Found ${skippedSelfHosted} DSN(s) that could not be resolved — you may not have access to these projects`,
]);
return new ContextError(
"Organization and project",
USAGE_HINT,
undefined,
`Found ${skippedSelfHosted} DSN(s) that could not be resolved — you may not have access to these projects`
);
}

return new ContextError("Organization and project", USAGE_HINT);
Expand Down
19 changes: 16 additions & 3 deletions src/lib/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,12 +166,16 @@ const DEFAULT_CONTEXT_ALTERNATIVES = [
* @param resource - What is required (e.g., "Organization", "Trace ID and span ID")
* @param command - Single-line CLI usage example (e.g., "sentry org view <org-slug>")
* @param alternatives - Alternative ways to provide the context
* @param note - Optional informational context (e.g., "Found 2 DSN(s) that could not be resolved").
* Rendered as a separate "Note:" section after alternatives. Use this for diagnostic
* information that explains what the CLI tried — keep alternatives purely actionable.
* @returns Formatted multi-line error message
*/
function buildContextMessage(
resource: string,
command: string,
alternatives: string[]
alternatives: string[],
note?: string
): string {
// Compound resources ("X and Y") need plural grammar
const isPlural = resource.includes(" and ");
Expand All @@ -187,6 +191,9 @@ function buildContextMessage(
lines.push(` - ${alt}`);
}
}
if (note) {
lines.push("", `Note: ${note}`);
}
return lines.join("\n");
}

Expand Down Expand Up @@ -229,23 +236,29 @@ function buildResolutionMessage(
* that should use {@link ResolutionError}.
* @param alternatives - Alternative ways to resolve (defaults to DSN/project detection hints).
* Pass `[]` when the defaults are irrelevant (e.g., for missing positional IDs like Trace ID).
* @param note - Optional informational context rendered as a separate "Note:" section.
* Use for diagnostic info (e.g., "Found 2 DSN(s) that could not be resolved").
* Keep alternatives purely actionable — put explanations here instead.
*/
export class ContextError extends CliError {
readonly resource: string;
readonly command: string;
readonly alternatives: string[];
readonly note?: string;

constructor(
resource: string,
command: string,
alternatives: string[] = [...DEFAULT_CONTEXT_ALTERNATIVES]
alternatives: string[] = [...DEFAULT_CONTEXT_ALTERNATIVES],
note?: string
) {
// Include full formatted message so it's shown even when caught by external handlers
super(buildContextMessage(resource, command, alternatives));
super(buildContextMessage(resource, command, alternatives, note));
this.name = "ContextError";
this.resource = resource;
this.command = command;
this.alternatives = alternatives;
this.note = note;

// Dev-time assertion: command must be a single-line CLI usage example.
// Multi-line commands are a sign the caller should use ResolutionError.
Expand Down
47 changes: 47 additions & 0 deletions test/lib/errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,53 @@ describe("ContextError", () => {
expect(formatted).toContain("Resource is required.");
expect(formatted).not.toContain("Or:");
});

test("format() includes note section after alternatives", () => {
const err = new ContextError(
"Organization",
"sentry org list",
undefined,
"Found 2 DSN(s) that could not be resolved"
);
const formatted = err.format();
expect(formatted).toContain("Organization is required.");
// Default alternatives are present
expect(formatted).toContain("Or:");
expect(formatted).toContain(
"Run from a directory with a Sentry-configured project"
);
// Note appears as a separate section
expect(formatted).toContain(
"Note: Found 2 DSN(s) that could not be resolved"
);
// Note appears after alternatives
const orIndex = formatted.indexOf("Or:");
const noteIndex = formatted.indexOf("Note:");
expect(noteIndex).toBeGreaterThan(orIndex);
});

test("format() includes note without alternatives", () => {
const err = new ContextError(
"Resource",
"sentry resource get",
[],
"Some diagnostic info"
);
const formatted = err.format();
expect(formatted).toContain("Resource is required.");
expect(formatted).not.toContain("Or:");
expect(formatted).toContain("Note: Some diagnostic info");
});

test("note field is stored on instance", () => {
const err = new ContextError(
"Organization",
"sentry org list",
undefined,
"test note"
);
expect(err.note).toBe("test note");
});
});

describe("ResolutionError", () => {
Expand Down
Loading