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";