diff --git a/src/commands/event/view.ts b/src/commands/event/view.ts index c257b5f6..faa72a43 100644 --- a/src/commands/event/view.ts +++ b/src/commands/event/view.ts @@ -19,6 +19,10 @@ import { resolveOrgAndProject, resolveProjectBySlug, } from "../../lib/resolve-target.js"; +import { + applySentryUrlContext, + parseSentryUrl, +} from "../../lib/sentry-url-parser.js"; import { buildEventSearchUrl } from "../../lib/sentry-urls.js"; import { getSpanTreeLines } from "../../lib/span-tree.js"; import type { SentryEvent, Writer } from "../../types/index.js"; @@ -64,7 +68,17 @@ const USAGE_HINT = "sentry event view / "; /** * Parse positional arguments for event view. - * Handles: `` or ` ` + * + * Handles: + * - `` — event ID only (auto-detect org/project) + * - ` ` — explicit target + event ID + * - `` — extract eventId and org from a Sentry event URL + * (e.g., `https://sentry.example.com/organizations/my-org/issues/123/events/abc/`) + * + * For event URLs, the org is returned as `targetArg` in `"{org}/"` format + * (OrgAll). Since event URLs don't contain a project slug, the caller + * must fall back to auto-detection for the project. The URL must contain + * an eventId segment — issue-only URLs are not valid for event view. * * @returns Parsed event ID and optional target arg */ @@ -81,6 +95,23 @@ export function parsePositionalArgs(args: string[]): { throw new ContextError("Event ID", USAGE_HINT); } + // URL detection — extract eventId and org from Sentry event URLs + const urlParsed = parseSentryUrl(first); + if (urlParsed) { + applySentryUrlContext(urlParsed.baseUrl); + if (urlParsed.eventId) { + // 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}/` }; + } + // URL recognized but no eventId — not valid for event view + throw new ContextError( + "Event ID in URL (use a URL like /issues/{id}/events/{eventId}/)", + USAGE_HINT + ); + } + if (args.length === 1) { // Single arg - must be event ID return { eventId: first, targetArg: undefined }; @@ -181,8 +212,11 @@ export const viewCommand = buildCommand({ } case ProjectSpecificationType.OrgAll: - throw new ContextError("Specific project", USAGE_HINT); - + // Org-only (e.g., from event URL that has no project slug). + // Fall through to auto-detect — SENTRY_URL is already set for + // self-hosted, and auto-detect will resolve the project from + // DSN, config defaults, or directory name inference. + // falls through case ProjectSpecificationType.AutoDetect: target = await resolveOrgAndProject({ cwd, usageHint: USAGE_HINT }); break; diff --git a/src/lib/arg-parsing.ts b/src/lib/arg-parsing.ts index 1a6c073f..b2bbd3a3 100644 --- a/src/lib/arg-parsing.ts +++ b/src/lib/arg-parsing.ts @@ -6,6 +6,9 @@ * project list) and single-item commands (issue view, explain, plan). */ +import { ValidationError } from "./errors.js"; +import type { ParsedSentryUrl } from "./sentry-url-parser.js"; +import { applySentryUrlContext, parseSentryUrl } from "./sentry-url-parser.js"; import { isAllDigits } from "./utils.js"; /** Default span depth when no value is provided */ @@ -96,11 +99,60 @@ export type ParsedOrgProject = | { type: typeof ProjectSpecificationType.ProjectSearch; projectSlug: string } | { type: typeof ProjectSpecificationType.AutoDetect }; +/** + * Map a parsed Sentry URL to a ParsedOrgProject. + * If the URL contains a project slug, returns explicit; otherwise org-all. + */ +function orgProjectFromUrl(parsed: ParsedSentryUrl): ParsedOrgProject { + if (parsed.project) { + return { type: "explicit", org: parsed.org, project: parsed.project }; + } + return { type: "org-all", org: parsed.org }; +} + +/** + * Map a parsed Sentry URL to a ParsedIssueArg. + * Handles numeric group IDs and short IDs (e.g., "CLI-G") from the URL path. + */ +function issueArgFromUrl(parsed: ParsedSentryUrl): ParsedIssueArg | null { + const { issueId } = parsed; + if (!issueId) { + return null; + } + + // Numeric group ID (e.g., /issues/32886/) + if (isAllDigits(issueId)) { + return { + type: "explicit-org-numeric", + org: parsed.org, + numericId: issueId, + }; + } + + // Short ID with dash (e.g., /issues/CLI-G/ or /issues/SPOTLIGHT-ELECTRON-4Y/) + const dashIdx = issueId.lastIndexOf("-"); + if (dashIdx > 0) { + const project = issueId.slice(0, dashIdx); + const suffix = issueId.slice(dashIdx + 1).toUpperCase(); + if (project && suffix) { + return { type: "explicit", org: parsed.org, project, suffix }; + } + } + + // No dash — treat as suffix-only with org context + return { + type: "explicit-org-suffix", + org: parsed.org, + suffix: issueId.toUpperCase(), + }; +} + /** * Parse an org/project positional argument string. * * Supports the following patterns: * - `undefined` or empty → auto-detect from DSN/config + * - `https://sentry.io/organizations/org/...` → extract from Sentry URL * - `sentry/cli` → explicit org and project * - `sentry/` → org with all projects * - `/cli` → search for project across all orgs (leading slash) @@ -123,6 +175,13 @@ export function parseOrgProjectArg(arg: string | undefined): ParsedOrgProject { const trimmed = arg.trim(); + // URL detection — extract org/project from Sentry web URLs + const urlParsed = parseSentryUrl(trimmed); + if (urlParsed) { + applySentryUrlContext(urlParsed.baseUrl); + return orgProjectFromUrl(urlParsed); + } + if (trimmed.includes("/")) { const slashIndex = trimmed.indexOf("/"); const org = trimmed.slice(0, slashIndex); @@ -287,6 +346,21 @@ function parseWithDash(arg: string): ParsedIssueArg { } export function parseIssueArg(arg: string): ParsedIssueArg { + // 0. URL detection — extract issue ID from Sentry web URLs + const urlParsed = parseSentryUrl(arg); + if (urlParsed) { + applySentryUrlContext(urlParsed.baseUrl); + const result = issueArgFromUrl(urlParsed); + if (result) { + return result; + } + // URL recognized but no issue ID (e.g., trace or project settings URL) + throw new ValidationError( + "This Sentry URL does not contain an issue ID. Use an issue URL like:\n" + + " https://sentry.io/organizations/{org}/issues/{id}/" + ); + } + // 1. Pure numeric → direct fetch by ID if (isAllDigits(arg)) { return { type: "numeric", id: arg }; diff --git a/src/lib/oauth.ts b/src/lib/oauth.ts index 1ecf0087..988fae20 100644 --- a/src/lib/oauth.ts +++ b/src/lib/oauth.ts @@ -15,8 +15,16 @@ import { setAuthToken } from "./db/auth.js"; import { ApiError, AuthError, ConfigError, DeviceFlowError } from "./errors.js"; import { withHttpSpan } from "./telemetry.js"; -// Sentry instance URL (supports self-hosted via env override) -const SENTRY_URL = process.env.SENTRY_URL ?? "https://sentry.io"; +/** + * Get the Sentry instance URL for OAuth endpoints. + * + * Read lazily (not at module load) so that SENTRY_URL set after import + * (e.g., from URL argument parsing for self-hosted instances) is respected + * by the device flow and token refresh. + */ +function getSentryUrl(): string { + return process.env.SENTRY_URL ?? "https://sentry.io"; +} /** * OAuth client ID @@ -82,7 +90,7 @@ async function fetchWithConnectionError( if (isConnectionError) { throw new ApiError( - `Cannot connect to Sentry at ${SENTRY_URL}`, + `Cannot connect to Sentry at ${getSentryUrl()}`, 0, "Check your network connection and SENTRY_URL configuration" ); @@ -103,7 +111,7 @@ function requestDeviceCode() { return withHttpSpan("POST", "/oauth/device/code/", async () => { const response = await fetchWithConnectionError( - `${SENTRY_URL}/oauth/device/code/`, + `${getSentryUrl()}/oauth/device/code/`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, @@ -146,7 +154,7 @@ function requestDeviceCode() { function pollForToken(deviceCode: string): Promise { return withHttpSpan("POST", "/oauth/token/", async () => { const response = await fetchWithConnectionError( - `${SENTRY_URL}/oauth/token/`, + `${getSentryUrl()}/oauth/token/`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, @@ -332,7 +340,7 @@ export function refreshAccessToken( return withHttpSpan("POST", "/oauth/token/", async () => { const response = await fetchWithConnectionError( - `${SENTRY_URL}/oauth/token/`, + `${getSentryUrl()}/oauth/token/`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, diff --git a/src/lib/sentry-client.ts b/src/lib/sentry-client.ts index 00e46045..34b47dbb 100644 --- a/src/lib/sentry-client.ts +++ b/src/lib/sentry-client.ts @@ -12,12 +12,6 @@ import { DEFAULT_SENTRY_URL, getUserAgent } from "./constants.js"; import { refreshToken } from "./db/auth.js"; import { withHttpSpan } from "./telemetry.js"; -/** - * Control silo URL - handles OAuth, user accounts, and region routing. - * This is always sentry.io for SaaS, or the base URL for self-hosted. - */ -const CONTROL_SILO_URL = process.env.SENTRY_URL || DEFAULT_SENTRY_URL; - /** Request timeout in milliseconds */ const REQUEST_TIMEOUT_MS = 30_000; @@ -291,9 +285,12 @@ export function getApiBaseUrl(): string { /** * Get the control silo URL. * This is always sentry.io for SaaS, or the custom URL for self-hosted. + * + * Read lazily (not at module load) so that SENTRY_URL set after import + * (e.g., from URL argument parsing for self-hosted instances) is respected. */ export function getControlSiloUrl(): string { - return CONTROL_SILO_URL; + return process.env.SENTRY_URL || DEFAULT_SENTRY_URL; } /** @@ -339,7 +336,7 @@ export function getDefaultSdkConfig() { * Used for endpoints that are always on the control silo (OAuth, user accounts, regions). */ export function getControlSdkConfig() { - return getSdkConfig(CONTROL_SILO_URL); + return getSdkConfig(getControlSiloUrl()); } /** diff --git a/src/lib/sentry-url-parser.ts b/src/lib/sentry-url-parser.ts new file mode 100644 index 00000000..9f20a417 --- /dev/null +++ b/src/lib/sentry-url-parser.ts @@ -0,0 +1,141 @@ +/** + * Sentry URL Parser + * + * Extracts org, project, issue, event, and trace identifiers from Sentry web URLs. + * Supports both SaaS (*.sentry.io) and self-hosted instances. + * + * For self-hosted URLs, also configures the SENTRY_URL environment variable + * so that subsequent API calls reach the correct instance. + */ + +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. + */ +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 */ + 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 */ + eventId?: string; + /** Project slug from /settings/{org}/projects/{project}/ paths */ + project?: string; + /** Trace ID from /organizations/{org}/traces/{traceId}/ paths */ + traceId?: string; +}; + +/** + * Try to match /organizations/{org}/... path patterns. + * + * @returns Parsed result or null if pattern doesn't match + */ +function matchOrganizationsPath( + baseUrl: string, + segments: string[] +): ParsedSentryUrl | null { + if (segments[0] !== "organizations" || !segments[1]) { + return null; + } + + const org = segments[1]; + + // /organizations/{org}/issues/{id}/ (optionally with /events/{eventId}/) + if (segments[2] === "issues" && segments[3]) { + const eventId = + segments[4] === "events" && segments[5] ? segments[5] : undefined; + return { baseUrl, org, issueId: segments[3], eventId }; + } + + // /organizations/{org}/traces/{traceId}/ + if (segments[2] === "traces" && segments[3]) { + return { baseUrl, org, traceId: segments[3] }; + } + + // /organizations/{org}/ (org only) + return { baseUrl, org }; +} + +/** + * Try to match /settings/{org}/projects/{project}/ path pattern. + * + * @returns Parsed result or null if pattern doesn't match + */ +function matchSettingsPath( + baseUrl: string, + segments: string[] +): ParsedSentryUrl | null { + if ( + segments[0] !== "settings" || + !segments[1] || + segments[2] !== "projects" || + !segments[3] + ) { + return null; + } + + return { baseUrl, org: segments[1], project: segments[3] }; +} + +/** + * Parse a Sentry web URL and extract its components. + * + * Recognizes these path patterns (both SaaS and self-hosted): + * - `/organizations/{org}/issues/{id}/` + * - `/organizations/{org}/issues/{id}/events/{eventId}/` + * - `/settings/{org}/projects/{project}/` + * - `/organizations/{org}/traces/{traceId}/` + * - `/organizations/{org}/` + * + * @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 + */ +export function parseSentryUrl(input: string): ParsedSentryUrl | null { + // Quick reject — must look like a URL + if (!(input.startsWith("http://") || input.startsWith("https://"))) { + return null; + } + + let url: URL; + try { + url = new URL(input); + } catch { + return null; + } + + const baseUrl = `${url.protocol}//${url.host}`; + const segments = url.pathname.split("/").filter(Boolean); + + return ( + matchOrganizationsPath(baseUrl, segments) ?? + matchSettingsPath(baseUrl, segments) + ); +} + +/** + * Configure `SENTRY_URL` for self-hosted instances detected from a parsed URL. + * + * Sets the env var when the URL is NOT a Sentry SaaS domain (*.sentry.io), + * since SaaS uses multi-region routing instead. + * + * The parsed URL always takes precedence over any existing `SENTRY_URL` value + * because an explicit URL argument is the strongest signal of user intent. + * + * @param baseUrl - The scheme + host extracted from the URL (e.g., "https://sentry.example.com") + */ +export function applySentryUrlContext(baseUrl: string): void { + if (isSentrySaasUrl(baseUrl)) { + // Clear any self-hosted URL so API calls fall back to default SaaS routing. + // Without this, a stale SENTRY_URL would route SaaS requests to the wrong host. + // biome-ignore lint/performance/noDelete: process.env requires delete to truly unset; assignment coerces to string in Node.js + delete process.env.SENTRY_URL; + return; + } + process.env.SENTRY_URL = baseUrl; +} diff --git a/test/commands/event/view.test.ts b/test/commands/event/view.test.ts index fb894d75..19eb9562 100644 --- a/test/commands/event/view.test.ts +++ b/test/commands/event/view.test.ts @@ -89,6 +89,67 @@ describe("parsePositionalArgs", () => { expect(result.eventId).toBe(""); }); }); + + // URL integration tests — applySentryUrlContext may set SENTRY_URL as a side effect + describe("Sentry URL inputs", () => { + let savedSentryUrl: string | undefined; + + beforeEach(() => { + savedSentryUrl = process.env.SENTRY_URL; + delete process.env.SENTRY_URL; + }); + + afterEach(() => { + if (savedSentryUrl !== undefined) { + process.env.SENTRY_URL = savedSentryUrl; + } else { + delete process.env.SENTRY_URL; + } + }); + + test("event URL extracts eventId and passes org as OrgAll target", () => { + const result = parsePositionalArgs([ + "https://sentry.io/organizations/my-org/issues/32886/events/abc123def456/", + ]); + expect(result.eventId).toBe("abc123def456"); + expect(result.targetArg).toBe("my-org/"); + }); + + test("self-hosted event URL extracts eventId, passes org, sets SENTRY_URL", () => { + const result = parsePositionalArgs([ + "https://sentry.example.com/organizations/acme/issues/999/events/deadbeef/", + ]); + expect(result.eventId).toBe("deadbeef"); + expect(result.targetArg).toBe("acme/"); + expect(process.env.SENTRY_URL).toBe("https://sentry.example.com"); + }); + + test("issue URL without event ID throws ContextError", () => { + expect(() => + parsePositionalArgs([ + "https://sentry.io/organizations/my-org/issues/32886/", + ]) + ).toThrow(ContextError); + }); + + test("issue-only URL error mentions event ID", () => { + try { + parsePositionalArgs([ + "https://sentry.io/organizations/my-org/issues/32886/", + ]); + expect.unreachable("Should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ContextError); + expect((error as ContextError).message).toContain("Event ID"); + } + }); + + test("org-only URL throws ContextError", () => { + expect(() => + parsePositionalArgs(["https://sentry.io/organizations/my-org/"]) + ).toThrow(ContextError); + }); + }); }); describe("resolveProjectBySlug", () => { diff --git a/test/lib/arg-parsing.test.ts b/test/lib/arg-parsing.test.ts index 1b8d66ab..95d05d03 100644 --- a/test/lib/arg-parsing.test.ts +++ b/test/lib/arg-parsing.test.ts @@ -6,11 +6,12 @@ * error messages and edge cases. */ -import { describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { parseIssueArg, parseOrgProjectArg, } from "../../src/lib/arg-parsing.js"; +import { ValidationError } from "../../src/lib/errors.js"; describe("parseOrgProjectArg", () => { // Representative examples for documentation (invariants covered by property tests) @@ -36,6 +37,67 @@ describe("parseOrgProjectArg", () => { 'Invalid format: "/" requires a project slug' ); }); + + // URL integration tests — applySentryUrlContext may set SENTRY_URL as a side effect + describe("Sentry URL inputs", () => { + let savedSentryUrl: string | undefined; + + beforeEach(() => { + savedSentryUrl = process.env.SENTRY_URL; + delete process.env.SENTRY_URL; + }); + + afterEach(() => { + if (savedSentryUrl !== undefined) { + process.env.SENTRY_URL = savedSentryUrl; + } else { + delete process.env.SENTRY_URL; + } + }); + + test("issue URL returns org-all", () => { + expect( + parseOrgProjectArg( + "https://sentry.io/organizations/my-org/issues/12345/" + ) + ).toEqual({ + type: "org-all", + org: "my-org", + }); + }); + + test("project settings URL returns explicit", () => { + expect( + parseOrgProjectArg( + "https://sentry.io/settings/my-org/projects/backend/" + ) + ).toEqual({ + type: "explicit", + org: "my-org", + project: "backend", + }); + }); + + test("org-only URL returns org-all", () => { + expect( + parseOrgProjectArg("https://sentry.io/organizations/my-org/") + ).toEqual({ + type: "org-all", + org: "my-org", + }); + }); + + test("self-hosted URL extracts org", () => { + expect( + parseOrgProjectArg( + "https://sentry.example.com/organizations/acme-corp/issues/99/" + ) + ).toEqual({ + type: "org-all", + org: "acme-corp", + }); + }); + }); }); describe("parseIssueArg", () => { @@ -95,6 +157,113 @@ describe("parseIssueArg", () => { }); }); + // URL integration tests — applySentryUrlContext may set SENTRY_URL as a side effect + describe("Sentry URL inputs", () => { + let savedSentryUrl: string | undefined; + + beforeEach(() => { + savedSentryUrl = process.env.SENTRY_URL; + delete process.env.SENTRY_URL; + }); + + afterEach(() => { + if (savedSentryUrl !== undefined) { + process.env.SENTRY_URL = savedSentryUrl; + } else { + delete process.env.SENTRY_URL; + } + }); + + test("issue URL with numeric ID returns explicit-org-numeric", () => { + expect( + parseIssueArg("https://sentry.io/organizations/my-org/issues/32886/") + ).toEqual({ + type: "explicit-org-numeric", + org: "my-org", + numericId: "32886", + }); + }); + + test("issue URL with short ID returns explicit", () => { + expect( + parseIssueArg("https://sentry.io/organizations/my-org/issues/CLI-G/") + ).toEqual({ + type: "explicit", + org: "my-org", + project: "CLI", + suffix: "G", + }); + }); + + test("issue URL with multi-part short ID returns explicit", () => { + expect( + parseIssueArg( + "https://sentry.io/organizations/my-org/issues/SPOTLIGHT-ELECTRON-4Y/" + ) + ).toEqual({ + type: "explicit", + org: "my-org", + project: "SPOTLIGHT-ELECTRON", + suffix: "4Y", + }); + }); + + test("self-hosted issue URL with query params", () => { + expect( + parseIssueArg( + "https://sentry.example.com/organizations/acme/issues/32886/?project=2" + ) + ).toEqual({ + type: "explicit-org-numeric", + org: "acme", + numericId: "32886", + }); + }); + + test("event URL extracts issue ID (ignores event part)", () => { + const result = parseIssueArg( + "https://sentry.io/organizations/my-org/issues/32886/events/abc123/" + ); + expect(result).toEqual({ + type: "explicit-org-numeric", + org: "my-org", + numericId: "32886", + }); + }); + + test("trace URL throws ValidationError (no issue ID in URL)", () => { + expect(() => + parseIssueArg( + "https://sentry.io/organizations/my-org/traces/a4d1aae7216b47ff/" + ) + ).toThrow(ValidationError); + }); + + test("org-only URL throws ValidationError (no issue ID in URL)", () => { + expect(() => + parseIssueArg("https://sentry.io/organizations/my-org/") + ).toThrow(ValidationError); + }); + + test("project settings URL throws ValidationError (no issue ID in URL)", () => { + expect(() => + parseIssueArg("https://sentry.io/settings/my-org/projects/backend/") + ).toThrow(ValidationError); + }); + + test("non-issue URL error mentions issue URL format", () => { + try { + parseIssueArg("https://sentry.io/organizations/my-org/traces/abc/"); + expect.unreachable("Should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ValidationError); + expect((error as ValidationError).message).toContain( + "does not contain an issue ID" + ); + } + }); + }); + // Edge cases - document tricky behaviors describe("edge cases", () => { test("/suffix returns suffix-only", () => { diff --git a/test/lib/sentry-url-parser.property.test.ts b/test/lib/sentry-url-parser.property.test.ts new file mode 100644 index 00000000..1885e28d --- /dev/null +++ b/test/lib/sentry-url-parser.property.test.ts @@ -0,0 +1,141 @@ +/** + * Property-Based Tests for Sentry URL Parser + * + * Round-trip tests: verifies that URLs built by sentry-urls.ts builders + * can be correctly parsed back by parseSentryUrl(). + */ + +import { describe, expect, test } from "bun:test"; +import { + assert as fcAssert, + property, + stringMatching, + tuple, +} from "fast-check"; +import { parseSentryUrl } from "../../src/lib/sentry-url-parser.js"; +import { + buildOrgUrl, + buildProjectUrl, + buildTraceUrl, +} from "../../src/lib/sentry-urls.js"; +import { DEFAULT_NUM_RUNS } from "../model-based/helpers.js"; + +/** Generates valid org slugs (lowercase, alphanumeric with hyphens) */ +const orgSlugArb = stringMatching(/^[a-z][a-z0-9-]{1,20}[a-z0-9]$/); + +/** Generates valid project slugs (lowercase, alphanumeric with hyphens) */ +const projectSlugArb = stringMatching(/^[a-z][a-z0-9-]{1,20}[a-z0-9]$/); + +/** Generates valid 32-character hex trace IDs */ +const traceIdArb = stringMatching(/^[0-9a-f]{32}$/); + +/** Generates numeric issue IDs */ +const numericIdArb = stringMatching(/^[1-9][0-9]{0,10}$/); + +/** Generates hex-like event IDs (32 chars) */ +const eventIdArb = stringMatching(/^[0-9a-f]{32}$/); + +describe("parseSentryUrl round-trip properties", () => { + test("buildOrgUrl → parseSentryUrl extracts org", async () => { + await fcAssert( + property(orgSlugArb, (org) => { + const url = buildOrgUrl(org); + const parsed = parseSentryUrl(url); + + expect(parsed).not.toBeNull(); + expect(parsed?.org).toBe(org); + expect(parsed?.issueId).toBeUndefined(); + expect(parsed?.project).toBeUndefined(); + expect(parsed?.traceId).toBeUndefined(); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("buildProjectUrl → parseSentryUrl extracts org and project", async () => { + await fcAssert( + property(tuple(orgSlugArb, projectSlugArb), ([org, project]) => { + const url = buildProjectUrl(org, project); + const parsed = parseSentryUrl(url); + + expect(parsed).not.toBeNull(); + expect(parsed?.org).toBe(org); + expect(parsed?.project).toBe(project); + expect(parsed?.issueId).toBeUndefined(); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("buildTraceUrl → parseSentryUrl extracts org and traceId", async () => { + await fcAssert( + property(tuple(orgSlugArb, traceIdArb), ([org, traceId]) => { + const url = buildTraceUrl(org, traceId); + const parsed = parseSentryUrl(url); + + expect(parsed).not.toBeNull(); + expect(parsed?.org).toBe(org); + expect(parsed?.traceId).toBe(traceId); + expect(parsed?.issueId).toBeUndefined(); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("issue URL round-trip: org and numeric issueId extracted", async () => { + await fcAssert( + property(tuple(orgSlugArb, numericIdArb), ([org, issueId]) => { + // Construct the URL pattern that Sentry uses for issues + const url = `https://sentry.io/organizations/${org}/issues/${issueId}/`; + const parsed = parseSentryUrl(url); + + expect(parsed).not.toBeNull(); + expect(parsed?.org).toBe(org); + expect(parsed?.issueId).toBe(issueId); + expect(parsed?.eventId).toBeUndefined(); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("event URL round-trip: org, issueId, and eventId extracted", async () => { + await fcAssert( + property( + tuple(orgSlugArb, numericIdArb, eventIdArb), + ([org, issueId, eventId]) => { + const url = `https://sentry.io/organizations/${org}/issues/${issueId}/events/${eventId}/`; + const parsed = parseSentryUrl(url); + + expect(parsed).not.toBeNull(); + expect(parsed?.org).toBe(org); + expect(parsed?.issueId).toBe(issueId); + expect(parsed?.eventId).toBe(eventId); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("non-URL strings always return null", async () => { + // Org slugs alone should never parse as URLs + await fcAssert( + property(orgSlugArb, (org) => { + expect(parseSentryUrl(org)).toBeNull(); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("baseUrl always contains scheme and host", async () => { + await fcAssert( + property(tuple(orgSlugArb, numericIdArb), ([org, issueId]) => { + const url = `https://sentry.io/organizations/${org}/issues/${issueId}/`; + const parsed = parseSentryUrl(url); + + expect(parsed).not.toBeNull(); + expect(parsed?.baseUrl).toMatch(/^https?:\/\/.+/); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); diff --git a/test/lib/sentry-url-parser.test.ts b/test/lib/sentry-url-parser.test.ts new file mode 100644 index 00000000..bf2a0267 --- /dev/null +++ b/test/lib/sentry-url-parser.test.ts @@ -0,0 +1,286 @@ +/** + * Sentry URL Parser Tests + * + * Unit tests for parseSentryUrl() and applySentryUrlContext(). + * Uses fictional domains (sentry.example.com, sentry.acme.internal) + * — never real customer data. + */ + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { + applySentryUrlContext, + parseSentryUrl, +} from "../../src/lib/sentry-url-parser.js"; + +describe("parseSentryUrl", () => { + describe("non-URL inputs return null", () => { + test("plain text", () => { + expect(parseSentryUrl("sentry/cli-G")).toBeNull(); + }); + + test("numeric ID", () => { + expect(parseSentryUrl("12345")).toBeNull(); + }); + + test("short ID", () => { + expect(parseSentryUrl("CLI-4Y")).toBeNull(); + }); + + test("org/project format", () => { + expect(parseSentryUrl("my-org/my-project")).toBeNull(); + }); + + test("empty string", () => { + expect(parseSentryUrl("")).toBeNull(); + }); + + test("malformed URL", () => { + expect(parseSentryUrl("http://")).toBeNull(); + }); + }); + + describe("organization URLs", () => { + test("SaaS /organizations/{org}/", () => { + const result = parseSentryUrl("https://sentry.io/organizations/my-org/"); + expect(result).toEqual({ + baseUrl: "https://sentry.io", + org: "my-org", + }); + }); + + test("self-hosted /organizations/{org}/", () => { + const result = parseSentryUrl( + "https://sentry.example.com/organizations/acme-corp/" + ); + expect(result).toEqual({ + baseUrl: "https://sentry.example.com", + org: "acme-corp", + }); + }); + + test("strips trailing path segments after org", () => { + // /organizations/{org}/ with no further recognized segments → org only + const result = parseSentryUrl("https://sentry.io/organizations/my-org/"); + expect(result?.org).toBe("my-org"); + expect(result?.issueId).toBeUndefined(); + }); + }); + + describe("issue URLs", () => { + test("/organizations/{org}/issues/{numericId}/", () => { + const result = parseSentryUrl( + "https://sentry.io/organizations/my-org/issues/32886/" + ); + expect(result).toEqual({ + baseUrl: "https://sentry.io", + org: "my-org", + issueId: "32886", + }); + }); + + test("/organizations/{org}/issues/{shortId}/", () => { + const result = parseSentryUrl( + "https://sentry.io/organizations/my-org/issues/CLI-G/" + ); + expect(result).toEqual({ + baseUrl: "https://sentry.io", + org: "my-org", + issueId: "CLI-G", + }); + }); + + test("self-hosted issue URL with query params", () => { + const result = parseSentryUrl( + "https://sentry.example.com/organizations/acme-corp/issues/32886/?project=2" + ); + expect(result).toEqual({ + baseUrl: "https://sentry.example.com", + org: "acme-corp", + issueId: "32886", + }); + }); + + test("self-hosted issue URL with port", () => { + const result = parseSentryUrl( + "https://sentry.acme.internal:9000/organizations/devops/issues/100/" + ); + expect(result).toEqual({ + baseUrl: "https://sentry.acme.internal:9000", + org: "devops", + issueId: "100", + }); + }); + + test("HTTP (non-HTTPS) self-hosted URL", () => { + const result = parseSentryUrl( + "http://sentry.local:8080/organizations/dev/issues/42/" + ); + expect(result).toEqual({ + baseUrl: "http://sentry.local:8080", + org: "dev", + issueId: "42", + }); + }); + }); + + describe("event URLs", () => { + test("/organizations/{org}/issues/{id}/events/{eventId}/", () => { + const result = parseSentryUrl( + "https://sentry.io/organizations/my-org/issues/32886/events/abc123def456/" + ); + expect(result).toEqual({ + baseUrl: "https://sentry.io", + org: "my-org", + issueId: "32886", + eventId: "abc123def456", + }); + }); + + test("self-hosted event URL", () => { + const result = parseSentryUrl( + "https://sentry.example.com/organizations/acme/issues/999/events/deadbeef/" + ); + expect(result).toEqual({ + baseUrl: "https://sentry.example.com", + org: "acme", + issueId: "999", + eventId: "deadbeef", + }); + }); + + test("event URL without trailing slash", () => { + const result = parseSentryUrl( + "https://sentry.io/organizations/my-org/issues/1/events/evt001" + ); + expect(result).toEqual({ + baseUrl: "https://sentry.io", + org: "my-org", + issueId: "1", + eventId: "evt001", + }); + }); + }); + + describe("trace URLs", () => { + test("/organizations/{org}/traces/{traceId}/", () => { + const result = parseSentryUrl( + "https://sentry.io/organizations/my-org/traces/a4d1aae7216b47ff8117cf4e09ce9d0a/" + ); + expect(result).toEqual({ + baseUrl: "https://sentry.io", + org: "my-org", + traceId: "a4d1aae7216b47ff8117cf4e09ce9d0a", + }); + }); + + test("self-hosted trace URL", () => { + const result = parseSentryUrl( + "https://sentry.example.com/organizations/devops/traces/00112233445566778899aabbccddeeff/" + ); + expect(result).toEqual({ + baseUrl: "https://sentry.example.com", + org: "devops", + traceId: "00112233445566778899aabbccddeeff", + }); + }); + }); + + describe("project settings URLs", () => { + test("/settings/{org}/projects/{project}/", () => { + const result = parseSentryUrl( + "https://sentry.io/settings/my-org/projects/backend/" + ); + expect(result).toEqual({ + baseUrl: "https://sentry.io", + org: "my-org", + project: "backend", + }); + }); + + test("self-hosted project settings URL", () => { + const result = parseSentryUrl( + "https://sentry.example.com/settings/acme/projects/web-frontend/" + ); + expect(result).toEqual({ + baseUrl: "https://sentry.example.com", + org: "acme", + project: "web-frontend", + }); + }); + }); + + describe("unrecognized paths return null", () => { + test("root URL", () => { + expect(parseSentryUrl("https://sentry.io/")).toBeNull(); + }); + + test("unknown path", () => { + expect(parseSentryUrl("https://sentry.io/auth/login/")).toBeNull(); + }); + + test("/settings without project segment", () => { + expect(parseSentryUrl("https://sentry.io/settings/my-org/")).toBeNull(); + }); + + test("/settings/{org}/projects/ without project slug", () => { + expect( + parseSentryUrl("https://sentry.io/settings/my-org/projects/") + ).toBeNull(); + }); + }); +}); + +describe("applySentryUrlContext", () => { + let originalSentryUrl: string | undefined; + + beforeEach(() => { + originalSentryUrl = process.env.SENTRY_URL; + delete process.env.SENTRY_URL; + }); + + afterEach(() => { + if (originalSentryUrl !== undefined) { + process.env.SENTRY_URL = originalSentryUrl; + } else { + delete process.env.SENTRY_URL; + } + }); + + test("sets SENTRY_URL for self-hosted instance", () => { + applySentryUrlContext("https://sentry.example.com"); + expect(process.env.SENTRY_URL).toBe("https://sentry.example.com"); + }); + + test("does not set SENTRY_URL for SaaS (sentry.io)", () => { + applySentryUrlContext("https://sentry.io"); + expect(process.env.SENTRY_URL).toBeUndefined(); + }); + + test("does not set SENTRY_URL for SaaS subdomain (us.sentry.io)", () => { + applySentryUrlContext("https://us.sentry.io"); + expect(process.env.SENTRY_URL).toBeUndefined(); + }); + + test("overrides existing SENTRY_URL (parsed URL takes precedence)", () => { + process.env.SENTRY_URL = "https://existing.example.com"; + applySentryUrlContext("https://sentry.other.com"); + expect(process.env.SENTRY_URL).toBe("https://sentry.other.com"); + }); + + test("sets SENTRY_URL for self-hosted with port", () => { + applySentryUrlContext("https://sentry.acme.internal:9000"); + expect(process.env.SENTRY_URL).toBe("https://sentry.acme.internal:9000"); + }); + + test("clears existing SENTRY_URL when SaaS URL is detected", () => { + process.env.SENTRY_URL = "https://sentry.example.com"; + applySentryUrlContext("https://sentry.io"); + expect(process.env.SENTRY_URL).toBeUndefined(); + }); + + test("clears existing SENTRY_URL when SaaS subdomain is detected", () => { + process.env.SENTRY_URL = "https://sentry.example.com"; + applySentryUrlContext("https://us.sentry.io"); + expect(process.env.SENTRY_URL).toBeUndefined(); + }); +});