Skip to content

Commit cb1f7c9

Browse files
committed
fix(issue): support share issue URLs (#CLI-T4)
Resolve Sentry share URLs (e.g., https://{org}.sentry.io/share/issue/{id}/) by threading support through the URL parsing, argument parsing, and issue resolution pipeline. Two-step resolution: call the public share API endpoint to get the numeric group ID, then fetch full details via the authenticated API. Changes: - Add shareId field and matchSharePath() to sentry-url-parser.ts - Add "share" variant to ParsedIssueArg in arg-parsing.ts - Add getSharedIssue() API function (public endpoint, no auth) - Add resolveShareIssue() and "share" case in issue resolution - Make org optional on ParsedSentryUrl (share URLs may lack org) - Guard org access in event/view.ts for type safety
1 parent 8245dec commit cb1f7c9

9 files changed

Lines changed: 427 additions & 12 deletions

File tree

src/commands/event/view.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -220,13 +220,13 @@ export function parsePositionalArgs(args: string[]): ParsedPositionalArgs {
220220
const urlParsed = parseSentryUrl(first);
221221
if (urlParsed) {
222222
applySentryUrlContext(urlParsed.baseUrl);
223-
if (urlParsed.eventId) {
223+
if (urlParsed.eventId && urlParsed.org) {
224224
// Event URL: pass org as OrgAll target ("{org}/").
225225
// Event URLs don't contain a project slug, so viewCommand falls
226226
// back to auto-detect for the project while keeping the org context.
227227
return { eventId: urlParsed.eventId, targetArg: `${urlParsed.org}/` };
228228
}
229-
if (urlParsed.issueId) {
229+
if (urlParsed.issueId && urlParsed.org) {
230230
// Issue URL without event ID — fetch the latest event for this issue.
231231
// The caller uses issueId to fetch via getLatestEvent.
232232
return {

src/commands/issue/utils.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
getIssue,
1212
getIssueByShortId,
1313
getIssueInOrg,
14+
getSharedIssue,
1415
ISSUE_DETAIL_COLLAPSE,
1516
type IssueSort,
1617
listIssuesPaginated,
@@ -79,6 +80,10 @@ export function buildCommandHint(
7980
issueId: string,
8081
base = "sentry issue"
8182
): string {
83+
// URLs are self-contained — no enrichment needed
84+
if (issueId.startsWith("http://") || issueId.startsWith("https://")) {
85+
return `${base} ${command} ${issueId}`;
86+
}
8287
// Selectors already include the @ prefix and are self-contained
8388
if (issueId.startsWith("@")) {
8489
return `${base} ${command} <org>/${issueId}`;
@@ -456,6 +461,51 @@ async function resolveSelector(
456461
return { org: orgSlug, issue };
457462
}
458463

464+
/**
465+
* Resolve a share URL to a full issue via two-step lookup:
466+
* 1. Call public share API to get numeric group ID
467+
* 2. Fetch full issue details via authenticated API
468+
*
469+
* When the share URL includes org context (from subdomain), uses org-scoped
470+
* endpoint for proper region routing. Otherwise falls back to the unscoped
471+
* endpoint and extracts org from the response permalink.
472+
*
473+
* @param shareId - The share ID from the URL
474+
* @param org - Optional organization slug (from share URL subdomain)
475+
* @param baseUrl - The Sentry instance base URL
476+
* @param cwd - Current working directory for context resolution
477+
*/
478+
async function resolveShareIssue(
479+
shareId: string,
480+
org: string | undefined,
481+
baseUrl: string,
482+
cwd: string
483+
): Promise<ResolvedIssueResult> {
484+
const shared = await getSharedIssue(baseUrl, shareId);
485+
const groupId = shared.groupID;
486+
487+
// Fetch full issue via authenticated API
488+
if (org) {
489+
const resolvedOrg = await resolveEffectiveOrg(org);
490+
const issue = await getIssueInOrg(resolvedOrg, groupId, {
491+
collapse: ISSUE_DETAIL_COLLAPSE,
492+
});
493+
return { org: resolvedOrg, issue };
494+
}
495+
496+
// No org from URL — try env/DSN context, then fall back to unscoped fetch
497+
const resolvedOrg = await resolveOrg({ cwd });
498+
const issue = resolvedOrg
499+
? await getIssueInOrg(resolvedOrg.org, groupId, {
500+
collapse: ISSUE_DETAIL_COLLAPSE,
501+
})
502+
: await getIssue(groupId, { collapse: ISSUE_DETAIL_COLLAPSE });
503+
return {
504+
org: resolvedOrg?.org ?? extractOrgFromPermalink(issue.permalink),
505+
issue,
506+
};
507+
}
508+
459509
/**
460510
* Options for resolving an issue ID.
461511
*/
@@ -631,6 +681,16 @@ export async function resolveIssue(
631681
);
632682
break;
633683

684+
case "share":
685+
// Share URL — resolve via public share API, then authenticated fetch
686+
result = await resolveShareIssue(
687+
parsed.shareId,
688+
parsed.org,
689+
parsed.baseUrl,
690+
cwd
691+
);
692+
break;
693+
634694
default: {
635695
// Exhaustive check - this should never be reached
636696
const _exhaustive: never = parsed;

src/lib/api-client.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ export {
5050
getIssue,
5151
getIssueByShortId,
5252
getIssueInOrg,
53+
getSharedIssue,
5354
ISSUE_DETAIL_COLLAPSE,
5455
type IssueCollapseField,
5556
type IssueSort,

src/lib/api/issues.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -407,3 +407,45 @@ export function updateIssueStatus(
407407
body: { status },
408408
});
409409
}
410+
411+
/**
412+
* Resolve a share ID to basic issue data via the public share endpoint.
413+
*
414+
* This endpoint does not require authentication and is not org-scoped.
415+
* The response includes the numeric `groupID` needed to fetch full issue
416+
* details via the authenticated API.
417+
*
418+
* @param baseUrl - The Sentry instance base URL (from the share URL)
419+
* @param shareId - The share ID extracted from the share URL
420+
* @returns Object containing the numeric groupID
421+
* @throws {ApiError} When the share link is expired, disabled, or invalid
422+
*/
423+
export async function getSharedIssue(
424+
baseUrl: string,
425+
shareId: string
426+
): Promise<{ groupID: string }> {
427+
const url = `${baseUrl}/api/0/shared/issues/${encodeURIComponent(shareId)}/`;
428+
const response = await fetch(url, {
429+
headers: { "Content-Type": "application/json" },
430+
});
431+
432+
if (!response.ok) {
433+
if (response.status === 404) {
434+
throw new ApiError(
435+
"Share link not found or expired",
436+
404,
437+
"The share link may have been disabled by the issue owner.\n" +
438+
" Ask them to re-enable sharing, or use the issue ID directly.",
439+
`shared/issues/${shareId}`
440+
);
441+
}
442+
throw new ApiError(
443+
"Failed to resolve share link",
444+
response.status,
445+
undefined,
446+
`shared/issues/${shareId}`
447+
);
448+
}
449+
450+
return (await response.json()) as { groupID: string };
451+
}

src/lib/arg-parsing.ts

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -394,8 +394,12 @@ export type ParsedOrgProject =
394394
/**
395395
* Map a parsed Sentry URL to a ParsedOrgProject.
396396
* If the URL contains a project slug, returns explicit; otherwise org-all.
397+
* Share URLs without org context fall back to auto-detect.
397398
*/
398399
function orgProjectFromUrl(parsed: ParsedSentryUrl): ParsedOrgProject {
400+
if (!parsed.org) {
401+
return { type: "auto-detect" };
402+
}
399403
if (parsed.project) {
400404
return { type: "explicit", org: parsed.org, project: parsed.project };
401405
}
@@ -404,19 +408,35 @@ function orgProjectFromUrl(parsed: ParsedSentryUrl): ParsedOrgProject {
404408

405409
/**
406410
* Map a parsed Sentry URL to a ParsedIssueArg.
407-
* Handles numeric group IDs and short IDs (e.g., "CLI-G") from the URL path.
411+
* Handles share URLs, numeric group IDs, and short IDs (e.g., "CLI-G") from the URL path.
408412
*/
409413
function issueArgFromUrl(parsed: ParsedSentryUrl): ParsedIssueArg | null {
414+
// Share URL → resolve via public share API
415+
if (parsed.shareId) {
416+
return {
417+
type: "share",
418+
shareId: parsed.shareId,
419+
org: parsed.org,
420+
baseUrl: parsed.baseUrl,
421+
};
422+
}
423+
410424
const { issueId } = parsed;
411425
if (!issueId) {
412426
return null;
413427
}
414428

429+
// Non-share URLs always have org from their matchers; guard narrows the type
430+
const { org } = parsed;
431+
if (!org) {
432+
return null;
433+
}
434+
415435
// Numeric group ID (e.g., /issues/32886/)
416436
if (isAllDigits(issueId)) {
417437
return {
418438
type: "explicit-org-numeric",
419-
org: parsed.org,
439+
org,
420440
numericId: issueId,
421441
};
422442
}
@@ -430,7 +450,7 @@ function issueArgFromUrl(parsed: ParsedSentryUrl): ParsedIssueArg | null {
430450
// Lowercase project slug — Sentry slugs are always lowercase.
431451
return {
432452
type: "explicit",
433-
org: parsed.org,
453+
org,
434454
project: project.toLowerCase(),
435455
suffix,
436456
};
@@ -440,7 +460,7 @@ function issueArgFromUrl(parsed: ParsedSentryUrl): ParsedIssueArg | null {
440460
// No dash — treat as suffix-only with org context
441461
return {
442462
type: "explicit-org-suffix",
443-
org: parsed.org,
463+
org,
444464
suffix: issueId.toUpperCase(),
445465
};
446466
}
@@ -638,7 +658,8 @@ export type ParsedIssueArg =
638658
| { type: "explicit-org-numeric"; org: string; numericId: string }
639659
| { type: "project-search"; projectSlug: string; suffix: string }
640660
| { type: "suffix-only"; suffix: string }
641-
| { type: "selector"; selector: IssueSelector; org?: string };
661+
| { type: "selector"; selector: IssueSelector; org?: string }
662+
| { type: "share"; shareId: string; org?: string; baseUrl: string };
642663

643664
/**
644665
* Parse a CLI issue argument into its component parts.

src/lib/sentry-url-parser.ts

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,16 @@ import { isSentrySaasUrl } from "./sentry-urls.js";
1515
/**
1616
* Components extracted from a Sentry web URL.
1717
*
18-
* All fields except `baseUrl` and `org` are optional — presence depends
19-
* on which URL pattern was matched.
18+
* `baseUrl` is always present. `org` is present for most URL patterns but
19+
* absent for share URLs on bare domains (e.g., `sentry.io/share/issue/...`).
20+
* All other fields are optional — presence depends on which URL pattern
21+
* was matched.
2022
*/
2123
export type ParsedSentryUrl = {
2224
/** Scheme + host of the Sentry instance (e.g., "https://sentry.io" or "https://sentry.example.com") */
2325
baseUrl: string;
24-
/** Organization slug from the URL path or subdomain */
25-
org: string;
26+
/** Organization slug from the URL path or subdomain (absent for share URLs without org context) */
27+
org?: string;
2628
/** Issue identifier — numeric group ID (e.g., "32886") or short ID (e.g., "CLI-G") */
2729
issueId?: string;
2830
/** Event ID from /issues/{id}/events/{eventId}/ paths */
@@ -31,6 +33,8 @@ export type ParsedSentryUrl = {
3133
project?: string;
3234
/** Trace ID from /organizations/{org}/traces/{traceId}/ paths */
3335
traceId?: string;
36+
/** Share ID from /share/issue/{shareId}/ paths (32-char hex string) */
37+
shareId?: string;
3438
};
3539

3640
/**
@@ -129,6 +133,11 @@ function matchSubdomainOrg(
129133
return { baseUrl, org, project: segments[2] };
130134
}
131135

136+
// /share/issue/{shareId}/ — share URL with org from subdomain
137+
if (segments[0] === "share" && segments[1] === "issue" && segments[2]) {
138+
return { baseUrl, org, shareId: segments[2] };
139+
}
140+
132141
// Bare org subdomain URL
133142
if (segments.length === 0) {
134143
return { baseUrl, org };
@@ -137,6 +146,25 @@ function matchSubdomainOrg(
137146
return null;
138147
}
139148

149+
/**
150+
* Try to match /share/issue/{shareId}/ path pattern.
151+
*
152+
* Catches share URLs on non-subdomain hosts (bare `sentry.io`, self-hosted).
153+
* Subdomain share URLs (e.g., `gibush-kq.sentry.io/share/issue/...`) are
154+
* handled by {@link matchSubdomainOrg} which extracts the org from the subdomain.
155+
*
156+
* @returns Parsed result or null if pattern doesn't match
157+
*/
158+
function matchSharePath(
159+
baseUrl: string,
160+
segments: string[]
161+
): ParsedSentryUrl | null {
162+
if (segments[0] !== "share" || segments[1] !== "issue" || !segments[2]) {
163+
return null;
164+
}
165+
return { baseUrl, shareId: segments[2] };
166+
}
167+
140168
/**
141169
* Parse a Sentry web URL and extract its components.
142170
*
@@ -146,11 +174,13 @@ function matchSubdomainOrg(
146174
* - `/settings/{org}/projects/{project}/`
147175
* - `/organizations/{org}/traces/{traceId}/`
148176
* - `/organizations/{org}/`
177+
* - `/share/issue/{shareId}/`
149178
*
150179
* Also recognizes SaaS subdomain-style URLs:
151180
* - `https://{org}.sentry.io/issues/{id}/`
152181
* - `https://{org}.sentry.io/traces/{traceId}/`
153182
* - `https://{org}.sentry.io/issues/{id}/events/{eventId}/`
183+
* - `https://{org}.sentry.io/share/issue/{shareId}/`
154184
*
155185
* @param input - Raw string that may or may not be a URL
156186
* @returns Parsed components, or null if input is not a recognized Sentry URL
@@ -174,7 +204,8 @@ export function parseSentryUrl(input: string): ParsedSentryUrl | null {
174204
return (
175205
matchOrganizationsPath(baseUrl, segments) ??
176206
matchSettingsPath(baseUrl, segments) ??
177-
matchSubdomainOrg(baseUrl, url.hostname, segments)
207+
matchSubdomainOrg(baseUrl, url.hostname, segments) ??
208+
matchSharePath(baseUrl, segments)
178209
);
179210
}
180211

0 commit comments

Comments
 (0)