From 6e709a59df6f34373a56178c6475a19dfd7f54c4 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Sat, 7 Nov 2026 16:06:36 +0000 Subject: [PATCH 1/2] fix(cli): Use org-scoped issue routes and dsn-lookup instead of deprecated endpoints Sentry deprecated the unscoped GET/PUT /issues/{id}/ and GET /projects/?query=dsn: on 2026-05-15 for cell routing, and browns them out with 410s on SaaS. Numeric issue lookups now search the user's orgs through the org-scoped issue route, and status updates always use the org-scoped operation. DSNs without an org id are looked up per org with /organizations/{org}/dsn-lookup/. Self-hosted servers that don't have dsn-lookup yet fall back to /projects/?query=dsn:; SaaS never does. Fixes #2089 Co-Authored-By: BYK <126780+BYK@users.noreply.github.com> --- packages/cli/src/commands/feedback/utils.ts | 6 +- packages/cli/src/commands/issue/events.ts | 11 +- packages/cli/src/commands/issue/merge.ts | 21 +- packages/cli/src/commands/issue/resolve.ts | 12 - packages/cli/src/commands/issue/tags.ts | 9 +- packages/cli/src/commands/issue/utils.ts | 142 +++---- packages/cli/src/lib/api-client.ts | 4 +- packages/cli/src/lib/api/completion-seed.ts | 27 +- packages/cli/src/lib/api/issues.ts | 129 +++---- packages/cli/src/lib/api/projects.ts | 158 +++++--- packages/cli/src/lib/db/issue-org-cache.ts | 10 +- packages/cli/src/lib/db/schema.ts | 10 +- packages/cli/src/lib/dsn/resolver.ts | 4 +- packages/cli/src/lib/issue-links.ts | 4 - packages/cli/src/lib/resolve-target.ts | 10 +- packages/cli/src/types/index.ts | 1 + packages/cli/src/types/sentry.ts | 11 + .../test/commands/issue/events.func.test.ts | 18 - .../test/commands/issue/merge.func.test.ts | 30 -- .../test/commands/issue/resolve.func.test.ts | 18 - .../cli/test/commands/issue/utils.test.ts | 201 +++------- .../cli/test/lib/api-client.coverage.test.ts | 82 ++-- .../test/lib/api-client.multiregion.test.ts | 360 ++++++++++-------- packages/cli/test/lib/api/issues.test.ts | 61 +-- .../cli/test/lib/db/completion-cache.test.ts | 37 +- packages/cli/test/lib/dsn/errors.test.ts | 2 +- packages/cli/test/lib/dsn/resolver.test.ts | 23 +- packages/cli/test/lib/issue-links.test.ts | 2 +- .../test/lib/resolve-target.mocked.test.ts | 12 +- packages/cli/test/mocks/multiregion.ts | 2 +- 30 files changed, 609 insertions(+), 808 deletions(-) diff --git a/packages/cli/src/commands/feedback/utils.ts b/packages/cli/src/commands/feedback/utils.ts index e142860bf..1a9157d65 100644 --- a/packages/cli/src/commands/feedback/utils.ts +++ b/packages/cli/src/commands/feedback/utils.ts @@ -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; }; @@ -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`}`], ); } diff --git a/packages/cli/src/commands/issue/events.ts b/packages/cli/src/commands/issue/events.ts index e268ac929..7d5145752 100644 --- a/packages/cli/src/commands/issue/events.ts +++ b/packages/cli/src/commands/issue/events.ts @@ -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"; @@ -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"; // --------------------------------------------------------------------------- @@ -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", diff --git a/packages/cli/src/commands/issue/merge.ts b/packages/cli/src/commands/issue/merge.ts index 99dda56b8..5a3577a0d 100644 --- a/packages/cli/src/commands/issue/merge.ts +++ b/packages/cli/src/commands/issue/merge.ts @@ -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. /) 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` + @@ -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."); } diff --git a/packages/cli/src/commands/issue/resolve.ts b/packages/cli/src/commands/issue/resolve.ts index a015a828b..c7a6127d0 100644 --- a/packages/cli/src/commands/issue/resolve.ts +++ b/packages/cli/src/commands/issue/resolve.ts @@ -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"; @@ -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 / --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 }; } diff --git a/packages/cli/src/commands/issue/tags.ts b/packages/cli/src/commands/issue/tags.ts index 523fc0f7e..4edb5fd73 100644 --- a/packages/cli/src/commands/issue/tags.ts +++ b/packages/cli/src/commands/issue/tags.ts @@ -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 { @@ -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 = { @@ -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 }, diff --git a/packages/cli/src/commands/issue/utils.ts b/packages/cli/src/commands/issue/utils.ts index bfcd05b1f..6bfd88821 100644 --- a/packages/cli/src/commands/issue/utils.ts +++ b/packages/cli/src/commands/issue/utils.ts @@ -6,9 +6,9 @@ import pLimit from "p-limit"; import { + findIssueInOrgs, findProjectsBySlug, getAutofixState, - getIssue, getIssueByShortId, getIssueInOrg, getSharedIssue, @@ -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"; @@ -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; }; @@ -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. @@ -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 { +): Promise { 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); @@ -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 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 ?? ""; const hint = appendCommandArguments( `${commandBase} ${command} ${orgHint}/${id}`, @@ -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, diff --git a/packages/cli/src/lib/api-client.ts b/packages/cli/src/lib/api-client.ts index 5f79900b8..d2280fffd 100644 --- a/packages/cli/src/lib/api-client.ts +++ b/packages/cli/src/lib/api-client.ts @@ -107,7 +107,7 @@ export { export { getIssueTag } from "./api/issue-tags.js"; export { buildIssueListCollapse, - getIssue, + findIssueInOrgs, getIssueByShortId, getIssueInOrg, getSharedIssue, @@ -151,7 +151,7 @@ export { createProjectWithAutoTeam, createProjectWithDsn, deleteProject, - findProjectByDsnKey, + findProjectByDsn, findProjectsByPattern, findProjectsBySlug, getProject, diff --git a/packages/cli/src/lib/api/completion-seed.ts b/packages/cli/src/lib/api/completion-seed.ts index 37ca957af..fc3b63fcc 100644 --- a/packages/cli/src/lib/api/completion-seed.ts +++ b/packages/cli/src/lib/api/completion-seed.ts @@ -52,7 +52,6 @@ import { } from "../db/completion-cache.js"; import { reportCliError } from "../error-reporting.js"; import { logger } from "../logger.js"; -import { parseSentryUrl } from "../sentry-url-parser.js"; import { isAllDigits } from "../utils.js"; const log = logger.withTag("completion-seed"); @@ -72,8 +71,6 @@ type CompletionSource = { query?: Readonly>; /** Entities in the parsed JSON body, in display order. */ extract: (body: unknown) => CompletionEntity[]; - /** Organization of a body whose path names none (unscoped endpoints). */ - org?: (body: unknown) => string | undefined; }; /** Read a string property of an untyped JSON value. */ @@ -235,12 +232,6 @@ function tagKeyEntities(body: unknown): CompletionEntity[] { return Array.isArray(tags) ? fieldEntities("key", "name")(tags) : []; } -/** The organization of an issue, from its permalink. */ -function permalinkOrg(body: unknown): string | undefined { - const permalink = stringField(body, "permalink"); - return permalink ? parseSentryUrl(permalink)?.org : undefined; -} - /** Public DSNs of client keys, described by the key name. */ function dsnEntities(body: unknown): CompletionEntity[] { return items(body).flatMap((key) => { @@ -332,21 +323,6 @@ const COMPLETION_SOURCES: readonly CompletionSource[] = [ : undefined, ), }, - { - // The deprecated unscoped lookup (`getIssue`, `updateIssueStatus` - // without an org) is not in `@sentry/api`. Its path names no org, so - // the org comes from the issue's permalink. - kind: "issue", - url: "/api/0/issues/{issue_id}/", - extract: issueEntities, - org: permalinkOrg, - }, - { - kind: "tag-key", - url: "/api/0/issues/{issue_id}/", - extract: tagKeyEntities, - org: permalinkOrg, - }, { kind: "dashboard", url: "/api/0/organizations/{organization_id_or_slug}/dashboards/" satisfies ListOrganizationDashboardsData["url"], @@ -544,8 +520,7 @@ async function seedFromBody( log.debug(`Not seeding completions from '${url}'`, error); return; } - for (const { source, orgSlug: pathOrg } of matches) { - const orgSlug = pathOrg ?? source.org?.(body); + for (const { source, orgSlug } of matches) { if (!orgSlug) { log.debug( `Not seeding ${source.kind} completions without an org: '${url}'`, diff --git a/packages/cli/src/lib/api/issues.ts b/packages/cli/src/lib/api/issues.ts index 0326dcfac..f47a4db3c 100644 --- a/packages/cli/src/lib/api/issues.ts +++ b/packages/cli/src/lib/api/issues.ts @@ -15,6 +15,7 @@ import { updateOrganizationIssue, updateOrganizationIssues, } from "@sentry/api"; +import pLimit from "p-limit"; import type { SentryIssue } from "../../types/index.js"; import type { IssueSubstatus } from "../../types/sentry.js"; @@ -27,13 +28,11 @@ import { } from "../custom-ca.js"; import { applyCustomHeaders } from "../custom-headers.js"; import { ApiError, ValidationError } from "../errors.js"; -import { invalidateCachedResponsesMatching } from "../response-cache.js"; -import { getApiBaseUrl } from "../sentry-client.js"; import { API_MAX_PER_PAGE, - apiRequest, getOrgSdkConfig, + ORG_FANOUT_CONCURRENCY, type PaginatedResponse, paginate, unwrapPaginatedResult, @@ -314,46 +313,77 @@ export async function listIssuesAllPages( } /** - * Get a specific issue by numeric ID. + * Get a specific issue by numeric ID, scoped to an organization. * - * Uses the legacy unscoped endpoint — no org context or region routing. - * Prefer {@link getIssueInOrg} when the org slug is known. + * Uses the org-scoped endpoint with region-aware routing. Without a known + * org, use {@link findIssueInOrgs}. * + * @param orgSlug - Organization slug (used for region routing) * @param issueId - Numeric issue ID * @param options - Optional collapse fields to skip expensive backend queries */ -export function getIssue( +export async function getIssueInOrg( + orgSlug: string, issueId: string, options?: { collapse?: IssueCollapseField[] }, ): Promise { const collapse = toIssueDetailsCollapse(options?.collapse); - return apiRequest(`/issues/${issueId}/`, { - params: collapse ? { collapse } : undefined, + const result = await getOrganizationIssue({ + ...getOrgSdkConfig(), + path: { organization_id_or_slug: orgSlug, issue_id: issueId }, + query: collapse ? { collapse } : undefined, }); + return unwrapResult(result, "Failed to get issue"); } /** - * Get a specific issue by numeric ID, scoped to an organization. + * Find a numeric issue in whichever of the given organizations owns it. * - * Uses the org-scoped endpoint with region-aware routing. - * Preferred over {@link getIssue} when the org slug is available. + * Asks each org's region rather than the unscoped `GET /issues/{id}/`, + * which Sentry deprecated for cell routing and browns out with 410s. + * Org-scoped issue routes exist on every Sentry since 24.1. Orgs are tried + * concurrently; no new request starts once one returns the issue. * - * @param orgSlug - Organization slug (used for region routing) + * @param orgSlugs - Organizations to search, in preference order * @param issueId - Numeric issue ID * @param options - Optional collapse fields to skip expensive backend queries + * @returns The owning org and the issue, or null when no org returns it + * @throws {ApiError} When no org has it and one failed with something other + * than a 404 or 403, since the issue may live in that org */ -export async function getIssueInOrg( - orgSlug: string, +export async function findIssueInOrgs( + orgSlugs: readonly string[], issueId: string, options?: { collapse?: IssueCollapseField[] }, -): Promise { - const collapse = toIssueDetailsCollapse(options?.collapse); - const result = await getOrganizationIssue({ - ...getOrgSdkConfig(), - path: { organization_id_or_slug: orgSlug, issue_id: issueId }, - query: collapse ? { collapse } : undefined, - }); - return unwrapResult(result, "Failed to get issue"); +): Promise<{ org: string; issue: SentryIssue } | null> { + const limit = pLimit(ORG_FANOUT_CONCURRENCY); + let found: { org: string; issue: SentryIssue } | null = null; + let failure: unknown; + await Promise.all( + orgSlugs.map((org) => + limit(async () => { + if (found) { + return; + } + try { + const issue = await getIssueInOrg(org, issueId, options); + found ??= { org, issue }; + } catch (error) { + if ( + error instanceof ApiError && + (error.status === 404 || error.status === 403) + ) { + return; + } + failure ??= error; + } + }), + ), + ); + if (!found && failure) { + throw failure; + } + return found; } /** @@ -599,44 +629,30 @@ export type IgnoreStatusDetails = { * optional `statusDetails` sets conditions (see {@link IgnoreStatusDetails}). * - `"unresolved"` — reopens the issue; no additional options needed. * - * When `options.orgSlug` is provided, the request is routed to that org's - * region via the org-scoped endpoint. Without it, falls back to the legacy - * global `/issues/{id}/` endpoint (works but not region-aware). + * The request goes to `options.orgSlug`'s region through the org-scoped + * endpoint. */ export async function updateIssueStatus( issueId: string, status: "resolved" | "unresolved" | "ignored", - options?: { + options: { statusDetails?: ResolveStatusDetails | IgnoreStatusDetails; /** Substatus for archive granularity. */ substatus?: IssueSubstatus; - orgSlug?: string; + orgSlug: string; }, ): Promise { const body = { status, - ...(options?.statusDetails && { statusDetails: options.statusDetails }), - ...(options?.substatus && { substatus: options.substatus }), + ...(options.statusDetails && { statusDetails: options.statusDetails }), + ...(options.substatus && { substatus: options.substatus }), }; - - if (options?.orgSlug) { - // Region-aware org-scoped endpoint — preferred when org is known. - const result = await updateOrganizationIssue({ - ...getOrgSdkConfig(), - path: { organization_id_or_slug: options.orgSlug, issue_id: issueId }, - body, - }); - return unwrapResult(result, "Failed to update issue"); - } - - // Legacy global endpoint — works without org but not region-aware. - return await apiRequest( - `/issues/${encodeURIComponent(issueId)}/`, - { - method: "PUT", - body, - }, - ); + const result = await updateOrganizationIssue({ + ...getOrgSdkConfig(), + path: { organization_id_or_slug: options.orgSlug, issue_id: issueId }, + body, + }); + return unwrapResult(result, "Failed to update issue"); } /** Result of a successful issue-merge operation. */ @@ -689,21 +705,6 @@ export async function mergeIssues( `/organizations/${encodeURIComponent(orgSlug)}/issues/?${groupIds.map((id) => `id=${encodeURIComponent(id)}`).join("&")}`, ); } - // HTTP-layer invalidation covers the region-scoped caches via the - // prefix sweep on `/organizations/{org}/issues/`, but it can't see - // the affected IDs (they're in query params, stripped from the - // URL the hook sees). Manually clear each affected issue's legacy - // cross-origin cache so subsequent `getIssue(id)` doesn't serve - // stale data. - const apiBase = getApiBaseUrl().replace(TRAILING_SLASH_RE, ""); - const affectedIds = merge.children.toSpliced(0, 0, merge.parent); - await Promise.all( - affectedIds.map((id) => - invalidateCachedResponsesMatching( - `${apiBase}/api/0/issues/${encodeURIComponent(id)}/`, - ), - ), - ); return merge; } diff --git a/packages/cli/src/lib/api/projects.ts b/packages/cli/src/lib/api/projects.ts index d61a76611..13a2df22f 100644 --- a/packages/cli/src/lib/api/projects.ts +++ b/packages/cli/src/lib/api/projects.ts @@ -17,11 +17,11 @@ import { import pLimit from "p-limit"; -import type { - ProjectKey, - Region, - SentryOrganization, - SentryProject, +import { + DsnLookupResponseSchema, + type ProjectKey, + type SentryOrganization, + type SentryProject, } from "../../types/index.js"; import { @@ -30,9 +30,11 @@ import { } from "../db/project-cache.js"; import { getCachedOrganizations } from "../db/regions.js"; import { reportCliError } from "../error-reporting.js"; -import { type AuthGuardSuccess, withAuthGuard } from "../errors.js"; +import { ApiError, type AuthGuardSuccess, withAuthGuard } from "../errors.js"; import { logger } from "../logger.js"; +import { resolveOrgRegion } from "../region.js"; import { getApiBaseUrl } from "../sentry-client.js"; +import { isSentrySaasUrl } from "../sentry-urls.js"; import { buildProjectUrl } from "../sentry-web-urls.js"; import { isAllDigits } from "../utils.js"; @@ -48,7 +50,7 @@ import { unwrapPaginatedResult, unwrapResult, } from "./infrastructure.js"; -import { getUserRegions, listOrganizations } from "./organizations.js"; +import { listOrganizations } from "./organizations.js"; const log = logger.withTag("api.projects"); @@ -552,66 +554,114 @@ export async function findProjectsByPattern( } /** - * Find a project by DSN public key. + * Bodies `dsn-lookup` sends with a 404 when the org has no project with the + * key. Any other 404 (an empty or `info` body from the catch-all route, or + * an empty body from the feature-flagged endpoint) means the server can't + * look up DSNs. + */ +const DSN_LOOKUP_MISSES = new Set(["DSN not found", "Invalid DSN"]); + +/** Outcome of looking a DSN up in one organization. */ +type DsnLookupOutcome = + | { kind: "found"; project: SentryProject } + | { kind: "miss" } + | { kind: "unsupported" }; + +/** + * Look a DSN up in one organization with `GET /organizations/{org}/dsn-lookup/`. * - * Uses the /api/0/projects/ endpoint with query=dsn: to search - * across all accessible projects in all regions. This works for both - * SaaS and self-hosted DSNs, even when the org ID is not embedded in the DSN. + * The endpoint isn't in `@sentry/api` yet (it's still private), so this is + * a helper call until the publish lands. + */ +async function lookUpDsnInOrg( + org: SentryOrganization, + dsn: string, +): Promise { + try { + const { data } = await apiRequestToRegion( + await resolveOrgRegion(org.slug), + `/organizations/${encodeURIComponent(org.slug)}/dsn-lookup/`, + { params: { dsn }, schema: DsnLookupResponseSchema }, + ); + return { + kind: "found", + project: { + id: data.projectId, + slug: data.projectSlug, + name: data.projectName, + organization: { id: org.id, slug: org.slug, name: org.name }, + }, + }; + } catch (error) { + if (error instanceof ApiError && error.status === 404) { + return DSN_LOOKUP_MISSES.has(error.detail ?? "") + ? { kind: "miss" } + : { kind: "unsupported" }; + } + reportBestEffortFailure( + `DSN lookup failed in organization '${org.slug}'`, + error, + ); + return { kind: "miss" }; + } +} + +/** + * Find a project with the deprecated `GET /projects/?query=dsn:`, for + * self-hosted servers whose `dsn-lookup` is missing (added 2026-02) or + * still behind its feature flag (removed 2026-04). Sentry doesn't brown out + * deprecated endpoints on self-hosted. + */ +async function findProjectByDsnKeyOnOldServer( + publicKey: string, +): Promise { + const { data: projects } = await apiRequestToRegion( + getApiBaseUrl(), + "/projects/", + { params: { query: `dsn:${publicKey}` } }, + ); + return projects[0] ?? null; +} + +/** + * Find the project a DSN belongs to, for DSNs that don't name their org. + * + * Asks each of the user's organizations with `dsn-lookup`, concurrently; + * no new request starts once one finds the project. On self-hosted servers + * without `dsn-lookup`, falls back to `/projects/?query=dsn:`. On SaaS it + * never does: that endpoint is deprecated there and browns out with 410s. * + * @param dsn - The full DSN * @param publicKey - The DSN public key (username portion of DSN URL) - * @returns The matching project, or null if not found + * @returns The matching project with its organization, or null if not found */ -export async function findProjectByDsnKey( +export async function findProjectByDsn( + dsn: string, publicKey: string, ): Promise { - const regionsResult = await withAuthGuard(() => getUserRegions()); - const regions = regionsResult.ok ? regionsResult.value : ([] as Region[]); - - if (regions.length === 0) { - // Escape hatch (intentionally raw, not an SDK call): the `?query=dsn:` filter - // on `/projects/` is an internal search param absent from the OpenAPI spec, - // so the SDK provides no typed operation for it. Kept as a documented raw - // call until the param is promoted (backend `@extend_schema` candidate). - // Fall back to the default region for self-hosted. - const { data: projects } = await apiRequestToRegion( - getApiBaseUrl(), - "/projects/", - { params: { query: `dsn:${publicKey}` } }, - ); - return projects[0] ?? null; - } - + const orgs = await listOrganizations(); const limit = pLimit(ORG_FANOUT_CONCURRENCY); - const results = await Promise.all( - regions.map((region) => + let project: SentryProject | null = null; + let unsupported = false; + await Promise.all( + orgs.map((org) => limit(async () => { - try { - // Same `?query=dsn:` escape hatch as above (see the region-fallback - // branch) — internal search param, no typed SDK operation yet. - const { data } = await apiRequestToRegion( - region.url, - "/projects/", - { params: { query: `dsn:${publicKey}` } }, - ); - return data; - } catch (error) { - reportBestEffortFailure( - `DSN key lookup failed in region '${region.url}'`, - error, - ); - return []; + if (project) { + return; + } + const outcome = await lookUpDsnInOrg(org, dsn); + if (outcome.kind === "found") { + project ??= outcome.project; + } else if (outcome.kind === "unsupported") { + unsupported = true; } }), ), ); - - for (const projects of results) { - if (projects.length > 0) { - return projects[0] ?? null; - } + if (!project && unsupported && !isSentrySaasUrl(getApiBaseUrl())) { + return await findProjectByDsnKeyOnOldServer(publicKey); } - - return null; + return project; } /** diff --git a/packages/cli/src/lib/db/issue-org-cache.ts b/packages/cli/src/lib/db/issue-org-cache.ts index 3e10710b0..69b495887 100644 --- a/packages/cli/src/lib/db/issue-org-cache.ts +++ b/packages/cli/src/lib/db/issue-org-cache.ts @@ -2,11 +2,9 @@ * Cache for numeric-issue-ID → organization-slug mappings. * * When a user runs `sentry issue view 123456789` without an org context, - * the CLI must fall back to the legacy unscoped `GET /api/0/issues/{id}/` - * endpoint (which does not support region routing) and then extract the - * org from the response `permalink`. Follow-up fetches (events/latest, - * trace/...) require the org slug, so without a cache every subsequent - * command run repeats the same unscoped lookup. + * the CLI searches the user's organizations for the issue through the + * org-scoped endpoint. Without a cache every subsequent command run + * repeats that search. * * This module records the resolved numeric-id → org-slug mapping so * future runs can skip straight to the org-scoped endpoint. It addresses @@ -84,7 +82,7 @@ export function setCachedIssueOrg(numericId: string, orgSlug: string): void { * Drop the cached mapping for a numeric issue ID. * * Called when an org-scoped fetch 404s so subsequent runs re-resolve - * the org via the legacy unscoped endpoint. + * the org by searching the user's organizations. * * @param numericId - Numeric issue group ID */ diff --git a/packages/cli/src/lib/db/schema.ts b/packages/cli/src/lib/db/schema.ts index 773f1d822..8bcdc41e8 100644 --- a/packages/cli/src/lib/db/schema.ts +++ b/packages/cli/src/lib/db/schema.ts @@ -247,11 +247,11 @@ export const TABLE_SCHEMAS: Record = { /** * Mapping from numeric issue group IDs to organization slugs. * - * Populated after `sentry issue view ` falls back to the legacy - * unscoped `/api/0/issues/{id}/` endpoint and extracts the org from the - * response permalink. Subsequent runs consult this cache to route directly - * via the org-scoped endpoint, avoiding the redundant unscoped lookup - * flagged as a "Consecutive HTTP" performance issue in Sentry. + * Populated after `sentry issue view ` finds the issue's org + * by searching the user's organizations. Subsequent runs consult this + * cache to make a single org-scoped request instead of repeating the + * search, which was flagged as a "Consecutive HTTP" performance issue in + * Sentry. * * Entries are best-effort: a stale mapping (issue moved / deleted / access * revoked) causes a 404 on the cached org call, which the caller evicts and diff --git a/packages/cli/src/lib/dsn/resolver.ts b/packages/cli/src/lib/dsn/resolver.ts index 9d16e5b5d..27a9cc0ca 100644 --- a/packages/cli/src/lib/dsn/resolver.ts +++ b/packages/cli/src/lib/dsn/resolver.ts @@ -6,7 +6,7 @@ */ import { - findProjectByDsnKey, + findProjectByDsn, listOrganizations, listProjects, resolveOrgDisplayName, @@ -57,7 +57,7 @@ export async function resolveProject( // Need to fetch from API // For DSNs without orgId, try to resolve by searching with the public key if (!dsn.orgId) { - const project = await findProjectByDsnKey(dsn.publicKey); + const project = await findProjectByDsn(dsn.raw, dsn.publicKey); if (!project?.organization) { throw new Error( "Cannot resolve project: DSN could not be matched to any accessible project. " + diff --git a/packages/cli/src/lib/issue-links.ts b/packages/cli/src/lib/issue-links.ts index 004caf57e..1901ab310 100644 --- a/packages/cli/src/lib/issue-links.ts +++ b/packages/cli/src/lib/issue-links.ts @@ -232,10 +232,6 @@ async function invalidateIssueLinks( await Promise.all([ invalidateCachedResponsesMatching(new URL(issuePath, regionUrl).href), invalidateCachedResponsesMatching(new URL(issuePath, base).href), - invalidateCachedResponsesMatching( - new URL(`/api/0/issues/${encodeURIComponent(options.issueId)}/`, base) - .href, - ), ]); } diff --git a/packages/cli/src/lib/resolve-target.ts b/packages/cli/src/lib/resolve-target.ts index 7a0e04057..954519e01 100644 --- a/packages/cli/src/lib/resolve-target.ts +++ b/packages/cli/src/lib/resolve-target.ts @@ -22,7 +22,7 @@ import type { SentryProject, } from "../types/index.js"; import { - findProjectByDsnKey, + findProjectByDsn, findProjectsByPattern, findProjectsBySlug, getProject, @@ -426,8 +426,8 @@ async function normalizeNumericOrg(orgId: string): Promise { } /** - * Resolve a DSN without orgId by searching for the project via DSN public key. - * Uses the /api/0/projects?query=dsn: endpoint. + * Resolve a DSN without orgId by asking the user's organizations for it + * (see {@link findProjectByDsn}). * * @param dsn - Detected DSN (must have publicKey) * @returns Resolved target or null if resolution failed @@ -453,7 +453,7 @@ async function resolveDsnByPublicKey( // Cache miss — search for project by DSN public key const result = await withAuthGuard(async () => { - const projectInfo = await findProjectByDsnKey(dsn.publicKey); + const projectInfo = await findProjectByDsn(dsn.raw, dsn.publicKey); if (!projectInfo) { return null; @@ -496,7 +496,7 @@ async function resolveDsnByPublicKey( * 1. DSNs with orgId: the DSN already encodes org+project identity, so no * discovery API call is made — cached slugs are used when available, * otherwise the target is built from the DSN's numeric IDs. - * 2. DSNs without orgId: Use findProjectByDsnKey(publicKey) API + * 2. DSNs without orgId: Use findProjectByDsn() API * * @param dsn - Detected DSN to resolve * @returns Resolved target or null if resolution failed diff --git a/packages/cli/src/types/index.ts b/packages/cli/src/types/index.ts index 408d10d90..e23353798 100644 --- a/packages/cli/src/types/index.ts +++ b/packages/cli/src/types/index.ts @@ -169,6 +169,7 @@ export { CustomerTrialInfoSchema, DetailedLogsResponseSchema, DetailedSentryLogSchema, + DsnLookupResponseSchema, type EventViewAttachment, ISSUE_LEVELS, ISSUE_STATUSES, diff --git a/packages/cli/src/types/sentry.ts b/packages/cli/src/types/sentry.ts index 7f723a634..504cf60f5 100644 --- a/packages/cli/src/types/sentry.ts +++ b/packages/cli/src/types/sentry.ts @@ -312,6 +312,17 @@ export const UserRegionsResponseSchema = object({ export type UserRegionsResponse = InferOutput; +/** + * Response from `/api/0/organizations/{org}/dsn-lookup/`. The endpoint is + * private, so `@sentry/api` has no type for it. + */ +export const DsnLookupResponseSchema = looseObject({ + organizationSlug: string(), + projectSlug: string(), + projectId: string(), + projectName: string(), +}); + // User /** diff --git a/packages/cli/test/commands/issue/events.func.test.ts b/packages/cli/test/commands/issue/events.func.test.ts index 0e7ccd5e7..a77919692 100644 --- a/packages/cli/test/commands/issue/events.func.test.ts +++ b/packages/cli/test/commands/issue/events.func.test.ts @@ -514,22 +514,4 @@ describe("eventsCommand.func()", () => { expect(output).toContain("Next:"); expect(output).toContain("-c next"); }); - - test("throws ContextError when org is undefined", async () => { - resolveIssueSpy.mockResolvedValue({ - org: undefined, - issue: makeMockIssue(), - }); - - const { context } = createMockContext(); - const func = await eventsCommand.loader(); - - await expect( - func.call( - context, - { limit: 25, json: false, full: false, period: parsePeriod("7d") }, - "123456789", - ), - ).rejects.toThrow("organization"); - }); }); diff --git a/packages/cli/test/commands/issue/merge.func.test.ts b/packages/cli/test/commands/issue/merge.func.test.ts index a61e27c59..c39f66527 100644 --- a/packages/cli/test/commands/issue/merge.func.test.ts +++ b/packages/cli/test/commands/issue/merge.func.test.ts @@ -222,36 +222,6 @@ describe("mergeCommand.func()", () => { expect(mergeSpy).not.toHaveBeenCalled(); }); - test("rejects issues where org could not be determined", async () => { - // One issue resolves without org (e.g. bare numeric ID, no DSN match), - // the other has a known org — without the guard, the merge would - // silently proceed to the known org and fail with a confusing API error. - resolveIssueSpy.mockImplementation( - byText(({ issueArg }: { issueArg: string }) => { - if (issueArg === "CLI-A") { - return Promise.resolve({ - org: "my-org", - issue: makeMockIssue({ shortId: "CLI-A", id: "10A" }), - }); - } - return Promise.resolve({ - org: undefined, - issue: makeMockIssue({ shortId: "NO-ORG", id: "999" }), - }); - }), - ); - - const { context } = createMockContext(); - const func = await mergeCommand.loader(); - const err = await func - .call(context, { json: false }, "CLI-A", "999") - .catch((e: Error) => e); - - expect(err.message).toContain("Could not determine the organization"); - expect(err.message).toContain("NO-ORG"); - expect(mergeSpy).not.toHaveBeenCalled(); - }); - test("rejects cross-org merges with a friendly message", async () => { resolveIssueSpy.mockImplementation( byText(({ issueArg }: { issueArg: string }) => diff --git a/packages/cli/test/commands/issue/resolve.func.test.ts b/packages/cli/test/commands/issue/resolve.func.test.ts index ab5d2c9bf..831497fab 100644 --- a/packages/cli/test/commands/issue/resolve.func.test.ts +++ b/packages/cli/test/commands/issue/resolve.func.test.ts @@ -225,24 +225,6 @@ describe("resolveCommand.func()", () => { } }); - test("--in @commit without an org context throws ContextError", async () => { - // No org resolved from the issue (e.g. bare numeric ID with no DSN cache hit) - resolveIssueSpy.mockResolvedValue({ - org: undefined, - issue: makeMockIssue(), - }); - - const { context } = createMockContext(); - const func = await resolveCommand.loader(); - const err = await func - .call(context, { json: false, in: "@commit" }, "123456789") - .catch((e: Error) => e); - - expect(err).toBeInstanceOf(Error); - expect(err.message).toContain("Organization"); - expect(updateSpy).not.toHaveBeenCalled(); - }); - test("JSON output includes resolved_in metadata", async () => { resolveIssueSpy.mockResolvedValue({ org: "test-org", diff --git a/packages/cli/test/commands/issue/utils.test.ts b/packages/cli/test/commands/issue/utils.test.ts index a60d8dfca..11d125a3b 100644 --- a/packages/cli/test/commands/issue/utils.test.ts +++ b/packages/cli/test/commands/issue/utils.test.ts @@ -198,6 +198,16 @@ afterEach(() => { globalThis.fetch = originalFetch; }); +/** An org list response from the control silo. */ +function orgListResponse(slugs: string[]): Response { + return new Response( + JSON.stringify( + slugs.map((slug, i) => ({ id: String(i + 1), slug, name: slug })), + ), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); +} + describe("resolveOrgAndIssueId", () => { test("uses a custom command domain in deep resolution suggestions", async () => { const error = await resolveIssue({ @@ -215,80 +225,27 @@ describe("resolveOrgAndIssueId", () => { ]); }); - test("throws for numeric ID (org cannot be resolved)", async () => { + test("finds a numeric ID by searching the user's orgs", async () => { + const calls: string[] = []; // @ts-expect-error - partial mock globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { const req = new Request(input, init); - const url = req.url; - - // Numeric ID is fetched directly - if (url.includes("/issues/123456789/")) { - return new Response( - JSON.stringify({ - id: "123456789", - shortId: "PROJECT-ABC", - title: "Test Issue", - status: "unresolved", - platform: "javascript", - type: "error", - count: "10", - userCount: 5, - }), - { - status: 200, - headers: { "Content-Type": "application/json" }, - }, - ); + calls.push(req.url); + const { pathname } = new URL(req.url); + if (pathname === "/api/0/organizations/") { + return orgListResponse(["other-org", "my-org"]); } - - return new Response(JSON.stringify({ detail: "Not found" }), { - status: 404, - }); - }; - - // Numeric IDs don't have org context, so resolveOrgAndIssueId should throw - await expect( - resolveOrgAndIssueId({ - issueArg: "123456789", - cwd: getConfigDir(), - command: "explain", - }), - ).rejects.toThrow("organization"); - }); - - test("resolves numeric ID when API response includes subdomain-style permalink", async () => { - // @ts-expect-error - partial mock - globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { - const req = new Request(input, init); - const url = req.url; - - if (url.includes("/issues/123456789/")) { + if (pathname === "/api/0/organizations/my-org/issues/123456789/") { return new Response( - JSON.stringify({ - id: "123456789", - shortId: "PROJECT-ABC", - title: "Test Issue", - status: "unresolved", - platform: "javascript", - type: "error", - count: "10", - userCount: 5, - // Org slug embedded in subdomain-style permalink - permalink: "https://my-org.sentry.io/issues/123456789/", - }), - { - status: 200, - headers: { "Content-Type": "application/json" }, - }, + JSON.stringify({ id: "123456789", shortId: "PROJECT-ABC" }), + { status: 200, headers: { "Content-Type": "application/json" } }, ); } - return new Response(JSON.stringify({ detail: "Not found" }), { status: 404, }); }; - // Org should be extracted from permalink — no longer throws const result = await resolveOrgAndIssueId({ issueArg: "123456789", cwd: getConfigDir(), @@ -296,48 +253,28 @@ describe("resolveOrgAndIssueId", () => { }); expect(result.org).toBe("my-org"); expect(result.issueId).toBe("123456789"); + expect(calls.some((u) => /\/api\/0\/issues\//.test(u))).toBe(false); }); - test("resolves numeric ID when API response includes path-style permalink", async () => { + test("throws a not-found error when no org has the numeric ID", async () => { // @ts-expect-error - partial mock globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { - const req = new Request(input, init); - const url = req.url; - - if (url.includes("/issues/55555555/")) { - return new Response( - JSON.stringify({ - id: "55555555", - shortId: "BACKEND-XY", - title: "Another Issue", - status: "unresolved", - platform: "python", - type: "error", - count: "1", - userCount: 1, - // Path-style permalink (sentry.io/organizations/{org}/issues/{id}/) - permalink: - "https://sentry.io/organizations/acme-corp/issues/55555555/", - }), - { - status: 200, - headers: { "Content-Type": "application/json" }, - }, - ); + const { pathname } = new URL(new Request(input, init).url); + if (pathname === "/api/0/organizations/") { + return orgListResponse(["org-a", "org-b"]); } - return new Response(JSON.stringify({ detail: "Not found" }), { status: 404, }); }; - const result = await resolveOrgAndIssueId({ - issueArg: "55555555", - cwd: getConfigDir(), - command: "explain", - }); - expect(result.org).toBe("acme-corp"); - expect(result.issueId).toBe("55555555"); + await expect( + resolveOrgAndIssueId({ + issueArg: "123456789", + cwd: getConfigDir(), + command: "explain", + }), + ).rejects.toThrow("123456789"); }); test("resolves explicit org prefix (org/ISSUE-ID)", async () => { @@ -1086,33 +1023,25 @@ describe("resolveOrgAndIssueId: issue-id → org cache (Pattern D)", () => { expect(getCachedIssueOrg("77777777")).toBe("cached-org"); }); - test("evicts stale cache entry on 404 and uses permalink org (does not leak stale slug)", async () => { + test("evicts a stale cache entry on 404 and searches the other orgs", async () => { const { setCachedIssueOrg, getCachedIssueOrg } = await import("../../../src/lib/db/issue-org-cache.js"); // Seed a stale mapping that will 404 on the org-scoped endpoint. setCachedIssueOrg("88888888", "stale-org"); setOrgRegion("stale-org", DEFAULT_SENTRY_URL); - setOrgRegion("correct-org", DEFAULT_SENTRY_URL); + const calls: string[] = []; // @ts-expect-error - partial mock globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { const req = new Request(input, init); - const url = req.url; - // Org-scoped call with the stale org → 404. - if (url.includes("/organizations/stale-org/issues/88888888/")) { - return new Response(JSON.stringify({ detail: "Not found" }), { - status: 404, - }); + calls.push(req.url); + const { pathname } = new URL(req.url); + if (pathname === "/api/0/organizations/") { + return orgListResponse(["stale-org", "correct-org"]); } - // Legacy unscoped fallback returns the correct org via permalink. - if (/\/api\/0\/issues\/88888888\/(\?|$)/.test(url)) { + if (pathname === "/api/0/organizations/correct-org/issues/88888888/") { return new Response( - JSON.stringify({ - id: "88888888", - shortId: "CORRECT-1", - permalink: - "https://sentry.io/organizations/correct-org/issues/88888888/", - }), + JSON.stringify({ id: "88888888", shortId: "CORRECT-1" }), { status: 200, headers: { "Content-Type": "application/json" } }, ); } @@ -1127,32 +1056,29 @@ describe("resolveOrgAndIssueId: issue-id → org cache (Pattern D)", () => { command: "view", }); - // Critical assertion: the returned org is the CORRECT one from the - // permalink, NOT the stale cached slug. expect(result.org).toBe("correct-org"); - expect(result.org).not.toBe("stale-org"); - // The stale cache entry should have been evicted and replaced with - // the corrected mapping from the permalink. expect(getCachedIssueOrg("88888888")).toBe("correct-org"); + // The search skips the org that just 404ed. + expect( + calls.filter((u) => + u.includes("/organizations/stale-org/issues/88888888/"), + ), + ).toHaveLength(1); }); - test("writes numeric-id → org mapping after legacy unscoped fallback", async () => { + test("writes the numeric-id → org mapping after an org search", async () => { const { getCachedIssueOrg } = await import("../../../src/lib/db/issue-org-cache.js"); - setOrgRegion("fresh-org", DEFAULT_SENTRY_URL); // @ts-expect-error - partial mock globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { - const req = new Request(input, init); - // Match /api/0/issues/99999999/ regardless of query string (?collapse=...). - if (/\/api\/0\/issues\/99999999\/(\?|$)/.test(req.url)) { + const { pathname } = new URL(new Request(input, init).url); + if (pathname === "/api/0/organizations/") { + return orgListResponse(["other-org", "fresh-org"]); + } + if (pathname === "/api/0/organizations/fresh-org/issues/99999999/") { return new Response( - JSON.stringify({ - id: "99999999", - shortId: "FRESH-1", - permalink: - "https://sentry.io/organizations/fresh-org/issues/99999999/", - }), + JSON.stringify({ id: "99999999", shortId: "FRESH-1" }), { status: 200, headers: { "Content-Type": "application/json" } }, ); } @@ -1180,7 +1106,7 @@ describe("resolveOrgAndIssueId: issue-id → org cache (Pattern D)", () => { setCachedIssueOrg("66666666", "still-valid-org"); setOrgRegion("still-valid-org", DEFAULT_SENTRY_URL); - let legacyFallbackCalled = false; + let orgSearchStarted = false; // @ts-expect-error - partial mock globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { const req = new Request(input, init); @@ -1190,23 +1116,18 @@ describe("resolveOrgAndIssueId: issue-id → org cache (Pattern D)", () => { status: 500, }); } - if (/\/api\/0\/issues\/66666666\/(\?|$)/.test(url)) { - legacyFallbackCalled = true; - return new Response( - JSON.stringify({ - id: "66666666", - permalink: - "https://sentry.io/organizations/still-valid-org/issues/66666666/", - }), - { status: 200, headers: { "Content-Type": "application/json" } }, - ); + if (new URL(url).pathname === "/api/0/organizations/") { + return orgListResponse(["still-valid-org", "other-org"]); + } + if (url.includes("/organizations/other-org/issues/66666666/")) { + orgSearchStarted = true; } return new Response(JSON.stringify({ detail: "Not found" }), { status: 404, }); }; - // Error must propagate — the helper only falls back on 404, not on 5xx. + // Error must propagate — the helper only searches on 404, not on 5xx. await expect( resolveOrgAndIssueId({ issueArg: "66666666", @@ -1218,8 +1139,8 @@ describe("resolveOrgAndIssueId: issue-id → org cache (Pattern D)", () => { // Cache must NOT have been evicted — the failure was transient, the // mapping may still be correct. expect(getCachedIssueOrg("66666666")).toBe("still-valid-org"); - // Legacy fallback must NOT have been reached. - expect(legacyFallbackCalled).toBe(false); + // The org search must NOT have been reached. + expect(orgSearchStarted).toBe(false); }); }); diff --git a/packages/cli/test/lib/api-client.coverage.test.ts b/packages/cli/test/lib/api-client.coverage.test.ts index cc910a80c..2faf0ba64 100644 --- a/packages/cli/test/lib/api-client.coverage.test.ts +++ b/packages/cli/test/lib/api-client.coverage.test.ts @@ -23,7 +23,7 @@ import { getCurrentUser, getDetailedTrace, getEvent, - getIssue, + findIssueInOrgs, getIssueByShortId, getIssueInOrg, getLatestEvent, @@ -113,36 +113,60 @@ function linkHeader(cursor: string, hasResults: boolean): string { // ============================================================================= describe("issues.ts", () => { - describe("getIssue", () => { - test("fetches issue by numeric ID via legacy endpoint", async () => { + describe("findIssueInOrgs", () => { + test("returns the issue from the org that has it, skipping 404s and 403s", async () => { const issue = mockIssue(); + const requested: string[] = []; globalThis.fetch = mockFetch(async (input, init) => { - const req = new Request(input!, init); - expect(req.url).toContain("/api/0/issues/12345/"); - return new Response(JSON.stringify(issue), { - status: 200, + const { pathname } = new URL(new Request(input!, init).url); + requested.push(pathname); + if (pathname.includes("/organizations/has-it/")) { + return new Response(JSON.stringify(issue), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + const status = pathname.includes("/no-access/") ? 403 : 404; + return new Response(JSON.stringify({ detail: "nope" }), { + status, headers: { "Content-Type": "application/json" }, }); }); - const result = await getIssue("12345"); - expect(result.id).toBe("12345"); - expect(result.title).toBe("Test Issue"); + const result = await findIssueInOrgs( + ["other", "no-access", "has-it"], + "12345", + ); + expect(result?.org).toBe("has-it"); + expect(result?.issue.id).toBe("12345"); + expect(requested.some((p) => p.startsWith("/api/0/issues/"))).toBe(false); }); - test("passes only the collapse values the details endpoint reads", async () => { - const issue = mockIssue(); + test("returns null when no org has the issue", async () => { + globalThis.fetch = mockFetch( + async () => + new Response(JSON.stringify({ detail: "Not found" }), { + status: 404, + headers: { "Content-Type": "application/json" }, + }), + ); + + expect(await findIssueInOrgs(["a", "b"], "12345")).toBeNull(); + }); + + test("throws an unexpected error when no org has the issue", async () => { globalThis.fetch = mockFetch(async (input, init) => { - const req = new Request(input!, init); - const url = new URL(req.url); - expect(url.searchParams.getAll("collapse")).toEqual(["stats"]); - return new Response(JSON.stringify(issue), { - status: 200, + const { pathname } = new URL(new Request(input!, init).url); + const status = pathname.includes("/organizations/broken/") ? 500 : 404; + return new Response(JSON.stringify({ detail: "boom" }), { + status, headers: { "Content-Type": "application/json" }, }); }); - await getIssue("12345", { collapse: ["stats", "lifetime"] }); + await expect( + findIssueInOrgs(["missing", "broken"], "12345"), + ).rejects.toMatchObject({ status: 500 }); }); }); @@ -291,27 +315,7 @@ describe("issues.ts", () => { }); describe("updateIssueStatus", () => { - test("sends PUT request with status body", async () => { - const issue = mockIssue({ status: "resolved" }); - let capturedBody: unknown; - - globalThis.fetch = mockFetch(async (input, init) => { - const req = new Request(input!, init); - capturedBody = await req.json(); - expect(req.method).toBe("PUT"); - expect(req.url).toContain("/api/0/issues/12345/"); - return new Response(JSON.stringify(issue), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); - }); - - const result = await updateIssueStatus("12345", "resolved"); - expect(result.status).toBe("resolved"); - expect(capturedBody).toEqual({ status: "resolved" }); - }); - - test("sends PUT to the org-scoped endpoint when the org is known", async () => { + test("sends PUT to the org-scoped endpoint", async () => { const issue = mockIssue({ status: "ignored" }); let capturedUrl = ""; let capturedBody: unknown; diff --git a/packages/cli/test/lib/api-client.multiregion.test.ts b/packages/cli/test/lib/api-client.multiregion.test.ts index 85487c8d1..cf8151d47 100644 --- a/packages/cli/test/lib/api-client.multiregion.test.ts +++ b/packages/cli/test/lib/api-client.multiregion.test.ts @@ -9,7 +9,7 @@ import * as Sentry from "@sentry/node"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { - findProjectByDsnKey, + findProjectByDsn, getUserRegions, listOrganizations, listOrganizationsPage, @@ -464,189 +464,140 @@ describe("listOrganizations (control silo)", () => { }); }); -describe("findProjectByDsnKey (multi-region)", () => { - test("searches all regions for project with DSN key", async () => { +// Legacy DSN host with no `o`: the org has to be looked up. +const TEST_DSN = "https://abc123@sentry.io/42"; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +/** Orgs from the control silo, each linked to its own region. */ +function orgsInRegions(): Response { + return jsonResponse([ + { + id: "100", + slug: "us-org", + name: "US Organization", + links: { + organizationUrl: "https://us.sentry.io/organizations/us-org/", + regionUrl: "https://us.sentry.io", + }, + }, + { + id: "200", + slug: "eu-org", + name: "EU Organization", + links: { + organizationUrl: "https://de.sentry.io/organizations/eu-org/", + regionUrl: "https://de.sentry.io", + }, + }, + ]); +} + +const DSN_NOT_FOUND = { detail: "DSN not found" }; + +const EU_LOOKUP = { + organizationSlug: "eu-org", + projectSlug: "eu-project", + projectId: "300", + projectName: "EU Project", + projectPlatform: "python", + keyLabel: "Default", + keyId: 7, +}; + +describe("findProjectByDsn (multi-region)", () => { + test("asks each org's region with dsn-lookup and returns the match", async () => { const requestedUrls: string[] = []; globalThis.fetch = createMultiRegionMockFetch({ controlSilo: (req) => { requestedUrls.push(req.url); - if (req.url.includes("/users/me/regions/")) { - return new Response( - JSON.stringify({ - regions: [ - { name: "us", url: "https://us.sentry.io" }, - { name: "de", url: "https://de.sentry.io" }, - ], - }), - { - status: 200, - headers: { "Content-Type": "application/json" }, - }, - ); - } - return new Response(JSON.stringify([]), { status: 200 }); + return orgsInRegions(); }, usRegion: (req) => { requestedUrls.push(req.url); - // US region doesn't have the project - return new Response(JSON.stringify([]), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); + return jsonResponse(DSN_NOT_FOUND, 404); }, euRegion: (req) => { requestedUrls.push(req.url); - // EU region has the project - // Check for URL-encoded query parameter (dsn%3Aabc123) - if (req.url.includes("/projects/") && req.url.includes("abc123")) { - return new Response( - JSON.stringify([ - { - id: "300", - slug: "eu-project", - name: "EU Project", - organization: { id: "200", slug: "eu-org", name: "EU Org" }, - }, - ]), - { - status: 200, - headers: { "Content-Type": "application/json" }, - }, - ); - } - return new Response(JSON.stringify([]), { status: 200 }); + return jsonResponse(EU_LOOKUP); }, }); - const project = await findProjectByDsnKey("abc123"); - - // Match the origin, not a hostname substring that could occur in another URL. - const searchedRegion = (region: string): boolean => - requestedUrls.some((requestedUrl) => { - const url = new URL(requestedUrl); - return ( - url.origin === `https://${region}.sentry.io` && - url.pathname.includes("/projects/") - ); - }); - expect(searchedRegion("us")).toBe(true); - expect(searchedRegion("de")).toBe(true); + const project = await findProjectByDsn(TEST_DSN, "abc123"); - // Should find the project from EU region - expect(project).not.toBeNull(); - expect(project?.slug).toBe("eu-project"); + const lookups = requestedUrls + .map((u) => new URL(u)) + .filter((u) => u.pathname.endsWith("/dsn-lookup/")); + expect(lookups.map((u) => `${u.origin}${u.pathname}`).sort()).toEqual([ + "https://de.sentry.io/api/0/organizations/eu-org/dsn-lookup/", + "https://us.sentry.io/api/0/organizations/us-org/dsn-lookup/", + ]); + expect(lookups.every((u) => u.searchParams.get("dsn") === TEST_DSN)).toBe( + true, + ); + expect(requestedUrls.some((u) => u.includes("/users/me/regions/"))).toBe( + false, + ); + expect(project).toEqual({ + id: "300", + slug: "eu-project", + name: "EU Project", + organization: { id: "200", slug: "eu-org", name: "EU Organization" }, + }); }); - test("returns null when project not found in any region", async () => { + test("returns null without falling back when no org has the DSN", async () => { + const requestedUrls: string[] = []; globalThis.fetch = createMultiRegionMockFetch({ controlSilo: (req) => { - if (req.url.includes("/users/me/regions/")) { - return new Response( - JSON.stringify({ - regions: [{ name: "us", url: "https://us.sentry.io" }], - }), - { - status: 200, - headers: { "Content-Type": "application/json" }, - }, - ); - } - return new Response(JSON.stringify([]), { status: 200 }); + requestedUrls.push(req.url); + return orgsInRegions(); + }, + default: (req) => { + requestedUrls.push(req.url); + return jsonResponse(DSN_NOT_FOUND, 404); }, - usRegion: () => - new Response(JSON.stringify([]), { - status: 200, - headers: { "Content-Type": "application/json" }, - }), }); - const project = await findProjectByDsnKey("nonexistent-key"); - - expect(project).toBeNull(); + expect(await findProjectByDsn(TEST_DSN, "abc123")).toBeNull(); + expect( + requestedUrls.some((u) => new URL(u).pathname === "/api/0/projects/"), + ).toBe(false); }); - test("falls back to default region for self-hosted (no regions)", async () => { - let capturedUrl: string | undefined; - + test("never falls back to the deprecated /projects/ lookup on SaaS", async () => { + const requestedUrls: string[] = []; globalThis.fetch = createMultiRegionMockFetch({ controlSilo: (req) => { - capturedUrl = req.url; - if (req.url.includes("/users/me/regions/")) { - return new Response( - JSON.stringify({ - regions: [], - }), - { - status: 200, - headers: { "Content-Type": "application/json" }, - }, - ); - } - if (req.url.includes("/projects/")) { - return new Response( - JSON.stringify([ - { - id: "400", - slug: "self-hosted-project", - name: "Self Hosted Project", - }, - ]), - { - status: 200, - headers: { "Content-Type": "application/json" }, - }, - ); - } - return new Response(JSON.stringify([]), { status: 200 }); + requestedUrls.push(req.url); + return orgsInRegions(); + }, + default: (req) => { + requestedUrls.push(req.url); + // Catch-all 404: the endpoint doesn't exist on this server. + return new Response("", { status: 404 }); }, }); - const project = await findProjectByDsnKey("self-hosted-key"); - - // Should query control silo directly when no regions - expect(capturedUrl).toContain("/projects/"); - // URL encodes the colon as %3A - expect(capturedUrl).toContain("self-hosted-key"); - expect(project?.slug).toBe("self-hosted-project"); + expect(await findProjectByDsn(TEST_DSN, "abc123")).toBeNull(); + expect( + requestedUrls.some((u) => new URL(u).pathname === "/api/0/projects/"), + ).toBe(false); }); - test("continues searching when one region fails", async () => { + test("reports one org's failure and keeps looking in the others", async () => { globalThis.fetch = createMultiRegionMockFetch({ - controlSilo: (req) => { - if (req.url.includes("/users/me/regions/")) { - return new Response( - JSON.stringify({ - regions: [ - { name: "us", url: "https://us.sentry.io" }, - { name: "de", url: "https://de.sentry.io" }, - ], - }), - { - status: 200, - headers: { "Content-Type": "application/json" }, - }, - ); - } - return new Response(JSON.stringify([]), { status: 200 }); - }, + controlSilo: () => orgsInRegions(), usRegion: () => { throw new Error("Network error"); }, - euRegion: () => - new Response( - JSON.stringify([ - { - id: "500", - slug: "found-project", - name: "Found Project", - }, - ]), - { - status: 200, - headers: { "Content-Type": "application/json" }, - }, - ), + euRegion: () => jsonResponse(EU_LOOKUP), }); const captureSpy = vi.spyOn(Sentry, "captureException"); @@ -665,10 +616,9 @@ describe("findProjectByDsnKey (multi-region)", () => { }); }); try { - const project = await findProjectByDsnKey("abc123"); + const project = await findProjectByDsn(TEST_DSN, "abc123"); - // Should find project despite US region failing - expect(project?.slug).toBe("found-project"); + expect(project?.slug).toBe("eu-project"); expect(captureSpy).toHaveBeenCalledTimes(1); expect(captureSpy.mock.calls[0]?.[0]).toMatchObject({ message: "Network error", @@ -678,6 +628,112 @@ describe("findProjectByDsnKey (multi-region)", () => { withScopeSpy.mockRestore(); } }); + + describe("self-hosted", () => { + const SELF_HOSTED_DSN = "http://shkey@localhost:9000/3"; + let savedUrl: string | undefined; + + beforeEach(() => { + savedUrl = process.env.SENTRY_URL; + process.env.SENTRY_URL = "http://localhost:9000"; + setAuthToken("self-hosted-token", undefined, undefined, { + host: "http://localhost:9000", + }); + }); + + afterEach(() => { + if (savedUrl === undefined) { + delete process.env.SENTRY_URL; + } else { + process.env.SENTRY_URL = savedUrl; + } + }); + + /** A self-hosted server: one org, no region links. */ + function selfHostedServer( + dsnLookup: () => Response, + requestedUrls: string[], + ): typeof globalThis.fetch { + return createMultiRegionMockFetch({ + controlSilo: (req) => { + requestedUrls.push(req.url); + const { pathname } = new URL(req.url); + if (pathname === "/api/0/organizations/") { + return jsonResponse([{ id: "1", slug: "sentry", name: "Sentry" }]); + } + if (pathname.endsWith("/dsn-lookup/")) { + return dsnLookup(); + } + if (pathname === "/api/0/projects/") { + return jsonResponse([ + { + id: "400", + slug: "self-hosted-project", + name: "Self Hosted Project", + organization: { id: "1", slug: "sentry", name: "Sentry" }, + }, + ]); + } + return jsonResponse({ detail: "Not found" }, 404); + }, + }); + } + + test("falls back to /projects/?query=dsn: when the server has no dsn-lookup", async () => { + const requestedUrls: string[] = []; + globalThis.fetch = selfHostedServer( + () => new Response("", { status: 404 }), + requestedUrls, + ); + + const project = await findProjectByDsn(SELF_HOSTED_DSN, "shkey"); + + expect(project?.slug).toBe("self-hosted-project"); + const fallback = requestedUrls + .map((u) => new URL(u)) + .find((u) => u.pathname === "/api/0/projects/"); + expect(fallback?.searchParams.get("query")).toBe("dsn:shkey"); + }); + + test("uses dsn-lookup when the server has it", async () => { + const requestedUrls: string[] = []; + globalThis.fetch = selfHostedServer( + () => + jsonResponse({ + organizationSlug: "sentry", + projectSlug: "internal", + projectId: "3", + projectName: "Internal", + }), + requestedUrls, + ); + + const project = await findProjectByDsn(SELF_HOSTED_DSN, "shkey"); + + expect(project).toEqual({ + id: "3", + slug: "internal", + name: "Internal", + organization: { id: "1", slug: "sentry", name: "Sentry" }, + }); + expect( + requestedUrls.some((u) => new URL(u).pathname === "/api/0/projects/"), + ).toBe(false); + }); + + test("doesn't fall back when dsn-lookup says the DSN isn't there", async () => { + const requestedUrls: string[] = []; + globalThis.fetch = selfHostedServer( + () => jsonResponse(DSN_NOT_FOUND, 404), + requestedUrls, + ); + + expect(await findProjectByDsn(SELF_HOSTED_DSN, "shkey")).toBeNull(); + expect( + requestedUrls.some((u) => new URL(u).pathname === "/api/0/projects/"), + ).toBe(false); + }); + }); }); describe("org-scoped requests use region cache", () => { diff --git a/packages/cli/test/lib/api/issues.test.ts b/packages/cli/test/lib/api/issues.test.ts index dd5a33cd3..9d832eb52 100644 --- a/packages/cli/test/lib/api/issues.test.ts +++ b/packages/cli/test/lib/api/issues.test.ts @@ -13,13 +13,8 @@ import { RESOLVE_COMMIT_SENTINEL, RESOLVE_NEXT_RELEASE_SENTINEL, } from "../../../src/lib/api-client.js"; -import { setAuthToken } from "../../../src/lib/db/auth.js"; import { ApiError, ValidationError } from "../../../src/lib/errors.js"; -import { - getCachedResponse, - storeCachedResponse, -} from "../../../src/lib/response-cache.js"; -import { mockFetch, useTestConfigDir } from "../../helpers.js"; +import { mockFetch } from "../../helpers.js"; describe("parseResolveSpec", () => { test("returns null for undefined", () => { @@ -240,57 +235,3 @@ describe("mergeIssues", () => { ); }); }); - -describe("mergeIssues: cross-origin legacy cache", () => { - useTestConfigDir("merge-legacy-cache-"); - - let originalFetch: typeof globalThis.fetch; - - beforeEach(() => { - originalFetch = globalThis.fetch; - setAuthToken("test-token", 3600, "test-refresh"); - }); - - afterEach(() => { - globalThis.fetch = originalFetch; - }); - - test("clears legacy /issues/{id}/ cache for every affected ID", async () => { - // Regression: the HTTP-layer invalidation hook only sees - // `organizations/{org}/issues/?id=...`, so it can't clear the - // per-ID legacy caches under the control-silo origin - // (`sentry.io/api/0/issues/{id}/`) that `getIssue()` reads. - // mergeIssues must do it manually after the merge response - // identifies the affected IDs. - const legacyUrl = (id: string) => `https://sentry.io/api/0/issues/${id}/`; - - for (const id of ["100", "200", "300"]) { - await storeCachedResponse( - "GET", - legacyUrl(id), - {}, - new Response(JSON.stringify({ id }), { - status: 200, - headers: { "content-type": "application/json" }, - }), - ); - expect(await getCachedResponse("GET", legacyUrl(id), {})).toBeDefined(); - } - - globalThis.fetch = mockFetch( - async () => - new Response( - JSON.stringify({ - merge: { parent: "100", children: ["200", "300"] }, - }), - { status: 200, headers: { "Content-Type": "application/json" } }, - ), - ); - - await mergeIssues("test-org", ["100", "200", "300"]); - - for (const id of ["100", "200", "300"]) { - expect(await getCachedResponse("GET", legacyUrl(id), {})).toBeUndefined(); - } - }); -}); diff --git a/packages/cli/test/lib/db/completion-cache.test.ts b/packages/cli/test/lib/db/completion-cache.test.ts index b43a3e2fa..05a1e0f92 100644 --- a/packages/cli/test/lib/db/completion-cache.test.ts +++ b/packages/cli/test/lib/db/completion-cache.test.ts @@ -97,27 +97,7 @@ describe("completion seeding from API responses", () => { ).toEqual(["CLI-3C", "CLI-2B", "CLI-1A"]); }); - test("unscoped issue lookups take the org from the permalink", async () => { - const issues = "https://sentry.io/api/0/issues"; - await seedCompletions( - `${issues}/123/`, - json({ - shortId: "CLI-1A", - title: "Boom", - permalink: "https://acme.sentry.io/issues/123/", - }), - ); - await seedCompletions( - `${issues}/456/`, - json({ shortId: "CLI-2B", title: "No permalink" }), - ); - - expect(getCachedCompletionEntities("issue", ["acme"])).toEqual([ - { orgSlug: "acme", value: "CLI-1A", label: "Boom" }, - ]); - }); - - test("issue details store their tag keys, scoped and unscoped", async () => { + test("issue details store their tag keys", async () => { await seedCompletions( `${API}/issues/123/`, json({ @@ -128,6 +108,14 @@ describe("completion seeding from API responses", () => { ], }), ); + await seedCompletions(`${API}/issues/`, json([{ shortId: "CLI-3C" }])); + + expect(getCachedCompletionEntities("tag-key", ["acme"])).toEqual([ + { orgSlug: "acme", value: "browser.name", label: "Browser.Name" }, + ]); + }); + + test("unscoped issue lookups are not seeded", async () => { await seedCompletions( "https://sentry.io/api/0/issues/456/", json({ @@ -136,12 +124,9 @@ describe("completion seeding from API responses", () => { tags: [{ key: "release", name: "Release" }], }), ); - await seedCompletions(`${API}/issues/`, json([{ shortId: "CLI-3C" }])); - expect(getCachedCompletionEntities("tag-key", ["acme"])).toEqual([ - { orgSlug: "acme", value: "release", label: "Release" }, - { orgSlug: "acme", value: "browser.name", label: "Browser.Name" }, - ]); + expect(getCachedCompletionEntities("issue", ["acme"])).toEqual([]); + expect(getCachedCompletionEntities("tag-key", ["acme"])).toEqual([]); }); test("dashboards are stored by numeric ID only", async () => { diff --git a/packages/cli/test/lib/dsn/errors.test.ts b/packages/cli/test/lib/dsn/errors.test.ts index 6781e46fa..cf5f0f980 100644 --- a/packages/cli/test/lib/dsn/errors.test.ts +++ b/packages/cli/test/lib/dsn/errors.test.ts @@ -19,7 +19,7 @@ const { mockListOrganizations, mockListProjects } = vi.hoisted(() => ({ vi.mock("../../../src/lib/api-client.js", () => ({ listOrganizations: mockListOrganizations, listProjects: mockListProjects, - findProjectByDsnKey: vi.fn(() => Promise.resolve(null)), + findProjectByDsn: vi.fn(() => Promise.resolve(null)), })); describe("formatConflictError", () => { diff --git a/packages/cli/test/lib/dsn/resolver.test.ts b/packages/cli/test/lib/dsn/resolver.test.ts index 8df92fb39..e60112750 100644 --- a/packages/cli/test/lib/dsn/resolver.test.ts +++ b/packages/cli/test/lib/dsn/resolver.test.ts @@ -5,12 +5,12 @@ import { createIsolatedDbContext } from "../../model-based/helpers.js"; const { mockListOrganizations, mockListProjects, - mockFindProjectByDsnKey, + mockFindProjectByDsn, mockResolveOrgDisplayName, } = vi.hoisted(() => ({ mockListOrganizations: vi.fn(() => Promise.resolve([])), mockListProjects: vi.fn(() => Promise.resolve([])), - mockFindProjectByDsnKey: vi.fn(() => Promise.resolve(null)), + mockFindProjectByDsn: vi.fn(() => Promise.resolve(null)), mockResolveOrgDisplayName: vi.fn( (slug: string, name?: string) => name ?? slug, ), @@ -19,7 +19,7 @@ const { vi.mock("../../../src/lib/api-client.js", () => ({ listOrganizations: mockListOrganizations, listProjects: mockListProjects, - findProjectByDsnKey: mockFindProjectByDsnKey, + findProjectByDsn: mockFindProjectByDsn, resolveOrgDisplayName: mockResolveOrgDisplayName, })); @@ -37,7 +37,7 @@ describe("resolveProject", () => { cleanup = createIsolatedDbContext(); mockListOrganizations.mockClear(); mockListProjects.mockClear(); - mockFindProjectByDsnKey.mockClear(); + mockFindProjectByDsn.mockClear(); }); afterEach(() => { @@ -71,10 +71,10 @@ describe("resolveProject", () => { expect(result.sourceDescription).toContain("SENTRY_DSN"); // Should not call API expect(mockListOrganizations).not.toHaveBeenCalled(); - expect(mockFindProjectByDsnKey).not.toHaveBeenCalled(); + expect(mockFindProjectByDsn).not.toHaveBeenCalled(); }); - test("resolves DSN without orgId using findProjectByDsnKey", async () => { + test("resolves DSN without orgId using findProjectByDsn", async () => { const dsn: DetectedDsn = { raw: "https://abc123@sentry.io/456", protocol: "https", @@ -86,7 +86,7 @@ describe("resolveProject", () => { sourcePath: ".env", }; - mockFindProjectByDsnKey.mockResolvedValue({ + mockFindProjectByDsn.mockResolvedValue({ id: "456", slug: "my-project", name: "My Project", @@ -103,7 +103,10 @@ describe("resolveProject", () => { expect(result.orgName).toBe("My Organization"); expect(result.projectSlug).toBe("my-project"); expect(result.projectName).toBe("My Project"); - expect(mockFindProjectByDsnKey).toHaveBeenCalledWith("abc123"); + expect(mockFindProjectByDsn).toHaveBeenCalledWith( + "https://abc123@sentry.io/456", + "abc123", + ); }); test("throws when DSN without orgId cannot be resolved", async () => { @@ -117,7 +120,7 @@ describe("resolveProject", () => { sourcePath: "app.js", }; - mockFindProjectByDsnKey.mockResolvedValue(null); + mockFindProjectByDsn.mockResolvedValue(null); await expect(resolveProject("/test/path", dsn)).rejects.toThrow( /Cannot resolve project.*DSN could not be matched/, @@ -134,7 +137,7 @@ describe("resolveProject", () => { source: "env", }; - mockFindProjectByDsnKey.mockResolvedValue({ + mockFindProjectByDsn.mockResolvedValue({ id: "456", slug: "orphan-project", name: "Orphan Project", diff --git a/packages/cli/test/lib/issue-links.test.ts b/packages/cli/test/lib/issue-links.test.ts index 9bf8406cf..e40163d1c 100644 --- a/packages/cli/test/lib/issue-links.test.ts +++ b/packages/cli/test/lib/issue-links.test.ts @@ -93,7 +93,7 @@ describe("external issue associations", () => { expect(invalidateCachedResponsesMatching).toHaveBeenCalledWith( "https://de.sentry.io/api/0/organizations/example/issues/123/", ); - expect(invalidateCachedResponsesMatching).toHaveBeenCalledWith( + expect(invalidateCachedResponsesMatching).not.toHaveBeenCalledWith( "https://sentry.io/api/0/issues/123/", ); }); diff --git a/packages/cli/test/lib/resolve-target.mocked.test.ts b/packages/cli/test/lib/resolve-target.mocked.test.ts index 8b2eb5bb1..d44734a45 100644 --- a/packages/cli/test/lib/resolve-target.mocked.test.ts +++ b/packages/cli/test/lib/resolve-target.mocked.test.ts @@ -39,7 +39,7 @@ const { mockGetCachedDsn, mockSetCachedDsn, mockGetProject, - mockFindProjectByDsnKey, + mockFindProjectByDsn, mockFindProjectsByPattern, mockListOrganizationsUncached, mockGetOrgByNumericId, @@ -75,7 +75,7 @@ const { /* no-op */ }), mockGetProject: vi.fn(() => Promise.resolve({ slug: "test", name: "Test" })), - mockFindProjectByDsnKey: vi.fn(() => Promise.resolve(null)), + mockFindProjectByDsn: vi.fn(() => Promise.resolve(null)), mockFindProjectsByPattern: vi.fn(() => Promise.resolve([])), mockListOrganizationsUncached: vi.fn(() => Promise.resolve([])), mockGetOrgByNumericId: vi.fn( @@ -143,7 +143,7 @@ vi.mock("../../src/lib/db/regions.js", () => ({ vi.mock("../../src/lib/api-client.js", () => ({ getProject: mockGetProject, - findProjectByDsnKey: mockFindProjectByDsnKey, + findProjectByDsn: mockFindProjectByDsn, findProjectsByPattern: mockFindProjectsByPattern, findProjectsBySlug: vi.fn(() => Promise.resolve({ projects: [], orgs: [] })), listOrganizations: vi.fn(() => Promise.resolve([])), @@ -177,7 +177,7 @@ function resetAllMocks() { mockGetCachedDsn.mockReset(); mockSetCachedDsn.mockReset(); mockGetProject.mockReset(); - mockFindProjectByDsnKey.mockReset(); + mockFindProjectByDsn.mockReset(); mockFindProjectsByPattern.mockReset(); mockListOrganizationsUncached.mockReset(); mockGetOrgByNumericId.mockReset(); @@ -884,8 +884,8 @@ describe("resolveAllTargets", () => { fingerprint: "", }); mockGetCachedProjectByDsnKey.mockReturnValue(null); - // findProjectByDsnKey returns null (project not found) - mockFindProjectByDsnKey.mockResolvedValue(null); + // findProjectByDsn returns null (project not found) + mockFindProjectByDsn.mockResolvedValue(null); mockFindProjectRoot.mockResolvedValue({ projectRoot: "/a", detectedFrom: "package.json", diff --git a/packages/cli/test/mocks/multiregion.ts b/packages/cli/test/mocks/multiregion.ts index 1504194ba..cfa10ecb1 100644 --- a/packages/cli/test/mocks/multiregion.ts +++ b/packages/cli/test/mocks/multiregion.ts @@ -119,7 +119,7 @@ function createRegionRoutes( return { status: 404, body: notFoundFixture }; }, }, - // All projects (used by findProjectByDsnKey) + // All projects (used by findProjectByDsn) { method: "GET", path: "/api/0/projects/", From 135573fa129475ad0543c58135e200e7a9974d47 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Sat, 7 Nov 2026 16:09:15 +0000 Subject: [PATCH 2/2] test(cli): Assert dsn list resolves unknown DSNs through dsn-lookup The test asserted the old /projects/?query=dsn: path inside the fetch mock, so the org list request threw and the test passed for the wrong reason. Co-Authored-By: BYK <126780+BYK@users.noreply.github.com> --- packages/cli/test/commands/dsn/list.test.ts | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/packages/cli/test/commands/dsn/list.test.ts b/packages/cli/test/commands/dsn/list.test.ts index 9ad1c0ad7..fd59a46c0 100644 --- a/packages/cli/test/commands/dsn/list.test.ts +++ b/packages/cli/test/commands/dsn/list.test.ts @@ -336,14 +336,20 @@ describe("dsn list", () => { join(configDir(), ".env"), `SENTRY_DSN=https://${"a".repeat(32)}@sentry.example.com/42\n`, ); + const requestedPaths: string[] = []; globalThis.fetch = mockFetch(async (input, init) => { const url = new URL(new Request(input, init).url); - if (url.pathname === "/api/0/users/me/regions/") { - return response({ regions: [] }); + requestedPaths.push(url.pathname); + if (url.pathname === "/api/0/organizations/") { + return response([{ id: "1", slug: "test-org", name: "Test Org" }]); } - expect(url.pathname).toBe("/api/0/projects/"); - expect(url.searchParams.get("query")).toBe(`dsn:${"a".repeat(32)}`); - return response([]); + if (url.pathname === "/api/0/organizations/test-org/dsn-lookup/") { + expect(url.searchParams.get("dsn")).toBe( + `https://${"a".repeat(32)}@sentry.example.com/42`, + ); + return response({ detail: "DSN not found" }, undefined, 404); + } + return response({ detail: "Not found" }, undefined, 404); }); await expect(invoke()).rejects.toMatchObject({ name: "ContextError", @@ -352,6 +358,10 @@ describe("dsn list", () => { "Found 1 DSN(s) that could not be resolved — you may not have access to these projects", ), }); + expect(requestedPaths).toContain( + "/api/0/organizations/test-org/dsn-lookup/", + ); + expect(requestedPaths).not.toContain("/api/0/projects/"); }); test.each([false, true])(