Skip to content

Commit cc0c300

Browse files
committed
fix(issue): improve numeric issue ID resolution with org context and region routing
For bare numeric IDs (e.g. `sentry issue explain 99124558`), the resolved issue had no org, causing `explain`/`plan` to fail with 'Organization is required' even though the fetch succeeded. Fix: extract org from the response permalink, and prefer org-scoped region-routed fetches when org context is available from DSN/env/config.
1 parent 18d012f commit cc0c300

File tree

7 files changed

+316
-29
lines changed

7 files changed

+316
-29
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,3 +48,6 @@ docs/.astro
4848

4949
# Finder (MacOS) folder config
5050
.DS_Store
51+
52+
# OpenCode
53+
.opencode/

AGENTS.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -622,3 +622,16 @@ mock.module("./some-module", () => ({
622622
| Add E2E tests | `test/e2e/` |
623623
| Test helpers | `test/model-based/helpers.ts` |
624624
| Add documentation | `docs/src/content/docs/` |
625+
626+
<!-- This section is auto-maintained by lore (https://github.com/BYK/opencode-lore) -->
627+
## Long-term Knowledge
628+
629+
### Gotcha
630+
631+
<!-- lore:019c9741-d78e-73b1-87c2-e360ef6c7475 -->
632+
* **useTestConfigDir without isolateProjectRoot causes DSN scanning of repo tree**: In getsentry/cli tests, \`useTestConfigDir()\` creates temp dirs under \`.test-tmp/\` inside the repo tree. When code calls \`detectDsn(cwd)\` with this temp dir as cwd (e.g., via \`resolveOrg({ cwd })\`), \`findProjectRoot\` walks up from \`.test-tmp/prefix-xxx\` and finds the repo's \`.git\` directory, causing DSN detection to scan the actual source code for Sentry DSNs. This can trigger network calls that hit test fetch mocks (returning 404s or unexpected responses), leading to 5-second test timeouts. Fix: always use \`useTestConfigDir(prefix, { isolateProjectRoot: true })\` when the test exercises any code path that might call \`resolveOrg\`, \`detectDsn\`, or \`findProjectRoot\` with the config dir as cwd. The \`isolateProjectRoot\` option creates a \`.git\` directory inside the temp dir, stopping the upward walk immediately.
633+
<!-- lore:019c972c-9f0d-7c8e-95b1-7beda99c36a8 -->
634+
* **parseSentryUrl does not handle subdomain-style SaaS URLs**: The URL parser in src/lib/sentry-url-parser.ts now handles both path-based (\`/organizations/{org}/...\`) and subdomain-style (\`https://my-org.sentry.io/issues/123/\`) SaaS URLs. The \`matchSubdomainOrg()\` function extracts the org from the hostname when it ends with \`.sentry.io\`, supports \`/issues/{id}/\`, \`/issues/{id}/events/{eventId}/\`, and \`/traces/{traceId}/\` paths. Region subdomains (\`us\`, \`de\`) are filtered out by requiring org slugs to be longer than 2 characters. The \`baseUrl\` for subdomain URLs is the full \`scheme://host\` (e.g., \`https://my-org.sentry.io\`). \`DEFAULT\_SENTRY\_HOST\` is \`sentry.io\` (from constants.ts). The \`isSentrySaasUrl\` helper in sentry-urls.ts is imported to gate subdomain extraction to SaaS URLs only.
635+
<!-- lore:019c972c-9f0f-75cd-9e24-9bdbb1ac03d6 -->
636+
* **Numeric issue ID resolution returns org:undefined despite API success**: Numeric issue ID resolution now uses a multi-step approach in \`resolveNumericIssue()\` (extracted from \`resolveIssue\` to reduce cognitive complexity). Resolution order: (1) \`resolveOrg({ cwd })\` tries DSN/env/config for org context, (2) if org found, uses \`getIssueInOrg(org, id)\` with region routing, (3) if no org, falls back to unscoped \`getIssue(id)\`, (4) extracts org from \`issue.permalink\` via \`parseSentryUrl\` as final fallback. The \`explicit-org-numeric\` case now uses \`getIssueInOrg(parsed.org, id)\` instead of the unscoped endpoint. \`getIssueInOrg\` was added to api-client.ts using the SDK's \`retrieveAnIssue\` with the standard \`getOrgSdkConfig + unwrapResult\` pattern. The \`resolveOrgAndIssueId\` wrapper (used by \`explain\`/\`plan\`) no longer throws "Organization is required" for bare numeric IDs when the permalink contains the org slug.
637+
<!-- End lore-managed section -->

src/commands/issue/utils.ts

Lines changed: 64 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
getAutofixState,
1010
getIssue,
1111
getIssueByShortId,
12+
getIssueInOrg,
1213
triggerRootCauseAnalysis,
1314
} from "../../lib/api-client.js";
1415
import { parseIssueArg } from "../../lib/arg-parsing.js";
@@ -18,7 +19,8 @@ import { ApiError, ContextError, ResolutionError } from "../../lib/errors.js";
1819
import { getProgressMessage } from "../../lib/formatters/seer.js";
1920
import { expandToFullShortId, isShortSuffix } from "../../lib/issue-id.js";
2021
import { poll } from "../../lib/polling.js";
21-
import { resolveOrgAndProject } from "../../lib/resolve-target.js";
22+
import { resolveOrg, resolveOrgAndProject } from "../../lib/resolve-target.js";
23+
import { parseSentryUrl } from "../../lib/sentry-url-parser.js";
2224
import { isAllDigits } from "../../lib/utils.js";
2325
import type { SentryIssue, Writer } from "../../types/index.js";
2426
import { type AutofixState, isTerminalStatus } from "../../types/seer.js";
@@ -240,6 +242,63 @@ export type ResolveIssueOptions = {
240242
command: string;
241243
};
242244

245+
/**
246+
* Extract the organization slug from a Sentry issue permalink.
247+
*
248+
* Handles both path-based (`https://sentry.io/organizations/{org}/issues/...`)
249+
* and subdomain-style (`https://{org}.sentry.io/issues/...`) SaaS URLs.
250+
* Returns undefined if the permalink is missing or not a recognized format.
251+
*
252+
* @param permalink - Issue permalink URL from the Sentry API response
253+
*/
254+
function extractOrgFromPermalink(
255+
permalink: string | undefined
256+
): string | undefined {
257+
if (!permalink) {
258+
return;
259+
}
260+
return parseSentryUrl(permalink)?.org;
261+
}
262+
263+
/**
264+
* Resolve a bare numeric issue ID.
265+
*
266+
* Attempts org-scoped resolution with region routing when org context can be
267+
* derived from the working directory (DSN / env vars / config defaults).
268+
* Falls back to the legacy unscoped endpoint otherwise.
269+
* Extracts the org slug from the response permalink so callers like
270+
* {@link resolveOrgAndIssueId} can proceed without explicit org context.
271+
*/
272+
async function resolveNumericIssue(
273+
id: string,
274+
cwd: string,
275+
command: string,
276+
commandHint: string
277+
): Promise<ResolvedIssueResult> {
278+
const resolvedOrg = await resolveOrg({ cwd });
279+
try {
280+
const issue = resolvedOrg
281+
? await getIssueInOrg(resolvedOrg.org, id)
282+
: await getIssue(id);
283+
// Extract org from the response permalink as a fallback so that callers
284+
// like resolveOrgAndIssueId (used by explain/plan) get the org slug even
285+
// when no org context was available before the fetch.
286+
const org = resolvedOrg?.org ?? extractOrgFromPermalink(issue.permalink);
287+
return { org, issue };
288+
} catch (err) {
289+
if (err instanceof ApiError && err.status === 404) {
290+
// Improve on the generic "Issue not found" message by including the ID
291+
// and suggesting the short-ID format, since users often confuse numeric
292+
// group IDs with short-ID suffixes.
293+
throw new ResolutionError(`Issue ${id}`, "not found", commandHint, [
294+
`No issue with numeric ID ${id} found — you may not have access, or it may have been deleted.`,
295+
`If this is a short ID suffix, try: sentry issue ${command} <project>-${id}`,
296+
]);
297+
}
298+
throw err;
299+
}
300+
}
301+
243302
/**
244303
* Resolve an issue ID to organization slug and full issue object.
245304
*
@@ -264,29 +323,8 @@ export async function resolveIssue(
264323
const commandHint = buildCommandHint(command, issueArg);
265324

266325
switch (parsed.type) {
267-
case "numeric": {
268-
// Direct fetch by numeric ID - no org context
269-
try {
270-
const issue = await getIssue(parsed.id);
271-
return { org: undefined, issue };
272-
} catch (err) {
273-
if (err instanceof ApiError && err.status === 404) {
274-
// Improve on the generic "Issue not found" message by including the ID
275-
// and suggesting the short-ID format, since users often confuse numeric
276-
// group IDs with short-ID suffixes.
277-
throw new ResolutionError(
278-
`Issue ${parsed.id}`,
279-
"not found",
280-
commandHint,
281-
[
282-
`No issue with numeric ID ${parsed.id} found — you may not have access, or it may have been deleted.`,
283-
`If this is a short ID suffix, try: sentry issue ${command} <project>-${parsed.id}`,
284-
]
285-
);
286-
}
287-
throw err;
288-
}
289-
}
326+
case "numeric":
327+
return resolveNumericIssue(parsed.id, cwd, command, commandHint);
290328

291329
case "explicit": {
292330
// Full context: org + project + suffix
@@ -296,9 +334,9 @@ export async function resolveIssue(
296334
}
297335

298336
case "explicit-org-numeric": {
299-
// Org + numeric ID
337+
// Org + numeric ID — use org-scoped endpoint for proper region routing.
300338
try {
301-
const issue = await getIssue(parsed.numericId);
339+
const issue = await getIssueInOrg(parsed.org, parsed.numericId);
302340
return { org: parsed.org, issue };
303341
} catch (err) {
304342
if (err instanceof ApiError && err.status === 404) {

src/lib/api-client.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
queryExploreEventsInTableFormat,
1616
resolveAShortId,
1717
retrieveAnEventForAProject,
18+
retrieveAnIssue,
1819
retrieveAnIssueEvent,
1920
retrieveAnOrganization,
2021
retrieveAProject,
@@ -1118,6 +1119,9 @@ export async function listIssuesAllPages(
11181119

11191120
/**
11201121
* Get a specific issue by numeric ID.
1122+
*
1123+
* Uses the legacy unscoped endpoint — no org context or region routing.
1124+
* Prefer {@link getIssueInOrg} when the org slug is known.
11211125
*/
11221126
export function getIssue(issueId: string): Promise<SentryIssue> {
11231127
// The @sentry/api SDK's retrieveAnIssue requires org slug in path,
@@ -1126,6 +1130,27 @@ export function getIssue(issueId: string): Promise<SentryIssue> {
11261130
return apiRequest<SentryIssue>(`/issues/${issueId}/`);
11271131
}
11281132

1133+
/**
1134+
* Get a specific issue by numeric ID, scoped to an organization.
1135+
*
1136+
* Uses the org-scoped SDK endpoint with region-aware routing.
1137+
* Preferred over {@link getIssue} when the org slug is available.
1138+
*
1139+
* @param orgSlug - Organization slug (used for region routing)
1140+
* @param issueId - Numeric issue ID
1141+
*/
1142+
export async function getIssueInOrg(
1143+
orgSlug: string,
1144+
issueId: string
1145+
): Promise<SentryIssue> {
1146+
const config = await getOrgSdkConfig(orgSlug);
1147+
const result = await retrieveAnIssue({
1148+
...config,
1149+
path: { organization_id_or_slug: orgSlug, issue_id: issueId },
1150+
});
1151+
return unwrapResult(result, "Failed to get issue") as unknown as SentryIssue;
1152+
}
1153+
11291154
/**
11301155
* Get an issue by short ID (e.g., SPOTLIGHT-ELECTRON-4D).
11311156
* Requires organization context to resolve the short ID.

src/lib/sentry-url-parser.ts

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
* so that subsequent API calls reach the correct instance.
99
*/
1010

11+
import { DEFAULT_SENTRY_HOST } from "./constants.js";
1112
import { isSentrySaasUrl } from "./sentry-urls.js";
1213

1314
/**
@@ -19,7 +20,7 @@ import { isSentrySaasUrl } from "./sentry-urls.js";
1920
export type ParsedSentryUrl = {
2021
/** Scheme + host of the Sentry instance (e.g., "https://sentry.io" or "https://sentry.example.com") */
2122
baseUrl: string;
22-
/** Organization slug from the URL path */
23+
/** Organization slug from the URL path or subdomain */
2324
org: string;
2425
/** Issue identifier — numeric group ID (e.g., "32886") or short ID (e.g., "CLI-G") */
2526
issueId?: string;
@@ -83,6 +84,53 @@ function matchSettingsPath(
8384
return { baseUrl, org: segments[1], project: segments[3] };
8485
}
8586

87+
/**
88+
* Try to extract org from a SaaS subdomain-style URL.
89+
*
90+
* Matches `https://{org}.sentry.io/issues/{id}/` and similar paths
91+
* where the org is in the hostname rather than the URL path.
92+
* Only applies to SaaS URLs — self-hosted instances don't use this pattern.
93+
*
94+
* @returns Parsed result or null if not a subdomain-style SaaS URL with a known path
95+
*/
96+
function matchSubdomainOrg(
97+
baseUrl: string,
98+
hostname: string,
99+
segments: string[]
100+
): ParsedSentryUrl | null {
101+
// Must be a subdomain of sentry.io (e.g., "my-org.sentry.io")
102+
if (!hostname.endsWith(`.${DEFAULT_SENTRY_HOST}`)) {
103+
return null;
104+
}
105+
106+
const org = hostname.slice(0, -`.${DEFAULT_SENTRY_HOST}`.length);
107+
108+
// Skip region subdomains (us.sentry.io, de.sentry.io, etc.) —
109+
// these are API hosts, not org subdomains.
110+
if (org.length <= 2) {
111+
return null;
112+
}
113+
114+
// /issues/{id}/ (optionally with /events/{eventId}/)
115+
if (segments[0] === "issues" && segments[1]) {
116+
const eventId =
117+
segments[2] === "events" && segments[3] ? segments[3] : undefined;
118+
return { baseUrl, org, issueId: segments[1], eventId };
119+
}
120+
121+
// /traces/{traceId}/
122+
if (segments[0] === "traces" && segments[1]) {
123+
return { baseUrl, org, traceId: segments[1] };
124+
}
125+
126+
// Bare org subdomain URL
127+
if (segments.length === 0) {
128+
return { baseUrl, org };
129+
}
130+
131+
return null;
132+
}
133+
86134
/**
87135
* Parse a Sentry web URL and extract its components.
88136
*
@@ -93,6 +141,11 @@ function matchSettingsPath(
93141
* - `/organizations/{org}/traces/{traceId}/`
94142
* - `/organizations/{org}/`
95143
*
144+
* Also recognizes SaaS subdomain-style URLs:
145+
* - `https://{org}.sentry.io/issues/{id}/`
146+
* - `https://{org}.sentry.io/traces/{traceId}/`
147+
* - `https://{org}.sentry.io/issues/{id}/events/{eventId}/`
148+
*
96149
* @param input - Raw string that may or may not be a URL
97150
* @returns Parsed components, or null if input is not a recognized Sentry URL
98151
*/
@@ -114,7 +167,8 @@ export function parseSentryUrl(input: string): ParsedSentryUrl | null {
114167

115168
return (
116169
matchOrganizationsPath(baseUrl, segments) ??
117-
matchSettingsPath(baseUrl, segments)
170+
matchSettingsPath(baseUrl, segments) ??
171+
matchSubdomainOrg(baseUrl, url.hostname, segments)
118172
);
119173
}
120174

test/commands/issue/utils.test.ts

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,90 @@ describe("resolveOrgAndIssueId", () => {
110110
).rejects.toThrow("Organization");
111111
});
112112

113+
test("resolves numeric ID when API response includes subdomain-style permalink", async () => {
114+
// @ts-expect-error - partial mock
115+
globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
116+
const req = new Request(input, init);
117+
const url = req.url;
118+
119+
if (url.includes("/issues/123456789/")) {
120+
return new Response(
121+
JSON.stringify({
122+
id: "123456789",
123+
shortId: "PROJECT-ABC",
124+
title: "Test Issue",
125+
status: "unresolved",
126+
platform: "javascript",
127+
type: "error",
128+
count: "10",
129+
userCount: 5,
130+
// Org slug embedded in subdomain-style permalink
131+
permalink: "https://my-org.sentry.io/issues/123456789/",
132+
}),
133+
{
134+
status: 200,
135+
headers: { "Content-Type": "application/json" },
136+
}
137+
);
138+
}
139+
140+
return new Response(JSON.stringify({ detail: "Not found" }), {
141+
status: 404,
142+
});
143+
};
144+
145+
// Org should be extracted from permalink — no longer throws
146+
const result = await resolveOrgAndIssueId({
147+
issueArg: "123456789",
148+
cwd: getConfigDir(),
149+
command: "explain",
150+
});
151+
expect(result.org).toBe("my-org");
152+
expect(result.issueId).toBe("123456789");
153+
});
154+
155+
test("resolves numeric ID when API response includes path-style permalink", async () => {
156+
// @ts-expect-error - partial mock
157+
globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
158+
const req = new Request(input, init);
159+
const url = req.url;
160+
161+
if (url.includes("/issues/55555555/")) {
162+
return new Response(
163+
JSON.stringify({
164+
id: "55555555",
165+
shortId: "BACKEND-XY",
166+
title: "Another Issue",
167+
status: "unresolved",
168+
platform: "python",
169+
type: "error",
170+
count: "1",
171+
userCount: 1,
172+
// Path-style permalink (sentry.io/organizations/{org}/issues/{id}/)
173+
permalink:
174+
"https://sentry.io/organizations/acme-corp/issues/55555555/",
175+
}),
176+
{
177+
status: 200,
178+
headers: { "Content-Type": "application/json" },
179+
}
180+
);
181+
}
182+
183+
return new Response(JSON.stringify({ detail: "Not found" }), {
184+
status: 404,
185+
});
186+
};
187+
188+
const result = await resolveOrgAndIssueId({
189+
issueArg: "55555555",
190+
cwd: getConfigDir(),
191+
command: "explain",
192+
});
193+
expect(result.org).toBe("acme-corp");
194+
expect(result.issueId).toBe("55555555");
195+
});
196+
113197
test("resolves explicit org prefix (org/ISSUE-ID)", async () => {
114198
// @ts-expect-error - partial mock
115199
globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
@@ -1271,7 +1355,9 @@ describe("ensureRootCauseAnalysis", () => {
12711355
});
12721356

12731357
describe("resolveIssue: numeric 404 error handling", () => {
1274-
const getResolveIssueConfigDir = useTestConfigDir("test-resolve-issue-");
1358+
const getResolveIssueConfigDir = useTestConfigDir("test-resolve-issue-", {
1359+
isolateProjectRoot: true,
1360+
});
12751361

12761362
let savedFetch: typeof globalThis.fetch;
12771363

0 commit comments

Comments
 (0)