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
40 changes: 37 additions & 3 deletions src/commands/event/view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -64,7 +68,17 @@ const USAGE_HINT = "sentry event view <org>/<project> <event-id>";

/**
* Parse positional arguments for event view.
* Handles: `<event-id>` or `<target> <event-id>`
*
* Handles:
* - `<event-id>` — event ID only (auto-detect org/project)
* - `<target> <event-id>` — explicit target + event ID
* - `<sentry-url>` — 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
*/
Expand All @@ -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 };
Expand Down Expand Up @@ -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:
Comment on lines +215 to 220
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The event view command discards the organization parsed from an event URL, causing it to potentially fetch the event from the wrong organization based on auto-detection.
Severity: MEDIUM

Suggested Fix

In the ProjectSpecificationType.OrgAll case within src/commands/event/view.ts, pass the parsed organization to the resolveOrgAndProject function. The call should be updated to await resolveOrgAndProject({ org: parsed.org, cwd, usageHint: USAGE_HINT }) to ensure the project lookup is constrained to the correct organization.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: src/commands/event/view.ts#L215-L220

Potential issue: When the `event view` command is used with a full event URL, the
organization is correctly parsed from the URL but is subsequently discarded. The
`resolveOrgAndProject` function is then called without the `org` parameter. This
triggers an auto-detection mechanism that may resolve to a different organization based
on local configuration or environment variables. As a result, the command may attempt to
fetch the event from the wrong organization, leading to a failure to find the specified
event.

target = await resolveOrgAndProject({ cwd, usageHint: USAGE_HINT });
break;
Expand Down
74 changes: 74 additions & 0 deletions src/lib/arg-parsing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -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)
Expand All @@ -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);
Expand Down Expand Up @@ -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 };
Expand Down
20 changes: 14 additions & 6 deletions src/lib/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
);
Expand All @@ -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" },
Expand Down Expand Up @@ -146,7 +154,7 @@ function requestDeviceCode() {
function pollForToken(deviceCode: string): Promise<TokenResponse> {
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" },
Expand Down Expand Up @@ -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" },
Expand Down
13 changes: 5 additions & 8 deletions src/lib/sentry-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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());
}

/**
Expand Down
Loading
Loading