Skip to content
Open
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
9 changes: 9 additions & 0 deletions apps/cli-docs/src/fragments/commands/issue.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,15 @@ sentry issue list my-org/
sentry issue list frontend
```

If auto-detection cannot find a project, authenticated non-interactive runs show
one copyable retry example and a compact list of other available `org/project`
targets. Choose the project that matches your task; the retry keeps your query,
period, limit, sort, and output flags. Navigation keywords such as `--cursor next`
become `--cursor first` because the chosen project may have no saved pagination state;
raw API cursors are preserved. Suggestions are a bounded sample, with
`sentry project list` commands to discover other projects. Showing suggestions
does not save defaults.

```
ID SHORT ID TITLE COUNT USERS
123456789 FRONT-ABC TypeError: Cannot read prop... 1.2k 234
Expand Down
32 changes: 32 additions & 0 deletions packages/cli/src/commands/issue/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ import {
appendQueryHint,
buildListCommand,
buildListLimitFlag,
CURSOR_KEYWORDS,
LIST_BASE_ALIASES,
LIST_MAX_LIMIT,
LIST_TARGET_POSITIONAL,
Expand Down Expand Up @@ -101,6 +102,7 @@ import {
timeRangeToApiParams,
} from "../../lib/time-range.js";
import { tipsSuppressed } from "../../lib/tips.js";
import { shellQuote } from "../../lib/utils.js";
import {
type SentryIssue,
SentryIssueSchema,
Expand Down Expand Up @@ -720,6 +722,35 @@ function trimWithProjectGuarantee(
);
}

/** Re-run the failed search with an explicit target, preserving search and output flags. */
function contextRecoveryCommand(target: string, flags: ListFlags): string {
const parts = [
`sentry issue list ${shellQuote(target)}`,
`--limit ${flags.limit}`,
`--sort ${flags.sort}`,
`--period ${shellQuote(formatTimeRangeFlag(flags.period))}`,
];
appendQueryHint(parts, flags.query);
if (flags.cursor !== undefined) {
// Navigation state belongs to a resolved target; a candidate may have none.
const cursor = CURSOR_KEYWORDS.has(flags.cursor) ? "first" : flags.cursor;
parts.push(`--cursor ${shellQuote(cursor)}`);
}
if (flags.json) {
parts.push("--json");
}
if (flags.fields) {
parts.push(`--fields ${shellQuote(flags.fields.join(","))}`);
}
if (flags.fresh) {
parts.push("--fresh");
}
if (flags.compact !== undefined) {
parts.push(flags.compact ? "--compact" : "--no-compact");
}
return parts.join(" ");
}

/** Build the CLI hint for fetching the next page, preserving active flags. */
/** Append active non-default issue list flags to a base command string. */
function appendIssueFlags(base: string, flags: ListFlags): string {
Expand Down Expand Up @@ -1216,6 +1247,7 @@ async function handleResolvedTargets(
await resolveProjectBoundTargets(parsed, {
cwd,
usageHint: USAGE_HINT,
formatRecoveryCommand: (target) => contextRecoveryCommand(target, flags),
Comment thread
sentry-warden[bot] marked this conversation as resolved.
projectSearchResolution,
enrichProjectIds: true,
checkIssueShortId: true,
Expand Down
152 changes: 125 additions & 27 deletions packages/cli/src/lib/resolve-target.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
getProject,
listOrganizations,
listProjects,
listProjectsPaginated,
ORG_FANOUT_CONCURRENCY,
type ProjectWithOrg,
resolveOrgDisplayName,
Expand Down Expand Up @@ -78,7 +79,7 @@ import { logger } from "./logger.js";
import { resolveEffectiveOrg } from "./region.js";
import { CONFIG_FILENAME, loadSentryCliRc } from "./sentryclirc.js";
import { setOrgProjectContext, withTracingSpan } from "./telemetry.js";
import { isAllDigits } from "./utils.js";
import { isAllDigits, shellQuote } from "./utils.js";

const log = logger.withTag("resolve-target");

Expand Down Expand Up @@ -250,6 +251,8 @@ export type ResolveOptions = {
cwd: string;
/** Usage hint shown when only one of org/project is provided */
usageHint?: string;
/** Format a retry command for an explicit org/project candidate after detection fails. */
formatRecoveryCommand?: (target: string) => string;
/**
* Whether an interactive org/project picker may be shown when auto-detection
* fails. Only consulted by {@link resolveOrgProjectOrGuide}. When omitted, it
Expand Down Expand Up @@ -1899,35 +1902,117 @@ async function promptForOrgProject(): Promise<ResolvedTarget | null> {
return toAccountTarget(org, project, "interactive selection");
}

/** Bound discovery work and error output when the account has many organizations. */
const CONTEXT_ORG_LIMIT = 3;

/** Number of explicit project candidates requested from each organization. */
const CONTEXT_PROJECT_LIMIT = 3;

/** Bounded project suggestions and an optional diagnostic for one organization. */
type ContextProjectCandidates = {
/** Explicit org/project values that the caller may choose. */
targets: string[];
/** Explains incomplete discovery without claiming the shown targets are exhaustive. */
note?: string;
};

/** Fetch one page of project candidates without turning discovery failures into command failures. */
async function getContextProjectCandidates(
org: string,
): Promise<ContextProjectCandidates> {
try {
const { data, nextCursor } = await listProjectsPaginated(org, {
perPage: CONTEXT_PROJECT_LIMIT,
});
const targets = data
.slice(0, CONTEXT_PROJECT_LIMIT)
.map((project) => `${org}/${project.slug}`);
if (nextCursor || data.length > targets.length) {
return {
targets,
note: `More projects are available in '${org}'.`,
};
}
return {
targets,
note:
targets.length === 0
? `No accessible projects were returned for '${org}'.`
: undefined,
};
} catch (error) {
log.debug(`Failed to list project candidates for '${org}'`, error);
return {
targets: [],
note: `Could not list projects in '${org}'.`,
};
}
}

/**
* Build an actionable {@link ContextError} for authenticated users whose
* org/project could not be auto-detected. Lists the accessible organizations so
* the next command is copy-pasteable, instead of the generic "run org list"
* guidance shown to logged-out users.
* Suggest explicit targets after detection fails, preserving any known org scope.
* Discovery is bounded to one project page for each of at most three orgs. The
* suggestions never select a target or save defaults, even if only one is shown.
*/
async function buildAccountContextError(
usageHint: string,
options: Partial<
Pick<ResolveOptions, "cwd" | "org" | "formatRecoveryCommand">
>,
): Promise<ContextError> {
let orgs: string[];
try {
const orgs = await listOrganizations();
if (orgs.length > 0) {
const shown = orgs.slice(0, 10).map((o) => ` ${o.slug}`);
const more =
orgs.length > shown.length
? ` …and ${orgs.length - shown.length} more`
: "";
const orgList = [...shown, more].filter(Boolean).join("\n");
return new ContextError("Organization and project", usageHint, [
`Specify one of your organizations:\n${orgList}`,
"List a project: sentry project list <org>/",
"Save a default: sentry cli defaults org <slug>",
]);
}
const scopedOrg =
options.org ??
(options.cwd ? (await resolveOrg({ cwd: options.cwd }))?.org : undefined);
orgs = scopedOrg
? [scopedOrg]
: (await listOrganizations()).map((org) => org.slug);
} catch (error) {
log.debug("Failed to list organizations for context error", error);
log.debug("Failed to discover organizations for context error", error);
return new ContextError("Organization and project", usageHint);
}
if (orgs.length === 0) {
return new ContextError("Organization and project", usageHint);
}
// Fall back to the standard auto-detect guidance.
return new ContextError("Organization and project", usageHint);

const shownOrgs = orgs.slice(0, CONTEXT_ORG_LIMIT);
const candidates = await Promise.all(
shownOrgs.map(getContextProjectCandidates),
);
const targets = candidates.flatMap((candidate) => candidate.targets);
const alternatives: string[] = [];
const [exampleTarget, ...otherTargets] = targets;
if (exampleTarget && options.formatRecoveryCommand) {
alternatives.push(
`Retry example:\n ${options.formatRecoveryCommand(exampleTarget)}`,
);
if (otherTargets.length > 0) {
alternatives.push(`Other available projects: ${otherTargets.join(", ")}`);
}
} else if (targets.length > 0) {
alternatives.push(
`Choose an explicit target:\n${targets.map((target) => ` ${target}`).join("\n")}`,
);
}
alternatives.push(
...shownOrgs.map(
(org) => `List projects: sentry project list ${shellQuote(`${org}/`)}`,
),
);
const notes = candidates.flatMap((candidate) =>
candidate.note ? [candidate.note] : [],
);
if (shownOrgs.length < orgs.length) {
notes.push(`Showing ${shownOrgs.length} of ${orgs.length} organizations.`);
alternatives.push("List all organizations: sentry org list");
}
return new ContextError(
"Organization and project",
usageHint,
alternatives,
notes.length > 0 ? notes.join(" ") : undefined,
);
}

/**
Expand All @@ -1938,8 +2023,8 @@ async function buildAccountContextError(
*
* 1. Offers an interactive org/project picker (TTY only; saves the choice as a
* default), then
* 2. For authenticated users, throws a {@link ContextError} listing their
* accessible organizations, otherwise
* 2. For authenticated users, throws a {@link ContextError} with explicit
* project candidates within any known organization scope, otherwise
* 3. Throws the standard auto-detect {@link ContextError}.
*
* Kept separate from {@link resolveOrgAndProject} so callers that mock the
Expand All @@ -1951,7 +2036,11 @@ async function buildAccountContextError(
* accessible projects.
*/
export async function guideOrgProjectFailure(
options: Pick<ResolveOptions, "usageHint" | "interactive">,
options: Pick<
ResolveOptions,
"usageHint" | "interactive" | "org" | "formatRecoveryCommand"
> &
Partial<Pick<ResolveOptions, "cwd">>,
): Promise<ResolvedTarget> {
const usageHint = options.usageHint ?? "sentry <command> <org>/<project>";

Expand All @@ -1963,7 +2052,7 @@ export async function guideOrgProjectFailure(
}

if (isAuthenticated()) {
throw await buildAccountContextError(usageHint);
throw await buildAccountContextError(usageHint, options);
}
throw new ContextError("Organization and project", usageHint);
}
Expand Down Expand Up @@ -2430,6 +2519,8 @@ export type ResolveProjectBoundTargetsOptions = {
cwd: string;
/** Usage hint shown in error messages (e.g. "sentry issue list <org>/<project>"). */
usageHint: string;
/** Format a retry command for an explicit org/project candidate after detection fails. */
formatRecoveryCommand?: (target: string) => string;
/** Request-scoped project-search classification from a dispatcher. */
projectSearchResolution?: ProjectSearchTargetResolution;
/**
Expand Down Expand Up @@ -2466,6 +2557,7 @@ export async function resolveProjectBoundTargets(
const {
cwd,
usageHint,
formatRecoveryCommand,
projectSearchResolution,
enrichProjectIds,
checkIssueShortId,
Expand All @@ -2483,7 +2575,13 @@ export async function resolveProjectBoundTargets(
// Offer an interactive picker (TTY) or an actionable error listing the
// user's accessible orgs, and let a single-org/single-project account
// resolve automatically (CLI-3B).
result.targets = [await resolveOrgProjectOrGuide({ cwd, usageHint })];
result.targets = [
await resolveOrgProjectOrGuide({
cwd,
usageHint,
formatRecoveryCommand,
}),
];
}
if (enrichProjectIds) {
result.targets = await Promise.all(
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/lib/trace-target.ts
Original file line number Diff line number Diff line change
Expand Up @@ -555,10 +555,10 @@ export async function resolveTraceOrgProject(
case "auto-detect": {
// resolveOrgAndProject already sets telemetry context. On a failed
// auto-detect, guideOrgProjectFailure offers an interactive picker (TTY)
// or an actionable error listing the user's accessible orgs.
// or an actionable error listing explicit project candidates.
const resolved =
(await resolveOrgAndProject({ cwd, usageHint })) ??
(await guideOrgProjectFailure({ usageHint }));
(await guideOrgProjectFailure({ cwd, usageHint }));
return {
traceId: parsed.traceId,
org: resolved.org,
Expand Down
Loading
Loading