Skip to content

Commit fa33c0a

Browse files
committed
feat(args): parse Sentry web URLs as CLI arguments
Users can now paste Sentry web URLs directly as CLI arguments instead of manually extracting org/project/issue IDs. Supports both SaaS (sentry.io) and self-hosted instances. URL patterns supported: - /organizations/{org}/issues/{id}/ (issue view/explain/plan) - /organizations/{org}/issues/{id}/events/{eventId}/ (event view) - /settings/{org}/projects/{project}/ (project commands) - /organizations/{org}/traces/{traceId}/ (trace commands) - /organizations/{org}/ (org-scoped commands) For self-hosted URLs, automatically sets SENTRY_URL so that API calls, OAuth device flow, and token refresh target the correct instance. Also fixes frozen module-level constants in oauth.ts and sentry-client.ts that captured SENTRY_URL at import time, preventing dynamic URL detection from working.
1 parent 2b25152 commit fa33c0a

File tree

9 files changed

+875
-15
lines changed

9 files changed

+875
-15
lines changed

src/commands/event/view.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ import {
1919
resolveOrgAndProject,
2020
resolveProjectBySlug,
2121
} from "../../lib/resolve-target.js";
22+
import {
23+
applySentryUrlContext,
24+
parseSentryUrl,
25+
} from "../../lib/sentry-url-parser.js";
2226
import { buildEventSearchUrl } from "../../lib/sentry-urls.js";
2327
import { getSpanTreeLines } from "../../lib/span-tree.js";
2428
import type { SentryEvent, Writer } from "../../types/index.js";
@@ -64,7 +68,16 @@ const USAGE_HINT = "sentry event view <org>/<project> <event-id>";
6468

6569
/**
6670
* Parse positional arguments for event view.
67-
* Handles: `<event-id>` or `<target> <event-id>`
71+
*
72+
* Handles:
73+
* - `<event-id>` — event ID only (auto-detect org/project)
74+
* - `<target> <event-id>` — explicit target + event ID
75+
* - `<sentry-url>` — extract eventId and org from a Sentry event URL
76+
* (e.g., `https://sentry.example.com/organizations/my-org/issues/123/events/abc/`)
77+
*
78+
* For event URLs, the org is extracted and passed as `targetArg` so the
79+
* downstream resolution logic can use it. The URL must contain an eventId
80+
* segment — issue-only URLs are not valid for event view.
6881
*
6982
* @returns Parsed event ID and optional target arg
7083
*/
@@ -81,6 +94,21 @@ export function parsePositionalArgs(args: string[]): {
8194
throw new ContextError("Event ID", USAGE_HINT);
8295
}
8396

97+
// URL detection — extract eventId and org from Sentry event URLs
98+
const urlParsed = parseSentryUrl(first);
99+
if (urlParsed) {
100+
applySentryUrlContext(urlParsed.baseUrl);
101+
if (urlParsed.eventId) {
102+
// Event URL: use org as target, eventId from the URL
103+
return { eventId: urlParsed.eventId, targetArg: `${urlParsed.org}/` };
104+
}
105+
// URL recognized but no eventId — not valid for event view
106+
throw new ContextError(
107+
"Event ID in URL (use a URL like /issues/{id}/events/{eventId}/)",
108+
USAGE_HINT
109+
);
110+
}
111+
84112
if (args.length === 1) {
85113
// Single arg - must be event ID
86114
return { eventId: first, targetArg: undefined };

src/lib/arg-parsing.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
* project list) and single-item commands (issue view, explain, plan).
77
*/
88

9+
import type { ParsedSentryUrl } from "./sentry-url-parser.js";
10+
import { applySentryUrlContext, parseSentryUrl } from "./sentry-url-parser.js";
911
import { isAllDigits } from "./utils.js";
1012

1113
/** Default span depth when no value is provided */
@@ -96,11 +98,60 @@ export type ParsedOrgProject =
9698
| { type: typeof ProjectSpecificationType.ProjectSearch; projectSlug: string }
9799
| { type: typeof ProjectSpecificationType.AutoDetect };
98100

