Skip to content

Commit e91ef21

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. Fixes https://sentry.sentry.io/issues/7340600304/ Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 77603fc commit e91ef21

File tree

3 files changed

+83
-14
lines changed

3 files changed

+83
-14
lines changed

src/commands/issue/utils.ts

Lines changed: 57 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,19 @@ 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+
ContextError,
25+
ResolutionError,
26+
withAuthGuard,
27+
} from "../../lib/errors.js";
2128
import { getProgressMessage } from "../../lib/formatters/seer.js";
2229
import { expandToFullShortId, isShortSuffix } from "../../lib/issue-id.js";
2330
import { logger } from "../../lib/logger.js";
@@ -173,7 +180,46 @@ async function resolveProjectSearch(
173180
return { org: dsnTarget.org, issue };
174181
}
175182

176-
// 3. Search for project across all accessible orgs
183+
// 3. Fast path: try resolving the short ID directly across all orgs.
184+
// The shortid endpoint validates both project existence and issue existence
185+
// in a single call, eliminating the separate getProject() round-trip.
186+
const fullShortId = expandToFullShortId(suffix, projectSlug);
187+
const orgs = await listOrganizations();
188+
189+
const results = await Promise.all(
190+
orgs.map((org) =>
191+
withAuthGuard(() => tryGetIssueByShortId(org.slug, fullShortId))
192+
)
193+
);
194+
195+
const successes: StrictResolvedIssue[] = [];
196+
for (let i = 0; i < results.length; i++) {
197+
const result = results[i];
198+
const org = orgs[i];
199+
if (result && org && result.ok && result.value) {
200+
successes.push({ org: org.slug, issue: result.value });
201+
}
202+
}
203+
204+
if (successes.length === 1 && successes[0]) {
205+
return successes[0];
206+
}
207+
208+
if (successes.length > 1) {
209+
const orgList = successes.map((s) => s.org).join(", ");
210+
throw new ResolutionError(
211+
`Project '${projectSlug}'`,
212+
"is ambiguous",
213+
commandHint,
214+
[
215+
`Found in: ${orgList}`,
216+
`Specify the org: sentry issue ... <org>/${projectSlug}-${suffix}`,
217+
]
218+
);
219+
}
220+
221+
// 4. All orgs returned 404 — fall back to findProjectsBySlug for a
222+
// precise error: "project not found" vs "issue not found in project".
177223
const { projects } = await findProjectsBySlug(projectSlug.toLowerCase());
178224

179225
if (projects.length === 0) {
@@ -198,18 +244,15 @@ async function resolveProjectSearch(
198244
);
199245
}
200246

201-
const project = projects[0];
202-
if (!project) {
203-
throw new ResolutionError(
204-
`Project '${projectSlug}'`,
205-
"not found",
206-
commandHint
207-
);
208-
}
209-
210-
const fullShortId = expandToFullShortId(suffix, project.slug);
211-
const issue = await getIssueByShortId(project.orgSlug, fullShortId);
212-
return { org: project.orgSlug, issue };
247+
// Project exists but issue doesn't
248+
throw new ResolutionError(
249+
`Issue '${fullShortId}'`,
250+
"not found",
251+
commandHint,
252+
[
253+
`Project '${projectSlug}' exists in org '${projects[0]?.orgSlug}', but no issue with short ID '${fullShortId}' was found.`,
254+
]
255+
);
213256
}
214257

215258
/**

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)