From cb1f7c9b6dd843ffc3c4d365b00c7d4a64b4c5db Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Sat, 11 Apr 2026 21:31:53 +0000 Subject: [PATCH] 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 --- src/commands/event/view.ts | 4 +- src/commands/issue/utils.ts | 60 ++++++++++++ src/lib/api-client.ts | 1 + src/lib/api/issues.ts | 42 ++++++++ src/lib/arg-parsing.ts | 31 +++++- src/lib/sentry-url-parser.ts | 41 +++++++- test/commands/issue/utils.test.ts | 149 +++++++++++++++++++++++++++++ test/lib/arg-parsing.test.ts | 39 ++++++++ test/lib/sentry-url-parser.test.ts | 72 ++++++++++++++ 9 files changed, 427 insertions(+), 12 deletions(-) diff --git a/src/commands/event/view.ts b/src/commands/event/view.ts index 2764f800d..c2b0886ef 100644 --- a/src/commands/event/view.ts +++ b/src/commands/event/view.ts @@ -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 { diff --git a/src/commands/issue/utils.ts b/src/commands/issue/utils.ts index 92a63a16b..74f006ec4 100644 --- a/src/commands/issue/utils.ts +++ b/src/commands/issue/utils.ts @@ -11,6 +11,7 @@ import { getIssue, getIssueByShortId, getIssueInOrg, + getSharedIssue, ISSUE_DETAIL_COLLAPSE, type IssueSort, listIssuesPaginated, @@ -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} /${issueId}`; @@ -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 { + 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. */ @@ -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; diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index 6a6287761..a4ca14ab2 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -50,6 +50,7 @@ export { getIssue, getIssueByShortId, getIssueInOrg, + getSharedIssue, ISSUE_DETAIL_COLLAPSE, type IssueCollapseField, type IssueSort, diff --git a/src/lib/api/issues.ts b/src/lib/api/issues.ts index 8b4d41356..67a7eda58 100644 --- a/src/lib/api/issues.ts +++ b/src/lib/api/issues.ts @@ -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 }; +} diff --git a/src/lib/arg-parsing.ts b/src/lib/arg-parsing.ts index f5da17a31..9c0d80cb1 100644 --- a/src/lib/arg-parsing.ts +++ b/src/lib/arg-parsing.ts @@ -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 }; } @@ -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, }; } @@ -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, }; @@ -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(), }; } @@ -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. diff --git a/src/lib/sentry-url-parser.ts b/src/lib/sentry-url-parser.ts index d985d91cd..65414e951 100644 --- a/src/lib/sentry-url-parser.ts +++ b/src/lib/sentry-url-parser.ts @@ -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 */ @@ -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; }; /** @@ -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 }; @@ -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. * @@ -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 @@ -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) ); } diff --git a/test/commands/issue/utils.test.ts b/test/commands/issue/utils.test.ts index ed3241538..ce3e701ce 100644 --- a/test/commands/issue/utils.test.ts +++ b/test/commands/issue/utils.test.ts @@ -71,6 +71,21 @@ describe("buildCommandHint", () => { "sentry issue explain sentry/cli/CLI-A1" ); }); + + test("returns URL as-is for share URLs", () => { + const shareUrl = + "https://gibush-kq.sentry.io/share/issue/f1abd515c51346778384ff25dfb341e5/"; + expect(buildCommandHint("view", shareUrl)).toBe( + `sentry issue view ${shareUrl}` + ); + }); + + test("returns URL as-is for regular issue URLs", () => { + const issueUrl = "https://sentry.io/organizations/my-org/issues/12345/"; + expect(buildCommandHint("view", issueUrl)).toBe( + `sentry issue view ${issueUrl}` + ); + }); }); const getConfigDir = useTestConfigDir("test-issue-utils-", { @@ -1977,3 +1992,137 @@ describe("resolveIssue: project-search DSN shortcut", () => { ).toBe(false); }); }); + +describe("resolveIssue with share URLs", () => { + const cwd = "/tmp/test-share"; + + test("resolves share URL with org from subdomain", async () => { + setOrgRegion("gibush-kq", DEFAULT_SENTRY_URL); + + // @ts-expect-error - partial mock + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + const url = req.url; + + // Share API endpoint (public, no auth) + if (url.includes("/shared/issues/f1abd515c51346778384ff25dfb341e5")) { + return new Response(JSON.stringify({ groupID: "99124558" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + + // Authenticated issue fetch + if (url.includes("/organizations/gibush-kq/issues/99124558/")) { + return new Response( + JSON.stringify({ + id: "99124558", + shortId: "BACKEND-A1", + title: "Share Test Issue", + status: "unresolved", + platform: "python", + type: "error", + count: "5", + userCount: 3, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + + return new Response(JSON.stringify({ detail: "Not found" }), { + status: 404, + headers: { "Content-Type": "application/json" }, + }); + }; + + const result = await resolveIssue({ + issueArg: + "https://gibush-kq.sentry.io/share/issue/f1abd515c51346778384ff25dfb341e5/", + cwd, + command: "view", + }); + + expect(result.org).toBe("gibush-kq"); + expect(result.issue.id).toBe("99124558"); + expect(result.issue.shortId).toBe("BACKEND-A1"); + }); + + test("resolves share URL without org via unscoped fetch", async () => { + // @ts-expect-error - partial mock + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + const url = req.url; + + // Share API endpoint + if (url.includes("/shared/issues/aabbccdd11223344aabbccdd11223344")) { + return new Response(JSON.stringify({ groupID: "55555" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + + // Unscoped issue fetch + if (url.includes("/issues/55555/")) { + return new Response( + JSON.stringify({ + id: "55555", + shortId: "WEB-B2", + title: "Unscoped Share Issue", + status: "unresolved", + platform: "javascript", + type: "error", + count: "1", + userCount: 1, + permalink: "https://test-org.sentry.io/issues/55555/", + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + + return new Response(JSON.stringify({ detail: "Not found" }), { + status: 404, + headers: { "Content-Type": "application/json" }, + }); + }; + + const result = await resolveIssue({ + issueArg: + "https://sentry.io/share/issue/aabbccdd11223344aabbccdd11223344/", + cwd, + command: "view", + }); + + expect(result.issue.id).toBe("55555"); + expect(result.org).toBe("test-org"); + }); + + test("throws ApiError when share link is expired/disabled", async () => { + // @ts-expect-error - partial mock + globalThis.fetch = async () => + new Response(JSON.stringify({ detail: "Not found" }), { + status: 404, + headers: { "Content-Type": "application/json" }, + }); + + await expect( + resolveIssue({ + issueArg: + "https://sentry.io/share/issue/deadbeefdeadbeefdeadbeefdeadbeef/", + cwd, + command: "view", + }) + ).rejects.toThrow(ApiError); + + try { + await resolveIssue({ + issueArg: + "https://sentry.io/share/issue/deadbeefdeadbeefdeadbeefdeadbeef/", + cwd, + command: "view", + }); + } catch (error) { + expect(error).toBeInstanceOf(ApiError); + expect((error as ApiError).message).toContain("Share link not found"); + } + }); +}); diff --git a/test/lib/arg-parsing.test.ts b/test/lib/arg-parsing.test.ts index 0ed31b7b4..8b1db9861 100644 --- a/test/lib/arg-parsing.test.ts +++ b/test/lib/arg-parsing.test.ts @@ -483,6 +483,45 @@ describe("parseIssueArg", () => { ); } }); + + test("SaaS subdomain share URL returns share type with org", () => { + expect( + parseIssueArg( + "https://gibush-kq.sentry.io/share/issue/f1abd515c51346778384ff25dfb341e5/" + ) + ).toEqual({ + type: "share", + shareId: "f1abd515c51346778384ff25dfb341e5", + org: "gibush-kq", + baseUrl: "https://gibush-kq.sentry.io", + }); + }); + + test("bare sentry.io share URL returns share type without org", () => { + expect( + parseIssueArg( + "https://sentry.io/share/issue/f1abd515c51346778384ff25dfb341e5/" + ) + ).toEqual({ + type: "share", + shareId: "f1abd515c51346778384ff25dfb341e5", + org: undefined, + baseUrl: "https://sentry.io", + }); + }); + + test("self-hosted share URL returns share type", () => { + expect( + parseIssueArg( + "https://sentry.example.com/share/issue/aabbccdd11223344aabbccdd11223344/" + ) + ).toEqual({ + type: "share", + shareId: "aabbccdd11223344aabbccdd11223344", + org: undefined, + baseUrl: "https://sentry.example.com", + }); + }); }); // Parser preserves DSN-style org identifiers (normalization moved to resolution layer) diff --git a/test/lib/sentry-url-parser.test.ts b/test/lib/sentry-url-parser.test.ts index 87dcf05fc..3e688d77b 100644 --- a/test/lib/sentry-url-parser.test.ts +++ b/test/lib/sentry-url-parser.test.ts @@ -288,6 +288,78 @@ describe("parseSentryUrl", () => { }); }); + describe("share URLs", () => { + test("SaaS subdomain share URL extracts org and shareId", () => { + const result = parseSentryUrl( + "https://gibush-kq.sentry.io/share/issue/f1abd515c51346778384ff25dfb341e5/" + ); + expect(result).toEqual({ + baseUrl: "https://gibush-kq.sentry.io", + org: "gibush-kq", + shareId: "f1abd515c51346778384ff25dfb341e5", + }); + }); + + test("bare sentry.io share URL has no org", () => { + const result = parseSentryUrl( + "https://sentry.io/share/issue/f1abd515c51346778384ff25dfb341e5/" + ); + expect(result).toEqual({ + baseUrl: "https://sentry.io", + shareId: "f1abd515c51346778384ff25dfb341e5", + }); + }); + + test("self-hosted share URL", () => { + const result = parseSentryUrl( + "https://sentry.example.com/share/issue/aabbccdd11223344aabbccdd11223344/" + ); + expect(result).toEqual({ + baseUrl: "https://sentry.example.com", + shareId: "aabbccdd11223344aabbccdd11223344", + }); + }); + + test("self-hosted share URL with port", () => { + const result = parseSentryUrl( + "https://sentry.acme.internal:9000/share/issue/deadbeefdeadbeefdeadbeefdeadbeef/" + ); + expect(result).toEqual({ + baseUrl: "https://sentry.acme.internal:9000", + shareId: "deadbeefdeadbeefdeadbeefdeadbeef", + }); + }); + + test("region subdomain share URL has no org", () => { + // us.sentry.io is a region, not an org — share URL falls through to matchSharePath + const result = parseSentryUrl( + "https://us.sentry.io/share/issue/f1abd515c51346778384ff25dfb341e5/" + ); + expect(result).toEqual({ + baseUrl: "https://us.sentry.io", + shareId: "f1abd515c51346778384ff25dfb341e5", + }); + }); + + test("share URL without trailing slash", () => { + const result = parseSentryUrl( + "https://sentry.io/share/issue/f1abd515c51346778384ff25dfb341e5" + ); + expect(result).toEqual({ + baseUrl: "https://sentry.io", + shareId: "f1abd515c51346778384ff25dfb341e5", + }); + }); + + test("/share/ without issue segment returns null", () => { + expect(parseSentryUrl("https://sentry.io/share/")).toBeNull(); + }); + + test("/share/issue/ without shareId returns null", () => { + expect(parseSentryUrl("https://sentry.io/share/issue/")).toBeNull(); + }); + }); + describe("unrecognized paths return null", () => { test("root URL", () => { expect(parseSentryUrl("https://sentry.io/")).toBeNull();