diff --git a/apps/cli-docs/src/fragments/commands/issue.md b/apps/cli-docs/src/fragments/commands/issue.md index 570edcc81..8146b128c 100644 --- a/apps/cli-docs/src/fragments/commands/issue.md +++ b/apps/cli-docs/src/fragments/commands/issue.md @@ -2,7 +2,11 @@ ## Context recovery -If auto-detection cannot find a project, authenticated non-interactive runs show +If auto-detection cannot find a project and your account can reach exactly one +organization, `issue list` lists that organization's issues, the same as +`sentry issue list /`. + +With several organizations, authenticated non-interactive runs show one copyable retry example and compact lists of other available organizations and `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` diff --git a/packages/cli/src/commands/issue/list.ts b/packages/cli/src/commands/issue/list.ts index 576d176ca..49899c568 100644 --- a/packages/cli/src/commands/issue/list.ts +++ b/packages/cli/src/commands/issue/list.ts @@ -1218,7 +1218,7 @@ async function handleResolvedTargets( ): Promise { const { parsed, flags, cwd, timeRange, projectSearchResolution } = options; - const { targets, footer, skippedSelfHosted, detectedDsns } = + const { targets, footer, skippedSelfHosted, detectedDsns, soleOrg } = await resolveProjectBoundTargets(parsed, { cwd, usageHint: USAGE_HINT, @@ -1226,8 +1226,15 @@ async function handleResolvedTargets( projectSearchResolution, enrichProjectIds: true, checkIssueShortId: true, + orgWideFallback: true, }); + // No detected context, and the account has a single organization: list it + // org-wide, the same as `sentry issue list /` (CLI-3B). + if (soleOrg) { + return await handleOrganizationIssues({ org: soleOrg, flags, timeRange }); + } + if (targets.length === 0) { if (skippedSelfHosted) { throw new ContextError( diff --git a/packages/cli/src/lib/resolve-target.ts b/packages/cli/src/lib/resolve-target.ts index 61927327f..0c31b2233 100644 --- a/packages/cli/src/lib/resolve-target.ts +++ b/packages/cli/src/lib/resolve-target.ts @@ -1776,8 +1776,7 @@ async function resolveSoleAccountTarget(): Promise { return null; } try { - const orgs = await listOrganizations(); - const org = orgs.length === 1 ? orgs[0] : undefined; + const org = await fetchSoleOrganization(); if (!org) { return null; } @@ -1793,6 +1792,39 @@ async function resolveSoleAccountTarget(): Promise { } } +/** The account's only accessible organization, or `undefined` for zero or several. */ +async function fetchSoleOrganization(): Promise< + SentryOrganization | undefined +> { + const orgs = await listOrganizations(); + return orgs.length === 1 ? orgs[0] : undefined; +} + +/** + * Last-resort org resolution for authenticated users: when the account can + * reach exactly one organization, it is the only possible org for an + * org-scoped command, so use it instead of failing auto-detection (CLI-3B). + * + * Returns `null` when not authenticated, when the lookup fails, or when the + * account has zero or several organizations. Never throws and never prompts. + */ +async function resolveSoleOrg(): Promise { + if (!isAuthenticated()) { + return null; + } + try { + const org = await fetchSoleOrganization(); + if (!org) { + return null; + } + setOrgProjectContext([org.slug], []); + return { org: org.slug, detectedFrom: "your only accessible organization" }; + } catch (error) { + log.debug("Account-based org auto-detect failed", error); + return null; + } +} + /** * Whether an interactive org/project picker may be shown. When `override` is * provided (e.g. derived from `--json`) it wins. Otherwise the picker is only @@ -2513,6 +2545,8 @@ export type MultiTargetResolutionResult = { footer?: string; skippedSelfHosted?: number; detectedDsns?: DetectedDsn[]; + /** Set instead of `targets` when `orgWideFallback` applied. */ + soleOrg?: string; }; /** Options for {@link resolveProjectBoundTargets}. */ @@ -2536,6 +2570,12 @@ export type ResolveProjectBoundTargetsOptions = { * (e.g. "CLI-123") before attempting cross-org project search. */ checkIssueShortId?: boolean; + /** + * Auto-detect mode only: when nothing is detected and the account can reach + * exactly one organization, return it as `soleOrg` (with no targets) instead + * of asking for a project. For commands that can run org-wide. + */ + orgWideFallback?: boolean; }; /** @@ -2563,11 +2603,22 @@ export async function resolveProjectBoundTargets( projectSearchResolution, enrichProjectIds, checkIssueShortId, + orgWideFallback, } = opts; switch (parsed.type) { case "auto-detect": { const result = await resolveAllTargets({ cwd, usageHint }); + if ( + orgWideFallback && + result.targets.length === 0 && + !result.skippedSelfHosted + ) { + const sole = await resolveSoleOrg(); + if (sole) { + return { targets: [], soleOrg: sole.org }; + } + } // Only guide when there is genuinely no context. If DSNs WERE found but // could not be resolved (self-hosted / no access), `skippedSelfHosted` is // set — preserve the empty result so the caller surfaces the @@ -2789,7 +2840,7 @@ export async function resolveOrgOptionalTarget( // auto-detect: resolve org only (no project required) if (parsed.type === "auto-detect") { - const resolved = await resolveOrg({ cwd }); + const resolved = (await resolveOrg({ cwd })) ?? (await resolveSoleOrg()); if (!resolved) { throw new ContextError("Organization", usageHint, [ "SENTRY_ORG environment variable", @@ -2839,7 +2890,7 @@ export async function resolveOrgOnlyTarget( usageHint = `sentry ${commandName} `, ): Promise { if (parsed.type === "auto-detect") { - const resolved = await resolveOrg({ cwd }); + const resolved = (await resolveOrg({ cwd })) ?? (await resolveSoleOrg()); if (!resolved) { throw new ContextError("Organization", usageHint); } diff --git a/packages/cli/test/lib/context-recovery.test.ts b/packages/cli/test/lib/context-recovery.test.ts index 0fd7f9bc3..f53efa35b 100644 --- a/packages/cli/test/lib/context-recovery.test.ts +++ b/packages/cli/test/lib/context-recovery.test.ts @@ -20,7 +20,11 @@ import { setDefaultOrganization, } from "../../src/lib/db/defaults.js"; import { ContextError, EXIT } from "../../src/lib/errors.js"; -import { guideOrgProjectFailure } from "../../src/lib/resolve-target.js"; +import { parseOrgProjectArg } from "../../src/lib/arg-parsing.js"; +import { + guideOrgProjectFailure, + resolveOrgOnlyTarget, +} 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"; @@ -500,3 +504,56 @@ describe("bounded target guidance", () => { expect(getDefaultProject()).toBeNull(); }); }); + +describe.skipIf(process.platform === "win32")( + "single accessible organization", + () => { + beforeEach(() => { + organizations = organizationsFixture.slice(0, 1); + }); + + test("issue list lists the only org when no project is detected", async () => { + const result = await runCommand([ + "issue", + "list", + "--json", + "--fields", + "id", + ]); + expect(result.exitCode, result.stderr).toBe(0); + expect(JSON.parse(result.stdout).data).toEqual([ + { id: issuesFixture[0].id }, + ]); + const issueRequests = requests.filter((url) => + url.pathname.endsWith("/issues/"), + ); + expect(issueRequests.map((url) => url.pathname)).toEqual([ + "/api/0/organizations/test-org/issues/", + ]); + expect(issueRequests[0]?.searchParams.has("project")).toBe(false); + expect(getDefaultOrganization()).toBeNull(); + }); + + test("org-scoped commands use the only org", async () => { + await expect( + resolveOrgOnlyTarget( + parseOrgProjectArg(undefined), + getConfigDir(), + "explore", + ), + ).resolves.toBe("test-org"); + }); + + test("org-scoped commands still ask when several orgs are accessible", async () => { + organizations = organizationsFixture; + const error = await failure( + resolveOrgOnlyTarget( + parseOrgProjectArg(undefined), + getConfigDir(), + "explore", + ), + ); + expect(error).toBeInstanceOf(ContextError); + }); + }, +);