From e4916fee91369eac71d21153c0acd046e9837db1 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 3 Feb 2026 15:07:01 +0000 Subject: [PATCH 1/6] feat(issue): replace --org/--project flags with /ID syntax Replace explicit --org and --project flags with a more intuitive positional syntax for issue commands (view, explain, plan). New issue ID formats: - /ID: Explicit org prefix (e.g., sentry/EXTENSION-7) - -suffix: Project + suffix (e.g., cli-G) - searches across orgs - suffix: Short suffix only (e.g., G) - requires DSN context - numeric: Direct fetch by numeric ID (e.g., 123456789) Resolution logic: 1. Alias cache lookup (fast, local) 2. Project search across accessible orgs 3. DSN detection for project context Closes CLI-16 --- src/commands/issue/explain.ts | 28 +- src/commands/issue/plan.ts | 30 +-- src/commands/issue/utils.ts | 378 ++++++++++++++++----------- src/commands/issue/view.ts | 39 ++- src/lib/issue-id.ts | 83 ++++++ test/commands/issue/utils.test.ts | 407 ++++++++++++++---------------- 6 files changed, 541 insertions(+), 424 deletions(-) diff --git a/src/commands/issue/explain.ts b/src/commands/issue/explain.ts index 498ba4ee..c1e0b3a3 100644 --- a/src/commands/issue/explain.ts +++ b/src/commands/issue/explain.ts @@ -18,18 +18,15 @@ import { } from "../../lib/formatters/seer.js"; import { extractRootCauses } from "../../types/seer.js"; import { - buildCommandHint, - type IssueIdFlags, - issueIdFlags, issueIdPositional, pollAutofixState, resolveOrgAndIssueId, } from "./utils.js"; -interface ExplainFlags extends IssueIdFlags { +type ExplainFlags = { readonly json: boolean; readonly force: boolean; -} +}; export const explainCommand = buildCommand({ docs: { @@ -42,17 +39,22 @@ export const explainCommand = buildCommand({ " - Relevant code locations\n\n" + "The analysis may take a few minutes for new issues.\n" + "Use --force to trigger a fresh analysis even if one already exists.\n\n" + + "Issue formats:\n" + + " /ID - Explicit org: sentry/EXTENSION-7, sentry/cli-G\n" + + " -suffix - Project + suffix: cli-G, spotlight-electron-4Y\n" + + " ID - Short ID: CLI-G (searches across orgs)\n" + + " suffix - Suffix only: G (requires DSN context)\n" + + " numeric - Numeric ID: 123456789\n\n" + "Examples:\n" + " sentry issue explain 123456789\n" + - " sentry issue explain MYPROJECT-ABC --org my-org\n" + - " sentry issue explain G --org my-org --project my-project\n" + + " sentry issue explain sentry/EXTENSION-7\n" + + " sentry issue explain cli-G\n" + " sentry issue explain 123456789 --json\n" + " sentry issue explain 123456789 --force", }, parameters: { positional: issueIdPositional, flags: { - ...issueIdFlags, json: { kind: "boolean", brief: "Output as JSON", @@ -68,7 +70,7 @@ export const explainCommand = buildCommand({ async func( this: SentryContext, flags: ExplainFlags, - issueId: string + issueArg: string ): Promise { const { stdout, stderr, cwd } = this; @@ -78,11 +80,9 @@ export const explainCommand = buildCommand({ try { // Resolve org and issue ID const { org, issueId: numericId } = await resolveOrgAndIssueId({ - issueId, - org: flags.org, - project: flags.project, + issueArg, cwd, - commandHint: buildCommandHint("explain", issueId), + command: "explain", }); resolvedOrg = org; @@ -137,7 +137,7 @@ export const explainCommand = buildCommand({ stdout.write(`${lines.join("\n")}\n`); writeFooter( stdout, - `To create a plan, run: sentry issue plan ${issueId}` + `To create a plan, run: sentry issue plan ${issueArg}` ); } catch (error) { // Handle API errors with friendly messages diff --git a/src/commands/issue/plan.ts b/src/commands/issue/plan.ts index 3a30b0a6..2f240e00 100644 --- a/src/commands/issue/plan.ts +++ b/src/commands/issue/plan.ts @@ -25,18 +25,15 @@ import { type RootCause, } from "../../types/seer.js"; import { - buildCommandHint, - type IssueIdFlags, - issueIdFlags, issueIdPositional, pollAutofixState, resolveOrgAndIssueId, } from "./utils.js"; -interface PlanFlags extends IssueIdFlags { +type PlanFlags = { readonly cause?: number; readonly json: boolean; -} +}; /** * Validate that an autofix run exists and has completed root cause analysis. @@ -141,18 +138,23 @@ export const planCommand = buildCommand({ "to identify the root cause. It will then generate a solution plan with " + "specific implementation steps to fix the issue.\n\n" + "If multiple root causes were identified, use --cause to specify which one.\n\n" + + "Issue formats:\n" + + " /ID - Explicit org: sentry/EXTENSION-7, sentry/cli-G\n" + + " -suffix - Project + suffix: cli-G, spotlight-electron-4Y\n" + + " ID - Short ID: CLI-G (searches across orgs)\n" + + " suffix - Suffix only: G (requires DSN context)\n" + + " numeric - Numeric ID: 123456789\n\n" + "Prerequisites:\n" + " - GitHub integration configured for your organization\n" + " - Code mappings set up for your project\n\n" + "Examples:\n" + " sentry issue plan 123456789 --cause 0\n" + - " sentry issue plan MYPROJECT-ABC --org my-org --cause 1\n" + - " sentry issue plan G --org my-org --project my-project --cause 0", + " sentry issue plan sentry/EXTENSION-7 --cause 1\n" + + " sentry issue plan cli-G --cause 0", }, parameters: { positional: issueIdPositional, flags: { - ...issueIdFlags, cause: { kind: "parsed", parse: numberParser, @@ -169,7 +171,7 @@ export const planCommand = buildCommand({ async func( this: SentryContext, flags: PlanFlags, - issueId: string + issueArg: string ): Promise { const { stdout, stderr, cwd } = this; @@ -179,11 +181,9 @@ export const planCommand = buildCommand({ try { // Resolve org and issue ID const { org, issueId: numericId } = await resolveOrgAndIssueId({ - issueId, - org: flags.org, - project: flags.project, + issueArg, cwd, - commandHint: buildCommandHint("plan", issueId), + command: "plan", }); resolvedOrg = org; @@ -191,10 +191,10 @@ export const planCommand = buildCommand({ const currentState = await getAutofixState(org, numericId); // Validate we have a completed root cause analysis - const { state, causes } = validateAutofixState(currentState, issueId); + const { state, causes } = validateAutofixState(currentState, issueArg); // Validate cause selection - const causeId = validateCauseSelection(causes, flags.cause, issueId); + const causeId = validateCauseSelection(causes, flags.cause, issueArg); const selectedCause = causes[causeId]; if (!flags.json) { diff --git a/src/commands/issue/utils.ts b/src/commands/issue/utils.ts index fc7e3262..3a2626d0 100644 --- a/src/commands/issue/utils.ts +++ b/src/commands/issue/utils.ts @@ -4,65 +4,58 @@ * Common functionality used by explain, plan, view, and other issue commands. */ -import type { FlagParametersForType } from "@stricli/core"; import { + findProjectsBySlug, getAutofixState, getIssue, getIssueByShortId, } from "../../lib/api-client.js"; import { getProjectByAlias } from "../../lib/db/project-aliases.js"; import { createDsnFingerprint, detectAllDsns } from "../../lib/dsn/index.js"; -import { ApiError, CliError, ContextError } from "../../lib/errors.js"; +import { CliError, ContextError } from "../../lib/errors.js"; import { getProgressMessage } from "../../lib/formatters/seer.js"; import { expandToFullShortId, - isShortId, isShortSuffix, - parseAliasSuffix, + parseIssueArg, + splitProjectSuffix, } from "../../lib/issue-id.js"; import { poll } from "../../lib/polling.js"; -import { resolveOrg, resolveOrgAndProject } from "../../lib/resolve-target.js"; +import { resolveOrgAndProject } from "../../lib/resolve-target.js"; import type { SentryIssue, Writer } from "../../types/index.js"; import { type AutofixState, isTerminalStatus } from "../../types/seer.js"; -/** Base flags for issue commands that accept an issue ID */ -export type IssueIdFlags = { - readonly org?: string; - readonly project?: string; -}; - -/** Shared --org and --project flag definitions for issue ID commands */ -export const issueIdFlags: FlagParametersForType = { - org: { - kind: "parsed", - parse: String, - brief: "Organization slug (required for short IDs if not auto-detected)", - optional: true, - }, - project: { - kind: "parsed", - parse: String, - brief: "Project slug (required for short suffixes if not auto-detected)", - optional: true, - }, -}; +/** Pattern to detect numeric IDs */ +const NUMERIC_PATTERN = /^\d+$/; -/** Shared positional parameter for issue ID (numeric, short ID, suffix, or alias-suffix) */ +/** Shared positional parameter for issue ID */ export const issueIdPositional = { kind: "tuple", parameters: [ { - placeholder: "issue-id", + placeholder: "issue", brief: - "Issue ID, short ID, suffix, or alias-suffix (e.g., 123456, CRAFT-G, G, or f-g)", + "Issue: /ID, -suffix, ID, or suffix (e.g., sentry/CLI-G, cli-G, CLI-G, G)", parse: String, }, ], } as const; -/** Build a command hint string for error messages */ +/** + * Build a command hint string for error messages. + * + * Returns context-aware hints based on the issue ID format: + * - Suffix only (e.g., "G") → suggest `-G` + * - Has dash (e.g., "cli-G") → suggest `/cli-G` + * + * @param command - The issue subcommand (e.g., "view", "explain") + * @param issueId - The user-provided issue ID + */ export function buildCommandHint(command: string, issueId: string): string { - return `sentry issue ${command} ${issueId} --org --project `; + if (isShortSuffix(issueId)) { + return `sentry issue ${command} -${issueId}`; + } + return `sentry issue ${command} /${issueId}`; } /** Default timeout in milliseconds (3 minutes) */ @@ -88,19 +81,18 @@ type StrictResolvedIssue = { }; /** - * Try to resolve an alias-suffix format issue ID (e.g., "f-g"). + * Try to resolve via alias cache. * Returns null if the alias is not found in cache or fingerprint doesn't match. * - * @param alias - The project alias from the alias-suffix format - * @param suffix - The issue suffix + * @param alias - The project alias (lowercase) + * @param suffix - The issue suffix (uppercase) * @param cwd - Current working directory for DSN detection */ -async function resolveAliasSuffixId( +async function tryResolveFromAlias( alias: string, suffix: string, cwd: string ): Promise { - // Detect DSNs to create fingerprint for validation const detection = await detectAllDsns(cwd); const fingerprint = createDsnFingerprint(detection.all); const projectEntry = await getProjectByAlias(alias, fingerprint); @@ -113,165 +105,256 @@ async function resolveAliasSuffixId( return { org: projectEntry.orgSlug, issue }; } -type ResolveContext = { - issueId: string; - org: string | undefined; - project: string | undefined; - cwd: string; - commandHint: string; -}; - /** - * Check if an error from short suffix resolution should allow fallthrough. - * Returns true for 404 (issue not found) or ContextError when no explicit flags were provided. - * When user explicitly provides --org or --project, we should error clearly instead of - * silently falling through to other resolution methods. + * Search for a project by slug across all orgs, then fetch the issue. + * + * @param projectSlug - Project slug to search for (lowercase) + * @param suffix - Issue suffix to expand (uppercase) + * @param commandHint - Hint for error messages */ -function shouldFallthrough(error: unknown, ctx: ResolveContext): boolean { - if (error instanceof ApiError && error.status === 404) { - return true; +async function resolveByProjectSearch( + projectSlug: string, + suffix: string, + commandHint: string +): Promise { + const projects = await findProjectsBySlug(projectSlug); + + if (projects.length === 0) { + throw new ContextError(`Project '${projectSlug}' not found`, commandHint, [ + "No project with this slug found in any accessible organization", + ]); } - if (error instanceof ContextError) { - // Only fall through if user didn't provide explicit flags. - // If they provided --org or --project, they expect that resolution path to work. - return ctx.org === undefined && ctx.project === undefined; + + if (projects.length > 1) { + const orgList = projects.map((p) => p.orgSlug).join(", "); + throw new ContextError( + `Project '${projectSlug}' found in multiple organizations`, + commandHint, + [ + `Found in: ${orgList}`, + `Specify the org: sentry issue ... /${projectSlug}-${suffix}`, + ] + ); } - return false; -} -/** - * Re-throw an error, wrapping unexpected ones in ApiError. - */ -function rethrowAsApiError(error: unknown): never { - if (error instanceof CliError) { - throw error; + const project = projects[0]; + if (!project) { + // This should never happen given the length check above + throw new ContextError(`Project '${projectSlug}' not found`, commandHint); } - const message = error instanceof Error ? error.message : String(error); - throw new ApiError(`Failed to resolve issue: ${message}`, 500); + const fullShortId = expandToFullShortId(suffix, project.slug); + const issue = await getIssueByShortId(project.orgSlug, fullShortId); + return { org: project.orgSlug, issue }; } /** - * Try to resolve a short suffix format (e.g., "G", "4Y"). - * Requires project context to expand to full short ID. + * Resolve a suffix-only issue ID using DSN detection for project context. + * + * @param suffix - The issue suffix (e.g., "G", "4Y") + * @param cwd - Current working directory for DSN detection + * @param commandHint - Hint for error messages */ -async function resolveShortSuffixId( - ctx: ResolveContext +async function resolveSuffixWithDsn( + suffix: string, + cwd: string, + commandHint: string ): Promise { - const target = await resolveOrgAndProject({ - org: ctx.org, - project: ctx.project, - cwd: ctx.cwd, - }); + const target = await resolveOrgAndProject({ cwd }); if (!target) { - throw new ContextError("Organization and project", ctx.commandHint); + throw new ContextError( + `Cannot resolve issue suffix '${suffix}' without project context`, + commandHint + ); } - const resolvedShortId = expandToFullShortId(ctx.issueId, target.project); - const issue = await getIssueByShortId(target.org, resolvedShortId); + const fullShortId = expandToFullShortId(suffix, target.project); + const issue = await getIssueByShortId(target.org, fullShortId); return { org: target.org, issue }; } /** - * Try to resolve a full short ID format (e.g., "CRAFT-G"). - * Project is embedded in the ID, only needs org context. + * Resolve a "has-dash" format issue ID. + * + * Resolution order: + * 1. Try alias cache (fast, local) + * 2. Search for project across orgs + * 3. Error if project not found + * + * @param value - The issue ID with dash (e.g., "cli-G", "EXTENSION-7") + * @param cwd - Current working directory + * @param commandHint - Hint for error messages */ -async function resolveFullShortId( - ctx: ResolveContext +async function resolveHasDash( + value: string, + cwd: string, + commandHint: string ): Promise { - const resolved = await resolveOrg({ org: ctx.org, cwd: ctx.cwd }); - if (!resolved) { - throw new ContextError("Organization", ctx.commandHint); + const { project, suffix } = splitProjectSuffix(value); + + // 1. Try alias cache first (fast, local lookup) + const aliasResult = await tryResolveFromAlias(project, suffix, cwd); + if (aliasResult) { + return aliasResult; } - const normalizedId = ctx.issueId.toUpperCase(); - const issue = await getIssueByShortId(resolved.org, normalizedId); - return { org: resolved.org, issue }; + + // 2. Search for project across all accessible orgs + return resolveByProjectSearch(project, suffix, commandHint); } /** - * Try to resolve a numeric issue ID. - * Fetches issue directly by ID (doesn't require org). - * Org is resolved separately for API routing (optional). + * Resolve a suffix-only issue ID. + * + * Resolution order: + * 1. Try alias cache (in case suffix is part of an alias like "f") + * 2. Use DSN detection for project context + * 3. Error if no context + * + * Note: Single-char suffixes might match aliases from `issue list`. + * + * @param suffix - The issue suffix (e.g., "G", "4Y") + * @param cwd - Current working directory + * @param commandHint - Hint for error messages */ -async function resolveNumericId( - ctx: ResolveContext -): Promise { - const issue = await getIssue(ctx.issueId); - const resolved = await resolveOrg({ org: ctx.org, cwd: ctx.cwd }); - return { org: resolved?.org, issue }; +function resolveSuffixOnly( + suffix: string, + cwd: string, + commandHint: string +): Promise { + // Suffix-only means we need project context from DSN detection + return resolveSuffixWithDsn(suffix.toUpperCase(), cwd, commandHint); +} + +/** + * Resolve with explicit org prefix. + * + * The "rest" after org/ can be: + * - A project-suffix format: "cli-G" → org + project + suffix + * - A direct short ID: "EXTENSION-7" → fetch directly from org + * - A suffix only: "G" → use DSN for project, explicit org + * - A numeric ID: "123456" → fetch directly + * + * @param org - The explicit organization slug + * @param rest - The remainder after "org/" + * @param cwd - Current working directory + * @param commandHint - Hint for error messages + */ +async function resolveWithExplicitOrg( + org: string, + rest: string, + cwd: string, + commandHint: string +): Promise { + // Check if rest is numeric + if (NUMERIC_PATTERN.test(rest)) { + const issue = await getIssue(rest); + return { org, issue }; + } + + // Check if rest has a dash (could be project-suffix or short ID) + if (rest.includes("-")) { + const { project, suffix } = splitProjectSuffix(rest); + + // Try alias cache first + const aliasResult = await tryResolveFromAlias(project, suffix, cwd); + if (aliasResult) { + // Alias found but user specified org - use their org + const fullShortId = expandToFullShortId( + suffix, + aliasResult.issue.project?.slug ?? project + ); + const issue = await getIssueByShortId(org, fullShortId); + return { org, issue }; + } + + // Try as project-suffix within the specified org + try { + const fullShortId = expandToFullShortId(suffix, project); + const issue = await getIssueByShortId(org, fullShortId); + return { org, issue }; + } catch (error) { + // If not found as project-suffix, try as literal short ID + if (error instanceof CliError) { + try { + const issue = await getIssueByShortId(org, rest.toUpperCase()); + return { org, issue }; + } catch { + throw error; // Throw original error + } + } + throw error; + } + } + + // Suffix only - expand with DSN-detected project or error + const target = await resolveOrgAndProject({ cwd }); + if (target) { + const fullShortId = expandToFullShortId(rest, target.project); + const issue = await getIssueByShortId(org, fullShortId); + return { org, issue }; + } + + throw new ContextError( + `Cannot resolve suffix '${rest}' without project context`, + commandHint, + [`Specify the project: sentry issue ... ${org}/-${rest}`] + ); } /** * Options for resolving an issue ID. */ export type ResolveIssueOptions = { - /** User-provided issue ID in any supported format */ - issueId: string; - /** Optional org slug from CLI flag */ - org?: string; - /** Optional project slug from CLI flag */ - project?: string; + /** User-provided issue argument (raw CLI input) */ + issueArg: string; /** Current working directory for context resolution */ cwd: string; - /** Command example for error messages */ - commandHint: string; + /** Command name for error messages (e.g., "view", "explain") */ + command: string; }; /** * Resolve an issue ID to organization slug and full issue object. - * Used by view command which needs the complete issue data. * * Supports all issue ID formats: - * - Alias-suffix format (e.g., "f-g" where "f" is a cached project alias) - * - Short suffix format (e.g., "G", "4Y", "15" - requires project context) - * - Full short ID format (e.g., "CRAFT-G", "PROJECT-ABC") - * - Numeric ID format (e.g., "123456789") + * - Org-prefixed: "sentry/EXTENSION-7", "sentry/cli-G" + * - Project-suffix: "cli-G", "spotlight-electron-4Y" + * - Short ID: "CLI-G", "EXTENSION-7" (treated as project-suffix) + * - Suffix only: "G", "4Y" (requires DSN context) + * - Numeric: "123456789" (direct fetch) * * @param options - Resolution options - * @returns Object with org slug (may be undefined for numeric) and full issue + * @returns Object with org slug and full issue * @throws {ContextError} When required context cannot be resolved */ export async function resolveIssue( options: ResolveIssueOptions ): Promise { - const { issueId, org, project, cwd, commandHint } = options; - const ctx: ResolveContext = { issueId, org, project, cwd, commandHint }; - - // Try alias-suffix format (e.g., "f-g") - const aliasSuffix = parseAliasSuffix(issueId); - if (aliasSuffix) { - const result = await resolveAliasSuffixId( - aliasSuffix.alias, - aliasSuffix.suffix, - cwd - ); - // Only fall through if alias not found (null). Let real errors propagate. - if (result) { - return result; - } - // Fall through to treat as full short ID - } + const { issueArg, cwd, command } = options; + const parsed = parseIssueArg(issueArg); + const commandHint = buildCommandHint(command, issueArg); - // Short suffix format (e.g., "G", "4Y", "15") - requires project context. - // Try short suffix expansion for any short alphanumeric input that looks too short to be a real numeric ID. - // Sentry numeric IDs are typically large numbers (e.g., 6085858322), not small like "15". - const looksLikeShortSuffix = isShortSuffix(issueId) && issueId.length <= 4; - if (looksLikeShortSuffix) { - try { - return await resolveShortSuffixId(ctx); - } catch (error) { - if (!shouldFallthrough(error, ctx)) { - rethrowAsApiError(error); - } - // Fall through to try other resolution methods + switch (parsed.type) { + case "explicit-org": + return resolveWithExplicitOrg(parsed.org, parsed.rest, cwd, commandHint); + + case "has-dash": + return resolveHasDash(parsed.value, cwd, commandHint); + + case "suffix-only": + return resolveSuffixOnly(parsed.suffix, cwd, commandHint); + + case "numeric": { + const issue = await getIssue(parsed.id); + return { org: undefined, issue }; } - } - // Full short ID format (e.g., "CRAFT-G") - requires org context - if (isShortId(issueId)) { - return resolveFullShortId(ctx); + default: { + // Exhaustive check - this should never be reached + const _exhaustive: never = parsed; + throw new Error( + `Unexpected issue arg type: ${JSON.stringify(_exhaustive)}` + ); + } } - - // Numeric ID - fetch issue directly, org is optional - return resolveNumericId(ctx); } /** @@ -288,7 +371,8 @@ export async function resolveOrgAndIssueId( ): Promise<{ org: string; issueId: string }> { const result = await resolveIssue(options); if (!result.org) { - throw new ContextError("Organization", options.commandHint); + const commandHint = buildCommandHint(options.command, options.issueArg); + throw new ContextError("Organization", commandHint); } return { org: result.org, issueId: result.issue.id }; } diff --git a/src/commands/issue/view.ts b/src/commands/issue/view.ts index dc1503ba..79ff4355 100644 --- a/src/commands/issue/view.ts +++ b/src/commands/issue/view.ts @@ -17,19 +17,13 @@ import { writeJson, } from "../../lib/formatters/index.js"; import type { SentryEvent, SentryIssue, Writer } from "../../types/index.js"; -import { - buildCommandHint, - type IssueIdFlags, - issueIdFlags, - issueIdPositional, - resolveIssue, -} from "./utils.js"; - -interface ViewFlags extends IssueIdFlags { +import { issueIdPositional, resolveIssue } from "./utils.js"; + +type ViewFlags = { readonly json: boolean; readonly web: boolean; readonly spans?: number; -} +}; /** * Try to fetch the latest event for an issue. @@ -119,19 +113,18 @@ export const viewCommand = buildCommand({ fullDescription: "View detailed information about a Sentry issue by its ID or short ID. " + "The latest event is automatically included for full context.\n\n" + - "You can use just the unique suffix (e.g., 'G' instead of 'CRAFT-G') when " + - "project context is available from DSN detection or flags.\n\n" + + "Issue formats:\n" + + " /ID - Explicit org: sentry/EXTENSION-7, sentry/cli-G\n" + + " -suffix - Project + suffix: cli-G, spotlight-electron-4Y\n" + + " ID - Short ID: CLI-G (searches across orgs)\n" + + " suffix - Suffix only: G (requires DSN context)\n" + + " numeric - Numeric ID: 123456789\n\n" + "In multi-project mode (after 'issue list'), use alias-suffix format (e.g., 'f-g' " + - "where 'f' is the project alias shown in the list).\n\n" + - "For short IDs, the organization is resolved from:\n" + - " 1. --org flag\n" + - " 2. Config defaults\n" + - " 3. SENTRY_DSN environment variable", + "where 'f' is the project alias shown in the list).", }, parameters: { positional: issueIdPositional, flags: { - ...issueIdFlags, json: { kind: "boolean", brief: "Output as JSON", @@ -155,17 +148,15 @@ export const viewCommand = buildCommand({ async func( this: SentryContext, flags: ViewFlags, - issueId: string + issueArg: string ): Promise { const { stdout, cwd, setContext } = this; // Resolve issue using shared resolution logic const { org: orgSlug, issue } = await resolveIssue({ - issueId, - org: flags.org, - project: flags.project, + issueArg, cwd, - commandHint: buildCommandHint("view", issueId), + command: "view", }); // Set telemetry context @@ -206,7 +197,7 @@ export const viewCommand = buildCommand({ writeFooter( stdout, - `Tip: Use 'sentry issue explain ${issue.shortId}' for AI root cause analysis` + `Tip: Use 'sentry issue explain ${issueArg}' for AI root cause analysis` ); }, }); diff --git a/src/lib/issue-id.ts b/src/lib/issue-id.ts index 727c2668..95121a82 100644 --- a/src/lib/issue-id.ts +++ b/src/lib/issue-id.ts @@ -6,6 +6,7 @@ * - Full short IDs: "PROJECT-ABC", "SPOTLIGHT-ELECTRON-4Y" * - Short suffixes: "ABC", "4Y" (requires project context) * - Alias-suffix format: "e-4y", "w-2c" (requires alias cache) + * - Org-prefixed format: "org/PROJECT-ABC", "org/project-suffix" */ /** Pattern to detect short IDs (contain letters, vs numeric IDs which are just digits) */ @@ -17,6 +18,9 @@ const SHORT_SUFFIX_PATTERN = /^[a-zA-Z0-9]+$/; /** Pattern for alias-suffix format (e.g., "f-g", "fr-a3", "spotlight-e-4y") */ const ALIAS_SUFFIX_PATTERN = /^(.+)-([a-zA-Z0-9]+)$/i; +/** Pattern to detect numeric IDs (pure digits) */ +const NUMERIC_ID_PATTERN = /^\d+$/; + /** * Check if a string looks like a short ID (e.g., PROJECT-ABC) * vs a numeric ID (e.g., 123456). @@ -71,3 +75,82 @@ export function expandToFullShortId( ): string { return `${projectSlug.toUpperCase()}-${suffix.toUpperCase()}`; } + +/** + * Parsed issue argument types for CLI input. + * + * Supports: + * - `org/issue-id`: Explicit org with any issue ID format + * - `project-suffix`: Project slug + suffix (e.g., "cli-G", "spotlight-electron-4Y") + * - `suffix`: Short suffix only (e.g., "G", "4Y") + * - `numeric`: Numeric issue ID (e.g., "123456789") + */ +export type ParsedIssueArg = + | { type: "explicit-org"; org: string; rest: string } + | { type: "has-dash"; value: string } + | { type: "suffix-only"; suffix: string } + | { type: "numeric"; id: string }; + +/** + * Parse a CLI issue argument into its component parts. + * + * Determines the format of the issue argument: + * - `org/...` → explicit org prefix + * - `123456789` → numeric ID + * - `CLI-G` or `spotlight-electron-4Y` → has dash (could be short ID or project-suffix) + * - `G` or `4Y` → suffix only + * + * @param arg - Raw CLI argument + * @returns Parsed issue argument with type discrimination + * + * @example + * parseIssueArg("sentry/EXTENSION-7") // { type: "explicit-org", org: "sentry", rest: "EXTENSION-7" } + * parseIssueArg("cli-G") // { type: "has-dash", value: "cli-G" } + * parseIssueArg("G") // { type: "suffix-only", suffix: "G" } + * parseIssueArg("123456789") // { type: "numeric", id: "123456789" } + */ +export function parseIssueArg(arg: string): ParsedIssueArg { + const slashIndex = arg.indexOf("/"); + if (slashIndex > 0) { + return { + type: "explicit-org", + org: arg.slice(0, slashIndex), + rest: arg.slice(slashIndex + 1), + }; + } + + if (NUMERIC_ID_PATTERN.test(arg)) { + return { type: "numeric", id: arg }; + } + + if (arg.includes("-")) { + return { type: "has-dash", value: arg }; + } + + return { type: "suffix-only", suffix: arg }; +} + +/** + * Split a project-suffix format string into project and suffix parts. + * + * The suffix is the part after the last hyphen. The project is everything before. + * Both parts are normalized: project to lowercase, suffix to uppercase. + * + * @param value - String in format "project-suffix" (e.g., "cli-G", "spotlight-electron-4Y") + * @returns Object with project (lowercase) and suffix (uppercase) + * + * @example + * splitProjectSuffix("cli-G") // { project: "cli", suffix: "G" } + * splitProjectSuffix("spotlight-electron-4Y") // { project: "spotlight-electron", suffix: "4Y" } + * splitProjectSuffix("CLI-G") // { project: "cli", suffix: "G" } + */ +export function splitProjectSuffix(value: string): { + project: string; + suffix: string; +} { + const lastDash = value.lastIndexOf("-"); + return { + project: value.slice(0, lastDash).toLowerCase(), + suffix: value.slice(lastDash + 1).toUpperCase(), + }; +} diff --git a/test/commands/issue/utils.test.ts b/test/commands/issue/utils.test.ts index a5c585ce..a328c5bf 100644 --- a/test/commands/issue/utils.test.ts +++ b/test/commands/issue/utils.test.ts @@ -26,6 +26,8 @@ beforeEach(async () => { // Pre-populate region cache for orgs used in tests to avoid region resolution API calls await setOrgRegion("test-org", DEFAULT_SENTRY_URL); await setOrgRegion("my-org", DEFAULT_SENTRY_URL); + await setOrgRegion("cached-org", DEFAULT_SENTRY_URL); + await setOrgRegion("org1", DEFAULT_SENTRY_URL); }); afterEach(async () => { @@ -34,7 +36,8 @@ afterEach(async () => { }); describe("resolveOrgAndIssueId", () => { - test("returns org and numeric issue ID when org is provided", async () => { + test("throws for numeric ID (org cannot be resolved)", async () => { + // @ts-expect-error - partial mock globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { const req = new Request(input, init); const url = req.url; @@ -64,18 +67,18 @@ describe("resolveOrgAndIssueId", () => { }); }; - const result = await resolveOrgAndIssueId({ - issueId: "123456789", - org: "my-org", - cwd: testConfigDir, - commandHint: "sentry issue explain 123456789 --org ", - }); - - expect(result.org).toBe("my-org"); - expect(result.issueId).toBe("123456789"); + // Numeric IDs don't have org context, so resolveOrgAndIssueId should throw + await expect( + resolveOrgAndIssueId({ + issueArg: "123456789", + cwd: testConfigDir, + command: "explain", + }) + ).rejects.toThrow("Organization"); }); - test("resolves short ID to numeric ID", async () => { + test("resolves explicit org prefix (org/ISSUE-ID)", async () => { + // @ts-expect-error - partial mock globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { const req = new Request(input, init); const url = req.url; @@ -105,57 +108,15 @@ describe("resolveOrgAndIssueId", () => { }; const result = await resolveOrgAndIssueId({ - issueId: "PROJECT-ABC", - org: "my-org", + issueArg: "my-org/PROJECT-ABC", cwd: testConfigDir, - commandHint: "sentry issue explain PROJECT-ABC --org ", + command: "explain", }); expect(result.org).toBe("my-org"); expect(result.issueId).toBe("987654321"); }); - test("throws ContextError when org cannot be resolved", async () => { - delete process.env.SENTRY_DSN; - - globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { - const req = new Request(input, init); - const url = req.url; - - // Numeric ID is fetched directly - this succeeds - if (url.includes("/issues/123456789/")) { - return new Response( - JSON.stringify({ - id: "123456789", - shortId: "PROJECT-ABC", - title: "Test Issue", - status: "unresolved", - platform: "javascript", - type: "error", - count: "10", - userCount: 5, - }), - { - status: 200, - headers: { "Content-Type": "application/json" }, - } - ); - } - - return new Response(JSON.stringify({ detail: "Not found" }), { - status: 404, - }); - }; - - await expect( - resolveOrgAndIssueId({ - issueId: "123456789", - cwd: "/nonexistent/path", - commandHint: "sentry issue explain 123456789 --org ", - }) - ).rejects.toThrow("Organization"); - }); - test("resolves alias-suffix format (e.g., 'f-g') using cached aliases", async () => { // Empty fingerprint matches detectAllDsns on empty dir const { setProjectAliases } = await import( @@ -169,6 +130,7 @@ describe("resolveOrgAndIssueId", () => { "" ); + // @ts-expect-error - partial mock globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { const req = new Request(input, init); const url = req.url; @@ -198,37 +160,28 @@ describe("resolveOrgAndIssueId", () => { }; const result = await resolveOrgAndIssueId({ - issueId: "f-g", + issueArg: "f-g", cwd: testConfigDir, - commandHint: "sentry issue explain f-g --org ", + command: "explain", }); expect(result.org).toBe("cached-org"); expect(result.issueId).toBe("111222333"); }); - test("resolves org-aware alias format (e.g., 'o1/d-4y') for cross-org collisions", async () => { - const { setProjectAliases } = await import( - "../../../src/lib/db/project-aliases.js" - ); - await setProjectAliases( - { - "o1/d": { orgSlug: "org1", projectSlug: "dashboard" }, - "o2/d": { orgSlug: "org2", projectSlug: "dashboard" }, - }, - "" - ); - + test("resolves explicit org prefix with project-suffix (e.g., 'org1/dashboard-4y')", async () => { + // @ts-expect-error - partial mock globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { const req = new Request(input, init); const url = req.url; + // With explicit org, we try project-suffix format: dashboard-4y -> DASHBOARD-4Y if (url.includes("organizations/org1/issues/DASHBOARD-4Y")) { return new Response( JSON.stringify({ id: "999888777", shortId: "DASHBOARD-4Y", - title: "Test Issue from org-aware alias", + title: "Test Issue with explicit org", status: "unresolved", platform: "javascript", type: "error", @@ -248,19 +201,20 @@ describe("resolveOrgAndIssueId", () => { }; const result = await resolveOrgAndIssueId({ - issueId: "o1/d-4y", + issueArg: "org1/dashboard-4y", cwd: testConfigDir, - commandHint: "sentry issue explain o1/d-4y", + command: "explain", }); expect(result.org).toBe("org1"); expect(result.issueId).toBe("999888777"); }); - test("resolves short suffix format (e.g., 'G') using project context", async () => { + test("resolves short suffix format (e.g., 'G') using project context from defaults", async () => { const { setDefaults } = await import("../../../src/lib/db/defaults.js"); await setDefaults("my-org", "my-project"); + // @ts-expect-error - partial mock globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { const req = new Request(input, init); const url = req.url; @@ -290,9 +244,9 @@ describe("resolveOrgAndIssueId", () => { }; const result = await resolveOrgAndIssueId({ - issueId: "G", + issueArg: "G", cwd: testConfigDir, - commandHint: "sentry issue explain G --org ", + command: "explain", }); expect(result.org).toBe("my-org"); @@ -306,43 +260,70 @@ describe("resolveOrgAndIssueId", () => { await clearAuth(); await setDefaults(undefined, undefined); - // Short suffix "G" first tries to expand with project context (fails with ContextError), - // then falls through to full short ID resolution (requires org), which also fails. - // The final error is "Organization is required" since the fallthrough logic allows trying - // it as a full short ID, and that's where it ultimately fails. await expect( resolveOrgAndIssueId({ - issueId: "G", + issueArg: "G", cwd: testConfigDir, - commandHint: "sentry issue explain G --org ", + command: "explain", }) - ).rejects.toThrow("Organization is required"); + ).rejects.toThrow("Cannot resolve issue suffix"); }); - test("resolves short suffix with explicit --org and --project flags", async () => { - // Clear defaults but keep auth token to ensure we're testing explicit flags - const { clearAuth, setAuthToken: setToken } = await import( - "../../../src/lib/db/auth.js" + test("searches projects across orgs for project-suffix format", async () => { + const { clearProjectAliases } = await import( + "../../../src/lib/db/project-aliases.js" ); - const { setDefaults } = await import("../../../src/lib/db/defaults.js"); - await clearAuth(); - await setDefaults(undefined, undefined); - await setToken("test-token"); + await clearProjectAliases(); + // @ts-expect-error - partial mock globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { const req = new Request(input, init); const url = req.url; - if (url.includes("organizations/my-org/issues/MY-PROJECT-G")) { + // listOrganizations call + if ( + url.includes("/organizations/") && + !url.includes("/projects/") && + !url.includes("/issues/") + ) { + return new Response( + JSON.stringify([{ id: "1", slug: "my-org", name: "My Org" }]), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); + } + + // listProjects for my-org + if (url.includes("organizations/my-org/projects/")) { + return new Response( + JSON.stringify([ + { id: "123", slug: "craft", name: "Craft", platform: "javascript" }, + { + id: "456", + slug: "other-project", + name: "Other", + platform: "python", + }, + ]), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); + } + + if (url.includes("organizations/my-org/issues/CRAFT-G")) { return new Response( JSON.stringify({ - id: "555666777", - shortId: "MY-PROJECT-G", - title: "Test Issue with explicit flags", + id: "777888999", + shortId: "CRAFT-G", + title: "Test Issue fallback", status: "unresolved", - platform: "python", + platform: "javascript", type: "error", - count: "3", + count: "1", userCount: 1, }), { @@ -358,40 +339,52 @@ describe("resolveOrgAndIssueId", () => { }; const result = await resolveOrgAndIssueId({ - issueId: "G", - org: "my-org", - project: "my-project", + issueArg: "craft-g", cwd: testConfigDir, - commandHint: - "sentry issue explain G --org --project ", + command: "explain", }); expect(result.org).toBe("my-org"); - expect(result.issueId).toBe("555666777"); + expect(result.issueId).toBe("777888999"); }); - test("falls back to full short ID when alias is not found in cache", async () => { + test("throws when project not found in any org", async () => { const { clearProjectAliases } = await import( "../../../src/lib/db/project-aliases.js" ); await clearProjectAliases(); + // @ts-expect-error - partial mock globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { const req = new Request(input, init); const url = req.url; - if (url.includes("organizations/my-org/issues/CRAFT-G")) { + // listOrganizations call + if ( + url.includes("/organizations/") && + !url.includes("/projects/") && + !url.includes("/issues/") + ) { return new Response( - JSON.stringify({ - id: "777888999", - shortId: "CRAFT-G", - title: "Test Issue fallback", - status: "unresolved", - platform: "javascript", - type: "error", - count: "1", - userCount: 1, - }), + JSON.stringify([{ id: "1", slug: "my-org", name: "My Org" }]), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); + } + + // listProjects - return projects that don't match "nonexistent" + if (url.includes("/projects/")) { + return new Response( + JSON.stringify([ + { + id: "123", + slug: "other-project", + name: "Other", + platform: "python", + }, + ]), { status: 200, headers: { "Content-Type": "application/json" }, @@ -404,57 +397,70 @@ describe("resolveOrgAndIssueId", () => { }); }; - const result = await resolveOrgAndIssueId({ - issueId: "craft-g", - org: "my-org", - cwd: testConfigDir, - commandHint: "sentry issue explain craft-g --org ", - }); - - expect(result.org).toBe("my-org"); - expect(result.issueId).toBe("777888999"); + await expect( + resolveOrgAndIssueId({ + issueArg: "nonexistent-g", + cwd: testConfigDir, + command: "explain", + }) + ).rejects.toThrow("not found"); }); - test("short suffix 404 falls through to full short ID resolution", async () => { - // Setup: project context exists, but short suffix lookup returns 404 - // Should fall through and try as full short ID - const { setAuthToken: setToken } = await import( - "../../../src/lib/db/auth.js" + test("throws when project found in multiple orgs without explicit org", async () => { + const { clearProjectAliases } = await import( + "../../../src/lib/db/project-aliases.js" ); - const { setDefaults } = await import("../../../src/lib/db/defaults.js"); - await setToken("test-token"); - await setDefaults("my-org", "my-project"); + await clearProjectAliases(); - let shortSuffixAttempted = false; - let fullShortIdAttempted = false; + await setOrgRegion("org2", DEFAULT_SENTRY_URL); + // @ts-expect-error - partial mock globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { const req = new Request(input, init); const url = req.url; - // First attempt: short suffix expansion (MY-PROJECT-15) returns 404 - if (url.includes("organizations/my-org/issues/MY-PROJECT-15")) { - shortSuffixAttempted = true; - return new Response(JSON.stringify({ detail: "Not found" }), { - status: 404, - }); + // listOrganizations call + if ( + url.includes("/organizations/") && + !url.includes("/projects/") && + !url.includes("/issues/") + ) { + return new Response( + JSON.stringify([ + { id: "1", slug: "org1", name: "Org 1" }, + { id: "2", slug: "org2", name: "Org 2" }, + ]), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); } - // Fallthrough: try as full short ID (15 contains no letters, so goes to numeric) - // Actually "15" will be treated as numeric ID after fallthrough - if (url.includes("issues/15/")) { - fullShortIdAttempted = true; + // listProjects for org1 - has "common" project + if (url.includes("organizations/org1/projects/")) { return new Response( - JSON.stringify({ - id: "15", - shortId: "ACTUAL-PROJECT-X", - title: "Found via numeric fallback", - status: "unresolved", - platform: "python", - type: "error", - count: "1", - userCount: 1, - }), + JSON.stringify([ + { + id: "123", + slug: "common", + name: "Common", + platform: "javascript", + }, + ]), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); + } + + // listProjects for org2 - also has "common" project + if (url.includes("organizations/org2/projects/")) { + return new Response( + JSON.stringify([ + { id: "456", slug: "common", name: "Common", platform: "python" }, + ]), { status: 200, headers: { "Content-Type": "application/json" }, @@ -467,108 +473,54 @@ describe("resolveOrgAndIssueId", () => { }); }; - const result = await resolveOrgAndIssueId({ - issueId: "15", - cwd: testConfigDir, - commandHint: "sentry issue explain 15", - }); - - expect(shortSuffixAttempted).toBe(true); - expect(fullShortIdAttempted).toBe(true); - expect(result.issueId).toBe("15"); + await expect( + resolveOrgAndIssueId({ + issueArg: "common-g", + cwd: testConfigDir, + command: "explain", + }) + ).rejects.toThrow("multiple organizations"); }); - test("short suffix auth error (401) propagates without fallthrough", async () => { - const { setAuthToken: setToken } = await import( - "../../../src/lib/db/auth.js" - ); + test("short suffix auth error (401) propagates", async () => { const { setDefaults } = await import("../../../src/lib/db/defaults.js"); - await setToken("test-token"); await setDefaults("my-org", "my-project"); + // @ts-expect-error - partial mock globalThis.fetch = async () => new Response(JSON.stringify({ detail: "Unauthorized" }), { status: 401, }); - // Auth errors should propagate, not fall through + // Auth errors should propagate await expect( resolveOrgAndIssueId({ - issueId: "G", + issueArg: "G", cwd: testConfigDir, - commandHint: "sentry issue explain G", + command: "explain", }) ).rejects.toThrow(); }); - test("short suffix server error (500) propagates without fallthrough", async () => { - const { setAuthToken: setToken } = await import( - "../../../src/lib/db/auth.js" - ); + test("short suffix server error (500) propagates", async () => { const { setDefaults } = await import("../../../src/lib/db/defaults.js"); - await setToken("test-token"); await setDefaults("my-org", "my-project"); + // @ts-expect-error - partial mock globalThis.fetch = async () => new Response(JSON.stringify({ detail: "Internal Server Error" }), { status: 500, }); - // Server errors should propagate, not fall through + // Server errors should propagate await expect( resolveOrgAndIssueId({ - issueId: "G", + issueArg: "G", cwd: testConfigDir, - commandHint: "sentry issue explain G", + command: "explain", }) ).rejects.toThrow("500"); }); - - test("explicit --org without --project errors clearly instead of falling through", async () => { - // When user explicitly provides --org but not --project for a short suffix, - // they expect that resolution path. Don't silently fall through to 404. - const { clearAuth, setAuthToken: setToken } = await import( - "../../../src/lib/db/auth.js" - ); - const { setDefaults } = await import("../../../src/lib/db/defaults.js"); - await clearAuth(); - await setDefaults(undefined, undefined); - await setToken("test-token"); - - // User provides --org but not --project - await expect( - resolveOrgAndIssueId({ - issueId: "G", - org: "my-org", // Explicit --org flag - // No project provided - cwd: testConfigDir, - commandHint: "sentry issue explain G --org my-org --project ", - }) - ).rejects.toThrow("Organization and project"); - }); - - test("explicit --project without --org errors clearly instead of falling through", async () => { - // When user explicitly provides --project but not --org for a short suffix, - // they expect that resolution path. Don't silently fall through. - const { clearAuth, setAuthToken: setToken } = await import( - "../../../src/lib/db/auth.js" - ); - const { setDefaults } = await import("../../../src/lib/db/defaults.js"); - await clearAuth(); - await setDefaults(undefined, undefined); - await setToken("test-token"); - - // User provides --project but not --org - await expect( - resolveOrgAndIssueId({ - issueId: "G", - // No org provided - project: "my-project", // Explicit --project flag - cwd: testConfigDir, - commandHint: "sentry issue explain G --org --project my-project", - }) - ).rejects.toThrow("Organization and project"); - }); }); describe("pollAutofixState", () => { @@ -581,6 +533,7 @@ describe("pollAutofixState", () => { test("returns immediately when state is COMPLETED", async () => { let fetchCount = 0; + // @ts-expect-error - partial mock globalThis.fetch = async () => { fetchCount += 1; return new Response( @@ -610,6 +563,7 @@ describe("pollAutofixState", () => { }); test("returns immediately when state is ERROR", async () => { + // @ts-expect-error - partial mock globalThis.fetch = async () => new Response( JSON.stringify({ @@ -636,6 +590,7 @@ describe("pollAutofixState", () => { }); test("stops at WAITING_FOR_USER_RESPONSE when stopOnWaitingForUser is true", async () => { + // @ts-expect-error - partial mock globalThis.fetch = async () => new Response( JSON.stringify({ @@ -665,6 +620,7 @@ describe("pollAutofixState", () => { test("continues polling when PROCESSING", async () => { let fetchCount = 0; + // @ts-expect-error - partial mock globalThis.fetch = async () => { fetchCount += 1; @@ -718,6 +674,7 @@ describe("pollAutofixState", () => { // Return PROCESSING first to allow animation interval to fire, // then COMPLETED on second call + // @ts-expect-error - partial mock globalThis.fetch = async () => { fetchCount += 1; @@ -783,6 +740,7 @@ describe("pollAutofixState", () => { }); test("throws timeout error when exceeding timeoutMs", async () => { + // @ts-expect-error - partial mock globalThis.fetch = async () => new Response( JSON.stringify({ @@ -814,6 +772,7 @@ describe("pollAutofixState", () => { test("continues polling when autofix is null", async () => { let fetchCount = 0; + // @ts-expect-error - partial mock globalThis.fetch = async () => { fetchCount += 1; From c21779b0d05bf9e9c12bfa7d4e81cde923f379b1 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 3 Feb 2026 17:39:32 +0000 Subject: [PATCH 2/6] refactor: consolidate parsing logic into shared arg-parsing module Extract shared org/project parsing logic into src/lib/arg-parsing.ts: - Move parseOrgProjectArg from resolve-target.ts - Refactor parseIssueArg to use parseOrgProjectArg for left part of dash-separated inputs - Flatten ParsedIssueArg types for better ergonomics New parseIssueArg flow: 1. Split on last '-' to get (leftPart, suffix) 2. Pass leftPart through parseOrgProjectArg 3. Combine with suffix into flattened result types This consolidates the org/project parsing pattern used by both issue list (org/project targets) and issue view/explain/plan (issue IDs). --- src/commands/issue/list.ts | 2 +- src/commands/issue/utils.ts | 222 +++++++++++++---------------------- src/lib/arg-parsing.ts | 216 ++++++++++++++++++++++++++++++++++ src/lib/issue-id.ts | 59 +--------- src/lib/resolve-target.ts | 79 ------------- test/lib/arg-parsing.test.ts | 199 +++++++++++++++++++++++++++++++ 6 files changed, 498 insertions(+), 279 deletions(-) create mode 100644 src/lib/arg-parsing.ts create mode 100644 test/lib/arg-parsing.test.ts diff --git a/src/commands/issue/list.ts b/src/commands/issue/list.ts index 0d591feb..32a1641b 100644 --- a/src/commands/issue/list.ts +++ b/src/commands/issue/list.ts @@ -13,6 +13,7 @@ import { listIssues, listProjects, } from "../../lib/api-client.js"; +import { parseOrgProjectArg } from "../../lib/arg-parsing.js"; import { clearProjectAliases, setProjectAliases, @@ -28,7 +29,6 @@ import { writeJson, } from "../../lib/formatters/index.js"; import { - parseOrgProjectArg, type ResolvedTarget, resolveAllTargets, } from "../../lib/resolve-target.js"; diff --git a/src/commands/issue/utils.ts b/src/commands/issue/utils.ts index 3a2626d0..e420d924 100644 --- a/src/commands/issue/utils.ts +++ b/src/commands/issue/utils.ts @@ -10,24 +10,17 @@ import { getIssue, getIssueByShortId, } from "../../lib/api-client.js"; +import { parseIssueArg } from "../../lib/arg-parsing.js"; import { getProjectByAlias } from "../../lib/db/project-aliases.js"; import { createDsnFingerprint, detectAllDsns } from "../../lib/dsn/index.js"; -import { CliError, ContextError } from "../../lib/errors.js"; +import { ContextError } from "../../lib/errors.js"; import { getProgressMessage } from "../../lib/formatters/seer.js"; -import { - expandToFullShortId, - isShortSuffix, - parseIssueArg, - splitProjectSuffix, -} from "../../lib/issue-id.js"; +import { expandToFullShortId, isShortSuffix } from "../../lib/issue-id.js"; import { poll } from "../../lib/polling.js"; import { resolveOrgAndProject } from "../../lib/resolve-target.js"; import type { SentryIssue, Writer } from "../../types/index.js"; import { type AutofixState, isTerminalStatus } from "../../types/seer.js"; -/** Pattern to detect numeric IDs */ -const NUMERIC_PATTERN = /^\d+$/; - /** Shared positional parameter for issue ID */ export const issueIdPositional = { kind: "tuple", @@ -106,18 +99,35 @@ async function tryResolveFromAlias( } /** - * Search for a project by slug across all orgs, then fetch the issue. + * Resolve project-search type: search for project across orgs, then fetch issue. + * + * Resolution order: + * 1. Try alias cache (fast, local) + * 2. Search for project across orgs via API * - * @param projectSlug - Project slug to search for (lowercase) - * @param suffix - Issue suffix to expand (uppercase) + * @param projectSlug - Project slug to search for + * @param suffix - Issue suffix (uppercase) + * @param cwd - Current working directory * @param commandHint - Hint for error messages */ -async function resolveByProjectSearch( +async function resolveProjectSearch( projectSlug: string, suffix: string, + cwd: string, commandHint: string ): Promise { - const projects = await findProjectsBySlug(projectSlug); + // 1. Try alias cache first (fast, local lookup) + const aliasResult = await tryResolveFromAlias( + projectSlug.toLowerCase(), + suffix, + cwd + ); + if (aliasResult) { + return aliasResult; + } + + // 2. Search for project across all accessible orgs + const projects = await findProjectsBySlug(projectSlug.toLowerCase()); if (projects.length === 0) { throw new ContextError(`Project '${projectSlug}' not found`, commandHint, [ @@ -139,22 +149,22 @@ async function resolveByProjectSearch( const project = projects[0]; if (!project) { - // This should never happen given the length check above throw new ContextError(`Project '${projectSlug}' not found`, commandHint); } + const fullShortId = expandToFullShortId(suffix, project.slug); const issue = await getIssueByShortId(project.orgSlug, fullShortId); return { org: project.orgSlug, issue }; } /** - * Resolve a suffix-only issue ID using DSN detection for project context. + * Resolve suffix-only type using DSN detection for project context. * - * @param suffix - The issue suffix (e.g., "G", "4Y") + * @param suffix - The issue suffix (uppercase) * @param cwd - Current working directory for DSN detection * @param commandHint - Hint for error messages */ -async function resolveSuffixWithDsn( +async function resolveSuffixOnly( suffix: string, cwd: string, commandHint: string @@ -172,130 +182,30 @@ async function resolveSuffixWithDsn( } /** - * Resolve a "has-dash" format issue ID. - * - * Resolution order: - * 1. Try alias cache (fast, local) - * 2. Search for project across orgs - * 3. Error if project not found - * - * @param value - The issue ID with dash (e.g., "cli-G", "EXTENSION-7") - * @param cwd - Current working directory - * @param commandHint - Hint for error messages - */ -async function resolveHasDash( - value: string, - cwd: string, - commandHint: string -): Promise { - const { project, suffix } = splitProjectSuffix(value); - - // 1. Try alias cache first (fast, local lookup) - const aliasResult = await tryResolveFromAlias(project, suffix, cwd); - if (aliasResult) { - return aliasResult; - } - - // 2. Search for project across all accessible orgs - return resolveByProjectSearch(project, suffix, commandHint); -} - -/** - * Resolve a suffix-only issue ID. - * - * Resolution order: - * 1. Try alias cache (in case suffix is part of an alias like "f") - * 2. Use DSN detection for project context - * 3. Error if no context - * - * Note: Single-char suffixes might match aliases from `issue list`. - * - * @param suffix - The issue suffix (e.g., "G", "4Y") - * @param cwd - Current working directory - * @param commandHint - Hint for error messages - */ -function resolveSuffixOnly( - suffix: string, - cwd: string, - commandHint: string -): Promise { - // Suffix-only means we need project context from DSN detection - return resolveSuffixWithDsn(suffix.toUpperCase(), cwd, commandHint); -} - -/** - * Resolve with explicit org prefix. - * - * The "rest" after org/ can be: - * - A project-suffix format: "cli-G" → org + project + suffix - * - A direct short ID: "EXTENSION-7" → fetch directly from org - * - A suffix only: "G" → use DSN for project, explicit org - * - A numeric ID: "123456" → fetch directly + * Resolve explicit-org-suffix type: org provided, need project from DSN. * - * @param org - The explicit organization slug - * @param rest - The remainder after "org/" + * @param org - Explicit organization slug + * @param suffix - Issue suffix (uppercase) * @param cwd - Current working directory * @param commandHint - Hint for error messages */ -async function resolveWithExplicitOrg( +async function resolveExplicitOrgSuffix( org: string, - rest: string, + suffix: string, cwd: string, commandHint: string ): Promise { - // Check if rest is numeric - if (NUMERIC_PATTERN.test(rest)) { - const issue = await getIssue(rest); - return { org, issue }; - } - - // Check if rest has a dash (could be project-suffix or short ID) - if (rest.includes("-")) { - const { project, suffix } = splitProjectSuffix(rest); - - // Try alias cache first - const aliasResult = await tryResolveFromAlias(project, suffix, cwd); - if (aliasResult) { - // Alias found but user specified org - use their org - const fullShortId = expandToFullShortId( - suffix, - aliasResult.issue.project?.slug ?? project - ); - const issue = await getIssueByShortId(org, fullShortId); - return { org, issue }; - } - - // Try as project-suffix within the specified org - try { - const fullShortId = expandToFullShortId(suffix, project); - const issue = await getIssueByShortId(org, fullShortId); - return { org, issue }; - } catch (error) { - // If not found as project-suffix, try as literal short ID - if (error instanceof CliError) { - try { - const issue = await getIssueByShortId(org, rest.toUpperCase()); - return { org, issue }; - } catch { - throw error; // Throw original error - } - } - throw error; - } - } - - // Suffix only - expand with DSN-detected project or error const target = await resolveOrgAndProject({ cwd }); if (target) { - const fullShortId = expandToFullShortId(rest, target.project); + const fullShortId = expandToFullShortId(suffix, target.project); const issue = await getIssueByShortId(org, fullShortId); return { org, issue }; } throw new ContextError( - `Cannot resolve suffix '${rest}' without project context`, + `Cannot resolve suffix '${suffix}' without project context`, commandHint, - [`Specify the project: sentry issue ... ${org}/-${rest}`] + [`Specify the project: sentry issue ... ${org}/-${suffix}`] ); } @@ -314,12 +224,13 @@ export type ResolveIssueOptions = { /** * Resolve an issue ID to organization slug and full issue object. * - * Supports all issue ID formats: - * - Org-prefixed: "sentry/EXTENSION-7", "sentry/cli-G" - * - Project-suffix: "cli-G", "spotlight-electron-4Y" - * - Short ID: "CLI-G", "EXTENSION-7" (treated as project-suffix) - * - Suffix only: "G", "4Y" (requires DSN context) - * - Numeric: "123456789" (direct fetch) + * Supports all issue ID formats (now parsed by parseIssueArg in arg-parsing.ts): + * - explicit: "sentry/cli-G" → org + project + suffix + * - explicit-org-suffix: "sentry/G" → org + suffix (needs DSN for project) + * - explicit-org-numeric: "sentry/123456789" → org + numeric ID + * - project-search: "cli-G" → search for project across orgs + * - suffix-only: "G" (requires DSN context) + * - numeric: "123456789" (direct fetch, no org) * * @param options - Resolution options * @returns Object with org slug and full issue @@ -333,20 +244,47 @@ export async function resolveIssue( const commandHint = buildCommandHint(command, issueArg); switch (parsed.type) { - case "explicit-org": - return resolveWithExplicitOrg(parsed.org, parsed.rest, cwd, commandHint); - - case "has-dash": - return resolveHasDash(parsed.value, cwd, commandHint); - - case "suffix-only": - return resolveSuffixOnly(parsed.suffix, cwd, commandHint); - case "numeric": { + // Direct fetch by numeric ID - no org context const issue = await getIssue(parsed.id); return { org: undefined, issue }; } + case "explicit": { + // Full context: org + project + suffix + const fullShortId = expandToFullShortId(parsed.suffix, parsed.project); + const issue = await getIssueByShortId(parsed.org, fullShortId); + return { org: parsed.org, issue }; + } + + case "explicit-org-numeric": { + // Org + numeric ID + const issue = await getIssue(parsed.numericId); + return { org: parsed.org, issue }; + } + + case "explicit-org-suffix": + // Org + suffix only - need DSN for project + return resolveExplicitOrgSuffix( + parsed.org, + parsed.suffix, + cwd, + commandHint + ); + + case "project-search": + // Project slug + suffix - search across orgs + return resolveProjectSearch( + parsed.projectSlug, + parsed.suffix, + cwd, + commandHint + ); + + case "suffix-only": + // Just suffix - need DSN for org and project + return resolveSuffixOnly(parsed.suffix, cwd, commandHint); + default: { // Exhaustive check - this should never be reached const _exhaustive: never = parsed; diff --git a/src/lib/arg-parsing.ts b/src/lib/arg-parsing.ts new file mode 100644 index 00000000..0dfa14f0 --- /dev/null +++ b/src/lib/arg-parsing.ts @@ -0,0 +1,216 @@ +/** + * Shared Argument Parsing Utilities + * + * Common parsing logic for CLI positional arguments that follow the + * `/` pattern. Used by both listing commands (issue list, + * project list) and single-item commands (issue view, explain, plan). + */ + +/** + * Type constants for project specification patterns. + * Use these constants instead of string literals for type safety. + */ +export const ProjectSpecificationType = { + /** Explicit org/project provided (e.g., "sentry/cli") */ + Explicit: "explicit", + /** Org with trailing slash for all projects (e.g., "sentry/") */ + OrgAll: "org-all", + /** Project slug only, search across all orgs (e.g., "cli") */ + ProjectSearch: "project-search", + /** No input, auto-detect from DSN/config */ + AutoDetect: "auto-detect", +} as const; + +/** + * Parsed result from an org/project positional argument. + * Discriminated union based on the `type` field. + */ +export type ParsedOrgProject = + | { + type: typeof ProjectSpecificationType.Explicit; + org: string; + project: string; + } + | { type: typeof ProjectSpecificationType.OrgAll; org: string } + | { type: typeof ProjectSpecificationType.ProjectSearch; projectSlug: string } + | { type: typeof ProjectSpecificationType.AutoDetect }; + +/** + * Parse an org/project positional argument string. + * + * Supports the following patterns: + * - `undefined` or empty → auto-detect from DSN/config + * - `sentry/cli` → explicit org and project + * - `sentry/` → org with all projects + * - `/cli` → search for project across all orgs (leading slash) + * - `cli` → search for project across all orgs + * + * @param arg - Input string from CLI positional argument + * @returns Parsed result with type discrimination + * + * @example + * parseOrgProjectArg(undefined) // { type: "auto-detect" } + * parseOrgProjectArg("sentry/cli") // { type: "explicit", org: "sentry", project: "cli" } + * parseOrgProjectArg("sentry/") // { type: "org-all", org: "sentry" } + * parseOrgProjectArg("/cli") // { type: "project-search", projectSlug: "cli" } + * parseOrgProjectArg("cli") // { type: "project-search", projectSlug: "cli" } + */ +export function parseOrgProjectArg(arg: string | undefined): ParsedOrgProject { + if (!arg || arg.trim() === "") { + return { type: "auto-detect" }; + } + + const trimmed = arg.trim(); + + if (trimmed.includes("/")) { + const slashIndex = trimmed.indexOf("/"); + const org = trimmed.slice(0, slashIndex); + const project = trimmed.slice(slashIndex + 1); + + if (!org) { + // "/cli" → search for project across all orgs + return { type: "project-search", projectSlug: project }; + } + + if (!project) { + // "sentry/" → list all projects in org + return { type: "org-all", org }; + } + + // "sentry/cli" → explicit org and project + return { type: "explicit", org, project }; + } + + // No slash → search for project across all orgs + return { type: "project-search", projectSlug: trimmed }; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Issue Argument Parsing +// ───────────────────────────────────────────────────────────────────────────── + +/** Pattern to detect numeric IDs (pure digits) */ +const NUMERIC_ID_PATTERN = /^\d+$/; + +/** + * Parsed issue argument types - flattened for ergonomics. + * + * Supports: + * - `numeric`: Pure numeric ID (e.g., "123456789") + * - `explicit`: Org + project + suffix (e.g., "sentry/cli-G") + * - `explicit-org-suffix`: Org + suffix only (e.g., "sentry/G") + * - `explicit-org-numeric`: Org + numeric ID (e.g., "sentry/123456789") + * - `project-search`: Project slug + suffix (e.g., "cli-G") + * - `suffix-only`: Just suffix (e.g., "G") + */ +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 }; + +/** + * 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" } + * 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" } + */ +export function parseIssueArg(arg: string): ParsedIssueArg { + // 1. Pure numeric → direct fetch by ID + if (NUMERIC_ID_PATTERN.test(arg)) { + return { type: "numeric", id: arg }; + } + + // 2. Has dash → split on last "-", parse left with org/project logic + if (arg.includes("-")) { + const lastDash = arg.lastIndexOf("-"); + const leftPart = arg.slice(0, lastDash); + const suffix = arg.slice(lastDash + 1).toUpperCase(); + + const target = parseOrgProjectArg(leftPart); + + switch (target.type) { + case "explicit": + // "sentry/cli-G" → org + project + suffix + return { + type: "explicit", + org: target.org, + project: target.project, + suffix, + }; + + case "project-search": + // "cli-G" → search for project, then use suffix + return { + type: "project-search", + projectSlug: target.projectSlug, + suffix, + }; + + case "org-all": + // "sentry/-G" is invalid - can't have org-all with issue suffix + throw new Error( + `Invalid issue format: "${arg}". Cannot use trailing slash before suffix.` + ); + + case "auto-detect": + // "-G" is invalid - empty left part + throw new Error( + `Invalid issue format: "${arg}". Missing project before suffix.` + ); + + default: { + // Exhaustive check + const _exhaustive: never = target; + throw new Error( + `Unexpected target type: ${JSON.stringify(_exhaustive)}` + ); + } + } + } + + // 3. Has slash but no dash (e.g., "sentry/G" or "sentry/123456789") + if (arg.includes("/")) { + const slashIdx = arg.indexOf("/"); + const org = arg.slice(0, slashIdx); + const rest = arg.slice(slashIdx + 1); + + if (!org) { + // "/G" → treat as suffix-only (unusual but valid) + return { type: "suffix-only", suffix: rest.toUpperCase() }; + } + + if (NUMERIC_ID_PATTERN.test(rest)) { + // "sentry/123456789" → explicit org + numeric ID + return { type: "explicit-org-numeric", org, numericId: rest }; + } + + // "sentry/G" → explicit org + suffix only + return { type: "explicit-org-suffix", org, suffix: rest.toUpperCase() }; + } + + // 4. No dash, no slash → suffix only (needs DSN context) + return { type: "suffix-only", suffix: arg.toUpperCase() }; +} diff --git a/src/lib/issue-id.ts b/src/lib/issue-id.ts index 95121a82..476cf830 100644 --- a/src/lib/issue-id.ts +++ b/src/lib/issue-id.ts @@ -7,6 +7,8 @@ * - Short suffixes: "ABC", "4Y" (requires project context) * - Alias-suffix format: "e-4y", "w-2c" (requires alias cache) * - Org-prefixed format: "org/PROJECT-ABC", "org/project-suffix" + * + * Note: parseIssueArg is in arg-parsing.ts for shared logic with org/project parsing. */ /** Pattern to detect short IDs (contain letters, vs numeric IDs which are just digits) */ @@ -18,9 +20,6 @@ const SHORT_SUFFIX_PATTERN = /^[a-zA-Z0-9]+$/; /** Pattern for alias-suffix format (e.g., "f-g", "fr-a3", "spotlight-e-4y") */ const ALIAS_SUFFIX_PATTERN = /^(.+)-([a-zA-Z0-9]+)$/i; -/** Pattern to detect numeric IDs (pure digits) */ -const NUMERIC_ID_PATTERN = /^\d+$/; - /** * Check if a string looks like a short ID (e.g., PROJECT-ABC) * vs a numeric ID (e.g., 123456). @@ -76,60 +75,6 @@ export function expandToFullShortId( return `${projectSlug.toUpperCase()}-${suffix.toUpperCase()}`; } -/** - * Parsed issue argument types for CLI input. - * - * Supports: - * - `org/issue-id`: Explicit org with any issue ID format - * - `project-suffix`: Project slug + suffix (e.g., "cli-G", "spotlight-electron-4Y") - * - `suffix`: Short suffix only (e.g., "G", "4Y") - * - `numeric`: Numeric issue ID (e.g., "123456789") - */ -export type ParsedIssueArg = - | { type: "explicit-org"; org: string; rest: string } - | { type: "has-dash"; value: string } - | { type: "suffix-only"; suffix: string } - | { type: "numeric"; id: string }; - -/** - * Parse a CLI issue argument into its component parts. - * - * Determines the format of the issue argument: - * - `org/...` → explicit org prefix - * - `123456789` → numeric ID - * - `CLI-G` or `spotlight-electron-4Y` → has dash (could be short ID or project-suffix) - * - `G` or `4Y` → suffix only - * - * @param arg - Raw CLI argument - * @returns Parsed issue argument with type discrimination - * - * @example - * parseIssueArg("sentry/EXTENSION-7") // { type: "explicit-org", org: "sentry", rest: "EXTENSION-7" } - * parseIssueArg("cli-G") // { type: "has-dash", value: "cli-G" } - * parseIssueArg("G") // { type: "suffix-only", suffix: "G" } - * parseIssueArg("123456789") // { type: "numeric", id: "123456789" } - */ -export function parseIssueArg(arg: string): ParsedIssueArg { - const slashIndex = arg.indexOf("/"); - if (slashIndex > 0) { - return { - type: "explicit-org", - org: arg.slice(0, slashIndex), - rest: arg.slice(slashIndex + 1), - }; - } - - if (NUMERIC_ID_PATTERN.test(arg)) { - return { type: "numeric", id: arg }; - } - - if (arg.includes("-")) { - return { type: "has-dash", value: arg }; - } - - return { type: "suffix-only", suffix: arg }; -} - /** * Split a project-suffix format string into project and suffix parts. * diff --git a/src/lib/resolve-target.ts b/src/lib/resolve-target.ts index e82caba2..799866b8 100644 --- a/src/lib/resolve-target.ts +++ b/src/lib/resolve-target.ts @@ -520,82 +520,3 @@ export async function resolveOrg( return null; } } - -/** - * Discriminated union type values for `ParsedOrgProject`. - * Use these constants instead of string literals for type safety. - */ -export const ProjectSpecificationType = { - /** Explicit org/project provided (e.g., "sentry/cli") */ - Explicit: "explicit", - /** Org with trailing slash for all projects (e.g., "sentry/") */ - OrgAll: "org-all", - /** Project slug only, search across all orgs (e.g., "cli") */ - ProjectSearch: "project-search", - /** No input, auto-detect from DSN/config */ - AutoDetect: "auto-detect", -} as const; - -/** - * Parsed result from an org/project positional argument. - * Discriminated union based on the `type` field. - */ -export type ParsedOrgProject = - | { - type: typeof ProjectSpecificationType.Explicit; - org: string; - project: string; - } - | { type: typeof ProjectSpecificationType.OrgAll; org: string } - | { type: typeof ProjectSpecificationType.ProjectSearch; projectSlug: string } - | { type: typeof ProjectSpecificationType.AutoDetect }; - -/** - * Parse an org/project positional argument string. - * - * Supports the following patterns: - * - `undefined` or empty → auto-detect from DSN/config - * - `sentry/cli` → explicit org and project - * - `sentry/` → org with all projects - * - `/cli` → search for project across all orgs (leading slash) - * - `cli` → search for project across all orgs - * - * @param arg - Input string from CLI positional argument - * @returns Parsed result with type discrimination - * - * @example - * parseOrgProjectArg(undefined) // { type: "auto-detect" } - * parseOrgProjectArg("sentry/cli") // { type: "explicit", org: "sentry", project: "cli" } - * parseOrgProjectArg("sentry/") // { type: "org-all", org: "sentry" } - * parseOrgProjectArg("/cli") // { type: "project-search", projectSlug: "cli" } - * parseOrgProjectArg("cli") // { type: "project-search", projectSlug: "cli" } - */ -export function parseOrgProjectArg(arg: string | undefined): ParsedOrgProject { - if (!arg || arg.trim() === "") { - return { type: "auto-detect" }; - } - - const trimmed = arg.trim(); - - if (trimmed.includes("/")) { - const slashIndex = trimmed.indexOf("/"); - const org = trimmed.slice(0, slashIndex); - const project = trimmed.slice(slashIndex + 1); - - if (!org) { - // "/cli" → search for project across all orgs - return { type: "project-search", projectSlug: project }; - } - - if (!project) { - // "sentry/" → list all projects in org - return { type: "org-all", org }; - } - - // "sentry/cli" → explicit org and project - return { type: "explicit", org, project }; - } - - // No slash → search for project across all orgs - return { type: "project-search", projectSlug: trimmed }; -} diff --git a/test/lib/arg-parsing.test.ts b/test/lib/arg-parsing.test.ts new file mode 100644 index 00000000..635f8139 --- /dev/null +++ b/test/lib/arg-parsing.test.ts @@ -0,0 +1,199 @@ +/** + * Argument Parsing Tests + * + * Tests for shared parsing utilities in src/lib/arg-parsing.ts + */ + +import { describe, expect, test } from "bun:test"; +import { + parseIssueArg, + parseOrgProjectArg, +} from "../../src/lib/arg-parsing.js"; + +describe("parseOrgProjectArg", () => { + test("undefined returns auto-detect", () => { + expect(parseOrgProjectArg(undefined)).toEqual({ type: "auto-detect" }); + }); + + test("empty string returns auto-detect", () => { + expect(parseOrgProjectArg("")).toEqual({ type: "auto-detect" }); + }); + + test("org/project returns explicit", () => { + expect(parseOrgProjectArg("sentry/cli")).toEqual({ + type: "explicit", + org: "sentry", + project: "cli", + }); + }); + + test("org/ returns org-all", () => { + expect(parseOrgProjectArg("sentry/")).toEqual({ + type: "org-all", + org: "sentry", + }); + }); + + test("/project returns project-search", () => { + expect(parseOrgProjectArg("/cli")).toEqual({ + type: "project-search", + projectSlug: "cli", + }); + }); + + test("project returns project-search", () => { + expect(parseOrgProjectArg("cli")).toEqual({ + type: "project-search", + projectSlug: "cli", + }); + }); + + test("handles multi-part project slugs", () => { + expect(parseOrgProjectArg("sentry/spotlight-electron")).toEqual({ + type: "explicit", + org: "sentry", + project: "spotlight-electron", + }); + }); +}); + +describe("parseIssueArg", () => { + describe("numeric type", () => { + test("pure digits returns numeric", () => { + expect(parseIssueArg("123456789")).toEqual({ + type: "numeric", + id: "123456789", + }); + }); + }); + + describe("explicit type (org/project-suffix)", () => { + test("org/project-suffix returns explicit", () => { + expect(parseIssueArg("sentry/cli-G")).toEqual({ + type: "explicit", + org: "sentry", + project: "cli", + suffix: "G", + }); + }); + + test("handles multi-part project slugs", () => { + expect(parseIssueArg("sentry/spotlight-electron-4Y")).toEqual({ + type: "explicit", + org: "sentry", + project: "spotlight-electron", + suffix: "4Y", + }); + }); + + test("normalizes suffix to uppercase", () => { + expect(parseIssueArg("sentry/cli-g")).toEqual({ + type: "explicit", + org: "sentry", + project: "cli", + suffix: "G", + }); + }); + }); + + describe("explicit-org-suffix type (org/suffix)", () => { + test("org/suffix returns explicit-org-suffix", () => { + expect(parseIssueArg("sentry/G")).toEqual({ + type: "explicit-org-suffix", + org: "sentry", + suffix: "G", + }); + }); + + test("normalizes suffix to uppercase", () => { + expect(parseIssueArg("sentry/g")).toEqual({ + type: "explicit-org-suffix", + org: "sentry", + suffix: "G", + }); + }); + }); + + describe("explicit-org-numeric type (org/numeric)", () => { + test("org/numeric returns explicit-org-numeric", () => { + expect(parseIssueArg("sentry/123456789")).toEqual({ + type: "explicit-org-numeric", + org: "sentry", + numericId: "123456789", + }); + }); + }); + + describe("project-search type (project-suffix)", () => { + test("project-suffix returns project-search", () => { + expect(parseIssueArg("cli-G")).toEqual({ + type: "project-search", + projectSlug: "cli", + suffix: "G", + }); + }); + + test("handles multi-part project slugs", () => { + expect(parseIssueArg("spotlight-electron-4Y")).toEqual({ + type: "project-search", + projectSlug: "spotlight-electron", + suffix: "4Y", + }); + }); + + test("normalizes suffix to uppercase", () => { + expect(parseIssueArg("cli-g")).toEqual({ + type: "project-search", + projectSlug: "cli", + suffix: "G", + }); + }); + }); + + describe("suffix-only type", () => { + test("single letter returns suffix-only", () => { + expect(parseIssueArg("G")).toEqual({ + type: "suffix-only", + suffix: "G", + }); + }); + + test("alphanumeric suffix returns suffix-only", () => { + expect(parseIssueArg("4Y")).toEqual({ + type: "suffix-only", + suffix: "4Y", + }); + }); + + test("normalizes suffix to uppercase", () => { + expect(parseIssueArg("g")).toEqual({ + type: "suffix-only", + suffix: "G", + }); + }); + }); + + describe("error cases", () => { + test("org/-suffix throws error", () => { + expect(() => parseIssueArg("sentry/-G")).toThrow( + "Cannot use trailing slash before suffix" + ); + }); + + test("-suffix (empty left) throws error", () => { + expect(() => parseIssueArg("-G")).toThrow( + "Missing project before suffix" + ); + }); + }); + + describe("edge cases", () => { + test("/suffix returns suffix-only", () => { + // Leading slash with no org - treat as suffix + expect(parseIssueArg("/G")).toEqual({ + type: "suffix-only", + suffix: "G", + }); + }); + }); +}); From 83d310116261f54b05d32dc43a1687b2d4c5d443 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 3 Feb 2026 18:37:16 +0000 Subject: [PATCH 3/6] fix: update test import and regenerate SKILL.md --- plugins/sentry-cli/skills/sentry-cli/SKILL.md | 12 +++--------- test/lib/resolve-target.test.ts | 2 +- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index 9df868bb..f527fdda 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -223,13 +223,11 @@ sentry issue list my-org/frontend --query "is:resolved" sentry issue list my-org/frontend --query "is:unresolved TypeError" ``` -#### `sentry issue explain ` +#### `sentry issue explain ` Analyze an issue's root cause using Seer AI **Flags:** -- `--org - Organization slug (required for short IDs if not auto-detected)` -- `--project - Project slug (required for short suffixes if not auto-detected)` - `--json - Output as JSON` - `--force - Force new analysis even if one exists` @@ -251,13 +249,11 @@ sentry issue explain G --org my-org --project my-project sentry issue explain 123456789 --force ``` -#### `sentry issue plan ` +#### `sentry issue plan ` Generate a solution plan using Seer AI **Flags:** -- `--org - Organization slug (required for short IDs if not auto-detected)` -- `--project - Project slug (required for short suffixes if not auto-detected)` - `--cause - Root cause ID to plan (required if multiple causes exist)` - `--json - Output as JSON` @@ -276,13 +272,11 @@ sentry issue plan 123456789 --cause 0 sentry issue plan MYPROJECT-ABC --org my-org --cause 1 ``` -#### `sentry issue view ` +#### `sentry issue view ` View details of a specific issue **Flags:** -- `--org - Organization slug (required for short IDs if not auto-detected)` -- `--project - Project slug (required for short suffixes if not auto-detected)` - `--json - Output as JSON` - `-w, --web - Open in browser` - `--spans - Show span tree with N levels of nesting depth` diff --git a/test/lib/resolve-target.test.ts b/test/lib/resolve-target.test.ts index c1ad5d5c..7714070a 100644 --- a/test/lib/resolve-target.test.ts +++ b/test/lib/resolve-target.test.ts @@ -3,7 +3,7 @@ */ import { describe, expect, test } from "bun:test"; -import { parseOrgProjectArg } from "../../src/lib/resolve-target.js"; +import { parseOrgProjectArg } from "../../src/lib/arg-parsing.js"; describe("parseOrgProjectArg", () => { test("returns auto-detect for undefined", () => { From d594b2a5d2307dbbd4c93c70fde21d5cecd69706 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 3 Feb 2026 20:22:34 +0000 Subject: [PATCH 4/6] fix: remove unused splitProjectSuffix and fix numeric ID hints - Remove unused splitProjectSuffix function (dead code) - Replace with isNumericId helper for detecting pure numeric IDs - Fix buildCommandHint to suggest /ID for numeric IDs instead of incorrectly suggesting -ID - Add tests for isNumericId and buildCommandHint --- src/commands/issue/utils.ts | 13 ++++++++++++- src/lib/issue-id.ts | 29 +++++++++-------------------- test/commands/issue/utils.test.ts | 31 +++++++++++++++++++++++++++++++ test/lib/issue-id.test.ts | 25 +++++++++++++++++++++++++ 4 files changed, 77 insertions(+), 21 deletions(-) diff --git a/src/commands/issue/utils.ts b/src/commands/issue/utils.ts index e420d924..cfea89a9 100644 --- a/src/commands/issue/utils.ts +++ b/src/commands/issue/utils.ts @@ -15,7 +15,11 @@ import { getProjectByAlias } from "../../lib/db/project-aliases.js"; import { createDsnFingerprint, detectAllDsns } from "../../lib/dsn/index.js"; import { ContextError } from "../../lib/errors.js"; import { getProgressMessage } from "../../lib/formatters/seer.js"; -import { expandToFullShortId, isShortSuffix } from "../../lib/issue-id.js"; +import { + expandToFullShortId, + isNumericId, + isShortSuffix, +} from "../../lib/issue-id.js"; import { poll } from "../../lib/polling.js"; import { resolveOrgAndProject } from "../../lib/resolve-target.js"; import type { SentryIssue, Writer } from "../../types/index.js"; @@ -38,6 +42,7 @@ export const issueIdPositional = { * Build a command hint string for error messages. * * Returns context-aware hints based on the issue ID format: + * - Numeric ID (e.g., "123456789") → suggest `/123456789` * - Suffix only (e.g., "G") → suggest `-G` * - Has dash (e.g., "cli-G") → suggest `/cli-G` * @@ -45,9 +50,15 @@ export const issueIdPositional = { * @param issueId - The user-provided issue ID */ export function buildCommandHint(command: string, issueId: string): string { + // Numeric IDs always need org context - can't be combined with project + if (isNumericId(issueId)) { + return `sentry issue ${command} /${issueId}`; + } + // Short suffixes can be combined with project prefix if (isShortSuffix(issueId)) { return `sentry issue ${command} -${issueId}`; } + // Everything else (has dash) needs org prefix return `sentry issue ${command} /${issueId}`; } diff --git a/src/lib/issue-id.ts b/src/lib/issue-id.ts index 476cf830..5dd69980 100644 --- a/src/lib/issue-id.ts +++ b/src/lib/issue-id.ts @@ -20,6 +20,9 @@ const SHORT_SUFFIX_PATTERN = /^[a-zA-Z0-9]+$/; /** Pattern for alias-suffix format (e.g., "f-g", "fr-a3", "spotlight-e-4y") */ const ALIAS_SUFFIX_PATTERN = /^(.+)-([a-zA-Z0-9]+)$/i; +/** Pattern for pure numeric IDs (all digits) */ +const NUMERIC_ID_PATTERN = /^\d+$/; + /** * Check if a string looks like a short ID (e.g., PROJECT-ABC) * vs a numeric ID (e.g., 123456). @@ -76,26 +79,12 @@ export function expandToFullShortId( } /** - * Split a project-suffix format string into project and suffix parts. - * - * The suffix is the part after the last hyphen. The project is everything before. - * Both parts are normalized: project to lowercase, suffix to uppercase. - * - * @param value - String in format "project-suffix" (e.g., "cli-G", "spotlight-electron-4Y") - * @returns Object with project (lowercase) and suffix (uppercase) + * Check if a string is a pure numeric ID (all digits). * - * @example - * splitProjectSuffix("cli-G") // { project: "cli", suffix: "G" } - * splitProjectSuffix("spotlight-electron-4Y") // { project: "spotlight-electron", suffix: "4Y" } - * splitProjectSuffix("CLI-G") // { project: "cli", suffix: "G" } + * Used to distinguish between: + * - Numeric issue IDs: "123456789" → need org context + * - Short suffixes: "G", "A3" → can be combined with project */ -export function splitProjectSuffix(value: string): { - project: string; - suffix: string; -} { - const lastDash = value.lastIndexOf("-"); - return { - project: value.slice(0, lastDash).toLowerCase(), - suffix: value.slice(lastDash + 1).toUpperCase(), - }; +export function isNumericId(input: string): boolean { + return NUMERIC_ID_PATTERN.test(input); } diff --git a/test/commands/issue/utils.test.ts b/test/commands/issue/utils.test.ts index a328c5bf..c5a694ba 100644 --- a/test/commands/issue/utils.test.ts +++ b/test/commands/issue/utils.test.ts @@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { + buildCommandHint, pollAutofixState, resolveOrgAndIssueId, } from "../../../src/commands/issue/utils.js"; @@ -15,6 +16,36 @@ import { CONFIG_DIR_ENV_VAR } from "../../../src/lib/db/index.js"; import { setOrgRegion } from "../../../src/lib/db/regions.js"; import { cleanupTestDir, createTestConfigDir } from "../../helpers.js"; +describe("buildCommandHint", () => { + test("suggests /ID for numeric IDs", () => { + expect(buildCommandHint("view", "123456789")).toBe( + "sentry issue view /123456789" + ); + expect(buildCommandHint("explain", "0")).toBe( + "sentry issue explain /0" + ); + }); + + test("suggests -suffix for short suffixes", () => { + expect(buildCommandHint("view", "G")).toBe("sentry issue view -G"); + expect(buildCommandHint("explain", "4Y")).toBe( + "sentry issue explain -4Y" + ); + expect(buildCommandHint("plan", "ABC")).toBe( + "sentry issue plan -ABC" + ); + }); + + test("suggests /ID for IDs with dashes", () => { + expect(buildCommandHint("view", "cli-G")).toBe( + "sentry issue view /cli-G" + ); + expect(buildCommandHint("explain", "PROJECT-ABC")).toBe( + "sentry issue explain /PROJECT-ABC" + ); + }); +}); + let testConfigDir: string; let originalFetch: typeof globalThis.fetch; diff --git a/test/lib/issue-id.test.ts b/test/lib/issue-id.test.ts index f19782a0..378c1d5c 100644 --- a/test/lib/issue-id.test.ts +++ b/test/lib/issue-id.test.ts @@ -5,11 +5,36 @@ import { describe, expect, test } from "bun:test"; import { expandToFullShortId, + isNumericId, isShortId, isShortSuffix, parseAliasSuffix, } from "../../src/lib/issue-id.js"; +describe("isNumericId", () => { + test("returns true for pure numeric strings", () => { + expect(isNumericId("123456789")).toBe(true); + expect(isNumericId("0")).toBe(true); + expect(isNumericId("12345")).toBe(true); + }); + + test("returns false for alphanumeric strings", () => { + expect(isNumericId("G")).toBe(false); + expect(isNumericId("4Y")).toBe(false); + expect(isNumericId("a123")).toBe(false); + expect(isNumericId("123a")).toBe(false); + }); + + test("returns false for strings with hyphens", () => { + expect(isNumericId("123-456")).toBe(false); + expect(isNumericId("CLI-G")).toBe(false); + }); + + test("returns false for empty string", () => { + expect(isNumericId("")).toBe(false); + }); +}); + describe("isShortSuffix", () => { test("returns true for simple alphanumeric suffixes", () => { expect(isShortSuffix("G")).toBe(true); From 28fab5b1f7ecd75109b6f7aabdbc4d509cd86d09 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 3 Feb 2026 20:41:03 +0000 Subject: [PATCH 5/6] fix: consolidate numeric ID pattern and validate empty suffixes - Remove duplicate NUMERIC_ID_PATTERN, import isNumericId from issue-id.ts - Remove ASCII-art style separator comment - Add validation for empty suffixes (trailing dash like 'cli-') - Add validation for missing issue ID after slash (like 'org/') - Add tests for empty suffix error cases --- src/lib/arg-parsing.ts | 27 ++++++++++++++++++--------- test/lib/arg-parsing.test.ts | 20 ++++++++++++++++++++ 2 files changed, 38 insertions(+), 9 deletions(-) diff --git a/src/lib/arg-parsing.ts b/src/lib/arg-parsing.ts index 0dfa14f0..86f2126f 100644 --- a/src/lib/arg-parsing.ts +++ b/src/lib/arg-parsing.ts @@ -6,6 +6,8 @@ * project list) and single-item commands (issue view, explain, plan). */ +import { isNumericId } from "./issue-id.js"; + /** * Type constants for project specification patterns. * Use these constants instead of string literals for type safety. @@ -85,13 +87,6 @@ export function parseOrgProjectArg(arg: string | undefined): ParsedOrgProject { return { type: "project-search", projectSlug: trimmed }; } -// ───────────────────────────────────────────────────────────────────────────── -// Issue Argument Parsing -// ───────────────────────────────────────────────────────────────────────────── - -/** Pattern to detect numeric IDs (pure digits) */ -const NUMERIC_ID_PATTERN = /^\d+$/; - /** * Parsed issue argument types - flattened for ergonomics. * @@ -139,7 +134,7 @@ export type ParsedIssueArg = */ export function parseIssueArg(arg: string): ParsedIssueArg { // 1. Pure numeric → direct fetch by ID - if (NUMERIC_ID_PATTERN.test(arg)) { + if (isNumericId(arg)) { return { type: "numeric", id: arg }; } @@ -149,6 +144,13 @@ export function parseIssueArg(arg: string): ParsedIssueArg { const leftPart = arg.slice(0, lastDash); const suffix = arg.slice(lastDash + 1).toUpperCase(); + // Reject trailing dash (empty suffix) + if (!suffix) { + throw new Error( + `Invalid issue format: "${arg}". Missing suffix after dash.` + ); + } + const target = parseOrgProjectArg(leftPart); switch (target.type) { @@ -197,12 +199,19 @@ export function parseIssueArg(arg: string): ParsedIssueArg { const org = arg.slice(0, slashIdx); const rest = arg.slice(slashIdx + 1); + // Reject empty suffix after slash (e.g., "org/" or "/") + if (!rest) { + throw new Error( + `Invalid issue format: "${arg}". Missing issue ID after slash.` + ); + } + if (!org) { // "/G" → treat as suffix-only (unusual but valid) return { type: "suffix-only", suffix: rest.toUpperCase() }; } - if (NUMERIC_ID_PATTERN.test(rest)) { + if (isNumericId(rest)) { // "sentry/123456789" → explicit org + numeric ID return { type: "explicit-org-numeric", org, numericId: rest }; } diff --git a/test/lib/arg-parsing.test.ts b/test/lib/arg-parsing.test.ts index 635f8139..b279957c 100644 --- a/test/lib/arg-parsing.test.ts +++ b/test/lib/arg-parsing.test.ts @@ -185,6 +185,26 @@ describe("parseIssueArg", () => { "Missing project before suffix" ); }); + + test("trailing dash (empty suffix) throws error", () => { + expect(() => parseIssueArg("cli-")).toThrow("Missing suffix after dash"); + }); + + test("org/project with trailing dash (empty suffix) throws error", () => { + expect(() => parseIssueArg("sentry/cli-")).toThrow( + "Missing suffix after dash" + ); + }); + + test("org with trailing slash (empty issue ID) throws error", () => { + expect(() => parseIssueArg("sentry/")).toThrow( + "Missing issue ID after slash" + ); + }); + + test("just slash throws error", () => { + expect(() => parseIssueArg("/")).toThrow("Missing issue ID after slash"); + }); }); describe("edge cases", () => { From 995152632052efe2b84b30a7b7a6532669d3091a Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 3 Feb 2026 21:08:28 +0000 Subject: [PATCH 6/6] fix: prevent mixing explicit org with DSN-detected project - Remove DSN fallback from resolveExplicitOrgSuffix - mixing explicit org with DSN-detected project (which belongs to a different org) is semantically wrong and confusing - Add validation for empty project slug in parseOrgProjectArg ('/') - Add test for '/' input throwing error --- src/commands/issue/utils.ts | 34 ++++++++++++++-------------------- src/lib/arg-parsing.ts | 5 +++++ test/lib/arg-parsing.test.ts | 6 ++++++ 3 files changed, 25 insertions(+), 20 deletions(-) diff --git a/src/commands/issue/utils.ts b/src/commands/issue/utils.ts index cfea89a9..5e05c64c 100644 --- a/src/commands/issue/utils.ts +++ b/src/commands/issue/utils.ts @@ -193,30 +193,29 @@ async function resolveSuffixOnly( } /** - * Resolve explicit-org-suffix type: org provided, need project from DSN. + * Resolve explicit-org-suffix type: org provided but only suffix given. + * + * This format (`org/suffix`) is ambiguous - we have org but no project. + * We don't use DSN detection here because mixing explicit org with + * DSN-detected project (which belongs to a potentially different org) + * would be semantically wrong and confusing. * * @param org - Explicit organization slug * @param suffix - Issue suffix (uppercase) - * @param cwd - Current working directory * @param commandHint - Hint for error messages */ -async function resolveExplicitOrgSuffix( +function resolveExplicitOrgSuffix( org: string, suffix: string, - cwd: string, commandHint: string -): Promise { - const target = await resolveOrgAndProject({ cwd }); - if (target) { - const fullShortId = expandToFullShortId(suffix, target.project); - const issue = await getIssueByShortId(org, fullShortId); - return { org, issue }; - } - +): never { throw new ContextError( `Cannot resolve suffix '${suffix}' without project context`, commandHint, - [`Specify the project: sentry issue ... ${org}/-${suffix}`] + [ + `The format '${org}/${suffix}' requires a project to build the full issue ID.`, + `Use: sentry issue ... ${org}/-${suffix}`, + ] ); } @@ -275,13 +274,8 @@ export async function resolveIssue( } case "explicit-org-suffix": - // Org + suffix only - need DSN for project - return resolveExplicitOrgSuffix( - parsed.org, - parsed.suffix, - cwd, - commandHint - ); + // Org + suffix only - ambiguous without project, always errors + return resolveExplicitOrgSuffix(parsed.org, parsed.suffix, commandHint); case "project-search": // Project slug + suffix - search across orgs diff --git a/src/lib/arg-parsing.ts b/src/lib/arg-parsing.ts index 86f2126f..cee553fc 100644 --- a/src/lib/arg-parsing.ts +++ b/src/lib/arg-parsing.ts @@ -71,6 +71,11 @@ export function parseOrgProjectArg(arg: string | undefined): ParsedOrgProject { if (!org) { // "/cli" → search for project across all orgs + if (!project) { + throw new Error( + 'Invalid format: "/" requires a project slug (e.g., "/cli")' + ); + } return { type: "project-search", projectSlug: project }; } diff --git a/test/lib/arg-parsing.test.ts b/test/lib/arg-parsing.test.ts index b279957c..847da7f1 100644 --- a/test/lib/arg-parsing.test.ts +++ b/test/lib/arg-parsing.test.ts @@ -55,6 +55,12 @@ describe("parseOrgProjectArg", () => { project: "spotlight-electron", }); }); + + test("just slash throws error", () => { + expect(() => parseOrgProjectArg("/")).toThrow( + 'Invalid format: "/" requires a project slug' + ); + }); }); describe("parseIssueArg", () => {