101+
/**
102+
* Map a parsed Sentry URL to a ParsedOrgProject.
103+
* If the URL contains a project slug, returns explicit; otherwise org-all.
104+
*/
105+
function orgProjectFromUrl(parsed: ParsedSentryUrl): ParsedOrgProject {
106+
if (parsed.project) {
107+
return { type: "explicit", org: parsed.org, project: parsed.project };
108+
}
109+
return { type: "org-all", org: parsed.org };
110+
}
111+
112+
/**
113+
* Map a parsed Sentry URL to a ParsedIssueArg.
114+
* Handles numeric group IDs and short IDs (e.g., "CLI-G") from the URL path.
115+
*/
116+
function issueArgFromUrl(parsed: ParsedSentryUrl): ParsedIssueArg | null {
117+
const { issueId } = parsed;
118+
if (!issueId) {
119+
return null;
120+
}
121+
122+
// Numeric group ID (e.g., /issues/32886/)
123+
if (isAllDigits(issueId)) {
124+
return {
125+
type: "explicit-org-numeric",
126+
org: parsed.org,
127+
numericId: issueId,
128+
};
129+
}
130+
131+
// Short ID with dash (e.g., /issues/CLI-G/ or /issues/SPOTLIGHT-ELECTRON-4Y/)
132+
const dashIdx = issueId.lastIndexOf("-");
133+
if (dashIdx > 0) {
134+
const project = issueId.slice(0, dashIdx);
135+
const suffix = issueId.slice(dashIdx + 1).toUpperCase();
136+
if (project && suffix) {
137+
return { type: "explicit", org: parsed.org, project, suffix };
138+
}
139+
}
140+
141+
// No dash — treat as suffix-only with org context
142+
return {
143+
type: "explicit-org-suffix",
144+
org: parsed.org,
145+
suffix: issueId.toUpperCase(),
146+
};
147+
}
148+
99149
/**
100150
* Parse an org/project positional argument string.
101151
*
102152
* Supports the following patterns:
103153
* - `undefined` or empty → auto-detect from DSN/config
154+
* - `https://sentry.io/organizations/org/...` → extract from Sentry URL
104155
* - `sentry/cli` → explicit org and project
105156
* - `sentry/` → org with all projects
106157
* - `/cli` → search for project across all orgs (leading slash)
@@ -123,6 +174,13 @@ export function parseOrgProjectArg(arg: string | undefined): ParsedOrgProject {
123174

124175
const trimmed = arg.trim();
125176

177+
// URL detection — extract org/project from Sentry web URLs
178+
const urlParsed = parseSentryUrl(trimmed);
179+
if (urlParsed) {
180+
applySentryUrlContext(urlParsed.baseUrl);
181+
return orgProjectFromUrl(urlParsed);
182+
}
183+
126184
if (trimmed.includes("/")) {
127185
const slashIndex = trimmed.indexOf("/");
128186
const org = trimmed.slice(0, slashIndex);
@@ -287,6 +345,18 @@ function parseWithDash(arg: string): ParsedIssueArg {
287345
}
288346

289347
export function parseIssueArg(arg: string): ParsedIssueArg {
348+
// 0. URL detection — extract issue ID from Sentry web URLs
349+
const urlParsed = parseSentryUrl(arg);
350+
if (urlParsed) {
351+
applySentryUrlContext(urlParsed.baseUrl);
352+
const result = issueArgFromUrl(urlParsed);
353+
if (result) {
354+
return result;
355+
}
356+
// URL recognized but no issue ID — fall through to normal parsing
357+
// (shouldn't happen for issue commands, but defensive)
358+
}
359+
290360
// 1. Pure numeric → direct fetch by ID
291361
if (isAllDigits(arg)) {
292362
return { type: "numeric", id: arg };

src/lib/oauth.ts

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,16 @@ import { setAuthToken } from "./db/auth.js";
1515
import { ApiError, AuthError, ConfigError, DeviceFlowError } from "./errors.js";
1616
import { withHttpSpan } from "./telemetry.js";
1717

18-
// Sentry instance URL (supports self-hosted via env override)
19-
const SENTRY_URL = process.env.SENTRY_URL ?? "https://sentry.io";
18+
/**
19+
* Get the Sentry instance URL for OAuth endpoints.
20+
*
21+
* Read lazily (not at module load) so that SENTRY_URL set after import
22+
* (e.g., from URL argument parsing for self-hosted instances) is respected
23+
* by the device flow and token refresh.
24+
*/
25+
function getSentryUrl(): string {
26+
return process.env.SENTRY_URL ?? "https://sentry.io";
27+
}
2028

