Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/commands/event/view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,13 +220,13 @@ export function parsePositionalArgs(args: string[]): ParsedPositionalArgs {
const urlParsed = parseSentryUrl(first);
if (urlParsed) {
applySentryUrlContext(urlParsed.baseUrl);
if (urlParsed.eventId) {
if (urlParsed.eventId && urlParsed.org) {
// Event URL: pass org as OrgAll target ("{org}/").
// Event URLs don't contain a project slug, so viewCommand falls
// back to auto-detect for the project while keeping the org context.
return { eventId: urlParsed.eventId, targetArg: `${urlParsed.org}/` };
}
if (urlParsed.issueId) {
if (urlParsed.issueId && urlParsed.org) {
// Issue URL without event ID — fetch the latest event for this issue.
// The caller uses issueId to fetch via getLatestEvent.
return {
Expand Down
60 changes: 60 additions & 0 deletions src/commands/issue/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
getIssue,
getIssueByShortId,
getIssueInOrg,
getSharedIssue,
ISSUE_DETAIL_COLLAPSE,
type IssueSort,
listIssuesPaginated,
Expand Down Expand Up @@ -79,6 +80,10 @@ export function buildCommandHint(
issueId: string,
base = "sentry issue"
): string {
// URLs are self-contained — no enrichment needed
if (issueId.startsWith("http://") || issueId.startsWith("https://")) {
return `${base} ${command} ${issueId}`;
}
// Selectors already include the @ prefix and are self-contained
if (issueId.startsWith("@")) {
return `${base} ${command} <org>/${issueId}`;
Expand Down Expand Up @@ -456,6 +461,51 @@ async function resolveSelector(
return { org: orgSlug, issue };
}

/**
* Resolve a share URL to a full issue via two-step lookup:
* 1. Call public share API to get numeric group ID
* 2. Fetch full issue details via authenticated API
*
* When the share URL includes org context (from subdomain), uses org-scoped
* endpoint for proper region routing. Otherwise falls back to the unscoped
* endpoint and extracts org from the response permalink.
*
* @param shareId - The share ID from the URL
* @param org - Optional organization slug (from share URL subdomain)
* @param baseUrl - The Sentry instance base URL
* @param cwd - Current working directory for context resolution
*/
async function resolveShareIssue(
shareId: string,
org: string | undefined,
baseUrl: string,
cwd: string
): Promise<ResolvedIssueResult> {
const shared = await getSharedIssue(baseUrl, shareId);
const groupId = shared.groupID;

// Fetch full issue via authenticated API
if (org) {
const resolvedOrg = await resolveEffectiveOrg(org);
const issue = await getIssueInOrg(resolvedOrg, groupId, {
collapse: ISSUE_DETAIL_COLLAPSE,
});
return { org: resolvedOrg, issue };
}

// No org from URL — try env/DSN context, then fall back to unscoped fetch
const resolvedOrg = await resolveOrg({ cwd });
const issue = resolvedOrg
? await getIssueInOrg(resolvedOrg.org, groupId, {
collapse: ISSUE_DETAIL_COLLAPSE,
})
: await getIssue(groupId, { collapse: ISSUE_DETAIL_COLLAPSE });
return {
org: resolvedOrg?.org ?? extractOrgFromPermalink(issue.permalink),
issue,
};
}

/**
* Options for resolving an issue ID.
*/
Expand Down Expand Up @@ -631,6 +681,16 @@ export async function resolveIssue(
);
break;

case "share":
// Share URL — resolve via public share API, then authenticated fetch
result = await resolveShareIssue(
parsed.shareId,
parsed.org,
parsed.baseUrl,
cwd
);
break;

default: {
// Exhaustive check - this should never be reached
const _exhaustive: never = parsed;
Expand Down
1 change: 1 addition & 0 deletions src/lib/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export {
getIssue,
getIssueByShortId,
getIssueInOrg,
getSharedIssue,
ISSUE_DETAIL_COLLAPSE,
type IssueCollapseField,
type IssueSort,
Expand Down
42 changes: 42 additions & 0 deletions src/lib/api/issues.ts
Original file line number Diff line number Diff line change
Expand Up @@ -407,3 +407,45 @@ export function updateIssueStatus(
body: { status },
});
}

/**
* Resolve a share ID to basic issue data via the public share endpoint.
*
* This endpoint does not require authentication and is not org-scoped.
* The response includes the numeric `groupID` needed to fetch full issue
* details via the authenticated API.
*
* @param baseUrl - The Sentry instance base URL (from the share URL)
* @param shareId - The share ID extracted from the share URL
* @returns Object containing the numeric groupID
* @throws {ApiError} When the share link is expired, disabled, or invalid
*/
export async function getSharedIssue(
baseUrl: string,
shareId: string
): Promise<{ groupID: string }> {
const url = `${baseUrl}/api/0/shared/issues/${encodeURIComponent(shareId)}/`;
const response = await fetch(url, {
headers: { "Content-Type": "application/json" },
});

if (!response.ok) {
if (response.status === 404) {
throw new ApiError(
"Share link not found or expired",
404,
"The share link may have been disabled by the issue owner.\n" +
" Ask them to re-enable sharing, or use the issue ID directly.",
`shared/issues/${shareId}`
);
}
throw new ApiError(
"Failed to resolve share link",
response.status,
undefined,
`shared/issues/${shareId}`
);
}

return (await response.json()) as { groupID: string };
}
31 changes: 26 additions & 5 deletions src/lib/arg-parsing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -394,8 +394,12 @@ export type ParsedOrgProject =
/**
* Map a parsed Sentry URL to a ParsedOrgProject.
* If the URL contains a project slug, returns explicit; otherwise org-all.
* Share URLs without org context fall back to auto-detect.
*/
function orgProjectFromUrl(parsed: ParsedSentryUrl): ParsedOrgProject {
if (!parsed.org) {
return { type: "auto-detect" };
}
if (parsed.project) {
return { type: "explicit", org: parsed.org, project: parsed.project };
}
Expand All @@ -404,19 +408,35 @@ function orgProjectFromUrl(parsed: ParsedSentryUrl): ParsedOrgProject {

/**
* Map a parsed Sentry URL to a ParsedIssueArg.
* Handles numeric group IDs and short IDs (e.g., "CLI-G") from the URL path.
* Handles share URLs, numeric group IDs, and short IDs (e.g., "CLI-G") from the URL path.
*/
function issueArgFromUrl(parsed: ParsedSentryUrl): ParsedIssueArg | null {
// Share URL → resolve via public share API
if (parsed.shareId) {
return {
type: "share",
shareId: parsed.shareId,
org: parsed.org,
baseUrl: parsed.baseUrl,
};
}

const { issueId } = parsed;
if (!issueId) {
return null;
}

// Non-share URLs always have org from their matchers; guard narrows the type
const { org } = parsed;
if (!org) {
return null;
}

// Numeric group ID (e.g., /issues/32886/)
if (isAllDigits(issueId)) {
return {
type: "explicit-org-numeric",
org: parsed.org,
org,
numericId: issueId,
};
}
Expand All @@ -430,7 +450,7 @@ function issueArgFromUrl(parsed: ParsedSentryUrl): ParsedIssueArg | null {
// Lowercase project slug — Sentry slugs are always lowercase.
return {
type: "explicit",
org: parsed.org,
org,
project: project.toLowerCase(),
suffix,
};
Expand All @@ -440,7 +460,7 @@ function issueArgFromUrl(parsed: ParsedSentryUrl): ParsedIssueArg | null {
// No dash — treat as suffix-only with org context
return {
type: "explicit-org-suffix",
org: parsed.org,
org,
suffix: issueId.toUpperCase(),
};
}
Expand Down Expand Up @@ -638,7 +658,8 @@ export type ParsedIssueArg =
| { type: "explicit-org-numeric"; org: string; numericId: string }
| { type: "project-search"; projectSlug: string; suffix: string }
| { type: "suffix-only"; suffix: string }
| { type: "selector"; selector: IssueSelector; org?: string };
| { type: "selector"; selector: IssueSelector; org?: string }
| { type: "share"; shareId: string; org?: string; baseUrl: string };

/**
* Parse a CLI issue argument into its component parts.
Expand Down
41 changes: 36 additions & 5 deletions src/lib/sentry-url-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,16 @@ import { isSentrySaasUrl } from "./sentry-urls.js";
/**
* Components extracted from a Sentry web URL.
*
* All fields except `baseUrl` and `org` are optional — presence depends
* on which URL pattern was matched.
* `baseUrl` is always present. `org` is present for most URL patterns but
* absent for share URLs on bare domains (e.g., `sentry.io/share/issue/...`).
* All other fields are optional — presence depends on which URL pattern
* was matched.
*/
export type ParsedSentryUrl = {
/** Scheme + host of the Sentry instance (e.g., "https://sentry.io" or "https://sentry.example.com") */
baseUrl: string;
/** Organization slug from the URL path or subdomain */
org: string;
/** Organization slug from the URL path or subdomain (absent for share URLs without org context) */
org?: string;
/** Issue identifier — numeric group ID (e.g., "32886") or short ID (e.g., "CLI-G") */
issueId?: string;
/** Event ID from /issues/{id}/events/{eventId}/ paths */
Expand All @@ -31,6 +33,8 @@ export type ParsedSentryUrl = {
project?: string;
/** Trace ID from /organizations/{org}/traces/{traceId}/ paths */
traceId?: string;
/** Share ID from /share/issue/{shareId}/ paths (32-char hex string) */
shareId?: string;
};

/**
Expand Down Expand Up @@ -129,6 +133,11 @@ function matchSubdomainOrg(
return { baseUrl, org, project: segments[2] };
}

// /share/issue/{shareId}/ — share URL with org from subdomain
if (segments[0] === "share" && segments[1] === "issue" && segments[2]) {
return { baseUrl, org, shareId: segments[2] };
}

// Bare org subdomain URL
if (segments.length === 0) {
return { baseUrl, org };
Expand All @@ -137,6 +146,25 @@ function matchSubdomainOrg(
return null;
}

/**
* Try to match /share/issue/{shareId}/ path pattern.
*
* Catches share URLs on non-subdomain hosts (bare `sentry.io`, self-hosted).
* Subdomain share URLs (e.g., `gibush-kq.sentry.io/share/issue/...`) are
* handled by {@link matchSubdomainOrg} which extracts the org from the subdomain.
*
* @returns Parsed result or null if pattern doesn't match
*/
function matchSharePath(
baseUrl: string,
segments: string[]
): ParsedSentryUrl | null {
if (segments[0] !== "share" || segments[1] !== "issue" || !segments[2]) {
return null;
}
return { baseUrl, shareId: segments[2] };
}

/**
* Parse a Sentry web URL and extract its components.
*
Expand All @@ -146,11 +174,13 @@ function matchSubdomainOrg(
* - `/settings/{org}/projects/{project}/`
* - `/organizations/{org}/traces/{traceId}/`
* - `/organizations/{org}/`
* - `/share/issue/{shareId}/`
*
* Also recognizes SaaS subdomain-style URLs:
* - `https://{org}.sentry.io/issues/{id}/`
* - `https://{org}.sentry.io/traces/{traceId}/`
* - `https://{org}.sentry.io/issues/{id}/events/{eventId}/`
* - `https://{org}.sentry.io/share/issue/{shareId}/`
*
* @param input - Raw string that may or may not be a URL
* @returns Parsed components, or null if input is not a recognized Sentry URL
Expand All @@ -174,7 +204,8 @@ export function parseSentryUrl(input: string): ParsedSentryUrl | null {
return (
matchOrganizationsPath(baseUrl, segments) ??
matchSettingsPath(baseUrl, segments) ??
matchSubdomainOrg(baseUrl, url.hostname, segments)
matchSubdomainOrg(baseUrl, url.hostname, segments) ??
matchSharePath(baseUrl, segments)
);
}

Expand Down
Loading
Loading