diff --git a/apps/cli-docs/src/fragments/commands/issue.md b/apps/cli-docs/src/fragments/commands/issue.md index 9cff62d1c..3b27c6f10 100644 --- a/apps/cli-docs/src/fragments/commands/issue.md +++ b/apps/cli-docs/src/fragments/commands/issue.md @@ -108,6 +108,19 @@ sentry issue view FRONT-ABC sentry issue view FRONT-ABC BACK-2 ``` +Full short IDs such as `FRONT-ABC` use your configured organization: `SENTRY_ORG`, +then `.sentryclirc`, then the default set with `sentry cli defaults org`. The lookup +stays within that organization. To select another organization, include it explicitly: + +```bash +sentry issue view my-org/FRONT-ABC +``` + +Project aliases such as `f-abc` keep the organization associated with the alias. +Without a configured organization, the CLI uses a matching detected project or +searches your accessible organizations. If the short ID matches in multiple +organizations, specify the organization explicitly. + ``` Issue: TypeError: Cannot read property 'foo' of undefined Short ID: FRONT-ABC diff --git a/apps/cli-docs/src/fragments/configuration.md b/apps/cli-docs/src/fragments/configuration.md index b9673a933..4a4c51840 100644 --- a/apps/cli-docs/src/fragments/configuration.md +++ b/apps/cli-docs/src/fragments/configuration.md @@ -55,6 +55,11 @@ When the CLI needs to determine your org and project, it checks these sources in The first source that provides both org and project wins. For org-only commands, only the org is needed. +Full issue short IDs, such as `sentry issue view FRONT-ABC`, only need the org: +`SENTRY_ORG`, `.sentryclirc`, and persistent defaults scope the lookup in that order. +Use `sentry issue view other-org/FRONT-ABC` to override the configured organization. +Project aliases retain their associated organization. See [View an issue](./commands/issue/#view-an-issue). + ### Backward Compatibility If you previously used the legacy `sentry-cli` and have a `~/.sentryclirc` file, the new CLI reads it automatically. The `[defaults]` and `[auth]` sections are fully compatible. The `[auth] token` value is mapped to the `SENTRY_AUTH_TOKEN` environment variable internally (only if the env var is not already set). diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/issue.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/issue.md index 643e67778..89c58b651 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/issue.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/issue.md @@ -232,6 +232,8 @@ sentry issue view FRONT-ABC # Multiple issues in one invocation (space-separated, not commas) sentry issue view FRONT-ABC BACK-2 +sentry issue view my-org/FRONT-ABC + # Open one or more issues in the browser (up to 5 tabs by default) sentry issue view FRONT-ABC BACK-2 -w diff --git a/packages/cli/src/commands/feedback/view.ts b/packages/cli/src/commands/feedback/view.ts index 6d9b74b4c..bb8823f5c 100644 --- a/packages/cli/src/commands/feedback/view.ts +++ b/packages/cli/src/commands/feedback/view.ts @@ -101,10 +101,12 @@ export const viewCommand = buildCommand({ "Feedback formats:\n" + " @latest Most recent unresolved Feedback\n" + " /@latest Most recent unresolved Feedback in an organization\n" + - " Search accessible organizations\n" + + " Use configured org or discover the organization\n" + " Resolve by numeric issue ID\n" + " / Explicit organization\n" + " // Explicit organization and project\n\n" + + "Full short IDs use SENTRY_ORG, .sentryclirc, or 'sentry cli defaults' for the org.\n" + + "Without a configured org, the CLI uses a matching DSN or searches accessible orgs.\n\n" + "The resolved issue must have issue.category:feedback. Use 'sentry issue view' for other issue categories.", }, output: { diff --git a/packages/cli/src/commands/issue/explain.ts b/packages/cli/src/commands/issue/explain.ts index a8f2467d3..4591510d4 100644 --- a/packages/cli/src/commands/issue/explain.ts +++ b/packages/cli/src/commands/issue/explain.ts @@ -95,9 +95,11 @@ export const explainCommand = buildCommand({ " /ID - Explicit org: sentry/EXTENSION-7, sentry/cli-G\n" + " /@selector - Selector with org: my-org/@latest\n" + " -suffix - Project + suffix: cli-G, spotlight-electron-4Y\n" + - " ID - Short ID: CLI-G (searches across orgs)\n" + + " ID - Short ID: CLI-G (uses configured org when set)\n" + " suffix - Suffix only: G (requires DSN context)\n" + " numeric - Numeric ID: 123456789\n\n" + + "Full short IDs use SENTRY_ORG, .sentryclirc, or 'sentry cli defaults' for the org.\n" + + "Without a configured org, the CLI uses a matching DSN or searches accessible orgs.\n\n" + "Multiple issue IDs can be passed as separate arguments or newline-separated\n" + "within a single argument.\n\n" + "Examples:\n" + diff --git a/packages/cli/src/commands/issue/plan.ts b/packages/cli/src/commands/issue/plan.ts index 4a6db7ede..a5e12ec40 100644 --- a/packages/cli/src/commands/issue/plan.ts +++ b/packages/cli/src/commands/issue/plan.ts @@ -211,9 +211,11 @@ export const planCommand = buildCommand({ " /ID - Explicit org: sentry/EXTENSION-7, sentry/cli-G\n" + " /@selector - Selector with org: my-org/@latest\n" + " -suffix - Project + suffix: cli-G, spotlight-electron-4Y\n" + - " ID - Short ID: CLI-G (searches across orgs)\n" + + " ID - Short ID: CLI-G (uses configured org when set)\n" + " suffix - Suffix only: G (requires DSN context)\n" + " numeric - Numeric ID: 123456789\n\n" + + "Full short IDs use SENTRY_ORG, .sentryclirc, or 'sentry cli defaults' for the org.\n" + + "Without a configured org, the CLI uses a matching DSN or searches accessible orgs.\n\n" + "Prerequisites:\n" + " - GitHub integration configured for your organization\n" + " - Code mappings set up for your project\n\n" + diff --git a/packages/cli/src/commands/issue/utils.ts b/packages/cli/src/commands/issue/utils.ts index 60ecdc76c..715639e7b 100644 --- a/packages/cli/src/commands/issue/utils.ts +++ b/packages/cli/src/commands/issue/utils.ts @@ -16,6 +16,7 @@ import { type IssueSort, listIssuesPaginated, listOrganizations, + listOrganizationsUncached, ORG_FANOUT_CONCURRENCY, triggerRootCauseAnalysis, tryGetIssueByShortId, @@ -32,6 +33,7 @@ import { setCachedIssueOrg, } from "../../lib/db/issue-org-cache.js"; import { getProjectByAlias } from "../../lib/db/project-aliases.js"; +import { getCachedOrganizations } from "../../lib/db/regions.js"; import { detectAllDsns } from "../../lib/dsn/index.js"; import { ApiError, @@ -46,6 +48,7 @@ import { logger } from "../../lib/logger.js"; import { poll } from "../../lib/polling.js"; import { resolveEffectiveOrg } from "../../lib/region.js"; import { + resolveConfiguredOrg, resolveFromDsn, resolveOrg, resolveOrgAndProject, @@ -331,14 +334,54 @@ async function resolveProjectSearchFallback( ); } +/** Search the supplied orgs and preserve failures when none could be queried. */ +async function findIssuesByShortId( + orgSlugs: readonly string[], + fullShortId: string +): Promise { + const limit = pLimit(ORG_FANOUT_CONCURRENCY); + const results = await Promise.all( + orgSlugs.map((org) => + limit(() => + withAuthGuard(async () => { + const issue = await tryGetIssueByShortId(org, fullShortId, { + collapse: ISSUE_DETAIL_COLLAPSE, + }); + return issue ? { org, issue } : null; + }) + ) + ) + ); + + // If every org failed with a real error (403, 5xx, network timeout), + // surface it instead of falling through to a misleading "not found". + // Only throw when ALL results are errors — if some orgs returned clean + // 404s ({ok: true, value: null}), fall through to the fallback for a + // precise error message. + const realErrors = results.filter( + (r): r is AuthGuardFailure => r !== undefined && !r.ok + ); + if (realErrors.length === results.length && realErrors.length > 0) { + const firstError = realErrors[0]?.error; + if (firstError instanceof Error) { + throw firstError; + } + } + + return results.flatMap((result) => + result.ok && result.value ? [result.value] : [] + ); +} + /** * Resolve project-search type: search for project across orgs, then fetch issue. * * Resolution order: * 1. Try alias cache (fast, local) - * 2. Check DSN detection cache - * 3. Try shortid endpoint directly across all orgs (fast path) - * 4. Fall back to findProjectsBySlug for precise error messages + * 2. Use the configured organization, if any + * 3. Check DSN detection cache for the matching project + * 4. Search accessible orgs, refreshing a cached list once on a miss + * 5. Fall back to findProjectsBySlug for precise error messages * * @param projectSlug - Project slug to search for * @param suffix - Issue suffix (uppercase) @@ -362,7 +405,17 @@ async function resolveProjectSearch( return aliasResult; } - // 2. Check if DSN detection already resolved this project. + const fullShortId = expandToFullShortId(suffix, projectSlug); + const configured = await resolveConfiguredOrg({ cwd }); + if (configured) { + const org = await resolveEffectiveOrg(configured.org); + const issue = await getIssueByShortId(org, fullShortId, { + collapse: ISSUE_DETAIL_COLLAPSE, + }); + return { org, issue }; + } + + // Check if DSN detection already resolved this project. // resolveFromDsn() reads from the DSN cache (populated by detectAllDsns // in tryResolveFromAlias above) + project cache. This avoids the expensive // listOrganizations() fan-out when the DSN matches the target project. @@ -378,40 +431,33 @@ async function resolveProjectSearch( dsnTarget && dsnTarget.project.toLowerCase() === projectSlug.toLowerCase() ) { - const fullShortId = expandToFullShortId(suffix, dsnTarget.project); const issue = await getIssueByShortId(dsnTarget.org, fullShortId, { collapse: ISSUE_DETAIL_COLLAPSE, }); return { org: dsnTarget.org, issue }; } - // 3. Fast path: try resolving the short ID directly across all orgs. + // Try resolving the short ID directly across all orgs. // The shortid endpoint validates both project existence and issue existence // in a single call, eliminating the separate getProject() round-trip. // Concurrency-limited to avoid overwhelming the API for enterprise users. - const fullShortId = expandToFullShortId(suffix, projectSlug); + const hadCachedOrganizations = getCachedOrganizations().length > 0; const orgs = await listOrganizations(); - const limit = pLimit(ORG_FANOUT_CONCURRENCY); - const results = await Promise.all( - orgs.map((org) => - limit(() => - withAuthGuard(() => - tryGetIssueByShortId(org.slug, fullShortId, { - collapse: ISSUE_DETAIL_COLLAPSE, - }) - ) - ) - ) + let successes = await findIssuesByShortId( + orgs.map((org) => org.slug), + fullShortId ); - const successes: StrictResolvedIssue[] = []; - for (let i = 0; i < results.length; i++) { - const result = results[i]; - const org = orgs[i]; - if (result && org && result.ok && result.value) { - successes.push({ org: org.slug, issue: result.value }); - } + // A nonempty cache need not include every accessible org. Refresh once on + // a miss, and only search orgs not already queried in this invocation. + if (successes.length === 0 && hadCachedOrganizations) { + const searchedOrgs = new Set(orgs.map((org) => org.slug)); + const freshOrgs = await listOrganizationsUncached(); + const newOrgSlugs = freshOrgs + .map((org) => org.slug) + .filter((org) => !searchedOrgs.has(org)); + successes = await findIssuesByShortId(newOrgSlugs, fullShortId); } if (successes.length === 1 && successes[0]) { @@ -431,22 +477,7 @@ async function resolveProjectSearch( ); } - // If every org failed with a real error (403, 5xx, network timeout), - // surface it instead of falling through to a misleading "not found". - // Only throw when ALL results are errors — if some orgs returned clean - // 404s ({ok: true, value: null}), fall through to the fallback for a - // precise error message. - const realErrors = results.filter( - (r): r is AuthGuardFailure => r !== undefined && !r.ok - ); - if (realErrors.length === results.length && realErrors.length > 0) { - const firstError = realErrors[0]?.error; - if (firstError instanceof Error) { - throw firstError; - } - } - - // 4. Fall back to findProjectsBySlug for precise error messages + // Fall back to findProjectsBySlug for precise error messages // and retry the issue lookup (handles transient failures). return resolveProjectSearchFallback(projectSlug, suffix, commandContext); } diff --git a/packages/cli/src/commands/issue/view.ts b/packages/cli/src/commands/issue/view.ts index 53051ab7f..9e8f7ac83 100644 --- a/packages/cli/src/commands/issue/view.ts +++ b/packages/cli/src/commands/issue/view.ts @@ -243,10 +243,12 @@ export const viewCommand = buildCommand({ " /ID - Explicit org: sentry/EXTENSION-7, sentry/cli-G\n" + " /@selector - Selector with org: my-org/@latest\n" + " -suffix - Project + suffix: cli-G, spotlight-electron-4Y\n" + - " ID - Short ID: CLI-G (searches across orgs)\n" + + " ID - Short ID: CLI-G (uses configured org when set)\n" + " suffix - Suffix only: G (requires DSN context)\n" + " numeric - Numeric ID: 123456789\n" + " org/project#ID - GitHub-style: my-org/my-project#PROJ-123\n\n" + + "Full short IDs use SENTRY_ORG, .sentryclirc, or 'sentry cli defaults' for the org.\n" + + "Without a configured org, the CLI uses a matching DSN or searches accessible orgs.\n\n" + "Multiple issue IDs can be passed as separate arguments or newline-separated\n" + "within a single argument (handy when piping from other commands).\n" + `With --web, up to ${MAX_WEB_ISSUES} issues open by default; pass --force to open all.\n\n` + diff --git a/packages/cli/src/lib/resolve-target.ts b/packages/cli/src/lib/resolve-target.ts index 219e43c86..811672e99 100644 --- a/packages/cli/src/lib/resolve-target.ts +++ b/packages/cli/src/lib/resolve-target.ts @@ -1841,20 +1841,8 @@ export async function resolveOrgProjectOrGuide( return resolved ?? (await guideOrgProjectFailure(options)); } -/** - * Resolve organization only from multiple sources. - * - * Resolution priority: - * 1. Positional argument - * 2. SENTRY_ORG / SENTRY_PROJECT env vars - * 3. `.sentryclirc` config file - * 4. Config defaults - * 5. DSN auto-detection - * - * @param options - Resolution options with flag and cwd - * @returns Resolved org, or null if resolution failed - */ -export async function resolveOrg( +/** Resolve an explicitly configured org, without inferring one from a DSN. */ +export async function resolveConfiguredOrg( options: ResolveOrgOptions ): Promise { const { org, cwd } = options; @@ -1889,10 +1877,25 @@ export async function resolveOrg( return { org: defaultOrg }; } + return null; +} + +/** + * Resolve an org from arguments, environment, .sentryclirc, defaults, then DSN. + * Configured context takes precedence over automatic detection. + */ +export async function resolveOrg( + options: ResolveOrgOptions +): Promise { + const configured = await resolveConfiguredOrg(options); + if (configured) { + return configured; + } + // 5. DSN auto-detection // biome-ignore lint/plugin: grandfathered silent catch — see #1531; drain by adding log.debug()/log.warn() or re-throwing. try { - const result = await resolveOrgFromDsn(cwd); + const result = await resolveOrgFromDsn(options.cwd); if (result) { // resolveOrgFromDsn may return a bare numeric org ID when the project // cache is cold. Normalize to a slug so API endpoints that reject diff --git a/packages/cli/test/commands/issue/short-id-resolution.test.ts b/packages/cli/test/commands/issue/short-id-resolution.test.ts new file mode 100644 index 000000000..31756bf41 --- /dev/null +++ b/packages/cli/test/commands/issue/short-id-resolution.test.ts @@ -0,0 +1,244 @@ +import { writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { resolveIssue } from "../../../src/commands/issue/utils.js"; +import { setAuthToken } from "../../../src/lib/db/auth.js"; +import { setDefaultOrganization } from "../../../src/lib/db/defaults.js"; +import { setProjectAliases } from "../../../src/lib/db/project-aliases.js"; +import { setCachedProject } from "../../../src/lib/db/project-cache.js"; +import { setOrgRegion, setOrgRegions } from "../../../src/lib/db/regions.js"; +import { ApiError, ResolutionError } from "../../../src/lib/errors.js"; +import issueFixture from "../../fixtures/issue.json"; +import organizationFixture from "../../fixtures/organization.json"; +import { mockFetch, useEnvSandbox, useTestConfigDir } from "../../helpers.js"; + +const SHORT_ID = "CUSTOM-5BS"; +const REGION_URL = "https://de.sentry.io"; + +function organization(slug: string, id: string) { + return { + ...organizationFixture, + slug, + id, + links: { regionUrl: REGION_URL }, + }; +} + +const cachedOrg = organization("cached-org", "1"); +const targetOrg = organization("target-org", "2"); +const secondOrg = organization("second-org", "3"); + +const getConfigDir = useTestConfigDir("test-short-id-resolution-", { + isolateProjectRoot: true, +}); +useEnvSandbox(["SENTRY_ORG", "SENTRY_PROJECT", "SENTRY_DSN"]); + +let originalFetch: typeof fetch; +let listedOrgs: ReturnType[]; +let listingStatus: number; +let requests: URL[]; +let issues: Map; + +function addIssue(org: string, shortId = SHORT_ID): void { + issues.set(`${org}/${shortId}`, { + ...issueFixture, + shortId, + // Sentry's short-ID prefix need not equal the current project slug. + project: { ...issueFixture.project, slug: "renamed-project" }, + }); +} + +function resolve(issueArg = SHORT_ID) { + return resolveIssue({ issueArg, cwd: getConfigDir(), command: "view" }); +} + +function listingRequests() { + return requests.filter((url) => url.pathname === "/api/0/organizations/"); +} + +function shortIdRequests() { + return requests.filter((url) => url.pathname.includes("/shortids/")); +} + +beforeEach(async () => { + originalFetch = globalThis.fetch; + requests = []; + listedOrgs = [cachedOrg]; + listingStatus = 200; + issues = new Map(); + await setAuthToken("test-token"); + setOrgRegions([ + { + slug: cachedOrg.slug, + regionUrl: REGION_URL, + orgId: cachedOrg.id, + orgName: cachedOrg.name, + }, + ]); + // Routing information alone does not make these orgs listing candidates. + setOrgRegion(targetOrg.slug, REGION_URL); + setOrgRegion(secondOrg.slug, REGION_URL); + + globalThis.fetch = mockFetch(async (input, init) => { + const url = new URL(new Request(input, init).url); + requests.push(url); + if (url.pathname === "/api/0/organizations/") { + return Response.json( + listingStatus === 200 ? listedOrgs : { detail: "Forbidden" }, + { status: listingStatus } + ); + } + for (const [key, issue] of issues) { + const [org, shortId] = key.split("/"); + if (url.pathname === `/api/0/organizations/${org}/shortids/${shortId}/`) { + return Response.json({ group: issue }); + } + } + return Response.json({ detail: "Not found" }, { status: 404 }); + }); +}); + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +describe("short IDs with configured organization context", () => { + test("uses a default org missing from the cached and API organization lists", async () => { + setDefaultOrganization(targetOrg.slug); + addIssue(targetOrg.slug); + + const result = await resolve(); + + expect(result.org).toBe(targetOrg.slug); + expect(result.issue.shortId).toBe(SHORT_ID); + expect(listingRequests()).toHaveLength(0); + expect(shortIdRequests().map((url) => url.href)).toEqual([ + expect.stringContaining(`${REGION_URL}/api/0/organizations/target-org/`), + ]); + }); + + test.each([ + { envOrg: secondOrg.slug, expectedOrg: secondOrg.slug }, + { envOrg: undefined, expectedOrg: targetOrg.slug }, + ])("honors env and .sentryclirc precedence: $expectedOrg", async ({ + envOrg, + expectedOrg, + }) => { + setDefaultOrganization(cachedOrg.slug); + writeFileSync( + join(getConfigDir(), ".sentryclirc"), + `[defaults]\norg = ${targetOrg.slug}\n` + ); + if (envOrg) { + process.env.SENTRY_ORG = envOrg; + } + for (const org of [cachedOrg, targetOrg, secondOrg]) { + addIssue(org.slug); + } + + expect((await resolve()).org).toBe(expectedOrg); + expect(listingRequests()).toHaveLength(0); + expect(shortIdRequests()).toHaveLength(1); + }); + + test("does not fall through to another org when the configured org returns 404", async () => { + setDefaultOrganization(targetOrg.slug); + addIssue(cachedOrg.slug); + + await expect(resolve()).rejects.toMatchObject({ status: 404 }); + + expect(listingRequests()).toHaveLength(0); + expect(shortIdRequests().map((url) => url.pathname)).toEqual([ + `/api/0/organizations/target-org/shortids/${SHORT_ID}/`, + ]); + }); + + test("preserves an alias's org even when a different default is configured", async () => { + setDefaultOrganization(targetOrg.slug); + setProjectAliases( + { f: { orgSlug: cachedOrg.slug, projectSlug: "frontend" } }, + "" + ); + addIssue(cachedOrg.slug, "FRONTEND-5BS"); + + expect((await resolve("f-5BS")).org).toBe(cachedOrg.slug); + expect(listingRequests()).toHaveLength(0); + }); +}); + +describe("short IDs missing from cached organizations", () => { + test("refreshes once and resolves a new EU org even when the project slug differs from the prefix", async () => { + listedOrgs = [cachedOrg, targetOrg]; + addIssue(targetOrg.slug); + + const result = await resolve(); + + expect(result.org).toBe(targetOrg.slug); + expect(result.issue.project?.slug).toBe("renamed-project"); + expect(listingRequests()).toHaveLength(1); + expect(shortIdRequests().map((url) => url.pathname)).toEqual([ + `/api/0/organizations/cached-org/shortids/${SHORT_ID}/`, + `/api/0/organizations/target-org/shortids/${SHORT_ID}/`, + ]); + expect(shortIdRequests().every((url) => url.origin === REGION_URL)).toBe( + true + ); + }); + + test.each([ + { label: "unchanged", inventory: [cachedOrg] }, + { label: "new organization", inventory: [cachedOrg, targetOrg] }, + ])("keeps a still-missing lookup bounded after refresh: $label", async ({ + inventory, + }) => { + listedOrgs = inventory; + + await expect(resolve()).rejects.toBeInstanceOf(ResolutionError); + + expect(listingRequests()).toHaveLength(1); + expect(shortIdRequests()).toHaveLength(inventory.length); + expect(new Set(shortIdRequests().map((url) => url.pathname)).size).toBe( + inventory.length + ); + }); + + test("reports ambiguity when the refresh discovers two matching orgs", async () => { + listedOrgs = [cachedOrg, targetOrg, secondOrg]; + addIssue(targetOrg.slug); + addIssue(secondOrg.slug); + + await expect(resolve()).rejects.toThrow("is ambiguous"); + + expect(listingRequests()).toHaveLength(1); + expect(shortIdRequests()).toHaveLength(3); + }); + + test("propagates refresh failures instead of reporting an absent issue", async () => { + listingStatus = 403; + + const error = await resolve().catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(ApiError); + expect(error).toMatchObject({ status: 403 }); + expect(listingRequests()).toHaveLength(1); + }); + + test("does not scope the search to a DSN for a different project", async () => { + writeFileSync( + join(getConfigDir(), ".env"), + "SENTRY_DSN=https://abc@o123.ingest.de.sentry.io/456" + ); + setCachedProject("123", "456", { + orgSlug: cachedOrg.slug, + orgName: cachedOrg.name, + projectSlug: "unrelated-project", + projectName: "Unrelated Project", + projectId: "456", + }); + listedOrgs = [cachedOrg, targetOrg]; + addIssue(targetOrg.slug); + + expect((await resolve()).org).toBe(targetOrg.slug); + expect(listingRequests()).toHaveLength(1); + }); +});