Skip to content

Commit 681c715

Browse files
betegonclaude
andcommitted
perf(issue): skip getProject round-trip in project-search resolution
resolveProjectSearch() was calling findProjectsBySlug() (which does getProject per org) then getIssueByShortId() sequentially. The shortid endpoint already validates project existence, so the getProject call is redundant. Now tries tryGetIssueByShortId() directly across all orgs in parallel, eliminating one HTTP round-trip (~500-800ms). Falls back to findProjectsBySlug() only for error diagnostics. Also: - Surface real API/network errors when every org fails instead of masking them with "not found" - Only throw when ALL orgs returned real errors (not when some returned clean 404s) - Retry getIssueByShortId when fallback finds the project (handles transient failures) - Extract resolveProjectSearchFallback() to stay under complexity limit Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 77603fc commit 681c715

File tree

3 files changed

+130
-23
lines changed

3 files changed

+130
-23
lines changed

src/commands/issue/utils.ts

Lines changed: 104 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,20 @@ import {
1212
getIssueInOrg,
1313
type IssueSort,
1414
listIssuesPaginated,
15+
listOrganizations,
1516
triggerRootCauseAnalysis,
17+
tryGetIssueByShortId,
1618
} from "../../lib/api-client.js";
1719
import { type IssueSelector, parseIssueArg } from "../../lib/arg-parsing.js";
1820
import { getProjectByAlias } from "../../lib/db/project-aliases.js";
1921
import { detectAllDsns } from "../../lib/dsn/index.js";
20-
import { ApiError, ContextError, ResolutionError } from "../../lib/errors.js";
22+
import {
23+
ApiError,
24+
type AuthGuardFailure,
25+
ContextError,
26+
ResolutionError,
27+
withAuthGuard,
28+
} from "../../lib/errors.js";
2129
import { getProgressMessage } from "../../lib/formatters/seer.js";
2230
import { expandToFullShortId, isShortSuffix } from "../../lib/issue-id.js";
2331
import { logger } from "../../lib/logger.js";
@@ -124,12 +132,66 @@ async function tryResolveFromAlias(
124132
return { org: projectEntry.orgSlug, issue };
125133
}
126134

