From 4c6a33649e58b6ce86ccc47833b2d8042697c10b Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 19:31:27 +0000 Subject: [PATCH 1/3] ref(cli): Parse event view arguments with the shared ID grammar Event IDs in `event view` now go through `parseIdArguments` with a new `ID_KINDS.event` kind, and the arguments are parsed before the command runs (`boundArguments`). Issue forms (short IDs, numeric IDs, issue URLs, `/`, ` `) are checked first. The hand-written slash, swap, and single-argument parsing is gone, and so is event view's exemption from `sentry-cli/no-ad-hoc-id-arguments`. The shared grammar now also reads more IDs after reversed arguments (` / ...`), which its header already promised and event view did before. Part of #1564 Co-Authored-By: BYK --- packages/cli/AGENTS.md | 10 +- packages/cli/grammar/id-arguments.pegjs | 8 +- packages/cli/lint-rules/cli-oxlint-plugin.js | 11 +- packages/cli/src/commands/event/view.ts | 494 ++++++++---------- packages/cli/src/lib/id-arguments.ts | 3 +- packages/cli/test/commands/event/view.test.ts | 168 +++--- packages/cli/test/lib/id-arguments.test.ts | 16 + .../test/lib/security/url-arg-poison.test.ts | 29 +- 8 files changed, 369 insertions(+), 370 deletions(-) diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md index cf76e6c98..aa642f2f5 100644 --- a/packages/cli/AGENTS.md +++ b/packages/cli/AGENTS.md @@ -228,8 +228,8 @@ export const myRoute = buildRouteMap({ ### Positional Arguments Required identifiers (trace IDs, span IDs) should be **positional args**, not -flags. Every command that takes trace, span, replay, or log IDs parses them -with `parseIdArguments` (or `parseIdArgument` for exactly one) from +flags. Every command that takes trace, span, replay, log, or event IDs parses +them with `parseIdArguments` (or `parseIdArgument` for exactly one) from `src/lib/id-arguments.ts`, so they all take the same forms: - ``, `/`, `//`, ` `, a Sentry URL @@ -248,8 +248,8 @@ target is known. The result is a `target` string for `parseOrgProjectInput` and the IDs. Add a kind to `ID_KINDS` rather than validating IDs in a command; the `sentry-cli/no-ad-hoc-id-arguments` lint rule rejects hand-written ID parsing (`parseSlashSeparatedArg`, `HEX_ID_RE`, ...) in commands and kinds -defined outside `ID_KINDS`. Issue IDs (and `event view`, which takes them) -keep their own syntax. +defined outside `ID_KINDS`. Issue IDs keep their own syntax; `event view`, +which also takes them, checks for an issue first (`parseEventViewInput`). **Issue positionals:** issue IDs, short IDs, selectors, and issue URLs are parsed by `parseIssueArgument` (`src/lib/issue-arguments.ts`), whose forms @@ -328,7 +328,7 @@ command's help instead. The same purity rule applies: return a pasted URL's serv (`baseUrl`) and any warnings from the parse, and apply them in `resolve` (`parseTraceTargetInput` and `parseDualModeInput` in `trace-target.ts` do this for trace targets). Commands that recover a malformed ID (`trace view`, -`span view`, `log view`) parse it unvalidated and validate or recover it in +`span view`, `log view`, `event view`) parse it unvalidated and validate or recover it in `resolve`, which can look the prefix up: `parseTraceArgumentsInput` and `resolveTraceArguments` do this for trace IDs. A missing ID or a bad target still fails the parse. diff --git a/packages/cli/grammar/id-arguments.pegjs b/packages/cli/grammar/id-arguments.pegjs index 8866e6c86..cb02204f5 100644 --- a/packages/cli/grammar/id-arguments.pegjs +++ b/packages/cli/grammar/id-arguments.pegjs @@ -53,11 +53,11 @@ arguments } // Any other URL is a target / sep t:url x:more { return { target: t, ids: x }; } - // ` /`: the arguments are reversed. When IDs are - // optional, only a real ID can come first. + // ` / [...]`: the arguments are reversed. When IDs + // are optional, only a real ID can come first. / sep i:segment &{ return !optional || isId(i); } sep !idPath - t:$([^/\0]* "/" [^\0]*) end - { return { target: t, ids: [{ text: i, id: i }], reversed: true }; } + t:$([^/\0]* "/" [^\0]*) x:more + { return { target: t, ids: [{ text: i, id: i }, ...x], reversed: true }; } // `[/[/]] [...]` / sep p:idPath x:more { return { diff --git a/packages/cli/lint-rules/cli-oxlint-plugin.js b/packages/cli/lint-rules/cli-oxlint-plugin.js index 97a95a26b..8ac95a893 100644 --- a/packages/cli/lint-rules/cli-oxlint-plugin.js +++ b/packages/cli/lint-rules/cli-oxlint-plugin.js @@ -66,6 +66,7 @@ const BOUND_TARGET_TYPES = new Set([ "OrgProjectTarget", "ProjectBoundTarget", "BoundArguments", + "EventViewArguments", "LogListArguments", "LogViewArguments", "ReplayArguments", @@ -165,12 +166,6 @@ const AD_HOC_ID_PARSING = new Set([ "SPAN_ID_RE", ]); -/** - * Commands still allowed to: `event view` also takes issue IDs, which are - * not hex IDs and have their own syntax. - */ -const AD_HOC_ID_PARSING_ALLOWED = ["/src/commands/event/view.ts"]; - const ID_ARGUMENT_PARSERS = new Set(["parseIdArguments", "parseIdArgument"]); /** Whether `node` is `ID_KINDS.`. */ @@ -355,9 +350,7 @@ const rules = { (_context, file, report) => { if (!file.includes("/src/") || file.endsWith("/lib/id-arguments.ts")) return {}; - const commands = - file.includes("/src/commands/") && - !AD_HOC_ID_PARSING_ALLOWED.some((allowed) => file.endsWith(allowed)); + const commands = file.includes("/src/commands/"); return { Identifier(node) { if ( diff --git a/packages/cli/src/commands/event/view.ts b/packages/cli/src/commands/event/view.ts index 9f64345eb..a39a7c4b2 100644 --- a/packages/cli/src/commands/event/view.ts +++ b/packages/cli/src/commands/event/view.ts @@ -18,17 +18,16 @@ import { resolveEventInOrg, } from "../../lib/api-client.js"; import { - detectSwappedViewArgs, looksLikeIssueShortId, + type ParsedOrgProject, ProjectSpecificationType, - parseOrgProjectArg, - parseSlashSeparatedArg, + parseOrgProjectInput, spansFlag, splitNewlineArg, } from "../../lib/arg-parsing.js"; import { openInBrowser } from "../../lib/browser.js"; import { buildCommand } from "../../lib/command.js"; -import { parseOrgProjectTarget } from "../../lib/completion-target.js"; +import { ORG_PROJECT_TARGET } from "../../lib/completion-target.js"; import { ApiError, AuthError, @@ -46,18 +45,23 @@ import { import { formatEventDetails } from "../../lib/formatters/index.js"; import { filterFields } from "../../lib/formatters/json.js"; import { CommandOutput } from "../../lib/formatters/output.js"; -import { HEX_ID_RE, normalizeHexId, validateHexId } from "../../lib/hex-id.js"; +import { validateHexId } from "../../lib/hex-id.js"; import { handleRecoveryResult, recoverHexId, resolveRecoveryOrg, } from "../../lib/hex-id-recovery.js"; +import { ID_KINDS, parseIdArguments } from "../../lib/id-arguments.js"; import { applyFreshFlag, FRESH_ALIASES, FRESH_FLAG, } from "../../lib/list-command.js"; import { logger } from "../../lib/logger.js"; +import { + type BoundArguments, + boundArguments, +} from "../../lib/positional-parse.js"; import { resolveEffectiveOrg } from "../../lib/region.js"; import { getReplayIdFromEvent } from "../../lib/replay-search.js"; import { @@ -67,6 +71,7 @@ import { } from "../../lib/resolve-target.js"; import { applySentryUrlContext, + type ParsedSentryUrl, parseSentryUrl, } from "../../lib/sentry-url-parser.js"; import { buildEventSearchUrl } from "../../lib/sentry-web-urls.js"; @@ -212,9 +217,9 @@ const USAGE_HINT = "sentry event view / "; * * When an agent pastes `"org/project/id1\nid2\nid3"` as a single arg, * this produces `["org/project/id1", "id2", "id3"]` — the first retains - * the org/project prefix so `parsePositionalArgs` can extract the target. + * the org/project prefix so the target is still found (CLI-1HT). */ -export function expandNewlineArgs(args: string[]): string[] { +export function expandNewlineArgs(args: readonly string[]): string[] { return args.flatMap(splitNewlineArg); } @@ -232,282 +237,184 @@ const LATEST_EVENT_SENTINEL = "@latest"; const BARE_LATEST_HINT = "sentry event view "; /** - * Parse a single positional arg for event view, handling issue short ID - * detection both in bare form ("BRUNCHIE-APP-29") and org-prefixed form - * ("figma/FULLSCREEN-2RN"). + * The arguments of `event view`, parsed. Parsing has no side effects: the + * server of a pasted URL and the warnings are applied when they resolve. + */ +type EventViewInput = { + /** + * The event ID, as typed (validated when it resolves), or + * {@link LATEST_EVENT_SENTINEL} for an issue's latest event + */ + eventId: string; + /** The org/project target argument; `undefined` to auto-detect */ + targetArg: string | undefined; + /** `targetArg`, parsed */ + target: ParsedOrgProject; + /** Numeric issue ID, from an issue URL or argument: its latest event */ + issueId?: string; + /** Issue short ID (e.g. `BRUNCHIE-APP-29`) the event belongs to */ + issueShortId?: string; + /** More event IDs, as typed, from more arguments or lines */ + extraEventIds?: string[]; + /** Server of a pasted Sentry URL, for `applySentryUrlContext` */ + baseUrl?: string; + /** Warnings to log, such as for reversed arguments */ + warnings: readonly string[]; +}; + +/** `event view` arguments that name an issue before the event, if any. */ +type IssueInput = Omit; + +/** + * Parse the arguments of `event view`. + * + * Event IDs take the forms every ID-taking command does (see + * `parseIdArguments` and `ID_KINDS.event`): ``, + * `//`, ` [...]`, + * reversed arguments, and Sentry event URLs. A lone `/` names + * project ``. * - * Must run before `parseSlashSeparatedArg` because that function throws - * ContextError for single-slash args like "org/SHORT-ID", which looks like - * "org/project" with a missing event ID. + * Before those, `event view` takes an issue instead of, or with, an event: + * - `[/]` or `[/]`: the issue's + * latest event; also as the ID after a target + * - `[/] ` and `/`: + * an event of the issue, whose project it is looked up in + * - an issue URL without an event: the issue's latest event + * + * @throws {ContextError} When the event ID is missing, or is `latest` + * without an issue + * @throws {ValidationError} When the target or an issue's event ID is + * invalid */ -function parseSingleArg(arg: string): ParsedPositionalArgs { - // Detect single-slash shortcuts ("org/SHORT-ID", "SHORT-ID/EVENT-ID", - // "project/EVENT-ID", "org/NUMERIC-ID") before parseSlashSeparatedArg, - // which throws ContextError for single-slash args. - const slashIdx = arg.indexOf("/"); - if (slashIdx !== -1 && arg.indexOf("/", slashIdx + 1) === -1) { - const singleSlash = parseSingleSlashArg( - arg.slice(0, slashIdx), - arg.slice(slashIdx + 1), - ); - if (singleSlash) { - return singleSlash; - } +export function parseEventViewInput(args: readonly string[]): EventViewInput { + const tokens = expandNewlineArgs(args); + const [first] = tokens; + if (first === undefined) { + throw new ContextError("Event ID", USAGE_HINT, []); } - const { id: eventId, targetArg } = parseSlashSeparatedArg( - arg, - "Event ID", - USAGE_HINT, - ); - - // Detect bare issue short ID passed as event ID (e.g., "BRUNCHIE-APP-29"). - if (!targetArg && looksLikeIssueShortId(eventId)) { - return { - eventId: LATEST_EVENT_SENTINEL, - targetArg: undefined, - issueShortId: eventId, + let input: IssueInput; + let baseUrl: string | undefined; + let warnings: readonly string[] = []; + const url = parseSentryUrl(first); + const issue = url ? issueFromUrl(url) : issueForms(tokens); + if (issue) { + input = issue; + baseUrl = url?.baseUrl; + } else { + const parsed = parseIdArguments(tokens, ID_KINDS.event, { + usageHint: USAGE_HINT, + multiple: true, + needsProject: true, + validate: false, + }); + const [eventId = "", ...extraEventIds] = parsed.ids; + input = issueFromEventId(eventId, parsed.target) ?? { + eventId, + targetArg: parsed.target, }; + if (extraEventIds.length > 0) { + input.extraEventIds = extraEventIds; + } + baseUrl = parsed.baseUrl; + warnings = parsed.warnings; } - // Reject bare "latest" (without the "@" prefix sentinel). There is no - // "latest event of a project" endpoint — the latest-event lookup is keyed - // by issue, so a bare "latest" has nothing to resolve against. - if (eventId.toLowerCase() === "latest") { + if (input.eventId.toLowerCase() === "latest") { + // There is no "latest event of a project" endpoint — the latest-event + // lookup is keyed by issue, so a bare "latest" has nothing to resolve throw new ContextError("Issue ID", BARE_LATEST_HINT, [ "'latest' resolves the newest event of an issue — pass an issue ID, e.g. 'sentry event view 17370'", ]); } - - // Detect numeric issue ID (e.g., "17370") — treat as issueId and fetch latest event. - if (isAllDigits(eventId)) { - return { eventId: LATEST_EVENT_SENTINEL, targetArg, issueId: eventId }; - } - - return { eventId, targetArg }; + const target = parseOrgProjectInput(input.targetArg); + return { + ...input, + target: target.parsed, + baseUrl: baseUrl ?? target.baseUrl, + warnings, + }; } /** - * Resolve a single-slash positional arg (`before/after`) to a parsed result, - * or `null` when it isn't one of the recognized shortcut shapes and should - * fall through to `parseSlashSeparatedArg`. - * - * Handled shapes: - * - `org/SHORT-ID` → org-all + issue short ID (latest event) - * - `SHORT-ID/EVENT-ID` → specific event via issue short ID - * - `org/NUMERIC-ID` → org-all + numeric issue ID (latest event) - * - `project/EVENT-ID` → project slug + hex event ID + * The issue in a Sentry URL without an event ID, whose latest event to + * show; `null` for an event URL. * - * Extracted from `parseSingleArg` to keep it under the cognitive-complexity - * limit. + * @throws {ContextError} When the URL names neither an event nor an issue */ -function parseSingleSlashArg( - beforeSlash: string, - afterSlash: string, -): ParsedPositionalArgs | null { - // "org/SHORT-ID" → auto-redirect to that issue's latest event. - // e.g., "figma/FULLSCREEN-2RN". Use "org/" (trailing slash) to signal - // OrgAll mode so downstream parseOrgProjectArg treats it as an org. - if (afterSlash && looksLikeIssueShortId(afterSlash)) { - return { - eventId: LATEST_EVENT_SENTINEL, - targetArg: `${beforeSlash}/`, - issueShortId: afterSlash, - }; - } - - // "SHORT-ID/EVENT-ID" → view a specific event identified by issue short ID. - // e.g., "CLI-G5/abc123def456abc123def456abc123de" - if ( - beforeSlash && - looksLikeIssueShortId(beforeSlash) && - afterSlash && - HEX_ID_RE.test(normalizeHexId(afterSlash)) - ) { - return { - eventId: normalizeHexId(afterSlash), - targetArg: undefined, - issueShortId: beforeSlash, - }; +function issueFromUrl(url: ParsedSentryUrl): IssueInput | null { + if (url.eventId && url.org) { + return null; } - - // "org/NUMERIC-ID" → org-all + numeric issue ID (latest event). - // e.g., "my-org/17370". Mirrors bare "17370" and "my-org/my-project 17370", - // and matches how `sentry issue view org/numericId` already behaves. - if (beforeSlash && afterSlash && isAllDigits(afterSlash)) { + if (url.issueId && url.org) { return { eventId: LATEST_EVENT_SENTINEL, - targetArg: `${beforeSlash}/`, - issueId: afterSlash, + targetArg: `${url.org}/`, + issueId: url.issueId, }; } - - // "project/EVENT-ID" → project slug + hex event ID. - // e.g., "my-project/abc123def456abc123def456abc123de" - if (afterSlash && HEX_ID_RE.test(normalizeHexId(afterSlash))) { - return { - eventId: normalizeHexId(afterSlash), - targetArg: beforeSlash, - }; - } - - return null; + throw new ContextError("Event ID", USAGE_HINT, [ + "Pass an event URL: https://sentry.io/organizations/{org}/issues/{id}/events/{eventId}/", + "Or an issue URL to view the latest event: https://sentry.io/organizations/{org}/issues/{id}/", + ]); } -/** Return type for parsePositionalArgs */ -type ParsedPositionalArgs = { - eventId: string; - targetArg: string | undefined; - /** Issue ID from a Sentry issue URL — triggers latest-event fetch */ - issueId?: string; - /** Issue short ID detected from positional args (e.g., "BRUNCHIE-APP-29") */ - issueShortId?: string; - /** Warning message if arguments appear to be in the wrong order */ - warning?: string; - /** Additional event IDs from newline-separated input or extra positional args */ - extraEventIds?: string[]; -}; - /** - * Parse positional arguments for event view. - * - * Handles: - * - `` — event ID only (auto-detect org/project) - * - ` ` — explicit target + event ID - * - `[/] ` — resolve the project from the issue - * - `` — extract eventId and org from a Sentry event URL - * (e.g., `https://sentry.example.com/organizations/my-org/issues/123/events/abc/`) - * - `` — extract issueId and org; caller fetches latest event - * (e.g., `https://sentry.example.com/organizations/my-org/issues/123/`) + * The issue `event view` arguments name in their own forms, before any + * event ID; `null` when they name none. * - * 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. - * - * For issue URLs (no eventId segment), the `issueId` field is set so the - * caller can fetch the latest event via `getLatestEvent(org, issueId)`. - * - * @returns Parsed event ID and optional target arg + * @throws {ValidationError} When the event ID after an issue short ID is + * invalid: it must not fall back to the latest event */ -// positional arg parsing has many format branches by design -export function parsePositionalArgs(args: string[]): ParsedPositionalArgs { - if (args.length === 0) { - throw new ContextError("Event ID", USAGE_HINT, []); - } +function issueForms(tokens: readonly string[]): IssueInput | null { + const [first = "", second] = tokens; + const slash = first.indexOf("/"); + // `[/]`: what follows the org, and the target it names + const [id, org] = + slash === -1 || first.includes("/", slash + 1) + ? [first, undefined] + : [first.slice(slash + 1), `${first.slice(0, slash)}/`]; - const first = args[0]; - if (first === undefined) { - 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 && 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 && urlParsed.org) { - // Issue URL without event ID — fetch the latest event for this issue. - // The caller uses issueId to fetch via getLatestEvent. + if (second === undefined) { + // `/`: the event of an issue + const shortId = org?.slice(0, -1) ?? ""; + if (looksLikeIssueShortId(shortId) && ID_KINDS.event.isId(id)) { return { - eventId: LATEST_EVENT_SENTINEL, - targetArg: `${urlParsed.org}/`, - issueId: urlParsed.issueId, + eventId: ID_KINDS.event.validate(id), + targetArg: undefined, + issueShortId: shortId, }; } - // URL recognized but no eventId or issueId — not useful for event view - throw new ContextError("Event ID", USAGE_HINT, [ - "Pass an event URL: https://sentry.io/organizations/{org}/issues/{id}/events/{eventId}/", - "Or an issue URL to view the latest event: https://sentry.io/organizations/{org}/issues/{id}/", - ]); - } - - if (args.length === 1) { - return parseSingleArg(first); - } - - // When newline expansion splits "org/project/id1\nid2" into multiple args, - // the first arg is "org/project/id1" (2+ slashes). Route it through the - // single-arg path to correctly extract org/project vs id, then collect the - // remaining args as extra event IDs (CLI-1HT). - const slashCount = (first.match(/\//g) ?? []).length; - if (slashCount >= 2) { - const parsed = parseSingleArg(first); - const extraEventIds = args.slice(1); - return { ...parsed, extraEventIds }; - } - - const second = args[1]; - if (second === undefined) { - // Should not happen given length check, but TypeScript needs this - return { eventId: first, targetArg: undefined }; - } - - // Detect swapped args: user put ID first and target second - const swapWarning = detectSwappedViewArgs(first, second); - if (swapWarning) { - const extraEventIds = args.length > 2 ? args.slice(2) : undefined; - return { - eventId: first, - targetArg: second, - warning: swapWarning, - extraEventIds, - }; + return issueFromEventId(id, org); } - - // An issue identifies the project; an explicit event ID must not be replaced - // by latest. Reuse the single-arg parser to preserve an optional org prefix. - const issueShortId = - slashCount === 1 ? first.slice(first.indexOf("/") + 1) : first; - if (looksLikeIssueShortId(issueShortId)) { + // `[/] `: the event of an issue. A second + // argument with a slash is a target: the arguments are reversed. + if (looksLikeIssueShortId(id) && !second.includes("/")) { return { - ...parseSingleArg(first), - // Issue shortcuts run before the normal event-ID validation path. - eventId: validateHexId(second, "event ID"), + // Issue shortcuts skip the event ID's recovery + eventId: ID_KINDS.event.validate(second), + targetArg: org, + issueShortId: id, }; } + return null; +} - // Reject bare "latest" as second arg (e.g., "my-org/my-project latest"). - // Even with an explicit project there is no "latest event of a project" - // endpoint — latest is resolved per-issue, so this has nothing to key on. - if (second.toLowerCase() === "latest") { - throw new ContextError("Issue ID", BARE_LATEST_HINT, [ - "'latest' resolves the newest event of an issue — pass an issue ID, e.g. 'sentry event view 17370'", - ]); +/** + * The issue named where an event ID goes (a short ID or a numeric issue + * ID), whose latest event to show; `null` for anything else. + */ +function issueFromEventId( + id: string, + targetArg: string | undefined, +): IssueInput | null { + if (looksLikeIssueShortId(id)) { + return { eventId: LATEST_EVENT_SENTINEL, targetArg, issueShortId: id }; } - - // Detect numeric issue ID as second arg (e.g., "my-org/my-project 17370"). - if (isAllDigits(second)) { - const extraEventIds = args.length > 2 ? args.slice(2) : undefined; - return { - eventId: LATEST_EVENT_SENTINEL, - targetArg: first, - issueId: second, - extraEventIds, - }; + if (isAllDigits(id)) { + return { eventId: LATEST_EVENT_SENTINEL, targetArg, issueId: id }; } - - // Detect issue short ID as second arg (e.g., "my-org/my-project BRUNCHIE-APP-29"). - // Auto-redirect to that issue's latest event instead of treating the short - // ID as an event hex ID (which would fail validation). - if (looksLikeIssueShortId(second)) { - const extraEventIds = args.length > 2 ? args.slice(2) : undefined; - return { - eventId: LATEST_EVENT_SENTINEL, - targetArg: first, - issueShortId: second, - extraEventIds, - }; - } - - // Two or more args - first is target, second is event ID. - // Any additional args are extra event IDs (from newline-separated input). - const extraEventIds = args.length > 2 ? args.slice(2) : undefined; - return { eventId: second, targetArg: first, extraEventIds }; + return null; } /** @@ -524,7 +431,7 @@ export function parsePositionalArgs(args: string[]): ParsedPositionalArgs { */ async function validateAndRecoverEventId( rawEventId: string, - parsed: ReturnType, + parsed: ParsedOrgProject, skipValidation: boolean, ): Promise { if (skipValidation) { @@ -547,6 +454,44 @@ async function validateAndRecoverEventId( } } +/** `event view` arguments, with the event ID validated or recovered. */ +type ResolvedEventViewInput = Omit; + +/** The arguments of `event view`. */ +type EventViewArguments = BoundArguments< + EventViewInput, + ResolvedEventViewInput +>; + +/** + * Resolve `event view` arguments: apply a pasted URL's server, log the + * warnings, and validate the event ID or recover a malformed one. An issue + * shortcut's event ID needs neither: the issue finds the event. + */ +async function resolveEventViewInput( + input: EventViewInput, +): Promise { + const { baseUrl, warnings, ...resolved } = input; + if (baseUrl) { + applySentryUrlContext(baseUrl); + } + for (const warning of warnings) { + logger.withTag("event.view").warn(warning); + } + const skipValidation = + resolved.eventId === LATEST_EVENT_SENTINEL || + resolved.issueId !== undefined || + resolved.issueShortId !== undefined; + return { + ...resolved, + eventId: await validateAndRecoverEventId( + resolved.eventId, + resolved.target, + skipValidation, + ), + }; +} + /** * Resolved target type for event commands. * @internal Exported for testing @@ -563,7 +508,7 @@ export type ResolvedEventTarget = { /** Options for resolving the event target */ type ResolveTargetOptions = { - parsed: ReturnType; + parsed: ParsedOrgProject; eventId: string; cwd: string; }; @@ -915,7 +860,7 @@ type IssueShortcutResult = { /** Options for resolving issue-based shortcuts */ type IssueShortcutOptions = { - parsed: ReturnType; + parsed: ParsedOrgProject; eventId: string; issueId: string | undefined; issueShortId: string | undefined; @@ -951,7 +896,7 @@ async function tryResolveIssueProject( * slug so attachment lookup can use the project-scoped endpoint. */ async function resolveIssueIdShortcut( - parsed: ReturnType, + parsed: ParsedOrgProject, issueId: string, cwd: string, spans: number, @@ -1222,15 +1167,14 @@ export const viewCommand = buildCommand({ schema: EventViewOutputSchema, }, parameters: { - positional: { - kind: "array", - parameter: { - placeholder: "org/project/event-id", - brief: - "[/] [...] - Target (optional) and one or more event IDs", - parse: parseOrgProjectTarget, - }, - }, + positional: boundArguments({ + placeholder: "org/project/event-id", + brief: + "[/] [...] - Target (optional) and one or more event IDs", + kind: ORG_PROJECT_TARGET, + parse: parseEventViewInput, + resolve: resolveEventViewInput, + }), flags: { web: { kind: "boolean", @@ -1242,25 +1186,18 @@ export const viewCommand = buildCommand({ }, aliases: { ...FRESH_ALIASES, w: "web" }, }, - async *func(this: SentryContext, flags: ViewFlags, ...args: string[]) { + async *func(this: SentryContext, flags: ViewFlags, args: EventViewArguments) { applyFreshFlag(flags); const { cwd } = this; const log = logger.withTag("event.view"); - - // Expand newline-separated args — agents paste multiple event IDs - // as a single newline-separated argument (CLI-1HT). - const expandedArgs = expandNewlineArgs(args); - - // Parse positional args - const parsedArgs = parsePositionalArgs(expandedArgs); - if (parsedArgs.warning) { - log.warn(parsedArgs.warning); - } - const { targetArg, issueId, issueShortId, extraEventIds } = parsedArgs; - let { eventId } = parsedArgs; - - const parsed = parseOrgProjectArg(targetArg); + const { + target: parsed, + eventId, + issueId, + issueShortId, + extraEventIds, + } = await args.resolved; // Handle issue-based shortcuts (issue URLs and short IDs) before // normal event resolution. When eventId is LATEST_EVENT_SENTINEL, @@ -1297,15 +1234,6 @@ export const viewCommand = buildCommand({ }; } - // Validate + attempt recovery. `skipValidation` is true when the ID is - // the LATEST_EVENT_SENTINEL or when resolveIssueShortcut already handled - // the request (issueId / issueShortId paths). - const skipValidation = - eventId === LATEST_EVENT_SENTINEL || - issueId !== undefined || - issueShortId !== undefined; - eventId = await validateAndRecoverEventId(eventId, parsed, skipValidation); - const target = await resolveEventTarget({ parsed, eventId, diff --git a/packages/cli/src/lib/id-arguments.ts b/packages/cli/src/lib/id-arguments.ts index d9072972e..3ab8b6d7c 100644 --- a/packages/cli/src/lib/id-arguments.ts +++ b/packages/cli/src/lib/id-arguments.ts @@ -2,7 +2,7 @@ * Positionals that name IDs and the org/project to look them up in, parsed * by `grammar/id-arguments.pegjs`. * - * Every command that takes trace, span, replay, or log IDs shares one + * Every command that takes trace, span, replay, log, or event IDs shares one * syntax, whatever the kind of ID: * * - ``: the org/project is auto-detected @@ -79,6 +79,7 @@ function hexKind( /** The kinds of ID commands take. Commands use these instead of their own. */ export const ID_KINDS = { trace: hexKind("Trace ID", (url) => url.traceId), + event: hexKind("Event ID", (url) => url.eventId), // Replays are looked up in an org, so a replay URL needs one replay: hexKind( "Replay ID", diff --git a/packages/cli/test/commands/event/view.test.ts b/packages/cli/test/commands/event/view.test.ts index f64dbd4c2..a9a6bea7d 100644 --- a/packages/cli/test/commands/event/view.test.ts +++ b/packages/cli/test/commands/event/view.test.ts @@ -15,7 +15,7 @@ import { fetchMultipleEvents, formatEventView, jsonTransformEventView, - parsePositionalArgs, + parseEventViewInput, resolveAutoDetectTarget, resolveEventTarget, resolveOrgAllTarget, @@ -91,16 +91,16 @@ vi.mock("../../../src/lib/span-tree.js", async (importOriginal) => { import * as spanTree from "../../../src/lib/span-tree.js"; import type { SentryEvent } from "../../../src/types/index.js"; -describe("parsePositionalArgs", () => { +describe("parseEventViewInput", () => { describe("single argument (event ID only)", () => { test("parses single arg as event ID", () => { - const result = parsePositionalArgs(["abc123def456"]); + const result = parseEventViewInput(["abc123def456"]); expect(result.eventId).toBe("abc123def456"); expect(result.targetArg).toBeUndefined(); }); test("parses UUID-like event ID", () => { - const result = parsePositionalArgs([ + const result = parseEventViewInput([ "a1b2c3d4-e5f6-7890-abcd-ef1234567890", ]); expect(result.eventId).toBe("a1b2c3d4-e5f6-7890-abcd-ef1234567890"); @@ -108,32 +108,32 @@ describe("parsePositionalArgs", () => { }); test("parses short event ID", () => { - const result = parsePositionalArgs(["abc"]); + const result = parseEventViewInput(["abc"]); expect(result.eventId).toBe("abc"); expect(result.targetArg).toBeUndefined(); }); test("detects issue short ID and sets issueShortId", () => { - const result = parsePositionalArgs(["BRUNCHIE-APP-29"]); + const result = parseEventViewInput(["BRUNCHIE-APP-29"]); expect(result.eventId).toBe("@latest"); expect(result.targetArg).toBeUndefined(); expect(result.issueShortId).toBe("BRUNCHIE-APP-29"); }); test("detects short issue ID like CLI-G", () => { - const result = parsePositionalArgs(["CLI-G"]); + const result = parseEventViewInput(["CLI-G"]); expect(result.eventId).toBe("@latest"); expect(result.issueShortId).toBe("CLI-G"); }); test("does not detect lowercase slug as issue short ID", () => { - const result = parsePositionalArgs(["my-project"]); + const result = parseEventViewInput(["my-project"]); expect(result.eventId).toBe("my-project"); expect(result.issueShortId).toBeUndefined(); }); test("detects org/ISSUE-SHORT-ID pattern (CLI-9K)", () => { - const result = parsePositionalArgs(["figma/FULLSCREEN-2RN"]); + const result = parseEventViewInput(["figma/FULLSCREEN-2RN"]); expect(result.eventId).toBe("@latest"); // Trailing slash signals OrgAll mode so downstream resolves org correctly expect(result.targetArg).toBe("figma/"); @@ -141,14 +141,14 @@ describe("parsePositionalArgs", () => { }); test("detects org/CLI-G pattern", () => { - const result = parsePositionalArgs(["sentry/CLI-G"]); + const result = parseEventViewInput(["sentry/CLI-G"]); expect(result.eventId).toBe("@latest"); expect(result.targetArg).toBe("sentry/"); expect(result.issueShortId).toBe("CLI-G"); }); test("detects SHORT-ID/EVENT-ID pattern (CLI-HV)", () => { - const result = parsePositionalArgs([ + const result = parseEventViewInput([ "CLI-G5/abc123def456abc123def456abc123de", ]); expect(result.eventId).toBe("abc123def456abc123def456abc123de"); @@ -157,7 +157,7 @@ describe("parsePositionalArgs", () => { }); test("detects multi-dash SHORT-ID/EVENT-ID pattern", () => { - const result = parsePositionalArgs([ + const result = parseEventViewInput([ "PHP-SYMFONY-HY/7388f6a62b7d436ab77bb5365f97a1ac", ]); expect(result.eventId).toBe("7388f6a62b7d436ab77bb5365f97a1ac"); @@ -166,7 +166,7 @@ describe("parsePositionalArgs", () => { }); test("normalizes UUID-format event ID in SHORT-ID/EVENT-ID", () => { - const result = parsePositionalArgs([ + const result = parseEventViewInput([ "CLI-G5/7388f6a6-2b7d-436a-b77b-b5365f97a1ac", ]); expect(result.eventId).toBe("7388f6a62b7d436ab77bb5365f97a1ac"); @@ -175,7 +175,7 @@ describe("parsePositionalArgs", () => { test("org/SHORT-ID takes precedence over SHORT-ID/EVENT-ID", () => { // "figma/FULLSCREEN-2RN" → org + issue, not issue + event - const result = parsePositionalArgs(["figma/FULLSCREEN-2RN"]); + const result = parseEventViewInput(["figma/FULLSCREEN-2RN"]); expect(result.eventId).toBe("@latest"); expect(result.targetArg).toBe("figma/"); expect(result.issueShortId).toBe("FULLSCREEN-2RN"); @@ -184,7 +184,7 @@ describe("parsePositionalArgs", () => { test("does not detect org/lowercase-slug as issue short ID", () => { // "my-org/my-project" is a normal org/project target, not an issue short ID. // parseSlashSeparatedArg will throw ContextError as expected. - expect(() => parsePositionalArgs(["my-org/my-project"])).toThrow( + expect(() => parseEventViewInput(["my-org/my-project"])).toThrow( "Event ID", ); }); @@ -192,19 +192,19 @@ describe("parsePositionalArgs", () => { describe("two arguments (target + event ID)", () => { test("parses org/project target and event ID", () => { - const result = parsePositionalArgs(["my-org/frontend", "abc123def456"]); + const result = parseEventViewInput(["my-org/frontend", "abc123def456"]); expect(result.targetArg).toBe("my-org/frontend"); expect(result.eventId).toBe("abc123def456"); }); test("parses project-only target and event ID", () => { - const result = parsePositionalArgs(["frontend", "abc123def456"]); + const result = parseEventViewInput(["frontend", "abc123def456"]); expect(result.targetArg).toBe("frontend"); expect(result.eventId).toBe("abc123def456"); }); test("parses org/ target (all projects) and event ID", () => { - const result = parsePositionalArgs(["my-org/", "abc123def456"]); + const result = parseEventViewInput(["my-org/", "abc123def456"]); expect(result.targetArg).toBe("my-org/"); expect(result.eventId).toBe("abc123def456"); }); @@ -215,16 +215,33 @@ describe("parsePositionalArgs", () => { ])( "preserves and normalizes the event ID after an issue short ID: %s", (eventId) => { - const result = parsePositionalArgs(["JAVASCRIPT-NUXT-52", eventId]); + const result = parseEventViewInput(["JAVASCRIPT-NUXT-52", eventId]); expect(result.issueShortId).toBe("JAVASCRIPT-NUXT-52"); expect(result.eventId).toBe("7388f6a62b7d436ab77bb5365f97a1ac"); expect(result.targetArg).toBeUndefined(); - expect(result.warning).toBeUndefined(); + expect(result.warnings).toEqual([]); }, ); + test("reads an issue short ID before a target as reversed arguments", () => { + const result = parseEventViewInput(["CAM-82X", "my-org/frontend"]); + expect(result.eventId).toBe("@latest"); + expect(result.issueShortId).toBe("CAM-82X"); + expect(result.targetArg).toBe("my-org/frontend"); + expect(result.warnings).toEqual([ + "Arguments appear reversed. Interpreting as: my-org/frontend CAM-82X", + ]); + }); + + test("reads an issue short ID after org/project/ as the issue", () => { + const result = parseEventViewInput(["my-org/frontend/CAM-82X"]); + expect(result.eventId).toBe("@latest"); + expect(result.issueShortId).toBe("CAM-82X"); + expect(result.targetArg).toBe("my-org/frontend"); + }); + test("rejects an incomplete event ID instead of selecting latest", () => { - expect(() => parsePositionalArgs(["CAM-82X", "95fd7f5a"])).toThrow( + expect(() => parseEventViewInput(["CAM-82X", "95fd7f5a"])).toThrow( ValidationError, ); }); @@ -232,12 +249,12 @@ describe("parsePositionalArgs", () => { describe("error cases", () => { test("throws ContextError for empty args", () => { - expect(() => parsePositionalArgs([])).toThrow(ContextError); + expect(() => parseEventViewInput([])).toThrow(ContextError); }); test("throws ContextError with usage hint", () => { try { - parsePositionalArgs([]); + parseEventViewInput([]); expect.unreachable("Should have thrown"); } catch (error) { expect(error).toBeInstanceOf(ContextError); @@ -248,14 +265,14 @@ describe("parsePositionalArgs", () => { describe("numeric issue IDs and bare 'latest'", () => { test("bare numeric ID maps to issueId + latest sentinel", () => { - const result = parsePositionalArgs(["17370"]); + const result = parseEventViewInput(["17370"]); expect(result.eventId).toBe("@latest"); expect(result.issueId).toBe("17370"); expect(result.targetArg).toBeUndefined(); }); test("org/numeric-ID maps to org-all target + issueId (CLI-1F5)", () => { - const result = parsePositionalArgs(["my-org/17370"]); + const result = parseEventViewInput(["my-org/17370"]); expect(result.eventId).toBe("@latest"); expect(result.issueId).toBe("17370"); // Trailing slash signals org-all so the org resolves downstream. @@ -263,25 +280,25 @@ describe("parsePositionalArgs", () => { }); test("org/project + numeric second arg maps to issueId", () => { - const result = parsePositionalArgs(["my-org/frontend", "17370"]); + const result = parseEventViewInput(["my-org/frontend", "17370"]); expect(result.eventId).toBe("@latest"); expect(result.issueId).toBe("17370"); expect(result.targetArg).toBe("my-org/frontend"); }); test("bare 'latest' (single arg) throws ContextError", () => { - expect(() => parsePositionalArgs(["latest"])).toThrow(ContextError); + expect(() => parseEventViewInput(["latest"])).toThrow(ContextError); }); test("'latest' as second arg throws ContextError", () => { - expect(() => parsePositionalArgs(["my-org/frontend", "latest"])).toThrow( + expect(() => parseEventViewInput(["my-org/frontend", "latest"])).toThrow( ContextError, ); }); test("bare 'latest' error mentions Issue ID", () => { try { - parsePositionalArgs(["latest"]); + parseEventViewInput(["latest"]); expect.unreachable("Should have thrown"); } catch (error) { expect(error).toBeInstanceOf(ContextError); @@ -290,19 +307,19 @@ describe("parsePositionalArgs", () => { }); test("'LATEST' is case-insensitive", () => { - expect(() => parsePositionalArgs(["LATEST"])).toThrow(ContextError); + expect(() => parseEventViewInput(["LATEST"])).toThrow(ContextError); }); }); describe("slash-separated org/project/eventId (single arg)", () => { test("parses org/project/eventId as target + event ID", () => { - const result = parsePositionalArgs(["sentry/cli/abc123def"]); + const result = parseEventViewInput(["sentry/cli/abc123def"]); expect(result.targetArg).toBe("sentry/cli"); expect(result.eventId).toBe("abc123def"); }); test("parses with long hex event ID", () => { - const result = parsePositionalArgs([ + const result = parseEventViewInput([ "my-org/frontend/a1b2c3d4e5f67890abcdef1234567890", ]); expect(result.targetArg).toBe("my-org/frontend"); @@ -310,22 +327,22 @@ describe("parsePositionalArgs", () => { }); test("handles hyphenated org and project slugs", () => { - const result = parsePositionalArgs(["my-org/my-project/deadbeef"]); + const result = parseEventViewInput(["my-org/my-project/deadbeef"]); expect(result.targetArg).toBe("my-org/my-project"); expect(result.eventId).toBe("deadbeef"); }); test("one slash (org/project, missing event ID) throws ContextError", () => { - expect(() => parsePositionalArgs(["sentry/cli"])).toThrow(ContextError); + expect(() => parseEventViewInput(["sentry/cli"])).toThrow(ContextError); }); test("trailing slash (org/project/) throws ContextError", () => { - expect(() => parsePositionalArgs(["sentry/cli/"])).toThrow(ContextError); + expect(() => parseEventViewInput(["sentry/cli/"])).toThrow(ContextError); }); test("one-slash ContextError mentions Event ID", () => { try { - parsePositionalArgs(["sentry/cli"]); + parseEventViewInput(["sentry/cli"]); expect.unreachable("Should have thrown"); } catch (error) { expect(error).toBeInstanceOf(ContextError); @@ -336,7 +353,7 @@ describe("parsePositionalArgs", () => { describe("edge cases", () => { test("collects extra args as additional event IDs", () => { - const result = parsePositionalArgs([ + const result = parseEventViewInput([ "my-org/frontend", "abc123", "extra-arg", @@ -346,10 +363,10 @@ describe("parsePositionalArgs", () => { expect(result.extraEventIds).toEqual(["extra-arg"]); }); - test("handles empty string event ID in two-arg case", () => { - const result = parsePositionalArgs(["my-org/frontend", ""]); - expect(result.targetArg).toBe("my-org/frontend"); - expect(result.eventId).toBe(""); + test("an empty event ID after a target is a missing one", () => { + expect(() => parseEventViewInput(["my-org/frontend", ""])).toThrow( + ContextError, + ); }); }); @@ -366,7 +383,7 @@ describe("parsePositionalArgs", () => { // First arg has 2+ slashes → routed through single-arg path to correctly // extract org/project and first event ID, remaining become extraEventIds. - const result = parsePositionalArgs(expanded); + const result = parseEventViewInput(expanded); expect(result.targetArg).toBe("perzimo/perzimo-server"); expect(result.eventId).toBe("189945b37884462cb9134bd5cabeaa3d"); expect(result.extraEventIds).toEqual([ @@ -377,7 +394,7 @@ describe("parsePositionalArgs", () => { test("single arg with newlines goes through single-arg path after expansion", () => { // When there's only one line (no newlines), single-arg path works normally - const result = parsePositionalArgs([ + const result = parseEventViewInput([ "perzimo/perzimo-server/189945b37884462cb9134bd5cabeaa3d", ]); expect(result.eventId).toBe("189945b37884462cb9134bd5cabeaa3d"); @@ -387,7 +404,7 @@ describe("parsePositionalArgs", () => { test("first arg with 2+ slashes routes through single-arg path and collects extras", () => { // Simulates expanded "org/project/id1\nid2\nid3" → 3 args - const result = parsePositionalArgs([ + const result = parseEventViewInput([ "perzimo/perzimo-server/189945b37884462cb9134bd5cabeaa3d", "60c277e6c73f41c58ca46231b46dc0f8", "722e1158dfa147ec90ed831c4d096ae7", @@ -401,7 +418,7 @@ describe("parsePositionalArgs", () => { }); test("collects multiple extra event IDs", () => { - const result = parsePositionalArgs([ + const result = parseEventViewInput([ "my-org/frontend", "abc123", "def456", @@ -413,12 +430,12 @@ describe("parsePositionalArgs", () => { }); test("no extra IDs when only two args", () => { - const result = parsePositionalArgs(["my-org/frontend", "abc123"]); + const result = parseEventViewInput(["my-org/frontend", "abc123"]); expect(result.extraEventIds).toBeUndefined(); }); }); - // URL integration tests — applySentryUrlContext may set SENTRY_HOST/SENTRY_URL as a side effect. + // URL tests — parsing returns the server; resolving applies it. // Host-scoping: self-hosted URLs now require the token to be scoped to the // same host. Tests seed SENTRY_HOST before parsing so env-token-host matches. describe("Sentry URL inputs", () => { @@ -452,25 +469,37 @@ describe("parsePositionalArgs", () => { }); test("event URL extracts eventId and passes org as OrgAll target", () => { - const result = parsePositionalArgs([ + const result = parseEventViewInput([ "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 (requires matching token host)", () => { + test("self-hosted event URL extracts eventId and org, and returns the server without applying it", () => { process.env.SENTRY_HOST = "https://sentry.example.com"; - const result = parsePositionalArgs([ + const result = parseEventViewInput([ "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"); + expect(result.baseUrl).toBe("https://sentry.example.com"); + // Parsing has no side effects; resolving the arguments applies it + expect(process.env.SENTRY_URL).toBeUndefined(); + }); + + test("self-hosted issue URL returns the server without applying it", () => { + process.env.SENTRY_HOST = "https://sentry.example.com"; + const result = parseEventViewInput([ + "https://sentry.example.com/organizations/acme/issues/999/", + ]); + expect(result.issueId).toBe("999"); + expect(result.baseUrl).toBe("https://sentry.example.com"); + expect(process.env.SENTRY_URL).toBeUndefined(); }); test("issue URL without event ID returns issueId for latest event fetch", () => { - const result = parsePositionalArgs([ + const result = parseEventViewInput([ "https://sentry.io/organizations/my-org/issues/32886/", ]); expect(result.issueId).toBe("32886"); @@ -480,7 +509,7 @@ describe("parsePositionalArgs", () => { test("org-only URL throws ContextError", () => { expect(() => - parsePositionalArgs(["https://sentry.io/organizations/my-org/"]), + parseEventViewInput(["https://sentry.io/organizations/my-org/"]), ).toThrow(ContextError); }); }); @@ -1337,6 +1366,22 @@ describe("viewCommand.func", () => { ).rejects.toThrow(); }); + test("parses the arguments before any API call", async () => { + const { context } = createMockContext(); + const func = await viewCommand.loader(); + + await expect( + func.call( + context, + { json: true, web: false, spans: 0 }, + "CAM-82X", + "95fd7f5a", + ), + ).rejects.toThrow("Invalid event ID"); + expect(getEventSpy).not.toHaveBeenCalled(); + expect(getIssueInOrgSpy).not.toHaveBeenCalled(); + }); + test("throws error for non-hex event ID", async () => { const { context } = createMockContext(); const func = await viewCommand.loader(); @@ -1732,7 +1777,7 @@ describe("collectEventIds", () => { }); // --------------------------------------------------------------------------- -// parsePositionalArgs: extraEventIds collection +// parseEventViewInput: extraEventIds collection // --------------------------------------------------------------------------- // --------------------------------------------------------------------------- @@ -1960,19 +2005,19 @@ describe("fetchMultipleEvents", () => { }); }); -describe("parsePositionalArgs: extraEventIds", () => { +describe("parseEventViewInput: extraEventIds", () => { test("no extras for single arg", () => { - const result = parsePositionalArgs(["abc123"]); + const result = parseEventViewInput(["abc123"]); expect(result.extraEventIds).toBeUndefined(); }); test("no extras for two args", () => { - const result = parsePositionalArgs(["my-org/proj", "abc123"]); + const result = parseEventViewInput(["my-org/proj", "abc123"]); expect(result.extraEventIds).toBeUndefined(); }); test("collects extras for three+ args", () => { - const result = parsePositionalArgs([ + const result = parseEventViewInput([ "my-org/proj", "abc123", "def456", @@ -1983,12 +2028,15 @@ describe("parsePositionalArgs: extraEventIds", () => { test("collects extras when args are swapped", () => { // When swap is detected: first looks like hex ID, second looks like target - const result = parsePositionalArgs([ + const result = parseEventViewInput([ "abc123def456abc123def456abc123de", "test-org/test-proj", "extra1", ]); - expect(result.warning).toBeDefined(); + expect(result.targetArg).toBe("test-org/test-proj"); + expect(result.warnings).toEqual([ + "Arguments appear reversed. Interpreting as: test-org/test-proj abc123def456abc123def456abc123de", + ]); expect(result.extraEventIds).toEqual(["extra1"]); }); }); diff --git a/packages/cli/test/lib/id-arguments.test.ts b/packages/cli/test/lib/id-arguments.test.ts index a333d582b..745cb14ed 100644 --- a/packages/cli/test/lib/id-arguments.test.ts +++ b/packages/cli/test/lib/id-arguments.test.ts @@ -25,6 +25,7 @@ const HINT = "sentry thing view [//]"; /** An ID of each kind, and a second one */ const EXAMPLES: Record = { trace: [HEX, HEX2], + event: [HEX, HEX2], replay: [HEX, HEX2], log: [HEX, HEX2], span: [SPAN, SPAN2], @@ -75,6 +76,14 @@ describe.each(KINDS)("%s IDs", (_name, kind, id, other) => { }); }); + test("reads more IDs after reversed arguments", () => { + expect(parse(kind, [id, "acme/web", other], { multiple: true })).toEqual({ + target: "acme/web", + ids: [id, other], + warnings: [`Arguments appear reversed. Interpreting as: acme/web ${id}`], + }); + }); + test("splits IDs off a target at spaces, and at line breaks", () => { const expected = { target: "acme/web", ids: [id, other], warnings: [] }; const multiple = { multiple: true }; @@ -244,6 +253,13 @@ describe("URLs", () => { baseUrl: "https://sentry.io", warnings: [], }); + const eventUrl = `https://sentry.io/organizations/acme/issues/1/events/${HEX}/`; + expect(parse(ID_KINDS.event, [eventUrl])).toEqual({ + target: "acme/", + ids: [HEX], + baseUrl: "https://sentry.io", + warnings: [], + }); }); test("a URL without one is missing the ID, with an example URL", () => { diff --git a/packages/cli/test/lib/security/url-arg-poison.test.ts b/packages/cli/test/lib/security/url-arg-poison.test.ts index 90d62de0e..47e83e2f0 100644 --- a/packages/cli/test/lib/security/url-arg-poison.test.ts +++ b/packages/cli/test/lib/security/url-arg-poison.test.ts @@ -11,8 +11,11 @@ * can establish trust for a new host. */ -import { afterEach, beforeEach, describe, expect, test } from "vitest"; -import { parsePositionalArgs } from "../../../src/commands/event/view.js"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { + parseEventViewInput, + viewCommand, +} from "../../../src/commands/event/view.js"; import { parseOrgProjectArg } from "../../../src/lib/arg-parsing.js"; import { parseIssueArg } from "../../../src/lib/issue-arguments.js"; import { resetEnvTokenHostForTesting } from "../../../src/lib/env-token-host.js"; @@ -48,12 +51,22 @@ describe("CVE: URL argument credential exfiltration", () => { expect(process.env.SENTRY_URL).toBeUndefined(); }); - test("event view with attacker URL throws before env is poisoned", () => { - expect(() => - parsePositionalArgs([ - "https://evil.com/organizations/acme/issues/999/events/deadbeef/", - ]), - ).toThrow(/does not match|sentry auth login --url/); + test("event view with attacker URL throws before env is poisoned", async () => { + const url = + "https://evil.com/organizations/acme/issues/999/events/deadbeef/"; + // Parsing only returns the server, for the command to check and apply + expect(parseEventViewInput([url]).baseUrl).toBe("https://evil.com"); + expect(process.env.SENTRY_URL).toBeUndefined(); + + const func = await viewCommand.loader(); + const context = { + stdout: { write: vi.fn(() => true) }, + stderr: { write: vi.fn(() => true) }, + cwd: "/tmp", + }; + await expect( + func.call(context as never, { json: true, web: false, spans: 0 }, url), + ).rejects.toThrow(/does not match|sentry auth login --url/); expect(process.env.SENTRY_HOST).toBeUndefined(); expect(process.env.SENTRY_URL).toBeUndefined(); From d85b68dfadfe172fdff11438c4266cb92f170f38 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 19:37:53 +0000 Subject: [PATCH 2/3] test(cli): Expect event view to be linted for ad-hoc ID parsing Co-Authored-By: BYK --- scripts/cli-lint-rules.test.mjs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/scripts/cli-lint-rules.test.mjs b/scripts/cli-lint-rules.test.mjs index a0e397338..5184b826d 100644 --- a/scripts/cli-lint-rules.test.mjs +++ b/scripts/cli-lint-rules.test.mjs @@ -130,11 +130,6 @@ tester.run("no-ad-hoc-id-arguments", plugin.rules["no-ad-hoc-id-arguments"], { filename: commands, code: "import { HEX_ID_RE } from './hex-id.js'; parseIdArguments(args, ID_KINDS.trace, options);", }, - { - // Issue and event IDs keep their own syntax - filename: "/repo/packages/cli/src/commands/event/view.ts", - code: "parseSlashSeparatedArg(arg, label, hint);", - }, { filename: "/repo/packages/cli/src/lib/id-arguments.ts", code: "parseIdArguments(args, kind, options);", @@ -146,6 +141,12 @@ tester.run("no-ad-hoc-id-arguments", plugin.rules["no-ad-hoc-id-arguments"], { code: "parseSlashSeparatedArg(arg, label, hint);", errors: 1, }, + { + // No command is exempt, not even `event view`, which also takes issues + filename: "/repo/packages/cli/src/commands/event/view.ts", + code: "parseSlashSeparatedArg(arg, label, hint);", + errors: 1, + }, { filename: commands, code: "const swapped = detectSwappedViewArgs(first, second);", From 574691f6965be0ee48b0ae7bf95d2f5756fe5e49 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 20:02:39 +0000 Subject: [PATCH 3/3] fix(cli): Read event view issue forms after any target prefix `/` is always an event of the issue, so an incomplete event ID fails the parse instead of naming project ``. An issue short ID after `//` also takes an event ID after it. Co-Authored-By: BYK --- packages/cli/src/commands/event/view.ts | 47 ++++++++++--------- packages/cli/test/commands/event/view.test.ts | 15 ++++++ 2 files changed, 40 insertions(+), 22 deletions(-) diff --git a/packages/cli/src/commands/event/view.ts b/packages/cli/src/commands/event/view.ts index a39a7c4b2..8eace9600 100644 --- a/packages/cli/src/commands/event/view.ts +++ b/packages/cli/src/commands/event/view.ts @@ -275,10 +275,11 @@ type IssueInput = Omit; * project ``. * * Before those, `event view` takes an issue instead of, or with, an event: - * - `[/]` or `[/]`: the issue's - * latest event; also as the ID after a target - * - `[/] ` and `/`: - * an event of the issue, whose project it is looked up in + * - `[/[/]]` or `...`: the + * issue's latest event; also as the ID after a target + * - `[/[/]] ` and + * `/`: an event of the issue, whose project it is + * looked up in * - an issue URL without an event: the issue's latest event * * @throws {ContextError} When the event ID is missing, or is `latest` @@ -368,32 +369,34 @@ function issueFromUrl(url: ParsedSentryUrl): IssueInput | null { */ function issueForms(tokens: readonly string[]): IssueInput | null { const [first = "", second] = tokens; - const slash = first.indexOf("/"); - // `[/]`: what follows the org, and the target it names - const [id, org] = - slash === -1 || first.includes("/", slash + 1) - ? [first, undefined] - : [first.slice(slash + 1), `${first.slice(0, slash)}/`]; + // `[/[/]]`: the last segment, and the target before it + // (`/` names an org, `/` a project) + const slash = first.lastIndexOf("/"); + const id = first.slice(slash + 1); + const prefix = slash === -1 ? undefined : first.slice(0, slash); + const target = + prefix === undefined || prefix.includes("/") ? prefix : `${prefix}/`; if (second === undefined) { - // `/`: the event of an issue - const shortId = org?.slice(0, -1) ?? ""; - if (looksLikeIssueShortId(shortId) && ID_KINDS.event.isId(id)) { - return { - eventId: ID_KINDS.event.validate(id), - targetArg: undefined, - issueShortId: shortId, - }; + const issue = issueFromEventId(id, target); + if (issue || !(prefix && looksLikeIssueShortId(prefix))) { + return issue; } - return issueFromEventId(id, org); + // `/`: the event of an issue. Short IDs are + // uppercase, and project slugs never are. + return { + eventId: ID_KINDS.event.validate(id), + targetArg: undefined, + issueShortId: prefix, + }; } - // `[/] `: the event of an issue. A second - // argument with a slash is a target: the arguments are reversed. + // `[/] `: the event of an issue. A + // second argument with a slash is a target: the arguments are reversed. if (looksLikeIssueShortId(id) && !second.includes("/")) { return { // Issue shortcuts skip the event ID's recovery eventId: ID_KINDS.event.validate(second), - targetArg: org, + targetArg: target, issueShortId: id, }; } diff --git a/packages/cli/test/commands/event/view.test.ts b/packages/cli/test/commands/event/view.test.ts index a9a6bea7d..949c2ad68 100644 --- a/packages/cli/test/commands/event/view.test.ts +++ b/packages/cli/test/commands/event/view.test.ts @@ -240,6 +240,21 @@ describe("parseEventViewInput", () => { expect(result.targetArg).toBe("my-org/frontend"); }); + test("reads an event ID after org/project/ as the issue's event", () => { + const eventId = "7388f6a62b7d436ab77bb5365f97a1ac"; + const result = parseEventViewInput(["my-org/frontend/CAM-82X", eventId]); + expect(result.eventId).toBe(eventId); + expect(result.issueShortId).toBe("CAM-82X"); + expect(result.targetArg).toBe("my-org/frontend"); + }); + + test("rejects an incomplete event ID after /", () => { + // Not project `CAM-82X` with a partial event ID to recover + expect(() => parseEventViewInput(["CAM-82X/95fd7f5a"])).toThrow( + ValidationError, + ); + }); + test("rejects an incomplete event ID instead of selecting latest", () => { expect(() => parseEventViewInput(["CAM-82X", "95fd7f5a"])).toThrow( ValidationError,