2129
/**
2230
* OAuth client ID
@@ -82,7 +90,7 @@ async function fetchWithConnectionError(
8290

8391
if (isConnectionError) {
8492
throw new ApiError(
85-
`Cannot connect to Sentry at ${SENTRY_URL}`,
93+
`Cannot connect to Sentry at ${getSentryUrl()}`,
8694
0,
8795
"Check your network connection and SENTRY_URL configuration"
8896
);
@@ -103,7 +111,7 @@ function requestDeviceCode() {
103111

104112
return withHttpSpan("POST", "/oauth/device/code/", async () => {
105113
const response = await fetchWithConnectionError(
106-
`${SENTRY_URL}/oauth/device/code/`,
114+
`${getSentryUrl()}/oauth/device/code/`,
107115
{
108116
method: "POST",
109117
headers: { "Content-Type": "application/x-www-form-urlencoded" },
@@ -146,7 +154,7 @@ function requestDeviceCode() {
146154
function pollForToken(deviceCode: string): Promise<TokenResponse> {
147155
return withHttpSpan("POST", "/oauth/token/", async () => {
148156
const response = await fetchWithConnectionError(
149-
`${SENTRY_URL}/oauth/token/`,
157+
`${getSentryUrl()}/oauth/token/`,
150158
{
151159
method: "POST",
152160
headers: { "Content-Type": "application/x-www-form-urlencoded" },
@@ -332,7 +340,7 @@ export function refreshAccessToken(
332340

333341
return withHttpSpan("POST", "/oauth/token/", async () => {
334342
const response = await fetchWithConnectionError(
335-
`${SENTRY_URL}/oauth/token/`,
343+
`${getSentryUrl()}/oauth/token/`,
336344
{
337345
method: "POST",
338346
headers: { "Content-Type": "application/x-www-form-urlencoded" },

src/lib/sentry-client.ts

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,10 @@ import { DEFAULT_SENTRY_URL, getUserAgent } from "./constants.js";
1212
import { refreshToken } from "./db/auth.js";
1313
import { withHttpSpan } from "./telemetry.js";
1414

15-
/**
16-
* Control silo URL - handles OAuth, user accounts, and region routing.
17-
* This is always sentry.io for SaaS, or the base URL for self-hosted.
18-
*/
19-
const CONTROL_SILO_URL = process.env.SENTRY_URL || DEFAULT_SENTRY_URL;
15+
// CONTROL_SILO_URL was previously a module-level const that captured
16+
// process.env.SENTRY_URL at import time. This broke self-hosted URL
17+
// detection where SENTRY_URL is set after import. Now read lazily
18+
// via getControlSiloUrl().
2019

