From a23c4f7b63f2e8f964b6a6fde86e114c8fb126b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Tue, 7 Jul 2026 18:58:01 +0200 Subject: [PATCH 01/10] fix(issue): improve resolution errors for short ID and org/project confusion When issue short ID lookup fails, report the issue ID as not found instead of a misleading project slug error. Add guards for command-noun misparsing (issue-1), case-insensitive short ID recovery, and numeric project hints. Co-authored-by: Cursor --- docs/src/fragments/commands/issue.md | 7 +++ src/commands/issue/list.ts | 5 +- src/commands/issue/utils.ts | 13 +++-- src/lib/arg-parsing.ts | 69 ++++++++++++++++++++++++++- src/lib/resolve-target.ts | 45 ++++++++++++----- test/commands/issue/utils.test.ts | 2 +- test/lib/arg-parsing.property.test.ts | 9 ++++ test/lib/arg-parsing.test.ts | 28 +++++++++++ test/lib/resolve-target.test.ts | 21 ++++++++ 9 files changed, 177 insertions(+), 22 deletions(-) diff --git a/docs/src/fragments/commands/issue.md b/docs/src/fragments/commands/issue.md index 09d5b01b0..c48bc863b 100644 --- a/docs/src/fragments/commands/issue.md +++ b/docs/src/fragments/commands/issue.md @@ -73,6 +73,13 @@ sentry issue explain @most_frequent sentry issue plan @latest ``` +### Common mistakes + +- **Don't use command nouns as issue prefixes** — `sentry issue explain issue-1` is parsed as project `issue` + suffix `1`. Use a real project prefix: `sentry issue explain FRONT-1` or a numeric ID: `sentry issue explain 123456789`. +- **Issue short IDs use uppercase prefixes** — `CLI-G`, not `cli-g`. The CLI tolerates lowercase input in most commands, but agents should prefer the canonical form. +- **`org/project` targets use slugs, not numeric IDs** — `sentry issue list my-org/6775615880` fails when `6775615880` is a project ID copied from a URL. Run `sentry project list my-org/` to find the slug (e.g. `frontend`). +- **Prefer auto-detect over guessing slugs** — omit the target when possible; the CLI resolves org/project from DSNs, `.env` files, and your working directory. + ### List events for an issue ```bash diff --git a/src/commands/issue/list.ts b/src/commands/issue/list.ts index 7b2f679ca..135748f44 100644 --- a/src/commands/issue/list.ts +++ b/src/commands/issue/list.ts @@ -1516,10 +1516,11 @@ export const listCommand = buildListCommand("issue", { // Auto-recover: user passed an issue short ID (e.g., "ARMAX-3E") instead // of a project slug. Their intent is unambiguous — resolve and show it. if ( + target && parsed.type === "project-search" && - looksLikeIssueShortId(parsed.projectSlug) + looksLikeIssueShortId(target, { ignoreCase: true }) ) { - const shortId = parsed.projectSlug; + const shortId = target; log.warn( `'${shortId}' is an issue short ID, not a project slug. Showing the issue.` ); diff --git a/src/commands/issue/utils.ts b/src/commands/issue/utils.ts index 9996e1a54..18d944f80 100644 --- a/src/commands/issue/utils.ts +++ b/src/commands/issue/utils.ts @@ -161,8 +161,8 @@ async function tryResolveFromAlias( /** * Fallback for when the fast shortid fan-out found no matches. - * Uses findProjectsBySlug to give a precise error ("project not found" vs - * "issue not found") and retries the issue lookup on transient failures. + * Uses findProjectsBySlug to retry on transient failures; when no project + * slug matches, reports the issue short ID as not found (not the project). */ async function resolveProjectSearchFallback( projectSlug: string, @@ -172,11 +172,16 @@ async function resolveProjectSearchFallback( const { projects } = await findProjectsBySlug(projectSlug.toLowerCase()); if (projects.length === 0) { + const fullShortId = expandToFullShortId(suffix, projectSlug); throw new ResolutionError( - `Project '${projectSlug}'`, + `Issue '${fullShortId}'`, "not found", commandHint, - ["No project with this slug found in any accessible organization"] + [ + "No issue with this short ID found in any accessible organization", + "Check the project prefix and suffix, or use a numeric issue ID", + `Specify the org: sentry issue ... /${fullShortId}`, + ] ); } diff --git a/src/lib/arg-parsing.ts b/src/lib/arg-parsing.ts index 8d92daf8f..3d4fbccb7 100644 --- a/src/lib/arg-parsing.ts +++ b/src/lib/arg-parsing.ts @@ -84,9 +84,61 @@ const LINE_SPLIT_PATTERN = /\r?\n/; * looksLikeIssueShortId("SPOTLIGHT-ELECTRON-4Y") // true * looksLikeIssueShortId("my-project") // false (lowercase) * looksLikeIssueShortId("a9b4ad2c") // false (no dash) + * looksLikeIssueShortId("cam-82x", { ignoreCase: true }) // true */ -export function looksLikeIssueShortId(str: string): boolean { - return ISSUE_SHORT_ID_PATTERN.test(str); +export function looksLikeIssueShortId( + str: string, + opts?: { ignoreCase?: boolean } +): boolean { + const candidate = opts?.ignoreCase ? str.toUpperCase() : str; + return ISSUE_SHORT_ID_PATTERN.test(candidate); +} + +/** CLI route/resource nouns that are never valid project slugs in dash parsing. */ +const CLI_RESOURCE_NOUNS = new Set([ + "alert", + "alerts", + "api", + "dashboard", + "dashboards", + "event", + "events", + "explore", + "issue", + "issues", + "log", + "logs", + "monitor", + "monitors", + "org", + "orgs", + "project", + "projects", + "release", + "releases", + "replay", + "replays", + "repo", + "repos", + "schema", + "span", + "spans", + "team", + "teams", + "trace", + "traces", + "trial", + "trials", +]); + +/** + * Check if a slug matches a top-level CLI resource noun (e.g. "issue", "trace"). + * + * Used to detect when agents pass command-like tokens as project prefixes in + * dash-separated issue args (`issue-1` → project `issue`, suffix `1`). + */ +export function isCliResourceNoun(slug: string): boolean { + return CLI_RESOURCE_NOUNS.has(slug.toLowerCase()); } // --------------------------------------------------------------------------- @@ -1012,6 +1064,19 @@ function parseWithDash(arg: string): ParsedIssueArg { ); } + if (isCliResourceNoun(projectSlug)) { + throw new ValidationError( + `"${arg}" looks like a command token plus a suffix, not an issue short ID.\n` + + ` Parsed as project '${projectSlug.toLowerCase()}' + suffix '${suffix}', but '${projectSlug.toLowerCase()}' is a CLI resource name.\n\n` + + "Try:\n" + + " sentry issue view PROJECT-1\n" + + " sentry issue explain PROJECT-1\n" + + " sentry issue explain 123456789\n" + + " sentry issue explain my-org/PROJECT-1", + "issue" + ); + } + // "cli-G" or "spotlight-electron-4Y" // Lowercase the project slug since Sentry slugs are always lowercase. // Users often type short IDs in uppercase (e.g., "EASI-API-3Y4") which diff --git a/src/lib/resolve-target.ts b/src/lib/resolve-target.ts index e02196a78..50cac810a 100644 --- a/src/lib/resolve-target.ts +++ b/src/lib/resolve-target.ts @@ -830,6 +830,30 @@ export async function triageProjectNotFound( return { kind: "not-found", displaySlug, suggestions }; } +/** Build ResolutionError suggestions when `getProject(org, project)` returns 404. */ +function buildProjectNotFoundSuggestions( + org: string, + project: string, + similar: string[] +): string[] { + const suggestions: string[] = []; + if (isAllDigits(project)) { + suggestions.push( + "Project targets use slugs (e.g. 'frontend'), not numeric project IDs", + `List available slugs: sentry project list ${org}/` + ); + } + if (similar.length > 0) { + suggestions.push( + `Similar projects: ${similar.map((s) => `'${s}'`).join(", ")}` + ); + } + suggestions.push( + `Check the project slug at https://sentry.io/organizations/${org}/projects/` + ); + return suggestions; +} + /** * Fetch the numeric project ID for an explicit org/project pair. * @@ -869,20 +893,11 @@ export async function fetchProjectId( projectResult.error.status === 404 ) { const similar = await findSimilarProjects(org, project); - const suggestions: string[] = []; - if (similar.length > 0) { - suggestions.push( - `Similar projects: ${similar.map((s) => `'${s}'`).join(", ")}` - ); - } - suggestions.push( - `Check the project slug at https://sentry.io/organizations/${org}/projects/` - ); throw new ResolutionError( `Project '${project}'`, `not found in organization '${org}'`, `sentry project list ${org}/`, - suggestions + buildProjectNotFoundSuggestions(org, project, similar) ); } return; @@ -1894,11 +1909,15 @@ export async function resolveTargetsFromParsedArg( } case "project-search": { - if (checkIssueShortId && looksLikeIssueShortId(parsed.projectSlug)) { + if ( + checkIssueShortId && + looksLikeIssueShortId(parsed.projectSlug, { ignoreCase: true }) + ) { + const displayId = parsed.originalSlug ?? parsed.projectSlug; throw new ResolutionError( - `'${parsed.projectSlug}'`, + `'${displayId}'`, "looks like an issue short ID, not a project slug", - `sentry issue view ${parsed.projectSlug}`, + `sentry issue view ${displayId}`, ["To list issues in a project: sentry issue list /"] ); } diff --git a/test/commands/issue/utils.test.ts b/test/commands/issue/utils.test.ts index f105232f6..ffbe7874b 100644 --- a/test/commands/issue/utils.test.ts +++ b/test/commands/issue/utils.test.ts @@ -616,7 +616,7 @@ describe("resolveOrgAndIssueId", () => { cwd: getConfigDir(), command: "explain", }) - ).rejects.toThrow("not found"); + ).rejects.toThrow("Issue 'NONEXISTENT-G'"); }); test("throws when project found in multiple orgs without explicit org", async () => { diff --git a/test/lib/arg-parsing.property.test.ts b/test/lib/arg-parsing.property.test.ts index 6f45e35b9..78b1a67a9 100644 --- a/test/lib/arg-parsing.property.test.ts +++ b/test/lib/arg-parsing.property.test.ts @@ -473,6 +473,15 @@ describe("looksLikeIssueShortId properties", () => { ); }); + test("all-lowercase slugs with dashes match with ignoreCase", async () => { + await fcAssert( + property(lowercaseSlugWithDashArb, (input) => { + expect(looksLikeIssueShortId(input, { ignoreCase: true })).toBe(true); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + test("strings without dashes never match", async () => { await fcAssert( property(noDashAlphanumArb, (input) => { diff --git a/test/lib/arg-parsing.test.ts b/test/lib/arg-parsing.test.ts index 43096c7a6..f1617aef6 100644 --- a/test/lib/arg-parsing.test.ts +++ b/test/lib/arg-parsing.test.ts @@ -399,6 +399,24 @@ describe("parseIssueArg", () => { ); }); + test("issue-1 throws ValidationError for CLI resource noun prefix", () => { + expect(() => parseIssueArg("issue-1")).toThrow(ValidationError); + expect(() => parseIssueArg("issue-1")).toThrow( + "looks like a command token plus a suffix" + ); + expect(() => parseIssueArg("issue-1")).toThrow( + "sentry issue explain PROJECT-1" + ); + }); + + test("cli-g still parses as project-search", () => { + expect(parseIssueArg("cli-g")).toEqual({ + type: "project-search", + projectSlug: "cli", + suffix: "G", + }); + }); + test("trailing dash (empty suffix) throws error", () => { expect(() => parseIssueArg("cli-")).toThrow("Missing suffix after dash"); }); @@ -1020,6 +1038,16 @@ describe("looksLikeIssueShortId", () => { expect(looksLikeIssueShortId("cam-82x")).toBe(false); }); + test("cam-82x with ignoreCase", () => { + expect(looksLikeIssueShortId("cam-82x", { ignoreCase: true })).toBe(true); + }); + + test("javascript-react-mr-1b with ignoreCase", () => { + expect( + looksLikeIssueShortId("javascript-react-mr-1b", { ignoreCase: true }) + ).toBe(true); + }); + test("123 (pure numeric)", () => { expect(looksLikeIssueShortId("123")).toBe(false); }); diff --git a/test/lib/resolve-target.test.ts b/test/lib/resolve-target.test.ts index f27593a7c..5924fc5f1 100644 --- a/test/lib/resolve-target.test.ts +++ b/test/lib/resolve-target.test.ts @@ -436,6 +436,27 @@ describe("fetchProjectId", () => { } }); + test("includes numeric project ID hint on 404 for all-digit slug", async () => { + await setAuthToken("test-token"); + setOrgRegion("test-org", DEFAULT_SENTRY_URL); + globalThis.fetch = mockFetch( + async () => + new Response(JSON.stringify({ detail: "Not found" }), { + status: 404, + }) + ); + + try { + await fetchProjectId("test-org", "6775615880"); + expect.unreachable("should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ResolutionError); + const msg = (error as ResolutionError).message; + expect(msg).toContain("not numeric project IDs"); + expect(msg).toContain("sentry project list test-org/"); + } + }); + test("includes project list suggestion even when listProjects fails", async () => { await setAuthToken("test-token"); setOrgRegion("test-org", DEFAULT_SENTRY_URL); From ad5b5360539a1dad64b3f6235b44651e99b3a0a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Tue, 7 Jul 2026 19:44:08 +0200 Subject: [PATCH 02/10] fix(issue): narrow ignoreCase short ID detection to avoid slug false positives Only apply case-insensitive matching when the input already contains uppercase letters or has three+ dash segments, so project slugs like acme-corp and my-project are not misclassified as issue short IDs. Co-authored-by: Cursor --- src/lib/arg-parsing.ts | 22 +++++++++++++++++++--- test/lib/arg-parsing.property.test.ts | 19 +++++++++++++++++-- test/lib/arg-parsing.test.ts | 22 ++++++++++++++++++++-- 3 files changed, 56 insertions(+), 7 deletions(-) diff --git a/src/lib/arg-parsing.ts b/src/lib/arg-parsing.ts index 3d4fbccb7..1c8af05d9 100644 --- a/src/lib/arg-parsing.ts +++ b/src/lib/arg-parsing.ts @@ -66,6 +66,9 @@ function looksLikeDisplayName(input: string): boolean { */ const ISSUE_SHORT_ID_PATTERN = /^[A-Z][A-Z0-9]*(-[A-Z][A-Z0-9]*)*-[A-Z0-9]+$/; +/** Detects any uppercase ASCII letter — used for mixed-case short ID recovery. */ +const HAS_UPPERCASE_ASCII_RE = /[A-Z]/; + /** Splits a string into lines on LF or CRLF boundaries. */ const LINE_SPLIT_PATTERN = /\r?\n/; @@ -84,14 +87,27 @@ const LINE_SPLIT_PATTERN = /\r?\n/; * looksLikeIssueShortId("SPOTLIGHT-ELECTRON-4Y") // true * looksLikeIssueShortId("my-project") // false (lowercase) * looksLikeIssueShortId("a9b4ad2c") // false (no dash) - * looksLikeIssueShortId("cam-82x", { ignoreCase: true }) // true + * looksLikeIssueShortId("javascript-react-mr-1b", { ignoreCase: true }) // true + * looksLikeIssueShortId("my-project", { ignoreCase: true }) // false */ export function looksLikeIssueShortId( str: string, opts?: { ignoreCase?: boolean } ): boolean { - const candidate = opts?.ignoreCase ? str.toUpperCase() : str; - return ISSUE_SHORT_ID_PATTERN.test(candidate); + if (opts?.ignoreCase) { + // Lowercase dashed strings are usually project slugs (e.g. `my-project`, + // `acme-corp`). Only treat them as short IDs when the input already + // contains uppercase (CAM-82X / CaM-82x) or has 3+ dash segments + // (javascript-react-mr-1b). + const parts = str.split("-"); + const hasUppercase = HAS_UPPERCASE_ASCII_RE.test(str); + const multiSegmentShortId = parts.length >= 3; + if (!(hasUppercase || multiSegmentShortId)) { + return false; + } + return ISSUE_SHORT_ID_PATTERN.test(str.toUpperCase()); + } + return ISSUE_SHORT_ID_PATTERN.test(str); } /** CLI route/resource nouns that are never valid project slugs in dash parsing. */ diff --git a/test/lib/arg-parsing.property.test.ts b/test/lib/arg-parsing.property.test.ts index 78b1a67a9..4a5f16aa6 100644 --- a/test/lib/arg-parsing.property.test.ts +++ b/test/lib/arg-parsing.property.test.ts @@ -473,10 +473,25 @@ describe("looksLikeIssueShortId properties", () => { ); }); - test("all-lowercase slugs with dashes match with ignoreCase", async () => { + test("two-part lowercase slugs do not match with ignoreCase", async () => { await fcAssert( property(lowercaseSlugWithDashArb, (input) => { - expect(looksLikeIssueShortId(input, { ignoreCase: true })).toBe(true); + if (input.split("-").length === 2) { + expect(looksLikeIssueShortId(input, { ignoreCase: true })).toBe( + false + ); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("three-plus-part lowercase slugs match with ignoreCase", async () => { + await fcAssert( + property(lowercaseSlugWithDashArb, (input) => { + if (input.split("-").length >= 3) { + expect(looksLikeIssueShortId(input, { ignoreCase: true })).toBe(true); + } }), { numRuns: DEFAULT_NUM_RUNS } ); diff --git a/test/lib/arg-parsing.test.ts b/test/lib/arg-parsing.test.ts index f1617aef6..188b1954b 100644 --- a/test/lib/arg-parsing.test.ts +++ b/test/lib/arg-parsing.test.ts @@ -1038,8 +1038,14 @@ describe("looksLikeIssueShortId", () => { expect(looksLikeIssueShortId("cam-82x")).toBe(false); }); - test("cam-82x with ignoreCase", () => { - expect(looksLikeIssueShortId("cam-82x", { ignoreCase: true })).toBe(true); + test("cam-82x with ignoreCase is false (two-part lowercase slug)", () => { + expect(looksLikeIssueShortId("cam-82x", { ignoreCase: true })).toBe( + false + ); + }); + + test("CaM-82x with ignoreCase (mixed case short ID)", () => { + expect(looksLikeIssueShortId("CaM-82x", { ignoreCase: true })).toBe(true); }); test("javascript-react-mr-1b with ignoreCase", () => { @@ -1048,6 +1054,18 @@ describe("looksLikeIssueShortId", () => { ).toBe(true); }); + test("my-project with ignoreCase is false (project slug)", () => { + expect(looksLikeIssueShortId("my-project", { ignoreCase: true })).toBe( + false + ); + }); + + test("acme-corp with ignoreCase is false (org/project slug)", () => { + expect(looksLikeIssueShortId("acme-corp", { ignoreCase: true })).toBe( + false + ); + }); + test("123 (pure numeric)", () => { expect(looksLikeIssueShortId("123")).toBe(false); }); From 12437e5a54e3daacea17180594e741d728ae8862 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Tue, 7 Jul 2026 19:46:04 +0200 Subject: [PATCH 03/10] refactor(issue): polish CLI-A0 DX helpers for consistency Use validationError() for command-noun guard, extract ignoreCase matching, and dedupe displaySlug in resolve-target project-search handling. Co-authored-by: Cursor --- src/lib/arg-parsing.ts | 52 +++++++++++++++++++++++++-------------- src/lib/resolve-target.ts | 18 +++++++++----- 2 files changed, 45 insertions(+), 25 deletions(-) diff --git a/src/lib/arg-parsing.ts b/src/lib/arg-parsing.ts index 1c8af05d9..7d6117667 100644 --- a/src/lib/arg-parsing.ts +++ b/src/lib/arg-parsing.ts @@ -7,7 +7,7 @@ */ import type { LogSortDirection } from "./api/logs.js"; -import { ContextError, ValidationError } from "./errors.js"; +import { ContextError, ValidationError, validationError } from "./errors.js"; import { validateResourceId } from "./input-validation.js"; import type { ParsedSentryUrl } from "./sentry-url-parser.js"; @@ -69,6 +69,12 @@ const ISSUE_SHORT_ID_PATTERN = /^[A-Z][A-Z0-9]*(-[A-Z][A-Z0-9]*)*-[A-Z0-9]+$/; /** Detects any uppercase ASCII letter — used for mixed-case short ID recovery. */ const HAS_UPPERCASE_ASCII_RE = /[A-Z]/; +/** + * Minimum dash-separated parts for ignoreCase recovery when the input has no + * uppercase letters (e.g. `javascript-react-mr-1b` has four parts). + */ +const ISSUE_SHORT_ID_MULTI_SEGMENT_PARTS = 3; + /** Splits a string into lines on LF or CRLF boundaries. */ const LINE_SPLIT_PATTERN = /\r?\n/; @@ -79,6 +85,9 @@ const LINE_SPLIT_PATTERN = /\r?\n/; * (org/project) is expected — e.g., `sentry event view CAM-82X 95fd7f5a`. * * @param str - String to check + * @param opts.ignoreCase - When true, also match mixed-case and multi-segment + * lowercase inputs (e.g. `javascript-react-mr-1b`). Two-part all-lowercase + * slugs like `my-project` are still rejected — those are usually project names. * @returns true if the string matches the issue short ID pattern * * @example @@ -95,21 +104,24 @@ export function looksLikeIssueShortId( opts?: { ignoreCase?: boolean } ): boolean { if (opts?.ignoreCase) { - // Lowercase dashed strings are usually project slugs (e.g. `my-project`, - // `acme-corp`). Only treat them as short IDs when the input already - // contains uppercase (CAM-82X / CaM-82x) or has 3+ dash segments - // (javascript-react-mr-1b). - const parts = str.split("-"); - const hasUppercase = HAS_UPPERCASE_ASCII_RE.test(str); - const multiSegmentShortId = parts.length >= 3; - if (!(hasUppercase || multiSegmentShortId)) { - return false; - } - return ISSUE_SHORT_ID_PATTERN.test(str.toUpperCase()); + return matchesIssueShortIdIgnoreCase(str); } return ISSUE_SHORT_ID_PATTERN.test(str); } +/** + * Case-insensitive short ID match with guardrails against project-slug false positives. + */ +function matchesIssueShortIdIgnoreCase(str: string): boolean { + const parts = str.split("-"); + const hasUppercase = HAS_UPPERCASE_ASCII_RE.test(str); + const multiSegment = parts.length >= ISSUE_SHORT_ID_MULTI_SEGMENT_PARTS; + if (!(hasUppercase || multiSegment)) { + return false; + } + return ISSUE_SHORT_ID_PATTERN.test(str.toUpperCase()); +} + /** CLI route/resource nouns that are never valid project slugs in dash parsing. */ const CLI_RESOURCE_NOUNS = new Set([ "alert", @@ -1081,14 +1093,16 @@ function parseWithDash(arg: string): ParsedIssueArg { } if (isCliResourceNoun(projectSlug)) { - throw new ValidationError( + const normalizedProject = projectSlug.toLowerCase(); + throw validationError( `"${arg}" looks like a command token plus a suffix, not an issue short ID.\n` + - ` Parsed as project '${projectSlug.toLowerCase()}' + suffix '${suffix}', but '${projectSlug.toLowerCase()}' is a CLI resource name.\n\n` + - "Try:\n" + - " sentry issue view PROJECT-1\n" + - " sentry issue explain PROJECT-1\n" + - " sentry issue explain 123456789\n" + - " sentry issue explain my-org/PROJECT-1", + ` Parsed as project '${normalizedProject}' + suffix '${suffix}', but '${normalizedProject}' is a CLI resource name.`, + [ + "sentry issue view PROJECT-1", + "sentry issue explain PROJECT-1", + "sentry issue explain 123456789", + "sentry issue explain my-org/PROJECT-1", + ], "issue" ); } diff --git a/src/lib/resolve-target.ts b/src/lib/resolve-target.ts index 50cac810a..e66400844 100644 --- a/src/lib/resolve-target.ts +++ b/src/lib/resolve-target.ts @@ -830,7 +830,13 @@ export async function triageProjectNotFound( return { kind: "not-found", displaySlug, suggestions }; } -/** Build ResolutionError suggestions when `getProject(org, project)` returns 404. */ +/** + * Build {@link ResolutionError} suggestions when `getProject(org, project)` 404s. + * + * @param org - Organization slug from the user's target + * @param project - Project segment that failed lookup (slug or numeric ID) + * @param similar - Fuzzy-matched project slugs in the org, if any + */ function buildProjectNotFoundSuggestions( org: string, project: string, @@ -1909,20 +1915,20 @@ export async function resolveTargetsFromParsedArg( } case "project-search": { + const displaySlug = parsed.originalSlug ?? parsed.projectSlug; + if ( checkIssueShortId && - looksLikeIssueShortId(parsed.projectSlug, { ignoreCase: true }) + looksLikeIssueShortId(displaySlug, { ignoreCase: true }) ) { - const displayId = parsed.originalSlug ?? parsed.projectSlug; throw new ResolutionError( - `'${displayId}'`, + `'${displaySlug}'`, "looks like an issue short ID, not a project slug", - `sentry issue view ${displayId}`, + `sentry issue view ${displaySlug}`, ["To list issues in a project: sentry issue list /"] ); } - const displaySlug = parsed.originalSlug ?? parsed.projectSlug; // When the input is a display name (originalSlug set, contains spaces), // skip the slug-based API lookup and go straight to fuzzy matching. const isDisplayName = parsed.originalSlug !== undefined; From 48fd7e317a82a1b1280542ffa11891dc3e03c849 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Tue, 7 Jul 2026 20:03:41 +0200 Subject: [PATCH 04/10] fix(issue): tighten short ID heuristics per review feedback Only block CLI resource nouns with numeric suffixes (issue-1), and require a digit in the final segment for lowercase multi-part short ID recovery so project slugs like my-frontend-app are not misclassified. Co-authored-by: Cursor --- src/lib/arg-parsing.ts | 21 +++++++++++++++++---- test/lib/arg-parsing.property.test.ts | 21 +++++++++++++++++++-- test/lib/arg-parsing.test.ts | 14 ++++++++++++++ 3 files changed, 50 insertions(+), 6 deletions(-) diff --git a/src/lib/arg-parsing.ts b/src/lib/arg-parsing.ts index 7d6117667..bc3b62140 100644 --- a/src/lib/arg-parsing.ts +++ b/src/lib/arg-parsing.ts @@ -69,6 +69,12 @@ const ISSUE_SHORT_ID_PATTERN = /^[A-Z][A-Z0-9]*(-[A-Z][A-Z0-9]*)*-[A-Z0-9]+$/; /** Detects any uppercase ASCII letter — used for mixed-case short ID recovery. */ const HAS_UPPERCASE_ASCII_RE = /[A-Z]/; +/** Detects at least one digit — used to distinguish short ID suffixes from slugs. */ +const HAS_DIGIT_RE = /\d/; + +/** Matches purely numeric issue suffixes (e.g. `1` in `issue-1`). */ +const NUMERIC_SUFFIX_RE = /^\d+$/; + /** * Minimum dash-separated parts for ignoreCase recovery when the input has no * uppercase letters (e.g. `javascript-react-mr-1b` has four parts). @@ -86,8 +92,9 @@ const LINE_SPLIT_PATTERN = /\r?\n/; * * @param str - String to check * @param opts.ignoreCase - When true, also match mixed-case and multi-segment - * lowercase inputs (e.g. `javascript-react-mr-1b`). Two-part all-lowercase - * slugs like `my-project` are still rejected — those are usually project names. + * lowercase inputs whose final segment contains a digit (e.g. + * `javascript-react-mr-1b`). Two-part all-lowercase slugs like `my-project` + * and letter-only multi-segment slugs like `my-frontend-app` are rejected. * @returns true if the string matches the issue short ID pattern * * @example @@ -119,6 +126,10 @@ function matchesIssueShortIdIgnoreCase(str: string): boolean { if (!(hasUppercase || multiSegment)) { return false; } + // Letter-only multi-segment slugs (e.g. `my-frontend-app`) are project names. + if (!hasUppercase && multiSegment && !HAS_DIGIT_RE.test(parts.at(-1) ?? "")) { + return false; + } return ISSUE_SHORT_ID_PATTERN.test(str.toUpperCase()); } @@ -163,7 +174,9 @@ const CLI_RESOURCE_NOUNS = new Set([ * Check if a slug matches a top-level CLI resource noun (e.g. "issue", "trace"). * * Used to detect when agents pass command-like tokens as project prefixes in - * dash-separated issue args (`issue-1` → project `issue`, suffix `1`). + * dash-separated issue args (`issue-1` → project `issue`, suffix `1`). Only + * combined with a purely numeric suffix — alphanumeric suffixes like `api-G` + * are valid issue short IDs. */ export function isCliResourceNoun(slug: string): boolean { return CLI_RESOURCE_NOUNS.has(slug.toLowerCase()); @@ -1092,7 +1105,7 @@ function parseWithDash(arg: string): ParsedIssueArg { ); } - if (isCliResourceNoun(projectSlug)) { + if (isCliResourceNoun(projectSlug) && NUMERIC_SUFFIX_RE.test(suffix)) { const normalizedProject = projectSlug.toLowerCase(); throw validationError( `"${arg}" looks like a command token plus a suffix, not an issue short ID.\n` + diff --git a/test/lib/arg-parsing.property.test.ts b/test/lib/arg-parsing.property.test.ts index 4a5f16aa6..54be55e21 100644 --- a/test/lib/arg-parsing.property.test.ts +++ b/test/lib/arg-parsing.property.test.ts @@ -486,10 +486,27 @@ describe("looksLikeIssueShortId properties", () => { ); }); - test("three-plus-part lowercase slugs match with ignoreCase", async () => { + test("three-plus-part lowercase slugs without digit in final segment do not match with ignoreCase", async () => { await fcAssert( property(lowercaseSlugWithDashArb, (input) => { - if (input.split("-").length >= 3) { + const parts = input.split("-"); + const lastPart = parts.at(-1) ?? ""; + if (parts.length >= 3 && !/\d/.test(lastPart)) { + expect(looksLikeIssueShortId(input, { ignoreCase: true })).toBe( + false + ); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("three-plus-part lowercase slugs with digit in final segment match with ignoreCase", async () => { + await fcAssert( + property(lowercaseSlugWithDashArb, (input) => { + const parts = input.split("-"); + const lastPart = parts.at(-1) ?? ""; + if (parts.length >= 3 && /\d/.test(lastPart)) { expect(looksLikeIssueShortId(input, { ignoreCase: true })).toBe(true); } }), diff --git a/test/lib/arg-parsing.test.ts b/test/lib/arg-parsing.test.ts index 188b1954b..705ee1de1 100644 --- a/test/lib/arg-parsing.test.ts +++ b/test/lib/arg-parsing.test.ts @@ -409,6 +409,14 @@ describe("parseIssueArg", () => { ); }); + test("api-G parses as project-search despite api being a CLI noun", () => { + expect(parseIssueArg("api-G")).toEqual({ + type: "project-search", + projectSlug: "api", + suffix: "G", + }); + }); + test("cli-g still parses as project-search", () => { expect(parseIssueArg("cli-g")).toEqual({ type: "project-search", @@ -1054,6 +1062,12 @@ describe("looksLikeIssueShortId", () => { ).toBe(true); }); + test("my-frontend-app with ignoreCase is false (3-part project slug)", () => { + expect( + looksLikeIssueShortId("my-frontend-app", { ignoreCase: true }) + ).toBe(false); + }); + test("my-project with ignoreCase is false (project slug)", () => { expect(looksLikeIssueShortId("my-project", { ignoreCase: true })).toBe( false From 1d21cc72c8d64b9670fe0e269aa679ed2c8fac18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Wed, 8 Jul 2026 12:10:06 +0200 Subject: [PATCH 05/10] fix(issue): guard org-qualified CLI noun numeric suffixes Apply the same issue-1 ValidationError in parseAfterSlash so my-org/issue-1 no longer bypasses the bare issue-1 guard. Extract shared helper and update Try hints to use the org/project/suffix form. Co-authored-by: Cursor --- src/lib/arg-parsing.ts | 43 ++++++++++++++++++++++++------------ test/lib/arg-parsing.test.ts | 16 ++++++++++++++ 2 files changed, 45 insertions(+), 14 deletions(-) diff --git a/src/lib/arg-parsing.ts b/src/lib/arg-parsing.ts index bc3b62140..01984baa9 100644 --- a/src/lib/arg-parsing.ts +++ b/src/lib/arg-parsing.ts @@ -182,6 +182,32 @@ export function isCliResourceNoun(slug: string): boolean { return CLI_RESOURCE_NOUNS.has(slug.toLowerCase()); } +/** + * Reject dash-parsed args where the project segment is a CLI resource noun and + * the suffix is purely numeric (e.g. `issue-1`, `my-org/issue-1`). + */ +function rejectCliResourceNounWithNumericSuffix( + arg: string, + projectSlug: string, + suffix: string +): void { + if (!(isCliResourceNoun(projectSlug) && NUMERIC_SUFFIX_RE.test(suffix))) { + return; + } + const normalizedProject = projectSlug.toLowerCase(); + throw validationError( + `"${arg}" looks like a command token plus a suffix, not an issue short ID.\n` + + ` Parsed as project '${normalizedProject}' + suffix '${suffix}', but '${normalizedProject}' is a CLI resource name.`, + [ + "sentry issue view PROJECT-1", + "sentry issue explain PROJECT-1", + "sentry issue explain 123456789", + "sentry issue explain my-org/project/PROJECT-G", + ], + "issue" + ); +} + // --------------------------------------------------------------------------- // Path detection // --------------------------------------------------------------------------- @@ -917,6 +943,8 @@ function parseAfterSlash( ); } + rejectCliResourceNounWithNumericSuffix(arg, project, suffix); + // "my-org/cli-G" or "sentry/spotlight-electron-4Y" // Lowercase the project slug — Sentry slugs are always lowercase. return { type: "explicit", org, project: project.toLowerCase(), suffix }; @@ -1105,20 +1133,7 @@ function parseWithDash(arg: string): ParsedIssueArg { ); } - if (isCliResourceNoun(projectSlug) && NUMERIC_SUFFIX_RE.test(suffix)) { - const normalizedProject = projectSlug.toLowerCase(); - throw validationError( - `"${arg}" looks like a command token plus a suffix, not an issue short ID.\n` + - ` Parsed as project '${normalizedProject}' + suffix '${suffix}', but '${normalizedProject}' is a CLI resource name.`, - [ - "sentry issue view PROJECT-1", - "sentry issue explain PROJECT-1", - "sentry issue explain 123456789", - "sentry issue explain my-org/PROJECT-1", - ], - "issue" - ); - } + rejectCliResourceNounWithNumericSuffix(arg, projectSlug, suffix); // "cli-G" or "spotlight-electron-4Y" // Lowercase the project slug since Sentry slugs are always lowercase. diff --git a/test/lib/arg-parsing.test.ts b/test/lib/arg-parsing.test.ts index 705ee1de1..305f51abe 100644 --- a/test/lib/arg-parsing.test.ts +++ b/test/lib/arg-parsing.test.ts @@ -409,6 +409,22 @@ describe("parseIssueArg", () => { ); }); + test("my-org/issue-1 throws ValidationError for org-qualified CLI noun", () => { + expect(() => parseIssueArg("my-org/issue-1")).toThrow(ValidationError); + expect(() => parseIssueArg("my-org/issue-1")).toThrow( + "looks like a command token plus a suffix" + ); + }); + + test("my-org/api-G parses as explicit despite api being a CLI noun", () => { + expect(parseIssueArg("my-org/api-G")).toEqual({ + type: "explicit", + org: "my-org", + project: "api", + suffix: "G", + }); + }); + test("api-G parses as project-search despite api being a CLI noun", () => { expect(parseIssueArg("api-G")).toEqual({ type: "project-search", From 01323fd1c3163f6cfffa17bd42b426f20c12b9ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Wed, 8 Jul 2026 12:16:43 +0200 Subject: [PATCH 06/10] fix(issue): narrow command-token guard to issue/issues only Only block numeric-suffix dash args when the project segment is the issue command token, not all CLI resource nouns. Restores api-1 and release-123 for projects with those slugs while keeping issue-1 agent misparsing guard. Co-authored-by: Cursor --- src/lib/arg-parsing.ts | 51 +++++++----------------------------- test/lib/arg-parsing.test.ts | 25 ++++++++++++++++++ 2 files changed, 34 insertions(+), 42 deletions(-) diff --git a/src/lib/arg-parsing.ts b/src/lib/arg-parsing.ts index 01984baa9..0bdcee9a5 100644 --- a/src/lib/arg-parsing.ts +++ b/src/lib/arg-parsing.ts @@ -133,58 +133,25 @@ function matchesIssueShortIdIgnoreCase(str: string): boolean { return ISSUE_SHORT_ID_PATTERN.test(str.toUpperCase()); } -/** CLI route/resource nouns that are never valid project slugs in dash parsing. */ -const CLI_RESOURCE_NOUNS = new Set([ - "alert", - "alerts", - "api", - "dashboard", - "dashboards", - "event", - "events", - "explore", - "issue", - "issues", - "log", - "logs", - "monitor", - "monitors", - "org", - "orgs", - "project", - "projects", - "release", - "releases", - "replay", - "replays", - "repo", - "repos", - "schema", - "span", - "spans", - "team", - "teams", - "trace", - "traces", - "trial", - "trials", -]); +/** Issue command tokens agents often mistake for project slugs in dash args. */ +const CLI_COMMAND_TOKEN_NOUNS = new Set(["issue", "issues"]); /** - * Check if a slug matches a top-level CLI resource noun (e.g. "issue", "trace"). + * Check if a slug is an issue command token (e.g. "issue", "issues"). * * Used to detect when agents pass command-like tokens as project prefixes in * dash-separated issue args (`issue-1` → project `issue`, suffix `1`). Only * combined with a purely numeric suffix — alphanumeric suffixes like `api-G` - * are valid issue short IDs. + * are valid issue short IDs, and other CLI nouns like `api` or `release` may + * be real project slugs even with numeric suffixes (`api-1`, `release-123`). */ export function isCliResourceNoun(slug: string): boolean { - return CLI_RESOURCE_NOUNS.has(slug.toLowerCase()); + return CLI_COMMAND_TOKEN_NOUNS.has(slug.toLowerCase()); } /** - * Reject dash-parsed args where the project segment is a CLI resource noun and - * the suffix is purely numeric (e.g. `issue-1`, `my-org/issue-1`). + * Reject dash-parsed args where the project segment is an issue command token + * and the suffix is purely numeric (e.g. `issue-1`, `my-org/issue-1`). */ function rejectCliResourceNounWithNumericSuffix( arg: string, @@ -197,7 +164,7 @@ function rejectCliResourceNounWithNumericSuffix( const normalizedProject = projectSlug.toLowerCase(); throw validationError( `"${arg}" looks like a command token plus a suffix, not an issue short ID.\n` + - ` Parsed as project '${normalizedProject}' + suffix '${suffix}', but '${normalizedProject}' is a CLI resource name.`, + ` Parsed as project '${normalizedProject}' + suffix '${suffix}', but '${normalizedProject}' is an issue command name.`, [ "sentry issue view PROJECT-1", "sentry issue explain PROJECT-1", diff --git a/test/lib/arg-parsing.test.ts b/test/lib/arg-parsing.test.ts index 305f51abe..7629dd492 100644 --- a/test/lib/arg-parsing.test.ts +++ b/test/lib/arg-parsing.test.ts @@ -433,6 +433,31 @@ describe("parseIssueArg", () => { }); }); + test("api-1 parses as project-search for projects named api", () => { + expect(parseIssueArg("api-1")).toEqual({ + type: "project-search", + projectSlug: "api", + suffix: "1", + }); + }); + + test("release-123 parses as project-search for projects named release", () => { + expect(parseIssueArg("release-123")).toEqual({ + type: "project-search", + projectSlug: "release", + suffix: "123", + }); + }); + + test("my-org/api-1 parses as explicit for org-qualified api project", () => { + expect(parseIssueArg("my-org/api-1")).toEqual({ + type: "explicit", + org: "my-org", + project: "api", + suffix: "1", + }); + }); + test("cli-g still parses as project-search", () => { expect(parseIssueArg("cli-g")).toEqual({ type: "project-search", From ab1a3d10540d25a18cac88b7d30225cfb273bbce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Wed, 8 Jul 2026 12:45:56 +0200 Subject: [PATCH 07/10] fix(issue): reject versioned project slugs in short ID recovery For ignoreCase matching, require multi-segment lowercase finals to contain both a letter and a digit so slugs like my-app-2 are not misrouted as issue short IDs in issue list auto-recovery. Co-authored-by: Cursor --- src/lib/arg-parsing.ts | 20 ++++++++++++++------ test/lib/arg-parsing.property.test.ts | 14 ++++++++++---- test/lib/arg-parsing.test.ts | 12 ++++++++++++ 3 files changed, 36 insertions(+), 10 deletions(-) diff --git a/src/lib/arg-parsing.ts b/src/lib/arg-parsing.ts index 0bdcee9a5..91a28ff60 100644 --- a/src/lib/arg-parsing.ts +++ b/src/lib/arg-parsing.ts @@ -72,6 +72,9 @@ const HAS_UPPERCASE_ASCII_RE = /[A-Z]/; /** Detects at least one digit — used to distinguish short ID suffixes from slugs. */ const HAS_DIGIT_RE = /\d/; +/** Detects at least one ASCII letter — used for short ID suffix shape checks. */ +const HAS_LETTER_ASCII_RE = /[a-zA-Z]/; + /** Matches purely numeric issue suffixes (e.g. `1` in `issue-1`). */ const NUMERIC_SUFFIX_RE = /^\d+$/; @@ -92,9 +95,10 @@ const LINE_SPLIT_PATTERN = /\r?\n/; * * @param str - String to check * @param opts.ignoreCase - When true, also match mixed-case and multi-segment - * lowercase inputs whose final segment contains a digit (e.g. - * `javascript-react-mr-1b`). Two-part all-lowercase slugs like `my-project` - * and letter-only multi-segment slugs like `my-frontend-app` are rejected. + * lowercase inputs whose final segment is alphanumeric (e.g. + * `javascript-react-mr-1b`). Two-part slugs like `my-project`, letter-only + * multi-segment slugs like `my-frontend-app`, and versioned project slugs + * like `my-app-2` are rejected. * @returns true if the string matches the issue short ID pattern * * @example @@ -126,9 +130,13 @@ function matchesIssueShortIdIgnoreCase(str: string): boolean { if (!(hasUppercase || multiSegment)) { return false; } - // Letter-only multi-segment slugs (e.g. `my-frontend-app`) are project names. - if (!hasUppercase && multiSegment && !HAS_DIGIT_RE.test(parts.at(-1) ?? "")) { - return false; + // Multi-segment lowercase slugs are usually project names, not short IDs. + if (!hasUppercase && multiSegment) { + const lastPart = parts.at(-1) ?? ""; + // Letter-only finals: `my-frontend-app`. Digit-only finals: `my-app-2`. + if (!(HAS_DIGIT_RE.test(lastPart) && HAS_LETTER_ASCII_RE.test(lastPart))) { + return false; + } } return ISSUE_SHORT_ID_PATTERN.test(str.toUpperCase()); } diff --git a/test/lib/arg-parsing.property.test.ts b/test/lib/arg-parsing.property.test.ts index 54be55e21..5d7bd82dc 100644 --- a/test/lib/arg-parsing.property.test.ts +++ b/test/lib/arg-parsing.property.test.ts @@ -486,12 +486,14 @@ describe("looksLikeIssueShortId properties", () => { ); }); - test("three-plus-part lowercase slugs without digit in final segment do not match with ignoreCase", async () => { + test("three-plus-part lowercase slugs without alphanumeric final segment do not match with ignoreCase", async () => { await fcAssert( property(lowercaseSlugWithDashArb, (input) => { const parts = input.split("-"); const lastPart = parts.at(-1) ?? ""; - if (parts.length >= 3 && !/\d/.test(lastPart)) { + const hasDigit = /\d/.test(lastPart); + const hasLetter = /[a-z]/.test(lastPart); + if (parts.length >= 3 && !(hasDigit && hasLetter)) { expect(looksLikeIssueShortId(input, { ignoreCase: true })).toBe( false ); @@ -501,12 +503,16 @@ describe("looksLikeIssueShortId properties", () => { ); }); - test("three-plus-part lowercase slugs with digit in final segment match with ignoreCase", async () => { + test("three-plus-part lowercase slugs with alphanumeric final segment match with ignoreCase", async () => { await fcAssert( property(lowercaseSlugWithDashArb, (input) => { const parts = input.split("-"); const lastPart = parts.at(-1) ?? ""; - if (parts.length >= 3 && /\d/.test(lastPart)) { + if ( + parts.length >= 3 && + /\d/.test(lastPart) && + /[a-z]/.test(lastPart) + ) { expect(looksLikeIssueShortId(input, { ignoreCase: true })).toBe(true); } }), diff --git a/test/lib/arg-parsing.test.ts b/test/lib/arg-parsing.test.ts index 7629dd492..1e8e5b95d 100644 --- a/test/lib/arg-parsing.test.ts +++ b/test/lib/arg-parsing.test.ts @@ -1109,6 +1109,18 @@ describe("looksLikeIssueShortId", () => { ).toBe(false); }); + test("my-app-2 with ignoreCase is false (versioned project slug)", () => { + expect(looksLikeIssueShortId("my-app-2", { ignoreCase: true })).toBe( + false + ); + }); + + test("api-gateway-1 with ignoreCase is false (versioned project slug)", () => { + expect(looksLikeIssueShortId("api-gateway-1", { ignoreCase: true })).toBe( + false + ); + }); + test("my-project with ignoreCase is false (project slug)", () => { expect(looksLikeIssueShortId("my-project", { ignoreCase: true })).toBe( false From 8964e75eb841e8ab3027c63ca8155b0fd1c24af5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Wed, 8 Jul 2026 13:07:36 +0200 Subject: [PATCH 08/10] refactor(issue): polish CLI-A0 arg-parsing names and docs Rename issue command token helpers for accuracy, extract isAlphanumericSegment, expand ignoreCase JSDoc, and align test/docs labels with narrowed guard scope. Co-authored-by: Cursor --- docs/src/fragments/commands/issue.md | 2 +- src/lib/arg-parsing.ts | 37 ++++++++++++++++++++++------ test/lib/arg-parsing.test.ts | 11 +++++---- 3 files changed, 36 insertions(+), 14 deletions(-) diff --git a/docs/src/fragments/commands/issue.md b/docs/src/fragments/commands/issue.md index c48bc863b..26a1dbcec 100644 --- a/docs/src/fragments/commands/issue.md +++ b/docs/src/fragments/commands/issue.md @@ -75,7 +75,7 @@ sentry issue plan @latest ### Common mistakes -- **Don't use command nouns as issue prefixes** — `sentry issue explain issue-1` is parsed as project `issue` + suffix `1`. Use a real project prefix: `sentry issue explain FRONT-1` or a numeric ID: `sentry issue explain 123456789`. +- **Don't use the issue command name as an issue prefix** — `sentry issue explain issue-1` is parsed as project `issue` + suffix `1`. Use a real project prefix: `sentry issue explain FRONT-1` or a numeric ID: `sentry issue explain 123456789`. - **Issue short IDs use uppercase prefixes** — `CLI-G`, not `cli-g`. The CLI tolerates lowercase input in most commands, but agents should prefer the canonical form. - **`org/project` targets use slugs, not numeric IDs** — `sentry issue list my-org/6775615880` fails when `6775615880` is a project ID copied from a URL. Run `sentry project list my-org/` to find the slug (e.g. `frontend`). - **Prefer auto-detect over guessing slugs** — omit the target when possible; the CLI resolves org/project from DSNs, `.env` files, and your working directory. diff --git a/src/lib/arg-parsing.ts b/src/lib/arg-parsing.ts index 91a28ff60..55b55abd9 100644 --- a/src/lib/arg-parsing.ts +++ b/src/lib/arg-parsing.ts @@ -109,6 +109,8 @@ const LINE_SPLIT_PATTERN = /\r?\n/; * looksLikeIssueShortId("a9b4ad2c") // false (no dash) * looksLikeIssueShortId("javascript-react-mr-1b", { ignoreCase: true }) // true * looksLikeIssueShortId("my-project", { ignoreCase: true }) // false + * looksLikeIssueShortId("my-frontend-app", { ignoreCase: true }) // false + * looksLikeIssueShortId("my-app-2", { ignoreCase: true }) // false */ export function looksLikeIssueShortId( str: string, @@ -122,6 +124,19 @@ export function looksLikeIssueShortId( /** * Case-insensitive short ID match with guardrails against project-slug false positives. + * + * Guard tiers for all-lowercase input: + * 1. Two-part slugs (e.g. `my-project`) — rejected as project names + * 2. Multi-segment with letter-only final (e.g. `my-frontend-app`) — rejected + * 3. Multi-segment with digit-only final (e.g. `my-app-2`) — rejected + * + * Mixed-case input (e.g. `CaM-82x`) or multi-segment with alphanumeric final + * (e.g. `javascript-react-mr-1b`) may match when the uppercased form fits the + * short ID pattern. + * + * @example + * matchesIssueShortIdIgnoreCase("my-app-2") // false + * matchesIssueShortIdIgnoreCase("javascript-react-mr-1b") // true */ function matchesIssueShortIdIgnoreCase(str: string): boolean { const parts = str.split("-"); @@ -130,17 +145,20 @@ function matchesIssueShortIdIgnoreCase(str: string): boolean { if (!(hasUppercase || multiSegment)) { return false; } - // Multi-segment lowercase slugs are usually project names, not short IDs. if (!hasUppercase && multiSegment) { const lastPart = parts.at(-1) ?? ""; - // Letter-only finals: `my-frontend-app`. Digit-only finals: `my-app-2`. - if (!(HAS_DIGIT_RE.test(lastPart) && HAS_LETTER_ASCII_RE.test(lastPart))) { + if (!isAlphanumericSegment(lastPart)) { return false; } } return ISSUE_SHORT_ID_PATTERN.test(str.toUpperCase()); } +/** True when a segment contains at least one letter and one digit (e.g. `1b`, not `2` or `app`). */ +function isAlphanumericSegment(segment: string): boolean { + return HAS_DIGIT_RE.test(segment) && HAS_LETTER_ASCII_RE.test(segment); +} + /** Issue command tokens agents often mistake for project slugs in dash args. */ const CLI_COMMAND_TOKEN_NOUNS = new Set(["issue", "issues"]); @@ -153,20 +171,23 @@ const CLI_COMMAND_TOKEN_NOUNS = new Set(["issue", "issues"]); * are valid issue short IDs, and other CLI nouns like `api` or `release` may * be real project slugs even with numeric suffixes (`api-1`, `release-123`). */ -export function isCliResourceNoun(slug: string): boolean { +export function isIssueCommandToken(slug: string): boolean { return CLI_COMMAND_TOKEN_NOUNS.has(slug.toLowerCase()); } /** * Reject dash-parsed args where the project segment is an issue command token * and the suffix is purely numeric (e.g. `issue-1`, `my-org/issue-1`). + * + * @throws {ValidationError} When the project segment is `issue`/`issues` and the + * suffix is purely numeric */ -function rejectCliResourceNounWithNumericSuffix( +function rejectIssueCommandTokenWithNumericSuffix( arg: string, projectSlug: string, suffix: string ): void { - if (!(isCliResourceNoun(projectSlug) && NUMERIC_SUFFIX_RE.test(suffix))) { + if (!(isIssueCommandToken(projectSlug) && NUMERIC_SUFFIX_RE.test(suffix))) { return; } const normalizedProject = projectSlug.toLowerCase(); @@ -918,7 +939,7 @@ function parseAfterSlash( ); } - rejectCliResourceNounWithNumericSuffix(arg, project, suffix); + rejectIssueCommandTokenWithNumericSuffix(arg, project, suffix); // "my-org/cli-G" or "sentry/spotlight-electron-4Y" // Lowercase the project slug — Sentry slugs are always lowercase. @@ -1108,7 +1129,7 @@ function parseWithDash(arg: string): ParsedIssueArg { ); } - rejectCliResourceNounWithNumericSuffix(arg, projectSlug, suffix); + rejectIssueCommandTokenWithNumericSuffix(arg, projectSlug, suffix); // "cli-G" or "spotlight-electron-4Y" // Lowercase the project slug since Sentry slugs are always lowercase. diff --git a/test/lib/arg-parsing.test.ts b/test/lib/arg-parsing.test.ts index 1e8e5b95d..9e78168fc 100644 --- a/test/lib/arg-parsing.test.ts +++ b/test/lib/arg-parsing.test.ts @@ -3,7 +3,8 @@ * * Note: Core invariants (return type determination, suffix normalization) are tested * via property-based tests in arg-parsing.property.test.ts. These tests focus on - * error messages and edge cases. + * error messages and edge cases. `looksLikeIssueShortId` ignoreCase slug-false-positive + * cases from PR review are documented here; general invariants live in the property file. */ import { afterEach, beforeEach, describe, expect, test } from "vitest"; @@ -399,7 +400,7 @@ describe("parseIssueArg", () => { ); }); - test("issue-1 throws ValidationError for CLI resource noun prefix", () => { + test("issue-1 throws ValidationError for issue command token prefix", () => { expect(() => parseIssueArg("issue-1")).toThrow(ValidationError); expect(() => parseIssueArg("issue-1")).toThrow( "looks like a command token plus a suffix" @@ -409,14 +410,14 @@ describe("parseIssueArg", () => { ); }); - test("my-org/issue-1 throws ValidationError for org-qualified CLI noun", () => { + test("my-org/issue-1 throws ValidationError for org-qualified issue command token", () => { expect(() => parseIssueArg("my-org/issue-1")).toThrow(ValidationError); expect(() => parseIssueArg("my-org/issue-1")).toThrow( "looks like a command token plus a suffix" ); }); - test("my-org/api-G parses as explicit despite api being a CLI noun", () => { + test("my-org/api-G parses as explicit org/project-suffix", () => { expect(parseIssueArg("my-org/api-G")).toEqual({ type: "explicit", org: "my-org", @@ -425,7 +426,7 @@ describe("parseIssueArg", () => { }); }); - test("api-G parses as project-search despite api being a CLI noun", () => { + test("api-G parses as project-search", () => { expect(parseIssueArg("api-G")).toEqual({ type: "project-search", projectSlug: "api", From 6d9ec3e8dbf1de949c3bd378582a3556a70656ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Wed, 8 Jul 2026 13:17:13 +0200 Subject: [PATCH 09/10] fix(issue): close title-case and issue list token bypasses Apply multi-segment slug guards using lowercased final segments so title-case project names no longer match ignoreCase short IDs, and reject issue-1-style list targets before parseOrgProjectArg treats them as project slugs. Co-authored-by: Cursor --- src/commands/issue/list.ts | 5 +++++ src/lib/arg-parsing.ts | 38 ++++++++++++++++++++++++++++++------ test/lib/arg-parsing.test.ts | 38 ++++++++++++++++++++++++++++++++++++ 3 files changed, 75 insertions(+), 6 deletions(-) diff --git a/src/commands/issue/list.ts b/src/commands/issue/list.ts index 135748f44..ab03ef919 100644 --- a/src/commands/issue/list.ts +++ b/src/commands/issue/list.ts @@ -19,6 +19,7 @@ import { extractRequiredScopes } from "../../lib/api-scope.js"; import { looksLikeIssueShortId, parseOrgProjectArg, + rejectIssueCommandTokenListTarget, } from "../../lib/arg-parsing.js"; import { getActiveEnvVarName, isEnvTokenActive } from "../../lib/db/auth.js"; @@ -1511,6 +1512,10 @@ export const listCommand = buildListCommand("issue", { sort: rawFlags.sort ?? defaultIssueSort(), }; + if (target) { + rejectIssueCommandTokenListTarget(target); + } + const parsed = parseOrgProjectArg(target); // Auto-recover: user passed an issue short ID (e.g., "ARMAX-3E") instead diff --git a/src/lib/arg-parsing.ts b/src/lib/arg-parsing.ts index 55b55abd9..a105c49f9 100644 --- a/src/lib/arg-parsing.ts +++ b/src/lib/arg-parsing.ts @@ -130,12 +130,14 @@ export function looksLikeIssueShortId( * 2. Multi-segment with letter-only final (e.g. `my-frontend-app`) — rejected * 3. Multi-segment with digit-only final (e.g. `my-app-2`) — rejected * - * Mixed-case input (e.g. `CaM-82x`) or multi-segment with alphanumeric final - * (e.g. `javascript-react-mr-1b`) may match when the uppercased form fits the - * short ID pattern. + * Mixed-case input with short-ID shape (e.g. `CaM-82x`) or multi-segment with + * alphanumeric final (e.g. `javascript-react-mr-1b`) may match when the + * uppercased form fits the short ID pattern. Title-case project slugs + * (e.g. `My-App-2`) are rejected via the same lowercase final-segment rules. * * @example * matchesIssueShortIdIgnoreCase("my-app-2") // false + * matchesIssueShortIdIgnoreCase("My-App-2") // false * matchesIssueShortIdIgnoreCase("javascript-react-mr-1b") // true */ function matchesIssueShortIdIgnoreCase(str: string): boolean { @@ -145,9 +147,9 @@ function matchesIssueShortIdIgnoreCase(str: string): boolean { if (!(hasUppercase || multiSegment)) { return false; } - if (!hasUppercase && multiSegment) { - const lastPart = parts.at(-1) ?? ""; - if (!isAlphanumericSegment(lastPart)) { + if (multiSegment) { + const lastPartLower = (parts.at(-1) ?? "").toLowerCase(); + if (!isAlphanumericSegment(lastPartLower)) { return false; } } @@ -204,6 +206,30 @@ function rejectIssueCommandTokenWithNumericSuffix( ); } +/** + * Reject org/project list targets that look like `issue-1` command tokens. + * + * `parseOrgProjectArg` treats bare `issue-1` as a single project slug; this + * applies the same guard as `parseIssueArg` for bare and `org/issue-1` forms. + * + * @throws {ValidationError} When the target matches an issue command token with + * a purely numeric suffix + */ +export function rejectIssueCommandTokenListTarget(target: string): void { + const dashIdx = target.lastIndexOf("-"); + if (dashIdx === -1) { + return; + } + const suffix = target.slice(dashIdx + 1); + if (!NUMERIC_SUFFIX_RE.test(suffix)) { + return; + } + const prefix = target.slice(0, dashIdx); + const slashIdx = prefix.lastIndexOf("/"); + const projectSegment = slashIdx === -1 ? prefix : prefix.slice(slashIdx + 1); + rejectIssueCommandTokenWithNumericSuffix(target, projectSegment, suffix); +} + // --------------------------------------------------------------------------- // Path detection // --------------------------------------------------------------------------- diff --git a/test/lib/arg-parsing.test.ts b/test/lib/arg-parsing.test.ts index 9e78168fc..96c4bdac3 100644 --- a/test/lib/arg-parsing.test.ts +++ b/test/lib/arg-parsing.test.ts @@ -16,6 +16,7 @@ import { parseIssueArg, parseOrgProjectArg, parseSlashSeparatedArg, + rejectIssueCommandTokenListTarget, splitNewlineArg, } from "../../src/lib/arg-parsing.js"; import { stripDsnOrgPrefix } from "../../src/lib/dsn/index.js"; @@ -1122,6 +1123,18 @@ describe("looksLikeIssueShortId", () => { ); }); + test("My-App-2 with ignoreCase is false (title-case project slug)", () => { + expect(looksLikeIssueShortId("My-App-2", { ignoreCase: true })).toBe( + false + ); + }); + + test("My-Frontend-App with ignoreCase is false (title-case project slug)", () => { + expect( + looksLikeIssueShortId("My-Frontend-App", { ignoreCase: true }) + ).toBe(false); + }); + test("my-project with ignoreCase is false (project slug)", () => { expect(looksLikeIssueShortId("my-project", { ignoreCase: true })).toBe( false @@ -1140,6 +1153,31 @@ describe("looksLikeIssueShortId", () => { }); }); +describe("rejectIssueCommandTokenListTarget", () => { + test("issue-1 throws ValidationError", () => { + expect(() => rejectIssueCommandTokenListTarget("issue-1")).toThrow( + ValidationError + ); + expect(() => rejectIssueCommandTokenListTarget("issue-1")).toThrow( + "looks like a command token plus a suffix" + ); + }); + + test("my-org/issue-1 throws ValidationError", () => { + expect(() => rejectIssueCommandTokenListTarget("my-org/issue-1")).toThrow( + ValidationError + ); + }); + + test("api-1 does not throw", () => { + expect(() => rejectIssueCommandTokenListTarget("api-1")).not.toThrow(); + }); + + test("my-org/cli does not throw", () => { + expect(() => rejectIssueCommandTokenListTarget("my-org/cli")).not.toThrow(); + }); +}); + describe("detectSwappedViewArgs", () => { test("returns warning when second has slash but first does not (swapped)", () => { const result = detectSwappedViewArgs("a9b4ad2c", "mv-software/mvsoftware"); From 9a7c748a6b816b0d9adc422ac89909954e7fe5e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Wed, 8 Jul 2026 13:25:24 +0200 Subject: [PATCH 10/10] fix(issue): auto-recover leading-slash short IDs in issue list Use parsed.projectSlug for short-ID detection so `/CLI-G` matches bare `CLI-G` after parseOrgProjectArg strips the leading slash. Co-authored-by: Cursor --- src/commands/issue/list.ts | 7 +++--- test/commands/issue/list.test.ts | 37 ++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/src/commands/issue/list.ts b/src/commands/issue/list.ts index ab03ef919..9be5194af 100644 --- a/src/commands/issue/list.ts +++ b/src/commands/issue/list.ts @@ -1520,12 +1520,13 @@ export const listCommand = buildListCommand("issue", { // Auto-recover: user passed an issue short ID (e.g., "ARMAX-3E") instead // of a project slug. Their intent is unambiguous — resolve and show it. + // Use parsed.projectSlug (not raw target) so leading-slash project-search + // forms like "/CLI-G" match the same as bare "CLI-G". if ( - target && parsed.type === "project-search" && - looksLikeIssueShortId(target, { ignoreCase: true }) + looksLikeIssueShortId(parsed.projectSlug, { ignoreCase: true }) ) { - const shortId = target; + const shortId = parsed.projectSlug; log.warn( `'${shortId}' is an issue short ID, not a project slug. Showing the issue.` ); diff --git a/test/commands/issue/list.test.ts b/test/commands/issue/list.test.ts index 7ceb62cb9..247536474 100644 --- a/test/commands/issue/list.test.ts +++ b/test/commands/issue/list.test.ts @@ -7,6 +7,8 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { listCommand } from "../../../src/commands/issue/list.js"; +// biome-ignore lint/performance/noNamespaceImport: namespace needed for vi.spyOn on mocked module +import * as issueUtils from "../../../src/commands/issue/utils.js"; vi.mock("../../../src/lib/api/issues.js", async (importOriginal) => { const actual = @@ -153,6 +155,41 @@ function mockIssue(overrides?: Record) { }; } +describe("issue list: short ID auto-recovery", () => { + let resolveIssueSpy: ReturnType; + + beforeEach(() => { + resolveIssueSpy = vi.spyOn(issueUtils, "resolveIssue"); + }); + + afterEach(() => { + resolveIssueSpy.mockRestore(); + }); + + test("auto-recovers leading-slash short ID targets", async () => { + resolveIssueSpy.mockResolvedValue({ + org: "test-org", + issue: mockIssue({ shortId: "CLI-G" }), + }); + + const { context } = createContext(); + await func.call( + context, + { + limit: 10, + sort: "date", + period: parsePeriod("90d"), + json: true, + }, + "/CLI-G" + ); + + expect(resolveIssueSpy).toHaveBeenCalledWith( + expect.objectContaining({ issueArg: "CLI-G" }) + ); + }); +}); + describe("issue list: error propagation", () => { test("throws ApiError (not plain Error) when all fetches fail with 400", async () => { // Uses default org/project from setDefaultOrganization/setDefaultProject