Skip to content

Commit 8aa9cf6

Browse files
authored
feat(args): parse Sentry web URLs as CLI arguments (#252)
## Summary - Users can now paste Sentry web URLs directly as CLI arguments (e.g., `sentry issue view https://sentry.example.com/organizations/my-org/issues/32886/?project=2`) instead of manually extracting org/project/issue IDs - Supports both SaaS (sentry.io) and self-hosted instances — self-hosted URLs automatically configure `SENTRY_URL` for the session - Fixes frozen module-level constants in `oauth.ts` and `sentry-client.ts` that prevented dynamic URL detection from working ## URL Patterns Supported | Pattern | Used By | |---------|---------| | `/organizations/{org}/issues/{id}/` | `issue view`, `issue explain`, `issue plan` | | `/organizations/{org}/issues/{id}/events/{eventId}/` | `event view` | | `/settings/{org}/projects/{project}/` | `project view`, `project list` | | `/organizations/{org}/traces/{traceId}/` | `trace view` | | `/organizations/{org}/` | org-scoped commands | ## Changes ### New Files - **`src/lib/sentry-url-parser.ts`** — Pure URL parser (`parseSentryUrl`) + self-hosted env config (`applySentryUrlContext`) - **`test/lib/sentry-url-parser.test.ts`** — 30 unit tests covering all URL patterns, edge cases, and env var behavior - **`test/lib/sentry-url-parser.property.test.ts`** — Round-trip property tests verifying URLs built by `sentry-urls.ts` can be parsed back ### Modified Files - **`src/lib/arg-parsing.ts`** — URL pre-parsing in `parseIssueArg()` and `parseOrgProjectArg()` - **`src/commands/event/view.ts`** — URL handling in `parsePositionalArgs()` for event URLs - **`src/lib/oauth.ts`** — Replaced frozen `const SENTRY_URL` with lazy `getSentryUrl()` function - **`src/lib/sentry-client.ts`** — Removed frozen `const CONTROL_SILO_URL`, made `getControlSiloUrl()` read env var lazily ### Test Updates - **`test/lib/arg-parsing.test.ts`** — Added URL integration tests for both parsers - **`test/commands/event/view.test.ts`** — Added event URL handling tests ## Fixes Resolves CLI-66 (self-hosted users get confusing errors when pasting URLs)
1 parent b138f02 commit 8aa9cf6

File tree

9 files changed

+929
-18
lines changed

9 files changed

+929
-18
lines changed

src/commands/event/view.ts

Lines changed: 37 additions & 3 deletions
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,17 @@ 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 returned as `targetArg` in `"{org}/"` format
79+
* (OrgAll). Since event URLs don't contain a project slug, the caller
80+
* must fall back to auto-detection for the project. The URL must contain
81+
* an eventId segment — issue-only URLs are not valid for event view.
6882
*
6983
* @returns Parsed event ID and optional target arg
7084
*/
@@ -81,6 +95,23 @@ export function parsePositionalArgs(args: string[]): {
8195
throw new ContextError("Event ID", USAGE_HINT);
8296
}
8397

98+
// URL detection — extract eventId and org from Sentry event URLs
99+
const urlParsed = parseSentryUrl(first);
100+
if (urlParsed) {
101+
applySentryUrlContext(urlParsed.baseUrl);
102+
if (urlParsed.eventId) {
103+
// Event URL: pass org as OrgAll target ("{org}/").
104+
// Event URLs don't contain a project slug, so viewCommand falls
105+
// back to auto-detect for the project while keeping the org context.
106+
return { eventId: urlParsed.eventId, targetArg: `${urlParsed.org}/` };
107+
}
108+
// URL recognized but no eventId — not valid for event view
109+
throw new ContextError(
110+
"Event ID in URL (use a URL like /issues/{id}/events/{eventId}/)",
111+
USAGE_HINT
112+
);
113+
}
114+
84115
if (args.length === 1) {
85116
// Single arg - must be event ID
86117
return { eventId: first, targetArg: undefined };
@@ -181,8 +212,11 @@ export const viewCommand = buildCommand({
181212
}
182213

183214
case ProjectSpecificationType.OrgAll:
184-
throw new ContextError("Specific project", USAGE_HINT);
185-
215+
// Org-only (e.g., from event URL that has no project slug).
216+
// Fall through to auto-detect — SENTRY_URL is already set for
217+
// self-hosted, and auto-detect will resolve the project from
218+
// DSN, config defaults, or directory name inference.
219+
// falls through
186220
case ProjectSpecificationType.AutoDetect:
187221
target = await resolveOrgAndProject({ cwd, usageHint: USAGE_HINT });
188222
break;

src/lib/arg-parsing.ts

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

9+
import { ValidationError } from "./errors.js";
10+
import type { ParsedSentryUrl } from "./sentry-url-parser.js";
11+
import { applySentryUrlContext, parseSentryUrl } from "./sentry-url-parser.js";
912
import { isAllDigits } from "./utils.js";
1013

1114
/** Default span depth when no value is provided */
@@ -96,11 +99,60 @@ export type ParsedOrgProject =
9699
| { type: typeof ProjectSpecificationType.ProjectSearch; projectSlug: string }
97100
| { type: typeof ProjectSpecificationType.AutoDetect };
98101

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

124176
const trimmed = arg.trim();
125177

178+
// URL detection — extract org/project from Sentry web URLs
179+
const urlParsed = parseSentryUrl(trimmed);
180+
if (urlParsed) {
181+
applySentryUrlContext(urlParsed.baseUrl);
182+
return orgProjectFromUrl(urlParsed);
183+
}
184+
126185
if (trimmed.includes("/")) {
127186
const slashIndex = trimmed.indexOf("/");
128187
const org = trimmed.slice(0, slashIndex);
@@ -287,6 +346,21 @@ function parseWithDash(arg: string): ParsedIssueArg {
287346
}
288347

289348
export function parseIssueArg(arg: string): ParsedIssueArg {
349+
// 0. URL detection — extract issue ID from Sentry web URLs
350+
const urlParsed = parseSentryUrl(arg);
351+
if (urlParsed) {
352+
applySentryUrlContext(urlParsed.baseUrl);
353+
const result = issueArgFromUrl(urlParsed);
354+
if (result) {
355+
return result;
356+
}
357+
// URL recognized but no issue ID (e.g., trace or project settings URL)
358+
throw new ValidationError(
359+
"This Sentry URL does not contain an issue ID. Use an issue URL like:\n" +
360+
" https://sentry.io/organizations/{org}/issues/{id}/"
361+
);
362+
}
363+
290364
// 1. Pure numeric → direct fetch by ID
291365
if (isAllDigits(arg)) {
292366
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: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,6 @@ 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;
20-
2115
/** Request timeout in milliseconds */
2216
const REQUEST_TIMEOUT_MS = 30_000;
2317

@@ -291,9 +285,12 @@ export function getApiBaseUrl(): string {
291285
/**
292286
* Get the control silo URL.
293287
* This is always sentry.io for SaaS, or the custom URL for self-hosted.
288+
*
289+
* Read lazily (not at module load) so that SENTRY_URL set after import
290+
* (e.g., from URL argument parsing for self-hosted instances) is respected.
294291
*/
295292
export function getControlSiloUrl(): string {
296-
return CONTROL_SILO_URL;
293+
return process.env.SENTRY_URL || DEFAULT_SENTRY_URL;
297294
}
298295

299296
/**
@@ -339,7 +336,7 @@ export function getDefaultSdkConfig() {
339336
* Used for endpoints that are always on the control silo (OAuth, user accounts, regions).
340337
*/
341338
export function getControlSdkConfig() {
342-
return getSdkConfig(CONTROL_SILO_URL);
339+
return getSdkConfig(getControlSiloUrl());
343340
}
344341

345342
/**

0 commit comments

Comments
 (0)