From a6001750f820d25897945fdd559b06e3d8b01b2c Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 18:54:33 +0000 Subject: [PATCH] ref(cli): Parse issue arguments with their own grammar, before the command runs Issue IDs, short IDs, selectors, and issue URLs keep their own syntax: they are not hex IDs, so they stay out of the kind-agnostic ID grammar. grammar/issue-argument.pegjs now defines their forms, and parseIssueArgument (src/lib/issue-arguments.ts) validates and normalizes the parts without side effects, returning a pasted URL's server instead of applying it. parseIssueArg keeps its behavior on top of it. Commands that take one issue (archive, events, plan, resolve, unresolve, tags, link, unlink, event list) and the Feedback commands parse it in their positional, so a malformed issue fails before the auth guard. resolveIssue takes the parsed argument and applies the URL's server. Part of #1564 Co-Authored-By: BYK Co-Authored-By: BYK --- packages/cli/AGENTS.md | 12 +- packages/cli/grammar/issue-argument.d.ts | 88 +++ packages/cli/grammar/issue-argument.pegjs | 111 ++++ packages/cli/src/commands/event/list.ts | 29 +- .../cli/src/commands/feedback/positional.ts | 58 ++ packages/cli/src/commands/feedback/resolve.ts | 12 +- packages/cli/src/commands/feedback/spam.ts | 12 +- .../cli/src/commands/feedback/unresolve.ts | 12 +- packages/cli/src/commands/feedback/utils.ts | 31 +- packages/cli/src/commands/feedback/view.ts | 8 +- packages/cli/src/commands/issue/archive.ts | 12 +- packages/cli/src/commands/issue/events.ts | 15 +- packages/cli/src/commands/issue/link-utils.ts | 26 +- packages/cli/src/commands/issue/link.ts | 9 +- packages/cli/src/commands/issue/plan.ts | 13 +- packages/cli/src/commands/issue/positional.ts | 107 ++++ packages/cli/src/commands/issue/resolve.ts | 12 +- packages/cli/src/commands/issue/tags.ts | 16 +- packages/cli/src/commands/issue/unlink.ts | 12 +- packages/cli/src/commands/issue/unresolve.ts | 18 +- packages/cli/src/commands/issue/utils.ts | 55 +- packages/cli/src/lib/arg-parsing.ts | 544 +----------------- packages/cli/src/lib/issue-arguments.ts | 343 +++++++++++ packages/cli/src/lib/issue-id.ts | 2 +- .../cli/test/commands/feedback/status.test.ts | 9 +- .../test/commands/issue/archive.func.test.ts | 35 ++ .../cli/test/commands/issue/link.func.test.ts | 10 +- .../test/commands/issue/unlink.func.test.ts | 10 +- .../cli/test/lib/arg-parsing.property.test.ts | 2 +- packages/cli/test/lib/arg-parsing.test.ts | 2 +- packages/cli/test/lib/issue-arguments.test.ts | 128 +++++ .../test/lib/security/url-arg-poison.test.ts | 6 +- 32 files changed, 1054 insertions(+), 705 deletions(-) create mode 100644 packages/cli/grammar/issue-argument.d.ts create mode 100644 packages/cli/grammar/issue-argument.pegjs create mode 100644 packages/cli/src/commands/feedback/positional.ts create mode 100644 packages/cli/src/commands/issue/positional.ts create mode 100644 packages/cli/src/lib/issue-arguments.ts create mode 100644 packages/cli/test/lib/issue-arguments.test.ts diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md index 75a245188..cf76e6c98 100644 --- a/packages/cli/AGENTS.md +++ b/packages/cli/AGENTS.md @@ -251,6 +251,16 @@ 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. +**Issue positionals:** issue IDs, short IDs, selectors, and issue URLs are +parsed by `parseIssueArgument` (`src/lib/issue-arguments.ts`), whose forms +are defined by `grammar/issue-argument.pegjs`. A command that takes one +issue declares `issuePositional({ command })` (or `issueParameter` inside a +tuple) from `src/commands/issue/positional.ts`: `func` takes an +`IssueTarget` and awaits `target.resolved` for the org and issue. A command +that resolves it another way (`resolveOrgAndIssueId`, or recovery hints that +keep its other arguments) passes `requireIssueArgument(target.parsed, ...)` +instead of the raw string. + ```typescript import { ID_KINDS, parseIdArgument } from "../../lib/id-arguments.js"; @@ -334,7 +344,7 @@ dashboard use a completion-aware parser from `src/lib/completion-target.ts` instead of `String`: `parseOrgProjectTarget` completes `org/`, `org/project`, and project aliases; `parseOrgTarget` completes org slugs only; `parseIssueTarget` adds cached issue short IDs; `parseDashboardTarget` -completes cached dashboards. `issueIdPositional` already uses them. A +completes cached dashboards. `issueIdsPositional` already uses them. A parser tagged with `withCompletionTarget`, such as the `target-positional.ts` parsers behind `listTargetPositional`, completes the same way. Shared value parsers are not tagged where they live; `PARSER_TARGETS` in diff --git a/packages/cli/grammar/issue-argument.d.ts b/packages/cli/grammar/issue-argument.d.ts new file mode 100644 index 000000000..bcddda965 --- /dev/null +++ b/packages/cli/grammar/issue-argument.d.ts @@ -0,0 +1,88 @@ +// Types of the parser generated from issue-argument.pegjs. The generator +// copies this file to src/generated/issue-argument-grammar.d.ts. + +/** What the caller decides for the grammar. See `parseIssueArgument()`. */ +export type IssueArgumentGrammarOptions = { + /** Whether `value` is an `@` selector, such as `@latest`. */ + isSelector: (value: string) => boolean; +}; + +/** Why an issue argument is malformed. */ +export type IssueSyntaxError = + /** `/` without an ID */ + | "missing-id" + /** `//` with an empty project or ID */ + | "missing-project-or-id" + /** `/-` */ + | "slash-before-suffix" + /** `-` */ + | "missing-project" + /** `-` */ + | "missing-suffix"; + +/** + * The parts of an issue argument, as typed: neither validated nor + * normalized. + */ +export type IssueSyntax = + | { + readonly form: "selector"; + readonly org?: string; + readonly selector: string; + } + /** `[]#`, split at the first `#` */ + | { + readonly form: "hash"; + readonly prefix: string; + readonly fragment: string; + } + | { readonly form: "numeric"; readonly id: string } + /** `:` */ + | { + readonly form: "project-id"; + readonly project: string; + readonly id: string; + } + /** `[/]-` */ + | { + readonly form: "short-id"; + readonly project: string; + readonly suffix: string; + } + /** `[/]` */ + | { readonly form: "suffix"; readonly suffix: string } + /** `/` or `//` */ + | { readonly form: "org-numeric"; readonly org: string; readonly id: string } + /** `//` */ + | { + readonly form: "org-project-id"; + readonly org: string; + readonly project: string; + readonly id: string; + } + /** `/-` */ + | { + readonly form: "org-short-id"; + readonly org: string; + readonly project: string; + readonly suffix: string; + } + /** `/` */ + | { + readonly form: "org-suffix"; + readonly org: string; + readonly suffix: string; + } + | { readonly form: "invalid"; readonly reason: IssueSyntaxError }; + +export declare class SyntaxError extends Error { + expected: unknown; + found: string | null; + location: unknown; +} + +/** Split one trimmed, non-empty issue argument that is not a URL. */ +export declare function parse( + input: string, + options: IssueArgumentGrammarOptions, +): IssueSyntax; diff --git a/packages/cli/grammar/issue-argument.pegjs b/packages/cli/grammar/issue-argument.pegjs new file mode 100644 index 000000000..b2b34a882 --- /dev/null +++ b/packages/cli/grammar/issue-argument.pegjs @@ -0,0 +1,111 @@ +// Grammar for one argument that names an issue, for `issue view`, `issue +// explain`, and every other command that takes one: +// +// 123456789 +// [/]@ @latest, my-org/@most_frequent +// - cli-G, spotlight-electron-4Y (the project +// is found in any org) +// G (the project is auto-detected) +// : CLI:CLI-G, CLI:123456789 +// [/]# GitHub-style: my-org/cli#CLI-G, #CLI-G +// / my-org/123456789 +// /- my-org/cli-G +// / my-org/G (no project: always an error) +// // my-org/cli/G, my-org/cli/CLI-G, +// my-org/cli/123456789 +// +// `pnpm run generate:grammar` compiles it, with the types in +// issue-argument.d.ts, into the gitignored src/generated/. +// +// `parseIssueArgument()` (src/lib/issue-arguments.ts) hands it one line, +// trimmed and not empty; Sentry URLs never get here. The grammar only splits +// the argument into its parts: it accepts any input and never throws, and +// returns `invalid` for a malformed one. The caller validates characters, +// normalizes case, and turns the parts into a `ParsedIssueArg`. It parses +// the parts of a `#` form again, without the `#`. + +{ + const { isSelector } = options; + + function invalid(reason) { + return { form: "invalid", reason }; + } +} + +argument + = selector + / hash + / plain + +// `[/]@`: the org is everything before the first slash +selector + = o:$[^/]* "/" s:$.+ end &{ return isSelector(s); } + { return { form: "selector", org: o, selector: s }; } + / s:$.+ end &{ return isSelector(s); } { return { form: "selector", selector: s }; } + +// `[]#`, split at the first `#`; the caller validates both +// and parses them again +hash + = p:$[^#]* "#" f:$.* end { return { form: "hash", prefix: p, fragment: f }; } + +// Anything else. A slash takes precedence over a dash, so `my-org/123` names +// org `my-org`, not project `my`. +plain + = i:digits end { return { form: "numeric", id: i }; } + // Both parts non-empty, and no slash in either; else it is no colon form + / p:$[^:/]+ ":" i:$[^/]+ end { return { form: "project-id", project: p, id: i }; } + / path + / shortId + / s:$.+ end { return { form: "suffix", suffix: s }; } + +// `/`, split at the first slash +path + // `/` (or `/`) without an ID + = [^/]* "/" end { return invalid("missing-id"); } + // `/`: no org, so the project is found or detected + / "/" @shortId + / "/" s:$.+ end { return { form: "suffix", suffix: s }; } + / o:$[^/]+ "/" r:orgRest { return r.form === "invalid" ? r : { org: o, ...r }; } + +// What follows `/` +orgRest + = i:digits end { return { form: "org-numeric", id: i }; } + / p:$[^/]* "/" r:$.* end { + if (p === "" || r === "") { + return invalid("missing-project-or-id"); + } + // `//`: the project is redundant + if (/^[0-9]+$/.test(r)) { + return { form: "org-numeric", id: r }; + } + return { form: "org-project-id", project: p, id: r }; + } + / p:lastDashPrefix "-" s:$[^-]* end { + if (p === "") { + return invalid("slash-before-suffix"); + } + if (s === "") { + return invalid("missing-suffix"); + } + return { form: "org-short-id", project: p, suffix: s }; + } + / s:$.+ end { return { form: "org-suffix", suffix: s }; } + +// `-`, split at the last dash +shortId + = p:lastDashPrefix "-" s:$[^-]* end { + if (p === "") { + return invalid("missing-project"); + } + if (s === "") { + return invalid("missing-suffix"); + } + return { form: "short-id", project: p, suffix: s }; + } + +// Everything before the last dash +lastDashPrefix = $(!("-" [^-]* end) .)* + +digits = $[0-9]+ + +end = !. diff --git a/packages/cli/src/commands/event/list.ts b/packages/cli/src/commands/event/list.ts index 3761f952a..354dfa3be 100644 --- a/packages/cli/src/commands/event/list.ts +++ b/packages/cli/src/commands/event/list.ts @@ -24,11 +24,8 @@ import { timeRangeToApiParams, } from "../../lib/time-range.js"; import { IssueEventSchema } from "../../types/index.js"; -import { - buildCommandHint, - issueIdPositional, - resolveIssue, -} from "../issue/utils.js"; +import { type IssueTarget, issuePositional } from "../issue/positional.js"; +import { buildCommandHint } from "../issue/utils.js"; import { appendEventsFlags, EVENTS_ALIASES, @@ -132,28 +129,26 @@ export const listCommand = buildListCommand("event", { schema: IssueEventSchema, }, parameters: { - positional: issueIdPositional, + positional: issuePositional({ + command: COMMAND_NAME, + commandBase: COMMAND_BASE, + }), flags: EVENTS_FLAGS, aliases: EVENTS_ALIASES, }, - async *func(this: SentryContext, flags: EventsFlags, issueArg: string) { - // interceptSubcommand (in buildListCommand) may replace the positional - // with undefined when the user types e.g. "sentry events view" via the - // plural alias. The tip message is already printed — just bail out. + async *func(this: SentryContext, flags: EventsFlags, target: IssueTarget) { + // interceptSubcommand (in buildListCommand) may drop the positional when + // the user types e.g. "sentry events view" via the plural alias. The tip + // message is already printed — just bail out. + const issueArg = target.raw; if (!issueArg) { return; } - const { cwd } = this; const timeRange = flags.period; // Resolve issue using shared resolution logic (supports @latest, short IDs, etc.) - const { org, issue } = await resolveIssue({ - issueArg, - cwd, - command: COMMAND_NAME, - commandBase: COMMAND_BASE, - }); + const { org, issue } = await target.resolved; // Org is required for region-routed events endpoint if (!org) { diff --git a/packages/cli/src/commands/feedback/positional.ts b/packages/cli/src/commands/feedback/positional.ts new file mode 100644 index 000000000..1ca7460d1 --- /dev/null +++ b/packages/cli/src/commands/feedback/positional.ts @@ -0,0 +1,58 @@ +/** + * The positional of Feedback commands: a selector or an issue-style + * identifier, parsed before the command runs. + */ + +import { ORG_PROJECT_TARGET } from "../../lib/completion-target.js"; +import { ContextError } from "../../lib/errors.js"; +import { + type IssueArgument, + parseIssueArgument, +} from "../../lib/issue-arguments.js"; +import { + type BoundPositional, + boundPositional, +} from "../../lib/positional-parse.js"; +import { type ResolvedFeedback, resolveFeedback } from "./utils.js"; + +/** + * A parsed Feedback argument that resolves to the Feedback. `parsed` is + * `undefined` only when the argument was left out (the SDK invoke path). + */ +export type FeedbackTarget = BoundPositional< + IssueArgument | undefined, + ResolvedFeedback +>; + +/** + * Positional for a Feedback selector or issue-style identifier. The argument + * is parsed before the command runs; `target.resolved` resolves it with + * {@link resolveFeedback}. + * + * @param command - The subcommand, for recovery hints + */ +export function feedbackPositional(command: string) { + return { + kind: "tuple", + parameters: [ + { + placeholder: "feedback", + brief: + "Feedback: @latest, numeric ID, short ID, /SHORT-ID, or //", + parse: boundPositional( + ORG_PROJECT_TARGET, + (raw) => (raw === undefined ? undefined : parseIssueArgument(raw)), + async (feedback, context: { readonly cwd: string }) => { + if (!feedback) { + throw new ContextError( + "Feedback ID", + `sentry feedback ${command} `, + ); + } + return await resolveFeedback(feedback, context.cwd, command); + }, + ), + }, + ], + } as const; +} diff --git a/packages/cli/src/commands/feedback/resolve.ts b/packages/cli/src/commands/feedback/resolve.ts index 19777580a..5650f0315 100644 --- a/packages/cli/src/commands/feedback/resolve.ts +++ b/packages/cli/src/commands/feedback/resolve.ts @@ -9,7 +9,7 @@ import { formatResolvedFeedback } from "../../lib/formatters/feedback.js"; import { CommandOutput } from "../../lib/formatters/output.js"; import { disableResponseCache } from "../../lib/response-cache.js"; import { SentryFeedbackSchema } from "../../types/index.js"; -import { feedbackIdPositional, resolveFeedback } from "./utils.js"; +import { type FeedbackTarget, feedbackPositional } from "./positional.js"; export const resolveCommand = buildCommand({ docs: { @@ -43,16 +43,12 @@ export const resolveCommand = buildCommand({ schema: SentryFeedbackSchema, }, parameters: { - positional: feedbackIdPositional, + positional: feedbackPositional("resolve"), }, - async *func(this: SentryContext, _flags, feedbackArg: string) { + async *func(this: SentryContext, _flags, target: FeedbackTarget) { // A cached inbox could select Feedback that has already been resolved elsewhere. disableResponseCache(); - const { org, feedback } = await resolveFeedback( - feedbackArg, - this.cwd, - "resolve", - ); + const { org, feedback } = await target.resolved; const updated = await updateIssueStatus(feedback.id, "resolved", { orgSlug: org, }); diff --git a/packages/cli/src/commands/feedback/spam.ts b/packages/cli/src/commands/feedback/spam.ts index d1c008cb8..8ed8fcdfe 100644 --- a/packages/cli/src/commands/feedback/spam.ts +++ b/packages/cli/src/commands/feedback/spam.ts @@ -9,7 +9,7 @@ import { formatSpamFeedback } from "../../lib/formatters/feedback.js"; import { CommandOutput } from "../../lib/formatters/output.js"; import { disableResponseCache } from "../../lib/response-cache.js"; import { SentryFeedbackSchema } from "../../types/index.js"; -import { feedbackIdPositional, resolveFeedback } from "./utils.js"; +import { type FeedbackTarget, feedbackPositional } from "./positional.js"; export const spamCommand = buildCommand({ docs: { @@ -43,15 +43,11 @@ export const spamCommand = buildCommand({ schema: SentryFeedbackSchema, }, parameters: { - positional: feedbackIdPositional, + positional: feedbackPositional("spam"), }, - async *func(this: SentryContext, _flags, feedbackArg: string) { + async *func(this: SentryContext, _flags, target: FeedbackTarget) { disableResponseCache(); - const { org, feedback } = await resolveFeedback( - feedbackArg, - this.cwd, - "spam", - ); + const { org, feedback } = await target.resolved; const updated = await updateIssueStatus(feedback.id, "ignored", { orgSlug: org, }); diff --git a/packages/cli/src/commands/feedback/unresolve.ts b/packages/cli/src/commands/feedback/unresolve.ts index c51bb1cec..2ed6826da 100644 --- a/packages/cli/src/commands/feedback/unresolve.ts +++ b/packages/cli/src/commands/feedback/unresolve.ts @@ -9,7 +9,7 @@ import { formatReopenedFeedback } from "../../lib/formatters/feedback.js"; import { CommandOutput } from "../../lib/formatters/output.js"; import { disableResponseCache } from "../../lib/response-cache.js"; import { SentryFeedbackSchema } from "../../types/index.js"; -import { feedbackIdPositional, resolveFeedback } from "./utils.js"; +import { type FeedbackTarget, feedbackPositional } from "./positional.js"; export const unresolveCommand = buildCommand({ docs: { @@ -48,15 +48,11 @@ export const unresolveCommand = buildCommand({ schema: SentryFeedbackSchema, }, parameters: { - positional: feedbackIdPositional, + positional: feedbackPositional("unresolve"), }, - async *func(this: SentryContext, _flags, feedbackArg: string) { + async *func(this: SentryContext, _flags, target: FeedbackTarget) { disableResponseCache(); - const { org, feedback } = await resolveFeedback( - feedbackArg, - this.cwd, - "unresolve", - ); + const { org, feedback } = await target.resolved; const updated = await updateIssueStatus(feedback.id, "unresolved", { orgSlug: org, }); diff --git a/packages/cli/src/commands/feedback/utils.ts b/packages/cli/src/commands/feedback/utils.ts index 7123b834d..851b54af2 100644 --- a/packages/cli/src/commands/feedback/utils.ts +++ b/packages/cli/src/commands/feedback/utils.ts @@ -3,33 +3,23 @@ */ import { listFeedback } from "../../lib/api-client.js"; -import { type IssueSelector, parseIssueArg } from "../../lib/arg-parsing.js"; -import { parseOrgProjectTarget } from "../../lib/completion-target.js"; +import type { IssueSelector } from "../../lib/arg-parsing.js"; import { ApiError, ContextError, ResolutionError, ValidationError, } from "../../lib/errors.js"; +import { + type IssueArgument, + parseIssueArgument, +} from "../../lib/issue-arguments.js"; import { resolveEffectiveOrg } from "../../lib/region.js"; import { resolveOrg } from "../../lib/resolve-target.js"; import { setOrgProjectContext } from "../../lib/telemetry.js"; import type { SentryFeedback } from "../../types/index.js"; import { buildCommandHint, resolveIssue } from "../issue/utils.js"; -/** Positional parameter for a Feedback selector or issue-style identifier. */ -export const feedbackIdPositional = { - kind: "tuple", - parameters: [ - { - placeholder: "feedback", - brief: - "Feedback: @latest, numeric ID, short ID, /SHORT-ID, or //", - parse: parseOrgProjectTarget, - }, - ], -} as const; - /** Result of resolving and category-checking a modern Feedback issue. */ export type ResolvedFeedback = { /** Resolved organization slug, when available. */ @@ -89,11 +79,14 @@ async function resolveFeedbackSelector( * The command name keeps recovery hints aligned with the caller's operation. */ export async function resolveFeedback( - feedbackArg: string, + feedbackArg: string | IssueArgument, cwd: string, command = "view", ): Promise { - const parsed = parseIssueArg(feedbackArg); + const { text, parsed } = + typeof feedbackArg === "string" + ? parseIssueArgument(feedbackArg) + : feedbackArg; if (parsed.type === "selector") { return await resolveFeedbackSelector( parsed.selector, @@ -114,9 +107,9 @@ export async function resolveFeedback( } catch (error) { if (error instanceof ApiError && error.status === 404) { throw new ResolutionError( - `Feedback '${feedbackArg}'`, + `Feedback '${text}'`, "not found", - buildCommandHint(command, feedbackArg, "sentry feedback"), + buildCommandHint(command, text, "sentry feedback"), ["List available Feedback: sentry feedback list"], ); } diff --git a/packages/cli/src/commands/feedback/view.ts b/packages/cli/src/commands/feedback/view.ts index bac24b63a..92be7a08f 100644 --- a/packages/cli/src/commands/feedback/view.ts +++ b/packages/cli/src/commands/feedback/view.ts @@ -28,7 +28,7 @@ import { } from "../../lib/replay-search.js"; import type { FeedbackViewResult, SentryEvent } from "../../types/index.js"; import { FeedbackViewOutputSchema } from "../../types/index.js"; -import { feedbackIdPositional, resolveFeedback } from "./utils.js"; +import { type FeedbackTarget, feedbackPositional } from "./positional.js"; const log = logger.withTag("feedback.view"); @@ -146,7 +146,7 @@ export const viewCommand = buildCommand({ schema: FeedbackViewOutputSchema, }, parameters: { - positional: feedbackIdPositional, + positional: feedbackPositional("view"), flags: { web: { kind: "boolean", @@ -157,9 +157,9 @@ export const viewCommand = buildCommand({ }, aliases: { ...FRESH_ALIASES, w: "web" }, }, - async *func(this: SentryContext, flags: ViewFlags, feedbackArg: string) { + async *func(this: SentryContext, flags: ViewFlags, target: FeedbackTarget) { applyFreshFlag(flags); - const { org, feedback } = await resolveFeedback(feedbackArg, this.cwd); + const { org, feedback } = await target.resolved; if (flags.web) { await openInBrowser(feedback.permalink, "feedback"); diff --git a/packages/cli/src/commands/issue/archive.ts b/packages/cli/src/commands/issue/archive.ts index ee001bc72..a85af4e45 100644 --- a/packages/cli/src/commands/issue/archive.ts +++ b/packages/cli/src/commands/issue/archive.ts @@ -18,7 +18,7 @@ import { logger } from "../../lib/logger.js"; import { parseRelativeParts, UNIT_SECONDS } from "../../lib/time-range.js"; import type { SentryIssue } from "../../types/index.js"; import type { IssueSubstatus } from "../../types/sentry.js"; -import { issueIdPositional, resolveIssue } from "./utils.js"; +import { type IssueTarget, issuePositional } from "./positional.js"; const log = logger.withTag("issue.archive"); @@ -394,7 +394,7 @@ export const archiveCommand = buildCommand({ human: formatArchived, }, parameters: { - positional: issueIdPositional, + positional: issuePositional({ command: COMMAND }), flags: { until: { kind: "parsed", @@ -406,7 +406,7 @@ export const archiveCommand = buildCommand({ }, aliases: { u: "until" }, }, - async *func(this: SentryContext, flags: ArchiveFlags, issueArg: string) { + async *func(this: SentryContext, flags: ArchiveFlags, target: IssueTarget) { let substatus: IssueSubstatus = "archived_forever"; let statusDetails: IgnoreStatusDetails | undefined; @@ -417,11 +417,7 @@ export const archiveCommand = buildCommand({ statusDetails = opts.statusDetails; } - const { org, issue } = await resolveIssue({ - issueArg, - cwd: this.cwd, - command: COMMAND, - }); + const { org, issue } = await target.resolved; const updated = await updateIssueStatus(issue.id, "ignored", { statusDetails, diff --git a/packages/cli/src/commands/issue/events.ts b/packages/cli/src/commands/issue/events.ts index 8c1b9f111..80369f60a 100644 --- a/packages/cli/src/commands/issue/events.ts +++ b/packages/cli/src/commands/issue/events.ts @@ -29,7 +29,8 @@ import { formatEventsHuman, jsonTransformEvents, } from "../event/shared-events.js"; -import { buildCommandHint, issueIdPositional, resolveIssue } from "./utils.js"; +import { type IssueTarget, issuePositional } from "./positional.js"; +import { buildCommandHint } from "./utils.js"; // --------------------------------------------------------------------------- // Constants @@ -114,20 +115,16 @@ export const eventsCommand = buildListCommand("issue", { schema: IssueEventSchema, }, parameters: { - positional: issueIdPositional, + positional: issuePositional({ command: COMMAND_NAME }), flags: EVENTS_FLAGS, aliases: EVENTS_ALIASES, }, - async *func(this: SentryContext, flags: EventsFlags, issueArg: string) { - const { cwd } = this; + async *func(this: SentryContext, flags: EventsFlags, target: IssueTarget) { + const issueArg = target.raw ?? ""; const timeRange = flags.period; // Resolve issue using shared resolution logic (supports @latest, short IDs, etc.) - const { org, issue } = await resolveIssue({ - issueArg, - cwd, - command: COMMAND_NAME, - }); + const { org, issue } = await target.resolved; // Org is required for region-routed events endpoint if (!org) { diff --git a/packages/cli/src/commands/issue/link-utils.ts b/packages/cli/src/commands/issue/link-utils.ts index 186ad7f42..c33635fcf 100644 --- a/packages/cli/src/commands/issue/link-utils.ts +++ b/packages/cli/src/commands/issue/link-utils.ts @@ -1,20 +1,22 @@ /** Shared arguments for external issue association commands. */ import { ValidationError } from "../../lib/errors.js"; -import { issueIdPositional } from "./utils.js"; +import { issueParameter } from "./positional.js"; /** Required source issue and existing external resource URL for link and unlink. */ -export const EXTERNAL_ISSUE_POSITIONALS = { - kind: "tuple", - parameters: [ - ...issueIdPositional.parameters, - { - placeholder: "url", - parse: String, - brief: "URL of an existing tracker issue or GitHub pull request", - }, - ], -} as const; +export function externalIssuePositionals(command: string) { + return { + kind: "tuple", + parameters: [ + issueParameter({ command }), + { + placeholder: "url", + parse: String, + brief: "URL of an existing tracker issue or GitHub pull request", + }, + ], + } as const; +} /** Flags identifying an existing external issue and its Sentry integration. */ export const EXTERNAL_ISSUE_FLAGS = { diff --git a/packages/cli/src/commands/issue/link.ts b/packages/cli/src/commands/issue/link.ts index 003c01d3e..6fb77cefd 100644 --- a/packages/cli/src/commands/issue/link.ts +++ b/packages/cli/src/commands/issue/link.ts @@ -8,9 +8,10 @@ import { linkExternalIssue } from "../../lib/issue-links.js"; import { DRY_RUN_ALIASES, DRY_RUN_FLAG } from "../../lib/mutate-command.js"; import { EXTERNAL_ISSUE_FLAGS, - EXTERNAL_ISSUE_POSITIONALS, + externalIssuePositionals, parseIssueLinkFields, } from "./link-utils.js"; +import { type IssueTarget, requireIssueArgument } from "./positional.js"; import { resolveOrgAndIssueId } from "./utils.js"; type LinkFlags = { @@ -64,7 +65,7 @@ export const linkCommand = buildCommand({ }, output: { human: formatIssueLinkResult }, parameters: { - positional: EXTERNAL_ISSUE_POSITIONALS, + positional: externalIssuePositionals("link"), flags: { ...EXTERNAL_ISSUE_FLAGS, "dry-run": DRY_RUN_FLAG, @@ -81,12 +82,12 @@ export const linkCommand = buildCommand({ async *func( this: SentryContext, flags: LinkFlags, - issueArg: string, + target: IssueTarget, url: string, ) { const fields = parseIssueLinkFields(flags.field); const { org, issueId, projectId } = await resolveOrgAndIssueId({ - issueArg, + issueArg: requireIssueArgument(target.parsed, { command: "link" }), cwd: this.cwd, command: "link", }); diff --git a/packages/cli/src/commands/issue/plan.ts b/packages/cli/src/commands/issue/plan.ts index 2742d7bb4..f89cc3691 100644 --- a/packages/cli/src/commands/issue/plan.ts +++ b/packages/cli/src/commands/issue/plan.ts @@ -30,9 +30,13 @@ import { requireAutofixRunId, type SolutionArtifact, } from "../../types/seer.js"; +import { + type IssueTarget, + issuePositional, + requireIssueArgument, +} from "./positional.js"; import { ensureRootCauseAnalysis, - issueIdPositional, pollAutofixState, resolveOrgAndIssueId, } from "./utils.js"; @@ -242,7 +246,7 @@ export const planCommand = buildCommand({ human: formatPlanOutput, }, parameters: { - positional: issueIdPositional, + positional: issuePositional({ command: "plan" }), flags: { force: { kind: "boolean", @@ -253,9 +257,10 @@ export const planCommand = buildCommand({ }, aliases: FRESH_ALIASES, }, - async *func(this: SentryContext, flags: PlanFlags, issueArg: string) { + async *func(this: SentryContext, flags: PlanFlags, target: IssueTarget) { applyFreshFlag(flags); const { cwd } = this; + const issueArg = requireIssueArgument(target.parsed, { command: "plan" }); let resolvedOrg: string | undefined; @@ -312,7 +317,7 @@ export const planCommand = buildCommand({ "Plan creation timed out after 6 minutes. Try again or check the issue in Sentry web UI.", timeoutHint: "The plan may still be generated in the background.\n" + - ` Or retry: sentry issue plan ${issueArg}`, + ` Or retry: sentry issue plan ${issueArg.text}`, }); if (finalState.status === "ERROR") { diff --git a/packages/cli/src/commands/issue/positional.ts b/packages/cli/src/commands/issue/positional.ts new file mode 100644 index 000000000..d97255e75 --- /dev/null +++ b/packages/cli/src/commands/issue/positional.ts @@ -0,0 +1,107 @@ +/** + * Positionals that name one issue. + * + * The argument is parsed (`parseIssueArgument`) before the command runs, so + * a malformed one fails like any other argument. `target.resolved` finds the + * issue with `resolveIssue`, inside `func` after the auth guard; that is + * also where the server of a pasted issue URL is applied. See + * `positional-parse.ts`. + */ + +import { ISSUE_TARGET } from "../../lib/completion-target.js"; +import { ContextError } from "../../lib/errors.js"; +import { + type IssueArgument, + parseIssueArgument, +} from "../../lib/issue-arguments.js"; +import { + type BoundPositional, + boundPositional, +} from "../../lib/positional-parse.js"; +import { type ResolvedIssueResult, resolveIssue } from "./utils.js"; + +/** + * A parsed issue argument that resolves to the issue. `parsed` is + * `undefined` only when the argument was left out, such as by a plural + * alias the command intercepted. + */ +export type IssueTarget = BoundPositional< + IssueArgument | undefined, + ResolvedIssueResult +>; + +/** How the command is named in recovery hints. */ +type IssueCommand = { + /** The subcommand, e.g. `"view"` */ + readonly command: string; + /** The command before it (default: `"sentry issue"`) */ + readonly commandBase?: string; +}; + +/** What the issue resolver needs from the command context. */ +type IssueTargetContext = { readonly cwd: string }; + +/** Brief of an issue positional, listing the forms it takes. */ +const ISSUE_POSITIONAL_BRIEF = + "Issue: @latest, @most_frequent, /ID, /#ID, -suffix, ID, or suffix"; + +function parseIssueTargetInput(raw: string | undefined) { + return raw === undefined ? undefined : parseIssueArgument(raw); +} + +/** + * An issue target's parsed argument (`target.parsed`), for a command that + * resolves the issue itself, with recovery hints `target.resolved` cannot + * know (such as its other arguments). + * + * @throws {ContextError} When the argument was left out + */ +export function requireIssueArgument( + issue: IssueArgument | undefined, + issueCommand: IssueCommand, +): IssueArgument { + if (issue) { + return issue; + } + const { command, commandBase = "sentry issue" } = issueCommand; + throw new ContextError("Issue ID", `${commandBase} ${command} `); +} + +/** + * Positional parser for one issue, bound to {@link resolveIssue}. + * + * @param issueCommand - The command, for recovery hints + */ +function issueTarget( + issueCommand: IssueCommand, +): (this: IssueTargetContext, input?: string) => IssueTarget { + const { command, commandBase = "sentry issue" } = issueCommand; + return boundPositional( + ISSUE_TARGET, + parseIssueTargetInput, + async (issue, context: IssueTargetContext) => + await resolveIssue({ + issueArg: requireIssueArgument(issue, issueCommand), + cwd: context.cwd, + command, + commandBase, + }), + ); +} + +/** The `` parameter of a tuple positional, for {@link issueTarget}. */ +export function issueParameter(issueCommand: IssueCommand) { + return { + placeholder: "issue", + brief: ISSUE_POSITIONAL_BRIEF, + parse: issueTarget(issueCommand), + } as const; +} + +/** A positional that takes one issue. */ +export function issuePositional(issueCommand: IssueCommand) { + return { + kind: "tuple", + parameters: [issueParameter(issueCommand)], + } as const; +} diff --git a/packages/cli/src/commands/issue/resolve.ts b/packages/cli/src/commands/issue/resolve.ts index 07c0e09ce..23f7e4949 100644 --- a/packages/cli/src/commands/issue/resolve.ts +++ b/packages/cli/src/commands/issue/resolve.ts @@ -31,7 +31,7 @@ import { CommandOutput } from "../../lib/formatters/output.js"; import { logger } from "../../lib/logger.js"; import type { SentryIssue } from "../../types/index.js"; import { resolveCommitSpec } from "./resolve-commit-spec.js"; -import { issueIdPositional, resolveIssue } from "./utils.js"; +import { type IssueTarget, issuePositional } from "./positional.js"; const log = logger.withTag("issue.resolve"); @@ -145,7 +145,7 @@ export const resolveCommand = buildCommand({ jsonTransform, }, parameters: { - positional: issueIdPositional, + positional: issuePositional({ command: COMMAND }), flags: { in: { kind: "parsed", @@ -158,15 +158,11 @@ export const resolveCommand = buildCommand({ i: "in", }, }, - async *func(this: SentryContext, flags: ResolveFlags, issueArg: string) { + async *func(this: SentryContext, flags: ResolveFlags, target: IssueTarget) { const { cwd } = this; const parsed = parseResolveSpec(flags.in); - const { org, issue } = await resolveIssue({ - issueArg, - cwd, - command: COMMAND, - }); + const { org, issue } = await target.resolved; // Static specs (release / next-release / omitted) are ready to send. // Commit specs need git + Sentry-repo lookup before they become a diff --git a/packages/cli/src/commands/issue/tags.ts b/packages/cli/src/commands/issue/tags.ts index f5d6dd44c..e753e334a 100644 --- a/packages/cli/src/commands/issue/tags.ts +++ b/packages/cli/src/commands/issue/tags.ts @@ -19,7 +19,12 @@ import { FRESH_FLAG, } from "../../lib/list-command.js"; import { withProgress } from "../../lib/polling.js"; -import { buildCommandHint, issueIdPositional, resolveIssue } from "./utils.js"; +import { + type IssueTarget, + issueParameter, + requireIssueArgument, +} from "./positional.js"; +import { buildCommandHint, resolveIssue } from "./utils.js"; type TagsFlags = { /** Emit the API response as JSON instead of a human table; defaults to false. */ @@ -87,7 +92,7 @@ export const tagsCommand = buildCommand({ positional: { kind: "tuple", parameters: [ - ...issueIdPositional.parameters, + issueParameter({ command: "tags" }), { placeholder: "key", brief: "Tag key, e.g. release, server_name, browser.name", @@ -109,10 +114,13 @@ export const tagsCommand = buildCommand({ async *func( this: SentryContext, flags: TagsFlags, - issueArg: string, + target: IssueTarget, key: string, ) { applyFreshFlag(flags); + const issueArg = requireIssueArgument(target.parsed, { command: "tags" }); + // Resolved here, not with `target.resolved`: recovery hints keep the + // tag key and environment const trailingArgs = flags.environment === undefined ? [key] @@ -126,7 +134,7 @@ export const tagsCommand = buildCommand({ if (!org) { throw new ContextError( "Organization", - buildCommandHint("tags", issueArg, undefined, trailingArgs), + buildCommandHint("tags", issueArg.text, undefined, trailingArgs), ); } diff --git a/packages/cli/src/commands/issue/unlink.ts b/packages/cli/src/commands/issue/unlink.ts index b1f86df81..944568157 100644 --- a/packages/cli/src/commands/issue/unlink.ts +++ b/packages/cli/src/commands/issue/unlink.ts @@ -11,8 +11,9 @@ import { } from "../../lib/mutate-command.js"; import { EXTERNAL_ISSUE_FLAGS, - EXTERNAL_ISSUE_POSITIONALS, + externalIssuePositionals, } from "./link-utils.js"; +import { type IssueTarget, requireIssueArgument } from "./positional.js"; import { resolveOrgAndIssueId } from "./utils.js"; type UnlinkFlags = { @@ -56,15 +57,16 @@ export const unlinkCommand = buildDeleteCommand({ }, output: { human: formatIssueLinkResult }, parameters: { - positional: EXTERNAL_ISSUE_POSITIONALS, + positional: externalIssuePositionals("unlink"), flags: EXTERNAL_ISSUE_FLAGS, }, async *func( this: SentryContext, flags: UnlinkFlags, - issueArg: string, + target: IssueTarget, url: string, ) { + const issueArg = requireIssueArgument(target.parsed, { command: "unlink" }); const { org, issueId } = await resolveOrgAndIssueId({ issueArg, cwd: this.cwd, @@ -72,8 +74,8 @@ export const unlinkCommand = buildDeleteCommand({ }); if (!(flags["dry-run"] || isConfirmationBypassed(flags))) { const confirmed = await confirmByTyping( - issueArg, - `Type '${issueArg}' to unlink ${url}:`, + issueArg.text, + `Type '${issueArg.text}' to unlink ${url}:`, ); if (!confirmed) { return { hint: "Cancelled." }; diff --git a/packages/cli/src/commands/issue/unresolve.ts b/packages/cli/src/commands/issue/unresolve.ts index af2c06c4f..35101cfba 100644 --- a/packages/cli/src/commands/issue/unresolve.ts +++ b/packages/cli/src/commands/issue/unresolve.ts @@ -11,7 +11,7 @@ import { formatIssueDetails, muted } from "../../lib/formatters/index.js"; import { CommandOutput } from "../../lib/formatters/output.js"; import { logger } from "../../lib/logger.js"; import type { SentryIssue } from "../../types/index.js"; -import { issueIdPositional, resolveIssue } from "./utils.js"; +import { type IssueTarget, issuePositional } from "./positional.js"; const log = logger.withTag("issue.unresolve"); @@ -50,16 +50,14 @@ export const unresolveCommand = buildCommand({ human: formatUnresolved, }, parameters: { - positional: issueIdPositional, + positional: issuePositional({ command: COMMAND }), }, - async *func(this: SentryContext, _flags: UnresolveFlags, issueArg: string) { - const { cwd } = this; - - const { org, issue } = await resolveIssue({ - issueArg, - cwd, - command: COMMAND, - }); + async *func( + this: SentryContext, + _flags: UnresolveFlags, + target: IssueTarget, + ) { + const { org, issue } = await target.resolved; const updated = await updateIssueStatus(issue.id, "unresolved", { orgSlug: org, diff --git a/packages/cli/src/commands/issue/utils.ts b/packages/cli/src/commands/issue/utils.ts index be47f4969..42463b1ab 100644 --- a/packages/cli/src/commands/issue/utils.ts +++ b/packages/cli/src/commands/issue/utils.ts @@ -21,12 +21,7 @@ import { triggerRootCauseAnalysis, tryGetIssueByShortId, } from "../../lib/api-client.js"; -import { - type IssueSelector, - type ParsedIssueArg, - parseIssueArg, - splitNewlineArg, -} from "../../lib/arg-parsing.js"; +import { type IssueSelector, splitNewlineArg } from "../../lib/arg-parsing.js"; import { parseIssueTarget } from "../../lib/completion-target.js"; import { clearCachedIssueOrg, @@ -44,6 +39,11 @@ import { withAuthGuard, } from "../../lib/errors.js"; import { getProgressMessage } from "../../lib/formatters/seer.js"; +import { + type IssueArgument, + type ParsedIssueArg, + parseIssueArgument, +} from "../../lib/issue-arguments.js"; import { expandToFullShortId, isShortSuffix } from "../../lib/issue-id.js"; import { logger } from "../../lib/logger.js"; import { poll } from "../../lib/polling.js"; @@ -54,7 +54,10 @@ import { resolveOrg, resolveOrgAndProject, } from "../../lib/resolve-target.js"; -import { parseSentryUrl } from "../../lib/sentry-url-parser.js"; +import { + applySentryUrlContext, + parseSentryUrl, +} from "../../lib/sentry-url-parser.js"; import { buildIssueUrl } from "../../lib/sentry-web-urls.js"; import { setOrgProjectContext } from "../../lib/telemetry.js"; import { isAllDigits, shellQuote } from "../../lib/utils.js"; @@ -63,19 +66,6 @@ import { type AutofixState, isTerminalStatus } from "../../types/seer.js"; const log = logger.withTag("issue.utils"); -/** Shared positional parameter for issue ID */ -export const issueIdPositional = { - kind: "tuple", - parameters: [ - { - placeholder: "issue", - brief: - "Issue: @latest, @most_frequent, /ID, /#ID, -suffix, ID, or suffix", - parse: parseIssueTarget, - }, - ], -} as const; - /** Variadic positional parameter for commands that accept multiple issues. */ export const issueIdsPositional = { kind: "array", @@ -680,8 +670,11 @@ async function resolveShareIssue( * Options for resolving an issue ID. */ export type ResolveIssueOptions = { - /** User-provided issue argument (raw CLI input) */ - issueArg: string; + /** + * The issue argument: parsed by the command's positional, or as typed (it + * is parsed here then) + */ + issueArg: string | IssueArgument; /** Current working directory for context resolution */ cwd: string; /** Command name for error messages (e.g., "view", "explain") */ @@ -848,10 +841,15 @@ async function resolveNumericIssue( } } +/** An issue argument, parsed unless the command's positional parsed it. */ +function issueArgumentOf(issueArg: string | IssueArgument): IssueArgument { + return typeof issueArg === "string" ? parseIssueArgument(issueArg) : issueArg; +} + /** * Resolve an issue ID to organization slug and full issue object. * - * Supports all issue ID formats (now parsed by parseIssueArg in arg-parsing.ts): + * Supports all issue ID formats (parsed by parseIssueArgument in issue-arguments.ts): * - selector: "@latest", "sentry/@most_frequent" → top issue by criteria * - explicit: "sentry/cli-G" → org + project + suffix * - explicit-org-suffix: "sentry/G" → org + suffix (needs DSN for project) @@ -868,12 +866,15 @@ async function resolveNumericIssue( export async function resolveIssue( options: ResolveIssueOptions, ): Promise { - const { issueArg, cwd, command, commandBase, trailingArgs = [] } = options; + const { cwd, command, commandBase, trailingArgs = [] } = options; const effectiveCommandBase = commandBase ?? "sentry issue"; - const parsed = parseIssueArg(issueArg); + const { text, parsed, baseUrl } = issueArgumentOf(options.issueArg); + if (baseUrl) { + applySentryUrlContext(baseUrl); + } const commandHint = buildCommandHint( command, - issueArg, + text, effectiveCommandBase, trailingArgs, ); @@ -1000,7 +1001,7 @@ export async function resolveOrgAndIssueId( if (!result.org) { const commandHint = buildCommandHint( options.command, - options.issueArg, + issueArgumentOf(options.issueArg).text, options.commandBase, options.trailingArgs, ); diff --git a/packages/cli/src/lib/arg-parsing.ts b/packages/cli/src/lib/arg-parsing.ts index 36595502a..c133dd0b6 100644 --- a/packages/cli/src/lib/arg-parsing.ts +++ b/packages/cli/src/lib/arg-parsing.ts @@ -14,7 +14,6 @@ import { isGlobFilter } from "./list-filter.js"; import type { ParsedSentryUrl } from "./sentry-url-parser.js"; import { applySentryUrlContext, parseSentryUrl } from "./sentry-url-parser.js"; -import { isAllDigits } from "./utils.js"; // --------------------------------------------------------------------------- // Slug normalization @@ -134,7 +133,7 @@ const LINE_SPLIT_PATTERN = /\r?\n/; * "contains a newline" error (CLI-1G1, CLI-1RA). Slugs and identifiers are * always single-line tokens, so the first non-blank line is the intended value. */ -function firstNonBlankLine(arg: string): string { +export function firstNonBlankLine(arg: string): string { return ( arg .split(LINE_SPLIT_PATTERN) @@ -211,7 +210,7 @@ export function isIssueCommandToken(slug: string): boolean { * @throws {ValidationError} When the project segment is `issue`/`issues` and the * suffix is purely numeric */ -function rejectIssueCommandTokenWithNumericSuffix( +export function rejectIssueCommandTokenWithNumericSuffix( arg: string, projectSlug: string, suffix: string, @@ -237,7 +236,8 @@ function rejectIssueCommandTokenWithNumericSuffix( * Reject org/project list targets that look like `issue-1` command tokens. * * `parseOrgProjectArg` treats bare `issue-1` as a single project slug; this - * applies the same guard as `parseIssueArg` for bare and `org/issue-1` forms. + * applies the same guard as `parseIssueArgument` for bare and `org/issue-1` + * forms. * * @throws {ValidationError} When the target matches an issue command token with * a purely numeric suffix @@ -650,70 +650,11 @@ function orgProjectFromUrl(parsed: ParsedSentryUrl): ParsedOrgProject { return { type: "org-all", org: parsed.org }; } -/** - * Map a parsed Sentry URL to a ParsedIssueArg. - * Handles share URLs, numeric group IDs, and short IDs (e.g., "CLI-G") from the URL path. - */ -function issueArgFromUrl(parsed: ParsedSentryUrl): ParsedIssueArg | null { - // Share URL → resolve via public share API - if (parsed.shareId) { - return { - type: "share", - shareId: parsed.shareId, - org: parsed.org, - baseUrl: parsed.baseUrl, - }; - } - - const { issueId } = parsed; - if (!issueId) { - return null; - } - - // Non-share URLs always have org from their matchers; guard narrows the type - const { org } = parsed; - if (!org) { - return null; - } - - // Numeric group ID (e.g., /issues/32886/) - if (isAllDigits(issueId)) { - return { - type: "explicit-org-numeric", - 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) { - // Lowercase project slug — Sentry slugs are always lowercase. - return { - type: "explicit", - org, - project: project.toLowerCase(), - suffix, - }; - } - } - - // No dash — treat as suffix-only with org context - return { - type: "explicit-org-suffix", - org, - suffix: issueId.toUpperCase(), - }; -} - /** * Reject `@`-prefixed values in org/project positions. * * `@latest` and `@most_frequent` are issue selectors supported by - * `parseIssueArg()` (for `issue view`, `explain`, `plan`). They are not + * `parseIssueArgument()` (for `issue view`, `explain`, `plan`). They are not * valid project slugs. This guard provides a helpful redirect instead of * the confusing "Project '@latest' not found" resolution error. * @@ -944,360 +885,6 @@ export function parseSelector(value: string): IssueSelector | undefined { return SELECTOR_MAP.get(value.toLowerCase()); } -export type ParsedIssueArg = - | { type: "numeric"; id: string } - | { type: "explicit"; org: string; project: string; suffix: string } - | { type: "explicit-org-suffix"; org: string; suffix: string } - | { type: "explicit-org-numeric"; org: string; numericId: string } - | { type: "project-search"; projectSlug: string; suffix: string } - | { type: "suffix-only"; suffix: string } - | { type: "selector"; selector: IssueSelector; org?: string } - | { type: "share"; shareId: string; org?: string; baseUrl: string }; - -/** - * Parse a CLI issue argument into its component parts. - * - * Uses `parseOrgProjectArg` internally for the left part of dash-separated - * inputs, providing consistent org/project parsing across commands. - * - * Flow: - * 1. Pure numeric → { type: "numeric" } - * 1b. Contains "#" → GitHub-style separator (org/project#SHORTID) → parseWithHash - * 2. Has dash → split on last "-", parse left with parseOrgProjectArg - * - "explicit" → { type: "explicit", org, project, suffix } - * - "project-search" → { type: "project-search", projectSlug, suffix } - * - "org-all" or "auto-detect" → rejected as invalid - * 3. Has slash but no dash → explicit org + suffix/numeric - * 4. Otherwise → suffix-only - * - * @param arg - Raw CLI argument - * @returns Parsed issue argument with type discrimination - * @throws {Error} If input has invalid format (e.g., "sentry/-G") - * - * @example - * parseIssueArg("123456789") // { type: "numeric", id: "123456789" } - * parseIssueArg("sentry/cli-G") // { type: "explicit", org: "sentry", project: "cli", suffix: "G" } - * parseIssueArg("cli-G") // { type: "project-search", projectSlug: "cli", suffix: "G" } - * parseIssueArg("sentry/G") // { type: "explicit-org-suffix", org: "sentry", suffix: "G" } - * parseIssueArg("G") // { type: "suffix-only", suffix: "G" } - * parseIssueArg("sentry/cli#CLI-G") // { type: "explicit", org: "sentry", project: "cli", suffix: "G" } - * parseIssueArg("cli#CLI-G") // { type: "project-search", projectSlug: "cli", suffix: "G" } - * parseIssueArg("#CLI-G") // { type: "project-search", projectSlug: "cli", suffix: "G" } - */ -/** - * Handle multi-slash issue args like "org/project/suffix" or "org/project/123". - * - * Splits `rest` on its first `/` to extract the project slug and a remainder - * that is treated as the issue reference (suffix, numeric ID, or short ID). - * - * Handles three remainder formats: - * - Pure digits → numeric issue ID (project context is redundant) - * - Full short ID whose prefix matches the project → extract just the suffix - * - Anything else → treat entire remainder as the suffix - */ -function parseMultiSlashIssueArg( - arg: string, - org: string, - rest: string, -): ParsedIssueArg { - const slashIdx = rest.indexOf("/"); - const project = rest.slice(0, slashIdx); - const remainder = rest.slice(slashIdx + 1); - - if (!(project && remainder)) { - throw new ValidationError( - `Invalid issue format: "${arg}". Missing project or issue ID segment.`, - "issue", - ); - } - - // Lowercase project slug — Sentry slugs are always lowercase. - const normalizedProject = project.toLowerCase(); - - // Pure numeric remainder: "org/project/123456789" → org + numeric ID. - // Project context is redundant for numeric IDs — Sentry resolves them globally. - if (isAllDigits(remainder)) { - return { type: "explicit-org-numeric", org, numericId: remainder }; - } - - // Remainder with dash: could be a full short ID like "CLI-A1" or "SPOTLIGHT-ELECTRON-4Y" - if (remainder.includes("-")) { - const lastDash = remainder.lastIndexOf("-"); - const prefix = remainder.slice(0, lastDash); - const suffix = remainder.slice(lastDash + 1).toUpperCase(); - - if (prefix && suffix) { - // Check if the prefix matches the project slug (case-insensitive). - // If so, the remainder is already a full short ID — use only the suffix. - // e.g., "sentry/cli/CLI-A1" → prefix "CLI" matches project "cli" → suffix "A1" - // e.g., "org/spotlight-electron/SPOTLIGHT-ELECTRON-4Y" → prefix matches → suffix "4Y" - if (prefix.toLowerCase() === normalizedProject) { - return { - type: "explicit", - org, - project: normalizedProject, - suffix, - }; - } - - // Prefix doesn't match project — treat entire remainder as the suffix. - // e.g., "org/project/SUBPROJ-G" where SUBPROJ ≠ project - return { - type: "explicit", - org, - project: normalizedProject, - suffix: `${prefix}-${suffix}`.toUpperCase(), - }; - } - } - - // No dash: "org/project/G" — treat remainder as suffix - return { - type: "explicit", - org, - project: normalizedProject, - suffix: remainder.toUpperCase(), - }; -} - -function parseAfterSlash( - arg: string, - org: string, - rest: string, -): ParsedIssueArg { - if (isAllDigits(rest)) { - // "my-org/123456789" → explicit org + numeric ID - return { type: "explicit-org-numeric", org, numericId: rest }; - } - - // Multi-slash: "org/project/suffix" or "org/project/123" - if (rest.includes("/")) { - return parseMultiSlashIssueArg(arg, org, rest); - } - - // Check if rest contains a dash (project-suffix pattern) - if (rest.includes("-")) { - const lastDash = rest.lastIndexOf("-"); - const project = rest.slice(0, lastDash); - const suffix = rest.slice(lastDash + 1).toUpperCase(); - - if (!project) { - throw new ValidationError( - `Invalid issue format: "${arg}". Cannot use trailing slash before suffix.`, - "issue", - ); - } - - if (!suffix) { - throw new ValidationError( - `Invalid issue format: "${arg}". Missing suffix after dash.`, - "issue", - ); - } - - rejectIssueCommandTokenWithNumericSuffix(arg, project, suffix); - - // "my-org/cli-G" or "sentry/spotlight-electron-4Y" - // Lowercase the project slug — Sentry slugs are always lowercase. - return { type: "explicit", org, project: project.toLowerCase(), suffix }; - } - - // "my-org/G" → explicit org + suffix only (no dash in rest) - return { type: "explicit-org-suffix", org, suffix: rest.toUpperCase() }; -} - -/** - * Parse issue arg with slash (org/...). - */ -function parseWithSlash(arg: string): ParsedIssueArg { - const slashIdx = arg.indexOf("/"); - const org = arg.slice(0, slashIdx); - const rest = arg.slice(slashIdx + 1); - - if (!rest) { - throw new ValidationError( - `Invalid issue format: "${arg}". Missing issue ID after slash.`, - "issue", - ); - } - - if (!org) { - // Leading slash with dash → project-search (e.g., "/cli-G") - if (rest.includes("-")) { - return parseWithDash(rest); - } - // "/G" → treat as suffix-only (unusual but valid) - return { type: "suffix-only", suffix: rest.toUpperCase() }; - } - - return parseAfterSlash(arg, org, rest); -} - -/** - * Resolve a `project` + `identifier` pair into a project-scoped issue lookup. - * - * Shared by the colon (`PROJECT:SHORTID`) and GitHub-style hash - * (`project#SHORTID`) parsers. The caller is responsible for validating the - * project slug and identifier against injection characters first. - * - * Handles the identifier in three forms: - * - Pure digits → numeric lookup (project context is redundant) - * - Full short ID (`PROJECT-SUFFIX`) → extract just the suffix from the last dash - * - Anything else → treat the whole identifier as the suffix - * - * @param project - Project slug (will be lowercased) - * @param id - Issue identifier portion - * @returns Parsed issue argument (`numeric` or `project-search`) - */ -function parseProjectIdentifier(project: string, id: string): ParsedIssueArg { - const projectSlug = project.toLowerCase(); - - // Numeric ID part → direct numeric lookup (project context not needed) - if (isAllDigits(id)) { - return { type: "numeric", id }; - } - - // ID part contains a dash → likely a full short ID like "PROJECT-SUFFIX". - // Extract just the suffix from the last dash. - if (id.includes("-")) { - const suffix = id.slice(id.lastIndexOf("-") + 1).toUpperCase(); - if (suffix) { - return { type: "project-search", projectSlug, suffix }; - } - } - - // Plain suffix (no dash, or empty trailing suffix) → use as-is. - return { type: "project-search", projectSlug, suffix: id.toUpperCase() }; -} - -/** - * Parse issue arg containing a colon as a project:identifier separator. - * - * Handles formats like: - * - `PROJECT:SUFFIX` → project-search with suffix - * - `PROJECT:PROJECT-SUFFIX` → project-search, extracts suffix from last dash - * - `PROJECT:NUMERICID` → numeric lookup (project context ignored) - * - * Returns null if the colon is not a valid separator (e.g., empty parts). - */ -function parseWithColon(arg: string): ParsedIssueArg | null { - const colonIdx = arg.indexOf(":"); - const projectPart = arg.slice(0, colonIdx); - const idPart = arg.slice(colonIdx + 1); - - // Both parts must be non-empty. Neither part should contain a slash — - // slashes indicate org/project notation which should be handled by parseWithSlash. - if ( - !(projectPart && idPart) || - projectPart.includes("/") || - idPart.includes("/") - ) { - return null; - } - - return parseProjectIdentifier(projectPart, idPart); -} - -/** - * Parse a GitHub-style `#`-separated issue identifier (CLI-1G1). - * - * AI agents (claude-code, codex) frequently pass GitHub-style references where - * `#` separates the project from the issue short ID. Supported forms: - * - `org/project#SHORTID` → explicit (delegates to the slash path, reusing the - * full-short-ID prefix-match logic in {@link parseMultiSlashIssueArg}) - * - `project#SHORTID` → project-search (project context, org auto-detected) - * - `#SHORTID` → bare identifier (numeric / project-search / suffix-only) - * - * IMPORTANT: this runs BEFORE parseIssueArg's main `validateResourceId` guard - * (which rejects `#`), so it must validate BOTH the project prefix and the - * fragment itself. `validateResourceId` permits `:`, so a `:` mixed with `#` - * is rejected explicitly to avoid silently swallowing the colon into a suffix - * (cf. the `sentry/CLI:W9` precedent). - * - * @param arg - Raw issue argument containing at least one `#` - * @returns Parsed issue argument - * @throws {ValidationError} If the fragment is empty, contains a second `#`, a - * `:`, or any forbidden character, or if the project prefix is invalid. - */ -function parseWithHash(arg: string): ParsedIssueArg { - const firstHash = arg.indexOf("#"); - const prefix = arg.slice(0, firstHash); - const fragment = arg.slice(firstHash + 1); - - if (fragment.includes("#") || fragment === "") { - throw new ValidationError( - `Invalid issue identifier: "${arg}".\n` + - " Use a single '#' separating the project from the short ID, e.g. `org/project#PROJ-123`.\n" + - " Or use a slash: `org/project/PROJ-123`.", - "issue identifier", - ); - } - if (fragment.includes(":")) { - throw new ValidationError( - `Invalid issue identifier: "${arg}". Do not mix '#' and ':' separators.\n` + - " Use `org/project#PROJ-123` or `project:PROJ-123`.", - "issue identifier", - ); - } - // Validate the fragment on its own — it must not contain forbidden characters - // (?, %, whitespace, control chars). The main guard at the call site is - // skipped for `#` inputs, so this validation happens here instead. - validateResourceId(fragment, "issue identifier"); - - // Bare `#SHORTID` → parse the fragment exactly like a standalone identifier. - if (prefix === "") { - return parseBareIssueIdentifier(fragment); - } - - // `org/project#SHORTID` → equivalent to `org/project/SHORTID`. Validate the - // org/project prefix components first — the `#` path skips the main - // validateResourceId guard, and parseWithSlash doesn't re-validate. - if (prefix.includes("/")) { - validateResourceId(prefix.replace(/\//g, ""), "issue identifier"); - return parseWithSlash(`${prefix}/${fragment}`); - } - - // `project#SHORTID` → project-scoped lookup. The `#` path skips the main - // validateResourceId guard, so validate the project slug here. - validateResourceId(prefix, "project slug"); - return parseProjectIdentifier(prefix, fragment); -} - -/** - * Parse issue arg with dash but no slash (project-suffix). - */ -function parseWithDash(arg: string): ParsedIssueArg { - const lastDash = arg.lastIndexOf("-"); - const projectSlug = arg.slice(0, lastDash); - const suffix = arg.slice(lastDash + 1).toUpperCase(); - - if (!projectSlug) { - throw new ValidationError( - `Invalid issue format: "${arg}". Missing project before suffix.`, - "issue", - ); - } - - if (!suffix) { - throw new ValidationError( - `Invalid issue format: "${arg}". Missing suffix after dash.`, - "issue", - ); - } - - rejectIssueCommandTokenWithNumericSuffix(arg, projectSlug, suffix); - - // "cli-G" or "spotlight-electron-4Y" - // Lowercase the project slug since Sentry slugs are always lowercase. - // Users often type short IDs in uppercase (e.g., "EASI-API-3Y4") which - // produces an uppercase project slug that would fail API lookups (CLI-C8). - return { - type: "project-search", - projectSlug: projectSlug.toLowerCase(), - suffix, - }; -} - /** * Parse a single positional arg that may be a plain hex ID or a slash-separated * `org/project/id` pattern. @@ -1361,127 +948,6 @@ export function parseSlashSeparatedArg( return { id, targetArg }; } -export function parseIssueArg(arg: string): ParsedIssueArg { - // Take the first non-blank line. A bare `.trim()` only strips leading and - // trailing whitespace, so multi-line input (command substitution that - // captured extra output, an identifier with an appended note, or several - // newline-separated IDs) left an *internal* newline that reached - // validateResourceId and threw a cryptic "contains a newline" error - // (CLI-1G1, 116+ users). An issue identifier is always a single line, so the - // first non-blank line is the intended value. Unlike `sentry api`, which - // rejoins wrapped URLs (CLI-FR), identifiers are atomic tokens — joining - // lines would produce garbage, so we keep only the first line. - // Splitting on `\n` (a control char) never breaks project display names with - // spaces (#1116), since those are rejected as control chars anyway. - const input = firstNonBlankLine(arg); - - if (!input) { - throw new ValidationError( - "Issue identifier is empty after trimming whitespace.", - "issue identifier", - ); - } - - // 0. URL detection — extract issue ID from Sentry web URLs - const urlParsed = parseSentryUrl(input); - 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. Magic @ selectors — detect before any other parsing. - // Supports bare `@latest` and org-prefixed `sentry/@latest`. - if (input.includes("@")) { - const slashIdx = input.indexOf("/"); - const selectorPart = slashIdx === -1 ? input : input.slice(slashIdx + 1); - const selector = parseSelector(selectorPart); - if (selector) { - if (slashIdx !== -1) { - const org = normalizeSlug(input.slice(0, slashIdx)).slug; - validateResourceId(org, "organization slug"); - return { type: "selector", selector, org }; - } - return { type: "selector", selector }; - } - // Not a recognized selector — fall through to normal parsing. - // The @ character will be caught by validateResourceId below. - } - - // 1b. GitHub-style "#" separator (CLI-1G1): org/project#SHORTID, project#SHORTID, - // or bare #SHORTID. AI agents frequently pass this form. Handle it before the - // validateResourceId guard below rejects "#" as a forbidden URL fragment. - if (input.includes("#")) { - return parseWithHash(input); - } - - // Validate raw input against injection characters before parsing. - // Slashes are allowed (they're structural separators), but ?, #, %, whitespace, - // and control characters are never valid in issue identifiers. - validateResourceId(input.replace(/\//g, ""), "issue identifier"); - - return parseBareIssueIdentifier(input); -} - -/** - * Parse a bare issue identifier (no Sentry URL, `@` selector, or `#` fragment) - * into its component parts. This is steps 2–5 of {@link parseIssueArg}. - * - * Callers MUST validate the input against injection characters before calling - * this function — it performs no validation of its own. - * - * Flow: - * - Pure numeric → `numeric` - * - Colon separator → `parseWithColon` (project:identifier) - * - Slash → `parseWithSlash` (org/...) - * - Dash → `parseWithDash` (project-suffix) - * - Otherwise → `suffix-only` - * - * @param input - Trimmed, validated issue identifier - * @returns Parsed issue argument with type discrimination - */ -function parseBareIssueIdentifier(input: string): ParsedIssueArg { - // 2. Pure numeric → direct fetch by ID - if (isAllDigits(input)) { - return { type: "numeric", id: input }; - } - - // 2b. Colon separator — treat as project:identifier notation. - // Users sometimes type "PROJECT:SHORTID" or "PROJECT:NUMERICID" where - // the colon separates the project slug from the issue identifier. - // e.g., "CHATEX:CHATEX-W9" → project=chatex, suffix=W9 - // "MYAH-FRONTEND:115562020" → numeric ID 115562020 - // "CHATEX:W9" → project=chatex, suffix=W9 - if (input.includes(":")) { - const colonResult = parseWithColon(input); - if (colonResult) { - return colonResult; - } - // Colon not parseable as project:id — fall through to normal parsing - } - - // 3. Has slash → check slash FIRST (takes precedence over dashes) - // This ensures "my-org/123" parses as org="my-org", not project="my" - if (input.includes("/")) { - return parseWithSlash(input); - } - - // 4. Has dash but no slash → split on last "-" for project-suffix - if (input.includes("-")) { - return parseWithDash(input); - } - - // 5. No dash, no slash → suffix only (needs DSN context) - return { type: "suffix-only", suffix: input.toUpperCase() }; -} - // --------------------------------------------------------------------------- // Query helpers // --------------------------------------------------------------------------- diff --git a/packages/cli/src/lib/issue-arguments.ts b/packages/cli/src/lib/issue-arguments.ts new file mode 100644 index 000000000..a9bc79bd7 --- /dev/null +++ b/packages/cli/src/lib/issue-arguments.ts @@ -0,0 +1,343 @@ +/** + * Issue arguments: the issue ID, short ID, selector, or URL that `issue + * view`, `issue explain`, and every other issue command takes. + * + * Which part of an argument is the org, the project, and the issue is + * decided by `grammar/issue-argument.pegjs`, which also lists the forms. + * Issue IDs are not hex IDs, so they have their own syntax rather than the + * shared one in `id-arguments.ts`. This module validates the parts the + * grammar found and normalizes them into a {@link ParsedIssueArg}. + */ + +import { + type IssueSyntax, + type IssueSyntaxError, + parse, +} from "../generated/issue-argument-grammar.js"; +import { + firstNonBlankLine, + type IssueSelector, + normalizeSlug, + parseSelector, + rejectIssueCommandTokenWithNumericSuffix, +} from "./arg-parsing.js"; +import { ValidationError } from "./errors.js"; +import { validateResourceId } from "./input-validation.js"; +import { + applySentryUrlContext, + type ParsedSentryUrl, + parseSentryUrl, +} from "./sentry-url-parser.js"; +import { isAllDigits } from "./utils.js"; + +export type ParsedIssueArg = + | { type: "numeric"; id: string } + | { type: "explicit"; org: string; project: string; suffix: string } + | { type: "explicit-org-suffix"; org: string; suffix: string } + | { type: "explicit-org-numeric"; org: string; numericId: string } + | { type: "project-search"; projectSlug: string; suffix: string } + | { type: "suffix-only"; suffix: string } + | { type: "selector"; selector: IssueSelector; org?: string } + | { type: "share"; shareId: string; org?: string; baseUrl: string }; + +/** An issue argument, parsed. */ +export type IssueArgument = { + /** The argument as typed, for recovery hints */ + readonly text: string; + readonly parsed: ParsedIssueArg; + /** Server of a pasted Sentry URL, for `applySentryUrlContext` */ + readonly baseUrl?: string; +}; + +/** + * Parse an issue argument, without side effects: the server of a pasted + * Sentry URL is returned as `baseUrl` instead of applied. + * + * Only the first non-blank line counts. Multi-line input (command + * substitution that captured extra output, an identifier with a note, or + * several newline-separated IDs) would otherwise reach `validateResourceId` + * and fail with a cryptic "contains a newline" (CLI-1G1). An issue + * identifier is one token, so joining the lines would make garbage. + * + * @throws {ValidationError} When the argument is empty or malformed, or a + * Sentry URL has no issue in it + * + * @example + * parseIssueArgument("123456789").parsed // { type: "numeric", id: "123456789" } + * parseIssueArgument("sentry/cli-G").parsed // { type: "explicit", org: "sentry", project: "cli", suffix: "G" } + * parseIssueArgument("cli-G").parsed // { type: "project-search", projectSlug: "cli", suffix: "G" } + * parseIssueArgument("sentry/G").parsed // { type: "explicit-org-suffix", org: "sentry", suffix: "G" } + * parseIssueArgument("G").parsed // { type: "suffix-only", suffix: "G" } + * parseIssueArgument("sentry/cli#CLI-G").parsed // { type: "explicit", org: "sentry", project: "cli", suffix: "G" } + * parseIssueArgument("#CLI-G").parsed // { type: "project-search", projectSlug: "cli", suffix: "G" } + */ +export function parseIssueArgument(arg: string): IssueArgument { + const input = firstNonBlankLine(arg); + if (!input) { + throw new ValidationError( + "Issue identifier is empty after trimming whitespace.", + "issue identifier", + ); + } + + const url = parseSentryUrl(input); + if (url) { + const parsed = issueArgFromUrl(url); + if (!parsed) { + // A Sentry URL without an issue (a trace or 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}/", + ); + } + return { text: arg, parsed, baseUrl: url.baseUrl }; + } + + const syntax = parse(input, { + isSelector: (value) => parseSelector(value) !== undefined, + }); + if (syntax.form === "selector") { + return { text: arg, parsed: selectorArg(syntax) }; + } + if (syntax.form === "hash") { + return { text: arg, parsed: hashArg(input, syntax) }; + } + // Slashes separate the parts; `?`, `#`, `%`, whitespace, and control + // characters are never valid in an issue identifier + validateResourceId(input.replace(/\//g, ""), "issue identifier"); + return { text: arg, parsed: issueArgFromSyntax(input, syntax) }; +} + +/** + * Parse an issue argument like {@link parseIssueArgument}, and apply the + * server of a pasted Sentry URL. + * + * @throws {ValidationError} When the argument is malformed + */ +export function parseIssueArg(arg: string): ParsedIssueArg { + const { parsed, baseUrl } = parseIssueArgument(arg); + if (baseUrl) { + applySentryUrlContext(baseUrl); + } + return parsed; +} + +/** + * The issue in a Sentry URL: a share link, a numeric group ID, or a short ID + * (`CLI-G`) from the path; `null` when the URL names no issue. + */ +function issueArgFromUrl(url: ParsedSentryUrl): ParsedIssueArg | null { + if (url.shareId) { + return { + type: "share", + shareId: url.shareId, + org: url.org, + baseUrl: url.baseUrl, + }; + } + // Every non-share issue URL has an org + const { issueId, org } = url; + if (!(issueId && org)) { + return null; + } + if (isAllDigits(issueId)) { + return { type: "explicit-org-numeric", org, numericId: issueId }; + } + const dash = issueId.lastIndexOf("-"); + const project = issueId.slice(0, dash); + const suffix = issueId.slice(dash + 1).toUpperCase(); + if (dash > 0 && suffix) { + return { type: "explicit", org, project: project.toLowerCase(), suffix }; + } + return { type: "explicit-org-suffix", org, suffix: issueId.toUpperCase() }; +} + +function selectorArg( + syntax: Extract, +): ParsedIssueArg { + // The grammar only matches recognized selectors + const selector = parseSelector(syntax.selector) as IssueSelector; + if (syntax.org === undefined) { + return { type: "selector", selector }; + } + const org = normalizeSlug(syntax.org).slug; + validateResourceId(org, "organization slug"); + return { type: "selector", selector, org }; +} + +/** + * A GitHub-style `#` reference, which AI agents often pass (CLI-1G1): + * `org/project#SHORTID` (like `org/project/SHORTID`), `project#SHORTID`, or + * `#SHORTID`. + * + * It skips the check of the whole argument, which rejects `#`, so it checks + * both parts itself. `validateResourceId` permits `:`, so a `:` next to a + * `#` is rejected here rather than swallowed into the suffix. + * + * @throws {ValidationError} When the fragment is empty or has a second `#`, + * a `:`, or a forbidden character, or the prefix is invalid + */ +function hashArg( + input: string, + syntax: Extract, +): ParsedIssueArg { + const { prefix, fragment } = syntax; + if (fragment.includes("#") || fragment === "") { + throw new ValidationError( + `Invalid issue identifier: "${input}".\n` + + " Use a single '#' separating the project from the short ID, e.g. `org/project#PROJ-123`.\n" + + " Or use a slash: `org/project/PROJ-123`.", + "issue identifier", + ); + } + if (fragment.includes(":")) { + throw new ValidationError( + `Invalid issue identifier: "${input}". Do not mix '#' and ':' separators.\n` + + " Use `org/project#PROJ-123` or `project:PROJ-123`.", + "issue identifier", + ); + } + validateResourceId(fragment, "issue identifier"); + + if (prefix === "") { + return issueArgFromSyntax(fragment, reparse(fragment)); + } + if (prefix.includes("/")) { + validateResourceId(prefix.replace(/\//g, ""), "issue identifier"); + const path = `${prefix}/${fragment}`; + return issueArgFromSyntax(path, reparse(path)); + } + validateResourceId(prefix, "project slug"); + return projectIdentifier(prefix, fragment); +} + +/** Parse a part of a `#` reference, which has neither `#` nor a selector. */ +function reparse(input: string): IssueSyntax { + return parse(input, { isSelector: () => false }); +} + +/** The messages of malformed issue arguments. */ +const SYNTAX_ERRORS: Record = { + "missing-id": "Missing issue ID after slash.", + "missing-project-or-id": "Missing project or issue ID segment.", + "slash-before-suffix": "Cannot use trailing slash before suffix.", + "missing-project": "Missing project before suffix.", + "missing-suffix": "Missing suffix after dash.", +}; + +/** + * Normalize the parts of an argument: Sentry slugs are lowercase, so a short + * ID typed in uppercase (`EASI-API-3Y4`) still finds its project (CLI-C8), + * and suffixes are uppercase. + * + * @throws {ValidationError} When the argument is malformed, or names an + * issue command and a number (`issue-1`) + */ +function issueArgFromSyntax( + input: string, + syntax: IssueSyntax, +): ParsedIssueArg { + switch (syntax.form) { + case "numeric": + return { type: "numeric", id: syntax.id }; + case "project-id": + return projectIdentifier(syntax.project, syntax.id); + case "short-id": { + const suffix = syntax.suffix.toUpperCase(); + rejectIssueCommandTokenWithNumericSuffix(input, syntax.project, suffix); + return { + type: "project-search", + projectSlug: syntax.project.toLowerCase(), + suffix, + }; + } + case "suffix": + return { type: "suffix-only", suffix: syntax.suffix.toUpperCase() }; + case "org-numeric": + return { + type: "explicit-org-numeric", + org: syntax.org, + numericId: syntax.id, + }; + case "org-project-id": + return orgProjectIdentifier(syntax.org, syntax.project, syntax.id); + case "org-short-id": { + const suffix = syntax.suffix.toUpperCase(); + rejectIssueCommandTokenWithNumericSuffix(input, syntax.project, suffix); + return { + type: "explicit", + org: syntax.org, + project: syntax.project.toLowerCase(), + suffix, + }; + } + case "org-suffix": + return { + type: "explicit-org-suffix", + org: syntax.org, + suffix: syntax.suffix.toUpperCase(), + }; + case "invalid": + throw new ValidationError( + `Invalid issue format: "${input}". ${SYNTAX_ERRORS[syntax.reason]}`, + "issue", + ); + default: + // A selector or `#` reference only comes from the whole argument + throw new ValidationError( + `Invalid issue identifier: "${input}".`, + "issue identifier", + ); + } +} + +/** + * `:` or `#`. A numeric ID needs no project; a + * full short ID (`PROJECT-SUFFIX`) gives its suffix; anything else is the + * suffix. + */ +function projectIdentifier(project: string, id: string): ParsedIssueArg { + if (isAllDigits(id)) { + return { type: "numeric", id }; + } + const projectSlug = project.toLowerCase(); + const suffix = id.slice(id.lastIndexOf("-") + 1).toUpperCase(); + return { + type: "project-search", + projectSlug, + suffix: suffix || id.toUpperCase(), + }; +} + +/** + * `//`: a full short ID whose prefix is the project + * (`sentry/cli/CLI-A1`) gives its suffix; anything else is the suffix + * (`org/project/SUBPROJ-G` → `SUBPROJ-G`). + */ +function orgProjectIdentifier( + org: string, + project: string, + id: string, +): ParsedIssueArg { + const projectSlug = project.toLowerCase(); + const dash = id.lastIndexOf("-"); + const prefix = id.slice(0, dash); + const suffix = id.slice(dash + 1).toUpperCase(); + if (dash !== -1 && prefix && suffix) { + return { + type: "explicit", + org, + project: projectSlug, + suffix: + prefix.toLowerCase() === projectSlug + ? suffix + : `${prefix}-${suffix}`.toUpperCase(), + }; + } + return { + type: "explicit", + org, + project: projectSlug, + suffix: id.toUpperCase(), + }; +} diff --git a/packages/cli/src/lib/issue-id.ts b/packages/cli/src/lib/issue-id.ts index 5306bc6af..72869791c 100644 --- a/packages/cli/src/lib/issue-id.ts +++ b/packages/cli/src/lib/issue-id.ts @@ -10,7 +10,7 @@ * - GitHub-style format: "org/project#PROJECT-ABC", "project#ABC" (the "#" * replaces the final slash; handled in arg-parsing.ts) * - * Note: parseIssueArg is in arg-parsing.ts for shared logic with org/project parsing. + * Note: issue arguments are parsed in issue-arguments.ts. */ /** Pattern to detect short IDs (contain letters, vs numeric IDs which are just digits) */ diff --git a/packages/cli/test/commands/feedback/status.test.ts b/packages/cli/test/commands/feedback/status.test.ts index 59ecc8b7b..80d2ab194 100644 --- a/packages/cli/test/commands/feedback/status.test.ts +++ b/packages/cli/test/commands/feedback/status.test.ts @@ -92,7 +92,14 @@ describe.each([ await func.call(context, { json: true }, "TEST-PROJECT-1A"); expect(resolveIssue).toHaveBeenCalledWith({ - issueArg: "TEST-PROJECT-1A", + issueArg: { + text: "TEST-PROJECT-1A", + parsed: { + type: "project-search", + projectSlug: "test-project", + suffix: "1A", + }, + }, cwd: "/tmp", command: name, commandBase: "sentry feedback", diff --git a/packages/cli/test/commands/issue/archive.func.test.ts b/packages/cli/test/commands/issue/archive.func.test.ts index 4098c1208..d6989c9cb 100644 --- a/packages/cli/test/commands/issue/archive.func.test.ts +++ b/packages/cli/test/commands/issue/archive.func.test.ts @@ -355,4 +355,39 @@ describe("archiveCommand.func()", () => { expect(output).toContain("Archived"); expect(output).toContain("CLI-G5"); }); + + test("parses the issue before resolving it", async () => { + resolveIssueSpy.mockResolvedValue({ + org: "test-org", + issue: makeMockIssue(), + }); + updateSpy.mockResolvedValue(makeMockIssue()); + + const { context } = createMockContext(); + await func.call(context, { json: false }, "test-org/CLI-G5"); + + expect(resolveIssueSpy).toHaveBeenCalledExactlyOnceWith({ + issueArg: { + text: "test-org/CLI-G5", + parsed: { + type: "explicit", + org: "test-org", + project: "cli", + suffix: "G5", + }, + }, + cwd: context.cwd, + command: "archive", + commandBase: "sentry issue", + }); + }); + + test("rejects a malformed issue without resolving it", async () => { + const { context } = createMockContext(); + await expect( + func.call(context, { json: false }, "test-org/"), + ).rejects.toThrow(/Missing issue ID after slash/); + expect(resolveIssueSpy).not.toHaveBeenCalled(); + expect(updateSpy).not.toHaveBeenCalled(); + }); }); diff --git a/packages/cli/test/commands/issue/link.func.test.ts b/packages/cli/test/commands/issue/link.func.test.ts index 016ee4fb7..9960f1144 100644 --- a/packages/cli/test/commands/issue/link.func.test.ts +++ b/packages/cli/test/commands/issue/link.func.test.ts @@ -74,7 +74,15 @@ describe("issue link", () => { ); expect(resolveOrgAndIssueId).toHaveBeenCalledExactlyOnceWith({ - issueArg: "test-org/APP-42", + issueArg: { + text: "test-org/APP-42", + parsed: { + type: "explicit", + org: "test-org", + project: "app", + suffix: "42", + }, + }, cwd: "/tmp/example-project", command: "link", }); diff --git a/packages/cli/test/commands/issue/unlink.func.test.ts b/packages/cli/test/commands/issue/unlink.func.test.ts index 1ab9fcdd2..b0e488aef 100644 --- a/packages/cli/test/commands/issue/unlink.func.test.ts +++ b/packages/cli/test/commands/issue/unlink.func.test.ts @@ -106,7 +106,15 @@ describe("issue unlink", () => { ); expect(resolveOrgAndIssueId).toHaveBeenCalledExactlyOnceWith({ - issueArg: "test-org/APP-42", + issueArg: { + text: "test-org/APP-42", + parsed: { + type: "explicit", + org: "test-org", + project: "app", + suffix: "42", + }, + }, cwd: "/tmp/example-project", command: "unlink", }); diff --git a/packages/cli/test/lib/arg-parsing.property.test.ts b/packages/cli/test/lib/arg-parsing.property.test.ts index 570f82996..2fddf0116 100644 --- a/packages/cli/test/lib/arg-parsing.property.test.ts +++ b/packages/cli/test/lib/arg-parsing.property.test.ts @@ -18,10 +18,10 @@ import { detectSwappedViewArgs, looksLikeIssueShortId, normalizeSlug, - parseIssueArg, parseOrgProjectArg, parseSelector, } from "../../src/lib/arg-parsing.js"; +import { parseIssueArg } from "../../src/lib/issue-arguments.js"; import { DEFAULT_NUM_RUNS } from "../model-based/helpers.js"; // Arbitraries for generating valid inputs diff --git a/packages/cli/test/lib/arg-parsing.test.ts b/packages/cli/test/lib/arg-parsing.test.ts index 45ee21df3..4cf88c2ab 100644 --- a/packages/cli/test/lib/arg-parsing.test.ts +++ b/packages/cli/test/lib/arg-parsing.test.ts @@ -12,13 +12,13 @@ import { detectSwappedViewArgs, looksLikeIssueShortId, normalizeSlug, - parseIssueArg, parseListTargetInput, parseOrgProjectArg, parseSlashSeparatedArg, rejectIssueCommandTokenListTarget, splitNewlineArg, } from "../../src/lib/arg-parsing.js"; +import { parseIssueArg } from "../../src/lib/issue-arguments.js"; import { stripDsnOrgPrefix } from "../../src/lib/dsn/index.js"; import { ValidationError } from "../../src/lib/errors.js"; diff --git a/packages/cli/test/lib/issue-arguments.test.ts b/packages/cli/test/lib/issue-arguments.test.ts new file mode 100644 index 000000000..14a1caea2 --- /dev/null +++ b/packages/cli/test/lib/issue-arguments.test.ts @@ -0,0 +1,128 @@ +/** + * Tests for the issue-argument grammar (grammar/issue-argument.pegjs), + * through src/lib/issue-arguments.ts. + * + * `parseIssueArg` keeps its own, older suite in arg-parsing.test.ts; these + * cover what the parser returns besides the parsed form, and that it has no + * side effects. + */ + +import { describe, expect, test } from "vitest"; +import { ValidationError } from "../../src/lib/errors.js"; +import { parseIssueArgument } from "../../src/lib/issue-arguments.js"; +import { useEnvSandbox } from "../helpers.js"; + +describe("parseIssueArgument", () => { + useEnvSandbox(["SENTRY_HOST", "SENTRY_URL"]); + + test.each([ + ["123456789", { type: "numeric", id: "123456789" }], + ["@latest", { type: "selector", selector: "@latest" }], + [ + "acme/@mostFrequent", + { type: "selector", selector: "@most_frequent", org: "acme" }, + ], + ["cli-g", { type: "project-search", projectSlug: "cli", suffix: "G" }], + [ + "/spotlight-electron-4y", + { + type: "project-search", + projectSlug: "spotlight-electron", + suffix: "4Y", + }, + ], + ["g", { type: "suffix-only", suffix: "G" }], + ["CLI:CLI-G", { type: "project-search", projectSlug: "cli", suffix: "G" }], + ["CLI:123", { type: "numeric", id: "123" }], + ["#CLI-G", { type: "project-search", projectSlug: "cli", suffix: "G" }], + ["cli#G", { type: "project-search", projectSlug: "cli", suffix: "G" }], + [ + "acme/cli#CLI-G", + { type: "explicit", org: "acme", project: "cli", suffix: "G" }, + ], + [ + "acme/123", + { type: "explicit-org-numeric", org: "acme", numericId: "123" }, + ], + [ + "acme/cli/123", + { type: "explicit-org-numeric", org: "acme", numericId: "123" }, + ], + [ + "acme/CLI-G", + { type: "explicit", org: "acme", project: "cli", suffix: "G" }, + ], + ["acme/g", { type: "explicit-org-suffix", org: "acme", suffix: "G" }], + [ + "acme/cli/CLI-G", + { type: "explicit", org: "acme", project: "cli", suffix: "G" }, + ], + [ + "acme/cli/SUB-G", + { type: "explicit", org: "acme", project: "cli", suffix: "SUB-G" }, + ], + ])("%j", (arg, parsed) => { + expect(parseIssueArgument(arg)).toEqual({ text: arg, parsed }); + }); + + test("keeps the argument as typed and parses its first line", () => { + expect(parseIssueArgument("\n CLI-G \nCLI-H\n")).toEqual({ + text: "\n CLI-G \nCLI-H\n", + parsed: { type: "project-search", projectSlug: "cli", suffix: "G" }, + }); + }); + + test.each([ + ["acme/", "Missing issue ID after slash."], + ["acme/cli/", "Missing project or issue ID segment."], + ["acme//G", "Missing project or issue ID segment."], + ["acme/-G", "Cannot use trailing slash before suffix."], + ["acme/cli-", "Missing suffix after dash."], + ["-G", "Missing project before suffix."], + ["cli-", "Missing suffix after dash."], + ])("rejects %j: %s", (arg, message) => { + expect(() => parseIssueArgument(arg)).toThrow( + new ValidationError( + `Invalid issue format: "${arg}". ${message}`, + "issue", + ), + ); + }); + + test.each([ + "cli#", + "cli#A#B", + "cli#CLI:G", + "CLI-G?x", + "issue-1", + "acme/issues-12", + " ", + ])("rejects %j", (arg) => { + expect(() => parseIssueArgument(arg)).toThrow(ValidationError); + }); + + test("returns a pasted URL's server without applying it", () => { + const arg = "https://sentry.example.com/organizations/acme/issues/123/"; + expect(parseIssueArgument(arg)).toEqual({ + text: arg, + parsed: { type: "explicit-org-numeric", org: "acme", numericId: "123" }, + baseUrl: "https://sentry.example.com", + }); + expect(process.env.SENTRY_HOST).toBeUndefined(); + expect(process.env.SENTRY_URL).toBeUndefined(); + }); + + test("reads short IDs from issue URLs", () => { + expect( + parseIssueArgument("https://acme.sentry.io/issues/CLI-G/").parsed, + ).toEqual({ type: "explicit", org: "acme", project: "cli", suffix: "G" }); + }); + + test("rejects a Sentry URL without an issue", () => { + expect(() => + parseIssueArgument( + "https://sentry.io/organizations/acme/explore/traces/trace/aaaa1111bbbb2222cccc3333dddd4444/", + ), + ).toThrow(/does not contain an issue ID/); + }); +}); 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 3e117f56f..90d62de0e 100644 --- a/packages/cli/test/lib/security/url-arg-poison.test.ts +++ b/packages/cli/test/lib/security/url-arg-poison.test.ts @@ -13,10 +13,8 @@ import { afterEach, beforeEach, describe, expect, test } from "vitest"; import { parsePositionalArgs } from "../../../src/commands/event/view.js"; -import { - parseIssueArg, - parseOrgProjectArg, -} from "../../../src/lib/arg-parsing.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"; import { useEnvSandbox } from "../../helpers.js";