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
13 changes: 13 additions & 0 deletions apps/cli-docs/src/fragments/commands/issue.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions apps/cli-docs/src/fragments/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 3 additions & 1 deletion packages/cli/src/commands/feedback/view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,10 +101,12 @@ export const viewCommand = buildCommand({
"Feedback formats:\n" +
" @latest Most recent unresolved Feedback\n" +
" <org>/@latest Most recent unresolved Feedback in an organization\n" +
" <short-id> Search accessible organizations\n" +
" <short-id> Use configured org or discover the organization\n" +
" <numeric-id> Resolve by numeric issue ID\n" +
" <org>/<short-id> Explicit organization\n" +
" <org>/<project>/<suffix> 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: {
Expand Down
4 changes: 3 additions & 1 deletion packages/cli/src/commands/issue/explain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,9 +95,11 @@ export const explainCommand = buildCommand({
" <org>/ID - Explicit org: sentry/EXTENSION-7, sentry/cli-G\n" +
" <org>/@selector - Selector with org: my-org/@latest\n" +
" <project>-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" +
Expand Down
4 changes: 3 additions & 1 deletion packages/cli/src/commands/issue/plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,9 +211,11 @@ export const planCommand = buildCommand({
" <org>/ID - Explicit org: sentry/EXTENSION-7, sentry/cli-G\n" +
" <org>/@selector - Selector with org: my-org/@latest\n" +
" <project>-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" +
Expand Down
113 changes: 72 additions & 41 deletions packages/cli/src/commands/issue/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
type IssueSort,
listIssuesPaginated,
listOrganizations,
listOrganizationsUncached,
ORG_FANOUT_CONCURRENCY,
triggerRootCauseAnalysis,
tryGetIssueByShortId,
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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<StrictResolvedIssue[]> {
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)
Expand All @@ -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.
Expand All @@ -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]) {
Expand All @@ -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);
}
Expand Down
4 changes: 3 additions & 1 deletion packages/cli/src/commands/issue/view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,10 +243,12 @@ export const viewCommand = buildCommand({
" <org>/ID - Explicit org: sentry/EXTENSION-7, sentry/cli-G\n" +
" <org>/@selector - Selector with org: my-org/@latest\n" +
" <project>-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` +
Expand Down
33 changes: 18 additions & 15 deletions packages/cli/src/lib/resolve-target.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ResolvedOrg | null> {
const { org, cwd } = options;
Expand Down Expand Up @@ -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<ResolvedOrg | null> {
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
Expand Down
Loading
Loading