Skip to content
Merged
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
81 changes: 25 additions & 56 deletions packages/cli/src/commands/issue/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,11 @@ import {
triggerRootCauseAnalysis,
tryGetIssueByShortId,
} from "../../lib/api-client.js";
import { type IssueSelector, parseIssueArg } from "../../lib/arg-parsing.js";
import {
type IssueSelector,
type ParsedIssueArg,
parseIssueArg,
} from "../../lib/arg-parsing.js";
import {
clearCachedIssueOrg,
getCachedIssueOrg,
Expand Down Expand Up @@ -513,61 +517,31 @@ async function resolveSelector(
* 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.
* Both requests require organization context, taken from the share URL
* or the usual defaults and DSN resolution.
*
* @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 share - Share URL components, including optional organization context
* @param cwd - Current working directory for context resolution
* @param commandHint - Recovery command when organization context is missing
*/
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 orgScopedIssue = await getIssueInOrg(resolvedOrg, groupId, {
collapse: ISSUE_DETAIL_COLLAPSE,
});
return { org: resolvedOrg, issue: orgScopedIssue };
share: Extract<ParsedIssueArg, { type: "share" }>,
cwd: string,
commandHint: string
): Promise<StrictResolvedIssue> {
const { shareId, org, baseUrl } = share;
const resolvedOrg = org
? await resolveEffectiveOrg(org)
: (await resolveOrg({ cwd }))?.org;
if (!resolvedOrg) {
throw new ContextError("Organization", commandHint);
}

// No org from URL — try env/DSN context, then the issue-id → org cache,
// then fall back to the unscoped fetch. See resolveNumericIssue for the
// full rationale behind the cache.
const resolvedOrg = await resolveOrg({ cwd });
const cachedOrg = resolvedOrg ? null : getCachedIssueOrg(groupId);
const { issue, cacheEvicted } = await fetchIssueByNumericId(
groupId,
resolvedOrg?.org,
cachedOrg
);
// When `cacheEvicted` is true, the cached org was stale (404'd) — do NOT
// let it win the `??` chain; re-derive from the permalink instead.
const effectiveCachedOrg = cacheEvicted ? null : cachedOrg;
const resolvedOrgSlug =
resolvedOrg?.org ??
effectiveCachedOrg ??
extractOrgFromPermalink(issue.permalink);
if (resolvedOrgSlug && !resolvedOrg && !effectiveCachedOrg) {
// Best-effort — a broken/read-only DB must not fail a successful lookup.
try {
setCachedIssueOrg(groupId, resolvedOrgSlug);
} catch (cacheErr) {
log.debug(
`Failed to cache issue-org mapping for ${groupId}: ${String(cacheErr)}`
);
}
}
return { org: resolvedOrgSlug, issue };
const shared = await getSharedIssue(baseUrl, resolvedOrg, shareId);
const issue = await getIssueInOrg(resolvedOrg, shared.id, {
collapse: ISSUE_DETAIL_COLLAPSE,
});
return { org: resolvedOrg, issue };
}

/**
Expand Down Expand Up @@ -844,12 +818,7 @@ export async function resolveIssue(

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

default: {
Expand Down
51 changes: 42 additions & 9 deletions packages/cli/src/lib/api/issues.ts
Original file line number Diff line number Diff line change
Expand Up @@ -692,20 +692,23 @@ export async function mergeIssues(
/**
* 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
* This org-scoped endpoint does not require authentication.
* The response includes the numeric issue `id` needed to fetch full issue
* details via the authenticated API.
*
* @param baseUrl - The Sentry instance base URL (from the share URL)
* @param orgSlug - The organization that owns the shared issue
* @param shareId - The share ID extracted from the share URL
* @returns Object containing the numeric groupID
* @returns Object containing the numeric issue ID
* @throws {ApiError} When the share link is expired, disabled, or invalid
*/
export async function getSharedIssue(
baseUrl: string,
orgSlug: string,
shareId: string
): Promise<{ groupID: string }> {
const url = `${baseUrl}/api/0/shared/issues/${encodeURIComponent(shareId)}/`;
): Promise<{ id: string }> {
const path = `organizations/${encodeURIComponent(orgSlug)}/shared/issues/${encodeURIComponent(shareId)}/`;
const url = `${baseUrl.replace(TRAILING_SLASH_RE, "")}/api/0/${path}`;
const headers = new Headers({ "Content-Type": "application/json" });
// URL-scoped: headers only attach when `url`'s origin matches the trusted
// host, so IAP tokens etc. can't leak to an attacker-controlled share URL.
Expand All @@ -721,7 +724,7 @@ export async function getSharedIssue(
"TLS certificate error",
0,
buildTlsErrorDetail(error),
`shared/issues/${shareId}`
path
);
}
throw error;
Expand All @@ -734,16 +737,46 @@ export async function getSharedIssue(
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}`
path
);
}
throw new ApiError(
"Failed to resolve share link",
response.status,
undefined,
`shared/issues/${shareId}`
path
);
}

let data: unknown;
try {
data = await response.json();
} catch (error) {
if (!(error instanceof SyntaxError)) {
throw error;
}
throw new ApiError(
"Share link returned invalid JSON",
response.status,
undefined,
path
);
}

if (
typeof data !== "object" ||
data === null ||
Comment thread
betegon marked this conversation as resolved.
!("id" in data) ||
typeof data.id !== "string" ||
data.id.length === 0
) {
throw new ApiError(
"Share link response missing a valid issue ID",
response.status,
undefined,
path
);
}

return (await response.json()) as { groupID: string };
return { id: data.id };
}
5 changes: 5 additions & 0 deletions packages/cli/src/lib/sentry-url-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,10 @@ function matchOrganizationsPath(
return { baseUrl, org, issueId: segments[3], eventId };
}

if (segments[2] === "share" && segments[3] === "issue" && segments[4]) {
return { baseUrl, org, shareId: segments[4] };
}

const tracePath = matchTracePath(segments, 2);
if (tracePath.status === "detail") {
return { baseUrl, org, traceId: tracePath.traceId };
Expand Down Expand Up @@ -344,6 +348,7 @@ function matchSharePath(
* Recognizes these path patterns (both SaaS and self-hosted):
* - `/organizations/{org}/issues/{id}/`
* - `/organizations/{org}/issues/{id}/events/{eventId}/`
* - `/organizations/{org}/share/issue/{shareId}/`
* - `/settings/{org}/projects/{project}/`
* - `/organizations/{org}/explore/traces/trace/{traceId}/` (canonical)
* - `/organizations/{org}/traces/{traceId}/` (legacy)
Expand Down
Loading
Loading