135+
/**
136+
* Fallback for when the fast shortid fan-out found no matches.
137+
* Uses findProjectsBySlug to give a precise error ("project not found" vs
138+
* "issue not found") and retries the issue lookup on transient failures.
139+
*/
140+
async function resolveProjectSearchFallback(
141+
projectSlug: string,
142+
suffix: string,
143+
commandHint: string
144+
): Promise<StrictResolvedIssue> {
145+
const { projects } = await findProjectsBySlug(projectSlug.toLowerCase());
146+
147+
if (projects.length === 0) {
148+
throw new ResolutionError(
149+
`Project '${projectSlug}'`,
150+
"not found",
151+
commandHint,
152+
["No project with this slug found in any accessible organization"]
153+
);
154+
}
155+
156+
if (projects.length > 1) {
157+
const orgList = projects.map((p) => p.orgSlug).join(", ");
158+
throw new ResolutionError(
159+
`Project '${projectSlug}'`,
160+
"is ambiguous",
161+
commandHint,
162+
[
163+
`Found in: ${orgList}`,
164+
`Specify the org: sentry issue ... <org>/${projectSlug}-${suffix}`,
165+
]
166+
);
167+
}
168+
169+
// Project exists — retry the issue lookup. The fast path may have failed
170+
// due to a transient error (5xx, timeout); retrying here either succeeds
171+
// or propagates the real error to the user.
172+
const matchedProject = projects[0];
173+
const matchedOrg = matchedProject?.orgSlug;
174+
if (matchedOrg && matchedProject) {
175+
const retryShortId = expandToFullShortId(suffix, matchedProject.slug);
176+
const issue = await getIssueByShortId(matchedOrg, retryShortId);
177+
return { org: matchedOrg, issue };
178+
}
179+
180+
throw new ResolutionError(
181+
`Project '${projectSlug}'`,
182+
"not found",
183+
commandHint
184+
);
185+
}
186+
127187
/**
128188
* Resolve project-search type: search for project across orgs, then fetch issue.
129189
*
130190
* Resolution order:
131191
* 1. Try alias cache (fast, local)
132-
* 2. Search for project across orgs via API
192+
* 2. Check DSN detection cache
193+
* 3. Try shortid endpoint directly across all orgs (fast path)
194+
* 4. Fall back to findProjectsBySlug for precise error messages
133195
*
134196
* @param projectSlug - Project slug to search for
135197
* @param suffix - Issue suffix (uppercase)
@@ -173,20 +235,33 @@ async function resolveProjectSearch(
173235
return { org: dsnTarget.org, issue };
174236
}
175237

176-
// 3. Search for project across all accessible orgs
177-
const { projects } = await findProjectsBySlug(projectSlug.toLowerCase());
238+
// 3. Fast path: try resolving the short ID directly across all orgs.
239+
// The shortid endpoint validates both project existence and issue existence
240+
// in a single call, eliminating the separate getProject() round-trip.
241+
const fullShortId = expandToFullShortId(suffix, projectSlug);
242+
const orgs = await listOrganizations();
178243

179-
if (projects.length === 0) {
180-
throw new ResolutionError(
181-
`Project '${projectSlug}'`,
182-
"not found",
183-
commandHint,
184-
["No project with this slug found in any accessible organization"]
185-
);
244+
const results = await Promise.all(
245+
orgs.map((org) =>
246+
withAuthGuard(() => tryGetIssueByShortId(org.slug, fullShortId))
247+
)
248+
);
249+
250+
const successes: StrictResolvedIssue[] = [];
251+
for (let i = 0; i < results.length; i++) {
252+
const result = results[i];
253+
const org = orgs[i];
254+
if (result && org && result.ok && result.value) {
255+
successes.push({ org: org.slug, issue: result.value });
256+
}
186257
}
187258

188-
if (projects.length > 1) {
189-
const orgList = projects.map((p) => p.orgSlug).join(", ");
259+
if (successes.length === 1 && successes[0]) {
260+
return successes[0];
261+
}
262+
263+
if (successes.length > 1) {
264+
const orgList = successes.map((s) => s.org).join(", ");
190265
throw new ResolutionError(
191266
`Project '${projectSlug}'`,
192267
"is ambiguous",
@@ -198,18 +273,24 @@ async function resolveProjectSearch(
198273
);
199274
}
200275

201-
const project = projects[0];
202-
if (!project) {
203-
throw new ResolutionError(
204-
`Project '${projectSlug}'`,
205-
"not found",
206-
commandHint
207-
);
276+
// If every org failed with a real error (403, 5xx, network timeout),
277+
// surface it instead of falling through to a misleading "not found".
278+
// Only throw when ALL results are errors — if some orgs returned clean
279+
// 404s ({ok: true, value: null}), fall through to the fallback for a
280+
// precise error message.
281+
const realErrors = results.filter(
282+
(r): r is AuthGuardFailure => r !== undefined && !r.ok
283+
);
284+
if (realErrors.length === results.length && realErrors.length > 0) {
285+
const firstError = realErrors[0]?.error;
286+
if (firstError instanceof Error) {
287+
throw firstError;
288+
}
208289
}
209290

210-
const fullShortId = expandToFullShortId(suffix, project.slug);
211-
const issue = await getIssueByShortId(project.orgSlug, fullShortId);
212-
return { org: project.orgSlug, issue };
291+
// 4. Fall back to findProjectsBySlug for precise error messages
292+
// and retry the issue lookup (handles transient failures).
293+
return resolveProjectSearchFallback(projectSlug, suffix, commandHint);
213294
}
214295

215296
/**

src/lib/api-client.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ export {
4949
type IssuesPage,
5050
listIssuesAllPages,
5151
listIssuesPaginated,
52+
tryGetIssueByShortId,
5253
updateIssueStatus,
5354
} from "./api/issues.js";
5455
export {

src/lib/api/issues.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,31 @@ export async function getIssueByShortId(
251251
return resolved.group;
252252
}
253253

254+
/**
255+
* Try to get an issue by short ID, returning null on 404.
256+
*
257+
* Same as {@link getIssueByShortId} but returns null instead of throwing
258+
* when the short ID is not found. Useful for parallel fan-out across orgs
259+
* where most will 404.
260+
*
261+
* @param orgSlug - Organization slug
262+
* @param shortId - Full short ID (e.g., "CONSUMER-MOBILE-1QNEK")
263+
* @returns The resolved issue, or null if not found in this org
264+
*/
265+
export async function tryGetIssueByShortId(
266+
orgSlug: string,
267+
shortId: string
268+
): Promise<SentryIssue | null> {
269+
try {
270+
return await getIssueByShortId(orgSlug, shortId);
271+
} catch (error) {
272+
if (error instanceof ApiError && error.status === 404) {
273+
return null;
274+
}
275+
throw error;
276+
}
277+
}
278+
254279
/**
255280
* Update an issue's status.
256281
*/

0 commit comments

Comments
 (0)