diff --git a/apps/cli-docs/src/fragments/commands/issue.md b/apps/cli-docs/src/fragments/commands/issue.md index 2c3256152..b3e03bcf8 100644 --- a/apps/cli-docs/src/fragments/commands/issue.md +++ b/apps/cli-docs/src/fragments/commands/issue.md @@ -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 diff --git a/packages/cli/src/commands/issue/list.ts b/packages/cli/src/commands/issue/list.ts index c5be1e075..624a61150 100644 --- a/packages/cli/src/commands/issue/list.ts +++ b/packages/cli/src/commands/issue/list.ts @@ -60,6 +60,7 @@ import { appendQueryHint, buildListCommand, buildListLimitFlag, + CURSOR_KEYWORDS, LIST_BASE_ALIASES, LIST_MAX_LIMIT, LIST_TARGET_POSITIONAL, @@ -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, @@ -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 { @@ -1216,6 +1247,7 @@ async function handleResolvedTargets( await resolveProjectBoundTargets(parsed, { cwd, usageHint: USAGE_HINT, + formatRecoveryCommand: (target) => contextRecoveryCommand(target, flags), projectSearchResolution, enrichProjectIds: true, checkIssueShortId: true, diff --git a/packages/cli/src/lib/resolve-target.ts b/packages/cli/src/lib/resolve-target.ts index 3d37ad0d5..4fc26750d 100644 --- a/packages/cli/src/lib/resolve-target.ts +++ b/packages/cli/src/lib/resolve-target.ts @@ -29,6 +29,7 @@ import { getProject, listOrganizations, listProjects, + listProjectsPaginated, ORG_FANOUT_CONCURRENCY, type ProjectWithOrg, resolveOrgDisplayName, @@ -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"); @@ -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 @@ -1899,35 +1902,117 @@ async function promptForOrgProject(): Promise { 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 { + 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 + >, ): Promise { + 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 /", - "Save a default: sentry cli defaults org ", - ]); - } + 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, + ); } /** @@ -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 @@ -1951,7 +2036,11 @@ async function buildAccountContextError( * accessible projects. */ export async function guideOrgProjectFailure( - options: Pick, + options: Pick< + ResolveOptions, + "usageHint" | "interactive" | "org" | "formatRecoveryCommand" + > & + Partial>, ): Promise { const usageHint = options.usageHint ?? "sentry /"; @@ -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); } @@ -2430,6 +2519,8 @@ export type ResolveProjectBoundTargetsOptions = { cwd: string; /** Usage hint shown in error messages (e.g. "sentry issue list /"). */ 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; /** @@ -2466,6 +2557,7 @@ export async function resolveProjectBoundTargets( const { cwd, usageHint, + formatRecoveryCommand, projectSearchResolution, enrichProjectIds, checkIssueShortId, @@ -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( diff --git a/packages/cli/src/lib/trace-target.ts b/packages/cli/src/lib/trace-target.ts index a8fceb53f..1801a93e3 100644 --- a/packages/cli/src/lib/trace-target.ts +++ b/packages/cli/src/lib/trace-target.ts @@ -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, diff --git a/packages/cli/test/lib/context-recovery.test.ts b/packages/cli/test/lib/context-recovery.test.ts new file mode 100644 index 000000000..22d26ef77 --- /dev/null +++ b/packages/cli/test/lib/context-recovery.test.ts @@ -0,0 +1,479 @@ +/** Context recovery through the real parser, resolver, and local HTTP API. */ + +import { execFileSync } from "node:child_process"; +import { run } from "@stricli/core"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + test, +} from "vitest"; +import { app } from "../../src/app.js"; +import { buildContext } from "../../src/context.js"; +import { setAuthToken } from "../../src/lib/db/auth.js"; +import { + getDefaultOrganization, + getDefaultProject, + setDefaultOrganization, +} from "../../src/lib/db/defaults.js"; +import { ContextError, EXIT } from "../../src/lib/errors.js"; +import { guideOrgProjectFailure } from "../../src/lib/resolve-target.js"; +import { resetCacheState } from "../../src/lib/response-cache.js"; +import { resetAuthenticatedFetch } from "../../src/lib/sentry-client.js"; +import issuesFixture from "../fixtures/issues.json"; +import organizationsFixture from "../fixtures/organizations.json"; +import projectsFixture from "../fixtures/projects.json"; +import { + mockFetch, + resetHostScopingState, + useEnvSandbox, + useTestConfigDir, +} from "../helpers.js"; +import { createMockServer, type MockServer } from "../mocks/server.js"; + +const TOKEN = "context-recovery-test-token"; +const QUERY = `is:unresolved message:"can't read 'name'"`; +const USAGE = "sentry issue list /"; +const getConfigDir = useTestConfigDir("context-recovery-", { + isolateProjectRoot: true, +}); +useEnvSandbox([ + "SENTRY_AUTH_TOKEN", + "SENTRY_TOKEN", + "SENTRY_FORCE_ENV_TOKEN", + "SENTRY_URL", + "SENTRY_HOST", + "SENTRY_ORG", + "SENTRY_PROJECT", + "SENTRY_NO_CACHE", + "SENTRY_OUTPUT_FORMAT", +]); + +let server: MockServer; +let originalFetch: typeof globalThis.fetch; +let organizations: typeof organizationsFixture; +let projectsByOrg: Map; +let failedOrgs: Set; +let paginatedOrgs: Set; +let requests: URL[]; + +beforeAll(async () => { + server = createMockServer( + [ + { + method: "GET", + path: "/api/0/organizations/", + response: () => ({ body: organizations }), + }, + { + method: "GET", + path: "/api/0/organizations/:orgSlug/projects/", + response: ( + _request: Request, + { orgSlug }: Record, + serverUrl: string, + ) => { + if (failedOrgs.has(orgSlug)) { + return { status: 403, body: { detail: "Not permitted" } }; + } + return { + body: projectsByOrg.get(orgSlug) ?? [], + headers: paginatedOrgs.has(orgSlug) + ? { + Link: `<${serverUrl}/api/0/organizations/${orgSlug}/projects/>; rel="next"; results="true"; cursor="next-page"`, + } + : undefined, + }; + }, + }, + { + method: "GET", + path: "/api/0/projects/:orgSlug/:projectSlug/", + response: ( + _request: Request, + { orgSlug, projectSlug }: Record, + ) => ({ + body: projectsByOrg + .get(orgSlug) + ?.find((project) => project.slug === projectSlug), + }), + }, + { + method: "GET", + path: "/api/0/organizations/:orgSlug/issues/", + response: issuesFixture.slice(0, 1), + }, + ], + { validTokens: [TOKEN] }, + ); + await server.start(); +}); + +afterAll(() => server.stop()); + +beforeEach(async () => { + await resetHostScopingState(); + resetAuthenticatedFetch(); + resetCacheState(); + originalFetch = globalThis.fetch; + organizations = organizationsFixture; + // Both orgs deliberately expose the same project slugs. Recovery must + // provide qualified targets rather than another ambiguous project lookup. + projectsByOrg = new Map( + organizations.map((org) => [org.slug, projectsFixture]), + ); + failedOrgs = new Set(); + paginatedOrgs = new Set(); + requests = []; + process.env.SENTRY_URL = server.url; + process.env.SENTRY_NO_CACHE = "1"; + setAuthToken(TOKEN, undefined, undefined, { host: server.url }); + + const realFetch = (globalThis as { __originalFetch?: typeof fetch }) + .__originalFetch; + if (!realFetch) { + throw new Error("Test preload did not preserve the real fetch"); + } + globalThis.fetch = mockFetch((input, init) => { + const request = new Request(input, init); + const url = new URL(request.url); + // Explicitly refuse any request outside this test server, including + // redirects or unexpected SaaS discovery during target resolution. + if (url.origin !== server.url) { + throw new Error(`Unexpected external request: ${url.origin}`); + } + requests.push(url); + return realFetch(request); + }); +}); + +afterEach(async () => { + globalThis.fetch = originalFetch; + resetAuthenticatedFetch(); + resetCacheState(); + await resetHostScopingState(); +}); + +async function failure(operation: Promise): Promise { + try { + await operation; + } catch (error) { + if (error instanceof Error) { + return error; + } + throw error; + } + throw new Error("Expected missing-context failure"); +} + +async function runCommand(args: string[]) { + const output = { stdout: "", stderr: "" }; + const process_ = { + env: process.env, + cwd: () => getConfigDir(), + stdin: process.stdin, + exitCode: undefined as number | undefined, + stdout: { + write: (chunk: string) => { + output.stdout += chunk; + return true; + }, + }, + stderr: { + write: (chunk: string) => { + output.stderr += chunk; + return true; + }, + }, + }; + await run(app, args, buildContext(process_ as unknown as NodeJS.Process)); + return { ...output, exitCode: process_.exitCode }; +} + +function recoveryCommands(error: Error): string[] { + return error.message.match(/sentry issue list (?!<)[^\n]+/g) ?? []; +} + +function guide() { + return guideOrgProjectFailure({ + cwd: getConfigDir(), + interactive: false, + usageHint: USAGE, + formatRecoveryCommand: (target) => `sentry issue list ${target}`, + }); +} + +describe.skipIf(process.platform === "win32")( + "issue list context recovery", + () => { + test.each([ + { + query: QUERY, + period: "24h", + expectedTime: { statsPeriod: "24h" }, + }, + { + query: QUERY, + period: "2026-10-15T00:00:00Z..2026-10-16T00:00:00Z", + expectedTime: { + start: "2026-10-15T00:00:00.000Z", + end: "2026-10-16T00:00:00.000Z", + }, + }, + { + query: QUERY, + period: ">=2026-10-15T00:00:00Z", + expectedTime: { start: "2026-10-15T00:00:00.000Z" }, + }, + { + query: "-timeout", + period: "24h", + expectedTime: { statsPeriod: "24h" }, + }, + { + query: "", + period: "24h", + expectedTime: { statsPeriod: "24h" }, + }, + { + query: QUERY, + period: "24h", + expectedTime: { statsPeriod: "24h" }, + cursor: "next", + expectedRetryCursor: "first", + }, + { + query: QUERY, + period: "24h", + expectedTime: { statsPeriod: "24h" }, + cursor: "0:7:0", + expectedRetryCursor: "0:7:0", + expectedApiCursor: "0:7:0", + }, + ])( + 'retries query "$query" and output options with $period and cursor $cursor', + async ({ + query, + period, + expectedTime, + cursor, + expectedRetryCursor, + expectedApiCursor, + }) => { + const first = await runCommand([ + "issue", + "list", + ...(query === "" ? ["--query", ""] : [`--query=${query}`]), + "--period", + period, + "--limit", + "7", + "--sort", + "freq", + "--json", + "--fields", + "id,title", + ...(cursor ? ["--cursor", cursor] : []), + ]); + expect(first.exitCode, first.stderr).toBe(EXIT.CONTEXT_MISSING); + expect(first.stdout).toBe(""); + const commands = recoveryCommands(new Error(first.stderr)); + expect(commands).toHaveLength(1); + expect(commands[0]).toContain("test-org/test-project"); + expect(first.stderr).toContain( + "Other available projects: test-org/test-project-2, test-org-2/test-project, test-org-2/test-project-2", + ); + for (const flag of [ + "--limit", + "--sort", + "--period", + "--json", + "--fields", + ]) { + expect(first.stderr.split(flag)).toHaveLength(2); + } + expect( + requests.filter((url) => url.pathname.endsWith("/issues/")), + ).toHaveLength(0); + + // Let a real POSIX shell parse the exact suggested command. The local + // sentry function only prints argv, so this cannot run the user's CLI. + const args = execFileSync( + "sh", + ["-c", `sentry() { printf '%s\\0' "$@"; }; ${commands[0]}`], + { encoding: "utf8" }, + ) + .split("\0") + .slice(0, -1); + expect(args).toContain("--json"); + expect(args[args.indexOf("--fields") + 1]).toBe("id,title"); + if (expectedRetryCursor) { + expect(args[args.indexOf("--cursor") + 1]).toBe(expectedRetryCursor); + } + const retry = await runCommand(args); + expect(retry.exitCode, retry.stderr).toBe(0); + expect(JSON.parse(retry.stdout)).toEqual({ + data: [{ id: issuesFixture[0].id, title: issuesFixture[0].title }], + hasMore: false, + hasPrev: false, + }); + const issueRequests = requests.filter((url) => + url.pathname.endsWith("/issues/"), + ); + expect(issueRequests).toHaveLength(1); + const request = issueRequests[0]; + expect(request.pathname).toBe("/api/0/organizations/test-org/issues/"); + expect(request.searchParams.get("project")).toBe(projectsFixture[0].id); + expect(request.searchParams.get("query")).toBe(query || null); + expect(request.searchParams.get("limit")).toBe("7"); + expect(request.searchParams.get("sort")).toBe("freq"); + expect(request.searchParams.get("cursor")).toBe( + expectedApiCursor ?? null, + ); + for (const [key, value] of Object.entries(expectedTime)) { + expect(request.searchParams.get(key)).toBe(value); + } + expect(request.searchParams.has("statsPeriod")).toBe(period === "24h"); + expect(getDefaultOrganization()).toBeNull(); + expect(getDefaultProject()).toBeNull(); + }, + ); + }, +); + +describe("bounded target guidance", () => { + test.each(["environment", "default"] as const)( + "keeps candidates within the %s organization without changing defaults", + async (source) => { + if (source === "environment") { + process.env.SENTRY_ORG = "test-org-2"; + } else { + setDefaultOrganization("test-org-2"); + } + + const error = await failure(guide()); + expect(error).toBeInstanceOf(ContextError); + const commands = recoveryCommands(error); + expect(commands).toEqual(["sentry issue list test-org-2/test-project"]); + expect(error.message).toContain( + "Other available projects: test-org-2/test-project-2", + ); + expect(error.message).not.toContain("test-org/"); + const lookups = requests.filter((url) => + url.pathname.endsWith("/projects/"), + ); + expect(lookups.map((url) => url.pathname)).toEqual([ + "/api/0/organizations/test-org-2/projects/", + ]); + expect(getDefaultOrganization()).toBe( + source === "default" ? "test-org-2" : null, + ); + expect(getDefaultProject()).toBeNull(); + }, + ); + + test("bounds discovery and still suggests accessible projects when one org fails", async () => { + organizations = Array.from({ length: 4 }, (_, index) => ({ + ...organizationsFixture[0], + id: String(100_010 + index), + slug: `candidate-org-${index}`, + })); + projectsByOrg = new Map( + organizations.map((org) => [ + org.slug, + Array.from({ length: 5 }, (_, index) => ({ + ...projectsFixture[0], + id: String(300_010 + index), + slug: `candidate-project-${index}`, + })), + ]), + ); + failedOrgs.add("candidate-org-0"); + paginatedOrgs.add("candidate-org-1"); + + const error = await failure(guide()); + expect(error).toBeInstanceOf(ContextError); + expect(error.message).toContain("Showing 3 of 4 organizations."); + expect(error.message).toContain( + "More projects are available in 'candidate-org-1'.", + ); + expect(error.message).toContain( + "Could not list projects in 'candidate-org-0'.", + ); + expect(error.message).toContain("sentry project list 'candidate-org-0/'"); + const commands = recoveryCommands(error); + expect(commands).toEqual([ + "sentry issue list candidate-org-1/candidate-project-0", + ]); + expect( + error.message.match(/candidate-org-\d\/candidate-project-\d/g), + ).toEqual([ + "candidate-org-1/candidate-project-0", + "candidate-org-1/candidate-project-1", + "candidate-org-1/candidate-project-2", + "candidate-org-2/candidate-project-0", + "candidate-org-2/candidate-project-1", + "candidate-org-2/candidate-project-2", + ]); + const lookups = requests.filter((url) => + url.pathname.endsWith("/projects/"), + ); + expect(lookups).toHaveLength(3); + expect(lookups.every((url) => !url.searchParams.has("cursor"))).toBe(true); + expect( + lookups.every((url) => url.searchParams.get("per_page") === "3"), + ).toBe(true); + expect(getDefaultOrganization()).toBeNull(); + expect(getDefaultProject()).toBeNull(); + }); + + test("shows one retry without an empty alternatives list for a single project", async () => { + organizations = organizationsFixture.slice(0, 1); + projectsByOrg = new Map([ + [organizations[0].slug, projectsFixture.slice(0, 1)], + ]); + + const error = await failure(guide()); + expect(error).toBeInstanceOf(ContextError); + expect(recoveryCommands(error)).toEqual([ + "sentry issue list test-org/test-project", + ]); + expect(error.message).not.toContain("Other available projects:"); + expect(getDefaultOrganization()).toBeNull(); + expect(getDefaultProject()).toBeNull(); + }); + + test("lists explicit targets when the caller does not provide a retry formatter", async () => { + const error = await failure( + guideOrgProjectFailure({ + cwd: getConfigDir(), + interactive: false, + usageHint: USAGE, + }), + ); + expect(error).toBeInstanceOf(ContextError); + expect(recoveryCommands(error)).toEqual([]); + expect(error.message).toContain("Choose an explicit target:"); + for (const org of organizationsFixture) { + for (const project of projectsFixture) { + expect(error.message).toContain(`${org.slug}/${project.slug}`); + } + } + expect(getDefaultOrganization()).toBeNull(); + expect(getDefaultProject()).toBeNull(); + }); + + test("keeps discovery actionable when no accessible projects are returned", async () => { + projectsByOrg = new Map(); + + const error = await failure(guide()); + expect(error).toBeInstanceOf(ContextError); + expect(recoveryCommands(error)).toEqual([]); + expect(error.message).toContain("sentry project list 'test-org/'"); + expect(getDefaultOrganization()).toBeNull(); + expect(getDefaultProject()).toBeNull(); + }); +});