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: 3 additions & 3 deletions packages/cli/src/commands/feedback/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ import { buildCommandHint, resolveIssue } from "../issue/utils.js";

/** Result of resolving and category-checking a modern Feedback issue. */
export type ResolvedFeedback = {
/** Resolved organization slug, when available. */
org: string | undefined;
/** Resolved organization slug. */
org: string;
/** Category-constrained Feedback issue. */
feedback: SentryFeedback;
};
Expand Down Expand Up @@ -122,7 +122,7 @@ export async function resolveFeedback(
throw new ResolutionError(
`Issue '${issue.shortId}'`,
"is not User Feedback",
sentry`issue view ${org ? `${org}/` : ""}${issue.shortId}`,
sentry`issue view ${org}/${issue.shortId}`,
[`Use a Feedback ID from: ${sentry`feedback list`}`],
);
}
Expand Down
11 changes: 1 addition & 10 deletions packages/cli/src/commands/issue/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
hasPreviousPage,
resolveCursor,
} from "../../lib/db/pagination.js";
import { ContextError, toSearchQueryError } from "../../lib/errors.js";
import { toSearchQueryError } from "../../lib/errors.js";
import { CommandOutput } from "../../lib/formatters/output.js";
import { buildListCommand, paginationHint } from "../../lib/list-command.js";
import { withProgress } from "../../lib/polling.js";
Expand All @@ -31,7 +31,6 @@ import {
jsonTransformEvents,
} from "../event/shared-events.js";
import { type IssueTarget, issuePositional } from "./positional.js";
import { buildCommandHint } from "./utils.js";
import { IssueEventSchema } from "../../generated/output-schemas/event.js";

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -127,14 +126,6 @@ export const eventsCommand = buildListCommand("issue", {
// Resolve issue using shared resolution logic (supports @latest, short IDs, etc.)
const { org, issue } = await target.resolved;

// Org is required for region-routed events endpoint
if (!org) {
throw new ContextError(
"Organization",
buildCommandHint(COMMAND_NAME, issueArg),
);
}

// Build context key for pagination (keyed by issue ID + query-varying params)
const contextKey = buildPaginationContextKey(
"issue-events",
Expand Down
21 changes: 3 additions & 18 deletions packages/cli/src/commands/issue/merge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,22 +108,8 @@ async function resolveAllIssues(
),
);

// Every resolved issue must have a concrete org slug — otherwise we
// can't safely decide whether a merge is cross-org. A bare numeric ID
// that couldn't pick up an org from DSN/env/config would return
// `org: undefined`; those must error before we proceed.
const missingOrg = resolved.filter((r) => !r.org);
if (missingOrg.length > 0) {
const badIds = missingOrg.map((r) => r.issue.shortId).join(", ");
throw new ValidationError(
`Could not determine the organization for: ${badIds}.\n\n` +
"Provide the org explicitly (e.g. <org>/<issue>) so the merge\n" +
"can verify all issues belong to the same organization.",
);
}

// Collect the fully-resolved orgs and require a single one.
const orgs = new Set(resolved.map((r) => r.org as string));
// Collect the resolved orgs and require a single one.
const orgs = new Set(resolved.map((r) => r.org));
if (orgs.size > 1) {
throw new ValidationError(
`Cannot merge issues across organizations (${[...orgs].join(", ")}).\n\n` +
Expand All @@ -133,8 +119,7 @@ async function resolveAllIssues(

const [org] = orgs;
if (!org) {
// Unreachable — resolved.length >= 1 (callers guard for <2) and we
// just asserted every entry has a non-empty org.
// Unreachable — resolved.length >= 1 (callers guard for <2).
throw new CliError("Internal error: resolved issue missing org slug.");
}

Expand Down
12 changes: 0 additions & 12 deletions packages/cli/src/commands/issue/resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,8 @@ import {
type ResolveStatusDetails,
updateIssueStatus,
} from "../../lib/api-client.js";
import { sentry } from "../../lib/app-identity.js";
import { buildCommand } from "../../lib/command.js";
import { choiceParser } from "../../lib/completion-target.js";
import { ContextError } from "../../lib/errors.js";
import { formatIssueDetails, muted } from "../../lib/formatters/index.js";
import { filterFieldsIfSet } from "../../lib/formatters/json.js";
import { CommandOutput } from "../../lib/formatters/output.js";
Expand Down Expand Up @@ -179,16 +177,6 @@ export const resolveCommand = buildCommand({
if (parsed?.kind === "static") {
statusDetails = parsed.details;
} else if (parsed?.kind === "commit") {
if (!org) {
// Sentry's InCommit validator looks up the repo by name within the
// issue's org — we can't resolve the commit without knowing the org.
throw new ContextError(
"Organization",
sentry`issue resolve <org>/<issue> --in @commit`,
[],
"--in @commit needs an organization context to look up the Sentry repo registry.",
);
}
const resolved = await resolveCommitSpec(parsed.spec, org, cwd);
statusDetails = { inCommit: resolved };
}
Expand Down
9 changes: 1 addition & 8 deletions packages/cli/src/commands/issue/tags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import type { SentryContext } from "../../context.js";
import { getIssueTag } from "../../lib/api-client.js";
import { buildCommand } from "../../lib/command.js";
import { parseEnvironment, parseTagKey } from "../../lib/completion-target.js";
import { ContextError } from "../../lib/errors.js";
import { formatIssueTags } from "../../lib/formatters/issue-tags.js";
import { CommandOutput } from "../../lib/formatters/output.js";
import {
Expand All @@ -23,7 +22,7 @@ import {
issueParameter,
requireIssueArgument,
} from "./positional.js";
import { buildCommandHint, resolveIssue } from "./utils.js";
import { resolveIssue } from "./utils.js";
import { IssueTagsOutputSchema } from "../../generated/output-schemas/issue.js";

type TagsFlags = {
Expand Down Expand Up @@ -115,12 +114,6 @@ export const tagsCommand = buildCommand({
command: "tags",
trailingArgs,
});
if (!org) {
throw new ContextError(
"Organization",
buildCommandHint("tags", issueArg.text, undefined, trailingArgs),
);
}

const tag = await withProgress(
{ message: "Fetching tag distribution...", json: flags.json },
Expand Down
142 changes: 55 additions & 87 deletions packages/cli/src/commands/issue/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@

import pLimit from "p-limit";
import {
findIssueInOrgs,
findProjectsBySlug,
getAutofixState,
getIssue,
getIssueByShortId,
getIssueInOrg,
getSharedIssue,
Expand Down Expand Up @@ -55,10 +55,7 @@ import {
resolveOrgAndProject,
resolveProjectIdsInOrg,
} from "../../lib/resolve-target.js";
import {
applySentryUrlContext,
parseSentryUrl,
} from "../../lib/sentry-url-parser.js";
import { applySentryUrlContext } from "../../lib/sentry-url-parser.js";
import { buildIssueUrl } from "../../lib/sentry-web-urls.js";
import { setOrgProjectContext } from "../../lib/telemetry.js";
import { isAllDigits, shellQuote } from "../../lib/utils.js";
Expand Down Expand Up @@ -208,8 +205,8 @@ const DEFAULT_TIMEOUT_MS = 360_000;
* Used by view command which needs the complete issue data.
*/
export type ResolvedIssueResult = {
/** Resolved organization slug (may be undefined for numeric IDs without context) */
org: string | undefined;
/** Resolved organization slug */
org: string;
/** Full issue object from API */
issue: SentryIssue;
};
Expand Down Expand Up @@ -743,43 +740,44 @@ export type ResolveIssueOptions = {
trailingArgs?: readonly string[];
};

/**
* Extract the organization slug from a Sentry issue permalink.
*
* Handles both path-based (`https://sentry.io/organizations/{org}/issues/...`)
* and subdomain-style (`https://{org}.sentry.io/issues/...`) SaaS URLs.
* Returns undefined if the permalink is missing or not a recognized format.
*
* @param permalink - Issue permalink URL from the Sentry API response
*/
function extractOrgFromPermalink(
permalink: string | undefined,
): string | undefined {
if (!permalink) {
return;
}
return parseSentryUrl(permalink)?.org;
}

/**
* Result of {@link fetchIssueByNumericId}.
*
* `cacheEvicted` is true when the helper invalidated a stale `cachedOrg`
* entry after a 404 and fell through to the legacy unscoped endpoint.
* Callers MUST treat their local `cachedOrg` as stale when this flag is
* set and re-derive the org from `issue.permalink` instead — otherwise
* a stale slug leaks into downstream API calls (issue events, traces).
* `fromCache` is true when `org` came from a still-valid issue-id → org
* cache entry, so the caller doesn't rewrite it.
*/
type FetchIssueByNumericIdResult = {
org: string;
issue: SentryIssue;
cacheEvicted: boolean;
fromCache: boolean;
};

/**
* Fetch an issue by numeric ID, preferring an org-scoped endpoint when
* the caller has explicit or cached org context. Falls back to the legacy
* unscoped `/api/0/issues/{id}/` endpoint when no org is known, and also
* when a cached org yields a 404 (stale mapping).
* Search the user's organizations for a numeric issue, skipping `skipOrg`
* (an org already known not to have it).
*
* @throws {ApiError} 404 when no organization returns the issue
*/
async function searchOrgsForIssue(
id: string,
skipOrg?: string,
): Promise<{ org: string; issue: SentryIssue }> {
const orgs = await listOrganizations();
const found = await findIssueInOrgs(
orgs.map((org) => org.slug).filter((slug) => slug !== skipOrg),
id,
{ collapse: ISSUE_DETAIL_COLLAPSE },
);
if (!found) {
throw new ApiError(`Issue ${id} not found`, 404);
}
return found;
}

/**
* Fetch an issue by numeric ID through org-scoped endpoints. Uses the
* explicit or cached org when there is one; otherwise, or when the cached
* org 404s (stale mapping, which is evicted), searches the user's orgs.
*
* Extracted from {@link resolveNumericIssue} to keep its cognitive
* complexity below the project's lint threshold.
Expand All @@ -793,77 +791,59 @@ async function fetchIssueByNumericId(
const issue = await getIssueInOrg(explicitOrg, id, {
collapse: ISSUE_DETAIL_COLLAPSE,
});
return { issue, cacheEvicted: false };
return { org: explicitOrg, issue, fromCache: false };
}
if (cachedOrg) {
try {
const issue = await getIssueInOrg(cachedOrg, id, {
collapse: ISSUE_DETAIL_COLLAPSE,
});
return { issue, cacheEvicted: false };
return { org: cachedOrg, issue, fromCache: true };
} catch (orgErr) {
if (orgErr instanceof ApiError && orgErr.status === 404) {
// Stale mapping (issue moved / deleted / access revoked). Evict the
// cache entry and fall through to the legacy unscoped endpoint.
// Stale mapping (issue moved / deleted / access revoked).
clearCachedIssueOrg(id);
const issue = await getIssue(id, { collapse: ISSUE_DETAIL_COLLAPSE });
return { issue, cacheEvicted: true };
return {
...(await searchOrgsForIssue(id, cachedOrg)),
fromCache: false,
};
}
throw orgErr;
}
}
const issue = await getIssue(id, { collapse: ISSUE_DETAIL_COLLAPSE });
return { issue, cacheEvicted: false };
return { ...(await searchOrgsForIssue(id)), fromCache: false };
}

/**
* Resolve a bare numeric issue ID.
*
* Attempts org-scoped resolution with region routing when org context can be
* derived from the working directory (DSN / env vars / config defaults), or
* from the issue-id → org cache populated on previous runs.
* Falls back to the legacy unscoped endpoint otherwise.
* Extracts the org slug from the response permalink so callers like
* {@link resolveOrgAndIssueId} can proceed without explicit org context.
* Uses the org from the working directory (DSN / env vars / config
* defaults) or from the issue-id → org cache populated on previous runs;
* without either, searches the user's organizations. Every request is
* org-scoped: the unscoped `GET /issues/{id}/` is deprecated and browns out.
*
* Caching: after a successful permalink-based org extraction, records the
* numeric-id → org mapping so future runs skip the unscoped fallback and
* route directly via the regional API. This addresses the
* `sentry.issue.view` "Consecutive HTTP" fan-out pattern for bare numeric
* IDs (Pattern D in the Sentry issue triage).
* Caching: after a search finds the owning org, records the numeric-id →
* org mapping so future runs make a single org-scoped request. This
* addresses the `sentry.issue.view` "Consecutive HTTP" fan-out pattern for
* bare numeric IDs (Pattern D in the Sentry issue triage).
*/
async function resolveNumericIssue(
id: string,
cwd: string,
commandContext: IssueCommandContext,
): Promise<ResolvedIssueResult> {
): Promise<StrictResolvedIssue> {
const { command, commandBase, trailingArgs } = commandContext;
const resolvedOrg = await resolveOrg({ cwd });
// Prefer explicit context over the cache — `resolveOrg()` already factors
// in env vars and config defaults that may point at a different org.
const cachedOrg = resolvedOrg ? null : getCachedIssueOrg(id);
try {
const { issue, cacheEvicted } = await fetchIssueByNumericId(
const { org, issue, fromCache } = await fetchIssueByNumericId(
id,
resolvedOrg?.org,
cachedOrg,
);
// When `cacheEvicted` is true, the cached org slug was stale (404'd) and
// the helper fell through to the unscoped endpoint. Do NOT let the stale
// `cachedOrg` participate in the `??` chain — re-derive from permalink.
const effectiveCachedOrg = cacheEvicted ? null : cachedOrg;
// Extract org from the response permalink as a fallback so that callers
// like resolveOrgAndIssueId (used by explain/plan) get the org slug even
// when no org context was available before the fetch.
const org =
resolvedOrg?.org ??
effectiveCachedOrg ??
extractOrgFromPermalink(issue.permalink);
// Best-effort: remember the numeric-id → org mapping so the next run
// skips the unscoped fallback. Skipped when the org came from a still-
// valid cache hit (already stored). When the cache was evicted we SHOULD
// re-write the corrected mapping derived from the permalink.
if (org && !resolvedOrg && !effectiveCachedOrg) {
if (!(resolvedOrg || fromCache)) {
// Best-effort — a broken/read-only DB must not fail a successful lookup.
try {
setCachedIssueOrg(id, org);
Expand All @@ -881,10 +861,9 @@ async function resolveNumericIssue(
// group IDs with short-ID suffixes. When org context is available, use
// the real org slug instead of <org> placeholder (CLI-BT, 18 users).
//
// Skip `cachedOrg` here: if the unscoped legacy endpoint 404'd too,
// the helper has already evicted the (proven-stale) cache entry, so
// suggesting the old slug in the hint would mislead the user. Only
// explicit `resolvedOrg` is worth preserving.
// Skip `cachedOrg` here: it 404'd and was evicted, so suggesting the
// old slug in the hint would mislead the user. Only explicit
// `resolvedOrg` is worth preserving.
const orgHint = resolvedOrg?.org ?? "<org>";
const hint = appendCommandArguments(
`${commandBase} ${command} ${orgHint}/${id}`,
Expand Down Expand Up @@ -1052,25 +1031,14 @@ export async function resolveIssue(
/**
* Resolve both organization slug and numeric issue ID.
* Required for autofix endpoints that need both org and issue ID.
* This is a stricter wrapper around resolveIssue that throws if org is undefined.
*
* @param options - Resolution options
* @returns Object with org slug, numeric issue ID, and the issue's project ID when known
* @throws {ContextError} When organization cannot be resolved
*/
export async function resolveOrgAndIssueId(
options: ResolveIssueOptions,
): Promise<{ org: string; issueId: string; projectId?: string }> {
const result = await resolveIssue(options);
if (!result.org) {
const commandHint = buildCommandHint(
options.command,
issueArgumentOf(options.issueArg).text,
options.commandBase,
options.trailingArgs,
);
throw new ContextError("Organization", commandHint);
}
return {
org: result.org,
issueId: result.issue.id,
Expand Down
Loading
Loading