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
6 changes: 5 additions & 1 deletion apps/cli-docs/src/fragments/commands/issue.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <org>/`.

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`
Expand Down
9 changes: 8 additions & 1 deletion packages/cli/src/commands/issue/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1218,16 +1218,23 @@ async function handleResolvedTargets(
): Promise<IssueListResult> {
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,
formatRecoveryCommand: (target) => contextRecoveryCommand(target, flags),
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 <org>/` (CLI-3B).
if (soleOrg) {
return await handleOrganizationIssues({ org: soleOrg, flags, timeRange });
}

if (targets.length === 0) {
if (skippedSelfHosted) {
throw new ContextError(
Expand Down
59 changes: 55 additions & 4 deletions packages/cli/src/lib/resolve-target.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1776,8 +1776,7 @@ async function resolveSoleAccountTarget(): Promise<ResolvedTarget | null> {
return null;
}
try {
const orgs = await listOrganizations();
const org = orgs.length === 1 ? orgs[0] : undefined;
const org = await fetchSoleOrganization();
if (!org) {
return null;
}
Expand All @@ -1793,6 +1792,39 @@ async function resolveSoleAccountTarget(): Promise<ResolvedTarget | null> {
}
}

/** 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<ResolvedOrg | null> {
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
Expand Down Expand Up @@ -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}. */
Expand All @@ -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;
};

/**
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -2839,7 +2890,7 @@ export async function resolveOrgOnlyTarget(
usageHint = `sentry ${commandName} <target>`,
): Promise<string> {
if (parsed.type === "auto-detect") {
const resolved = await resolveOrg({ cwd });
const resolved = (await resolveOrg({ cwd })) ?? (await resolveSoleOrg());
if (!resolved) {
throw new ContextError("Organization", usageHint);
}
Expand Down
59 changes: 58 additions & 1 deletion packages/cli/test/lib/context-recovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
});
},
);
Loading