2120
/** Request timeout in milliseconds */
2221
const REQUEST_TIMEOUT_MS = 30_000;
@@ -291,9 +290,12 @@ export function getApiBaseUrl(): string {
291290
/**
292291
* Get the control silo URL.
293292
* This is always sentry.io for SaaS, or the custom URL for self-hosted.
293+
*
294+
* Read lazily (not at module load) so that SENTRY_URL set after import
295+
* (e.g., from URL argument parsing for self-hosted instances) is respected.
294296
*/
295297
export function getControlSiloUrl(): string {
296-
return CONTROL_SILO_URL;
298+
return process.env.SENTRY_URL || DEFAULT_SENTRY_URL;
297299
}
298300

299301
/**
@@ -339,7 +341,7 @@ export function getDefaultSdkConfig() {
339341
* Used for endpoints that are always on the control silo (OAuth, user accounts, regions).
340342
*/
341343
export function getControlSdkConfig() {
342-
return getSdkConfig(CONTROL_SILO_URL);
344+
return getSdkConfig(getControlSiloUrl());
343345
}
344346

345347
/**

src/lib/sentry-url-parser.ts

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
/**
2+
* Sentry URL Parser
3+
*
4+
* Extracts org, project, issue, event, and trace identifiers from Sentry web URLs.
5+
* Supports both SaaS (*.sentry.io) and self-hosted instances.
6+
*
7+
* For self-hosted URLs, also configures the SENTRY_URL environment variable
8+
* so that subsequent API calls reach the correct instance.
9+
*/
10+
11+
import { isSentrySaasUrl } from "./sentry-urls.js";
12+
13+
/**
14+
* Components extracted from a Sentry web URL.
15+
*
16+
* All fields except `baseUrl` and `org` are optional — presence depends
17+
* on which URL pattern was matched.
18+
*/
19+
export type ParsedSentryUrl = {
20+
/** Scheme + host of the Sentry instance (e.g., "https://sentry.io" or "https://sentry.example.com") */
21+
baseUrl: string;
22+
/** Organization slug from the URL path */
23+
org: string;
24+
/** Issue identifier — numeric group ID (e.g., "32886") or short ID (e.g., "CLI-G") */
25+
issueId?: string;
26+
/** Event ID from /issues/{id}/events/{eventId}/ paths */
27+
eventId?: string;
28+
/** Project slug from /settings/{org}/projects/{project}/ paths */
29+
project?: string;
30+
/** Trace ID from /organizations/{org}/traces/{traceId}/ paths */
31+
traceId?: string;
32+
};
33+
34+
/**
35+
* Try to match /organizations/{org}/... path patterns.
36+
*
37+
* @returns Parsed result or null if pattern doesn't match
38+
*/
39+
function matchOrganizationsPath(
40+
baseUrl: string,
41+
segments: string[]
42+
): ParsedSentryUrl | null {
43+
if (segments[0] !== "organizations" || !segments[1]) {
44+
return null;
45+
}
46+
47+
const org = segments[1];
48+
49+
// /organizations/{org}/issues/{id}/ (optionally with /events/{eventId}/)
50+
if (segments[2] === "issues" && segments[3]) {
51+
const eventId =
52+
segments[4] === "events" && segments[5] ? segments[5] : undefined;
53+
return { baseUrl, org, issueId: segments[3], eventId };
54+
}
55+
56+
// /organizations/{org}/traces/{traceId}/
57+
if (segments[2] === "traces" && segments[3]) {
58+
return { baseUrl, org, traceId: segments[3] };
59+
}
60+
61+
// /organizations/{org}/ (org only)
62+
return { baseUrl, org };
63+
}
64+
65+
/**
66+
* Try to match /settings/{org}/projects/{project}/ path pattern.
67+
*
68+
* @returns Parsed result or null if pattern doesn't match
69+
*/
70+
function matchSettingsPath(
71+
baseUrl: string,
72+
segments: string[]
73+
): ParsedSentryUrl | null {
74+
if (
75+
segments[0] !== "settings" ||
76+
!segments[1] ||
77+
segments[2] !== "projects" ||
78+
!segments[3]
79+
) {
80+
return null;
81+
}
82+
83+
return { baseUrl, org: segments[1], project: segments[3] };
84+
}
85+
86+
/**
87+
* Parse a Sentry web URL and extract its components.
88+
*
89+
* Recognizes these path patterns (both SaaS and self-hosted):
90+
* - `/organizations/{org}/issues/{id}/`
91+
* - `/organizations/{org}/issues/{id}/events/{eventId}/`
92+
* - `/settings/{org}/projects/{project}/`
93+
* - `/organizations/{org}/traces/{traceId}/`
94+
* - `/organizations/{org}/`
95+
*
96+
* @param input - Raw string that may or may not be a URL
97+
* @returns Parsed components, or null if input is not a recognized Sentry URL
98+
*/
99+
export function parseSentryUrl(input: string): ParsedSentryUrl | null {
100+
// Quick reject — must look like a URL
101+
if (!(input.startsWith("http://") || input.startsWith("https://"))) {
102+
return null;
103+
}
104+
105+
let url: URL;
106+
try {
107+
url = new URL(input);
108+
} catch {
109+
return null;
110+
}
111+
112+
const baseUrl = `${url.protocol}//${url.host}`;
113+
const segments = url.pathname.split("/").filter(Boolean);
114+
115+
return (
116+
matchOrganizationsPath(baseUrl, segments) ??
117+
matchSettingsPath(baseUrl, segments)
118+
);
119+
}
120+
121+
/**
122+
* Configure `SENTRY_URL` for self-hosted instances detected from a parsed URL.
123+
*
124+
* Only sets the env var when ALL of these conditions are met:
125+
* 1. The URL is NOT a Sentry SaaS domain (*.sentry.io) — SaaS uses multi-region routing
126+
* 2. `SENTRY_URL` is not already set — respect explicit user configuration
127+
*
128+
* This ensures that API calls, OAuth device flow, and token refresh all target
129+
* the correct self-hosted instance when a user pastes a URL as an argument.
130+
*
131+
* @param baseUrl - The scheme + host extracted from the URL (e.g., "https://sentry.example.com")
132+
*/
133+
export function applySentryUrlContext(baseUrl: string): void {
134+
if (isSentrySaasUrl(baseUrl)) {
135+
return;
136+
}
137+
if (process.env.SENTRY_URL) {
138+
return;
139+
}
140+
process.env.SENTRY_URL = baseUrl;
141+
}

0 commit comments

Comments
 (0)