From 6e2061d614f2dd493011536670c87a5e0522adc5 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Wed, 18 Feb 2026 11:23:22 +0000 Subject: [PATCH 1/3] fix(commands): support org/project/id as single positional arg (#257) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parse slash-separated single arguments (e.g., `sentry event view sentry/cli/abc123`) as org/project/id instead of treating the entire string as the ID. Event IDs, trace IDs, and log IDs are hex strings that never contain slashes, so any single arg with slashes is unambiguously a structured format. - 0 slashes: plain ID (existing behavior) - 1 slash: org/project missing ID → ContextError - 2+ slashes: split on last / → target + ID --- src/commands/event/view.ts | 27 ++++++++++++-- src/commands/log/view.ts | 27 ++++++++++++-- src/commands/trace/view.ts | 27 ++++++++++++-- test/commands/event/view.test.ts | 40 +++++++++++++++++++++ test/commands/log/view.property.test.ts | 43 ++++++++++++++++++++--- test/commands/log/view.test.ts | 36 +++++++++++++++++++ test/commands/trace/view.property.test.ts | 40 +++++++++++++++++++-- test/commands/trace/view.test.ts | 36 +++++++++++++++++++ 8 files changed, 263 insertions(+), 13 deletions(-) diff --git a/src/commands/event/view.ts b/src/commands/event/view.ts index faa72a4349..7dae500d5e 100644 --- a/src/commands/event/view.ts +++ b/src/commands/event/view.ts @@ -113,8 +113,31 @@ export function parsePositionalArgs(args: string[]): { } if (args.length === 1) { - // Single arg - must be event ID - return { eventId: first, targetArg: undefined }; + const slashIdx = first.indexOf("/"); + + if (slashIdx === -1) { + // No slashes — plain event ID + return { eventId: first, targetArg: undefined }; + } + + // Event IDs are hex and never contain "/" — this must be a structured + // "org/project/eventId" or "org/project" (missing event ID) + const lastSlashIdx = first.lastIndexOf("/"); + + if (slashIdx === lastSlashIdx) { + // Exactly one slash: "org/project" without event ID + throw new ContextError("Event ID", USAGE_HINT); + } + + // Two+ slashes: split on last "/" → target + eventId + const targetArg = first.slice(0, lastSlashIdx); + const eventId = first.slice(lastSlashIdx + 1); + + if (!eventId) { + throw new ContextError("Event ID", USAGE_HINT); + } + + return { eventId, targetArg }; } const second = args[1]; diff --git a/src/commands/log/view.ts b/src/commands/log/view.ts index 36430ccda9..1981f2562d 100644 --- a/src/commands/log/view.ts +++ b/src/commands/log/view.ts @@ -48,8 +48,31 @@ export function parsePositionalArgs(args: string[]): { } if (args.length === 1) { - // Single arg - must be log ID - return { logId: first, targetArg: undefined }; + const slashIdx = first.indexOf("/"); + + if (slashIdx === -1) { + // No slashes — plain log ID + return { logId: first, targetArg: undefined }; + } + + // Log IDs are hex and never contain "/" — this must be a structured + // "org/project/logId" or "org/project" (missing log ID) + const lastSlashIdx = first.lastIndexOf("/"); + + if (slashIdx === lastSlashIdx) { + // Exactly one slash: "org/project" without log ID + throw new ContextError("Log ID", USAGE_HINT); + } + + // Two+ slashes: split on last "/" → target + logId + const targetArg = first.slice(0, lastSlashIdx); + const logId = first.slice(lastSlashIdx + 1); + + if (!logId) { + throw new ContextError("Log ID", USAGE_HINT); + } + + return { logId, targetArg }; } const second = args[1]; diff --git a/src/commands/trace/view.ts b/src/commands/trace/view.ts index f9ca92b22c..3e6a20d74a 100644 --- a/src/commands/trace/view.ts +++ b/src/commands/trace/view.ts @@ -55,8 +55,31 @@ export function parsePositionalArgs(args: string[]): { } if (args.length === 1) { - // Single arg - must be trace ID - return { traceId: first, targetArg: undefined }; + const slashIdx = first.indexOf("/"); + + if (slashIdx === -1) { + // No slashes — plain trace ID + return { traceId: first, targetArg: undefined }; + } + + // Trace IDs are hex and never contain "/" — this must be a structured + // "org/project/traceId" or "org/project" (missing trace ID) + const lastSlashIdx = first.lastIndexOf("/"); + + if (slashIdx === lastSlashIdx) { + // Exactly one slash: "org/project" without trace ID + throw new ContextError("Trace ID", USAGE_HINT); + } + + // Two+ slashes: split on last "/" → target + traceId + const targetArg = first.slice(0, lastSlashIdx); + const traceId = first.slice(lastSlashIdx + 1); + + if (!traceId) { + throw new ContextError("Trace ID", USAGE_HINT); + } + + return { traceId, targetArg }; } const second = args[1]; diff --git a/test/commands/event/view.test.ts b/test/commands/event/view.test.ts index 19eb9562fe..aff99fee68 100644 --- a/test/commands/event/view.test.ts +++ b/test/commands/event/view.test.ts @@ -72,6 +72,46 @@ describe("parsePositionalArgs", () => { }); }); + describe("slash-separated org/project/eventId (single arg)", () => { + test("parses org/project/eventId as target + event ID", () => { + const result = parsePositionalArgs(["sentry/cli/abc123def"]); + expect(result.targetArg).toBe("sentry/cli"); + expect(result.eventId).toBe("abc123def"); + }); + + test("parses with long hex event ID", () => { + const result = parsePositionalArgs([ + "my-org/frontend/a1b2c3d4e5f67890abcdef1234567890", + ]); + expect(result.targetArg).toBe("my-org/frontend"); + expect(result.eventId).toBe("a1b2c3d4e5f67890abcdef1234567890"); + }); + + test("handles hyphenated org and project slugs", () => { + const result = parsePositionalArgs(["my-org/my-project/deadbeef"]); + expect(result.targetArg).toBe("my-org/my-project"); + expect(result.eventId).toBe("deadbeef"); + }); + + test("one slash (org/project, missing event ID) throws ContextError", () => { + expect(() => parsePositionalArgs(["sentry/cli"])).toThrow(ContextError); + }); + + test("trailing slash (org/project/) throws ContextError", () => { + expect(() => parsePositionalArgs(["sentry/cli/"])).toThrow(ContextError); + }); + + test("one-slash ContextError mentions Event ID", () => { + try { + parsePositionalArgs(["sentry/cli"]); + expect.unreachable("Should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ContextError); + expect((error as ContextError).message).toContain("Event ID"); + } + }); + }); + describe("edge cases", () => { test("handles more than two args (ignores extras)", () => { const result = parsePositionalArgs([ diff --git a/test/commands/log/view.property.test.ts b/test/commands/log/view.property.test.ts index c0582d424e..50bfdde1bf 100644 --- a/test/commands/log/view.property.test.ts +++ b/test/commands/log/view.property.test.ts @@ -9,6 +9,7 @@ import { describe, expect, test } from "bun:test"; import { array, assert as fcAssert, + pre, property, string, stringMatching, @@ -27,10 +28,13 @@ const slugArb = stringMatching(/^[a-z][a-z0-9-]{1,20}[a-z0-9]$/); /** Non-empty strings for general args */ const nonEmptyStringArb = string({ minLength: 1, maxLength: 50 }); +/** Non-empty strings without slashes (valid plain IDs) */ +const plainIdArb = nonEmptyStringArb.filter((s) => !s.includes("/")); + describe("parsePositionalArgs properties", () => { - test("single arg: always returns it as logId with undefined targetArg", async () => { + test("single arg without slashes: returns it as logId with undefined targetArg", async () => { await fcAssert( - property(nonEmptyStringArb, (input) => { + property(plainIdArb, (input) => { const result = parsePositionalArgs([input]); expect(result.logId).toBe(input); expect(result.targetArg).toBeUndefined(); @@ -39,6 +43,29 @@ describe("parsePositionalArgs properties", () => { ); }); + test("single arg org/project/logId: splits into target and logId", async () => { + await fcAssert( + property(tuple(slugArb, slugArb, logIdArb), ([org, project, logId]) => { + const combined = `${org}/${project}/${logId}`; + const result = parsePositionalArgs([combined]); + expect(result.targetArg).toBe(`${org}/${project}`); + expect(result.logId).toBe(logId); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("single arg with one slash: throws ContextError (missing log ID)", async () => { + await fcAssert( + property(tuple(slugArb, slugArb), ([org, project]) => { + expect(() => parsePositionalArgs([`${org}/${project}`])).toThrow( + ContextError + ); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + test("two args: first is always targetArg, second is always logId", async () => { await fcAssert( property( @@ -91,6 +118,9 @@ describe("parsePositionalArgs properties", () => { property( array(nonEmptyStringArb, { minLength: 1, maxLength: 3 }), (args) => { + // Skip single-arg with slashes — those throw ContextError (tested separately) + pre(args.length > 1 || !args[0]?.includes("/")); + const result1 = parsePositionalArgs(args); const result2 = parsePositionalArgs(args); expect(result1).toEqual(result2); @@ -109,6 +139,9 @@ describe("parsePositionalArgs properties", () => { property( array(nonEmptyStringArb, { minLength: 1, maxLength: 3 }), (args) => { + // Skip single-arg with slashes — those throw ContextError (tested separately) + pre(args.length > 1 || !args[0]?.includes("/")); + const result = parsePositionalArgs(args); expect(result.logId).toBeDefined(); expect(typeof result.logId).toBe("string"); @@ -118,10 +151,10 @@ describe("parsePositionalArgs properties", () => { ); }); - test("result targetArg is undefined for single arg, defined for multiple", async () => { - // Single arg case + test("result targetArg is undefined for single slash-free arg, defined for multiple", async () => { + // Single arg case (without slashes) await fcAssert( - property(nonEmptyStringArb, (input) => { + property(plainIdArb, (input) => { const result = parsePositionalArgs([input]); expect(result.targetArg).toBeUndefined(); }), diff --git a/test/commands/log/view.test.ts b/test/commands/log/view.test.ts index db3b8c2f7b..5cdf098947 100644 --- a/test/commands/log/view.test.ts +++ b/test/commands/log/view.test.ts @@ -70,6 +70,42 @@ describe("parsePositionalArgs", () => { }); }); + describe("slash-separated org/project/logId (single arg)", () => { + test("parses org/project/logId as target + log ID", () => { + const result = parsePositionalArgs([ + "sentry/cli/968c763c740cfda8b6728f27fb9e9b01", + ]); + expect(result.targetArg).toBe("sentry/cli"); + expect(result.logId).toBe("968c763c740cfda8b6728f27fb9e9b01"); + }); + + test("handles hyphenated org and project slugs", () => { + const result = parsePositionalArgs([ + "my-org/my-project/deadbeef12345678", + ]); + expect(result.targetArg).toBe("my-org/my-project"); + expect(result.logId).toBe("deadbeef12345678"); + }); + + test("one slash (org/project, missing log ID) throws ContextError", () => { + expect(() => parsePositionalArgs(["sentry/cli"])).toThrow(ContextError); + }); + + test("trailing slash (org/project/) throws ContextError", () => { + expect(() => parsePositionalArgs(["sentry/cli/"])).toThrow(ContextError); + }); + + test("one-slash ContextError mentions Log ID", () => { + try { + parsePositionalArgs(["sentry/cli"]); + expect.unreachable("Should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ContextError); + expect((error as ContextError).message).toContain("Log ID"); + } + }); + }); + describe("edge cases", () => { test("handles more than two args (ignores extras)", () => { const result = parsePositionalArgs([ diff --git a/test/commands/trace/view.property.test.ts b/test/commands/trace/view.property.test.ts index 0ceaca5e36..479960795b 100644 --- a/test/commands/trace/view.property.test.ts +++ b/test/commands/trace/view.property.test.ts @@ -9,6 +9,7 @@ import { describe, expect, test } from "bun:test"; import { array, assert as fcAssert, + pre, property, string, stringMatching, @@ -27,10 +28,13 @@ const slugArb = stringMatching(/^[a-z][a-z0-9-]{1,20}[a-z0-9]$/); /** Non-empty strings for general args */ const nonEmptyStringArb = string({ minLength: 1, maxLength: 50 }); +/** Non-empty strings without slashes (valid plain IDs) */ +const plainIdArb = nonEmptyStringArb.filter((s) => !s.includes("/")); + describe("parsePositionalArgs properties", () => { - test("single arg: always returns it as traceId with undefined targetArg", async () => { + test("single arg without slashes: returns it as traceId with undefined targetArg", async () => { await fcAssert( - property(nonEmptyStringArb, (input) => { + property(plainIdArb, (input) => { const result = parsePositionalArgs([input]); expect(result.traceId).toBe(input); expect(result.targetArg).toBeUndefined(); @@ -39,6 +43,32 @@ describe("parsePositionalArgs properties", () => { ); }); + test("single arg org/project/traceId: splits into target and traceId", async () => { + await fcAssert( + property( + tuple(slugArb, slugArb, traceIdArb), + ([org, project, traceId]) => { + const combined = `${org}/${project}/${traceId}`; + const result = parsePositionalArgs([combined]); + expect(result.targetArg).toBe(`${org}/${project}`); + expect(result.traceId).toBe(traceId); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("single arg with one slash: throws ContextError (missing trace ID)", async () => { + await fcAssert( + property(tuple(slugArb, slugArb), ([org, project]) => { + expect(() => parsePositionalArgs([`${org}/${project}`])).toThrow( + ContextError + ); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + test("two args: first is always targetArg, second is always traceId", async () => { await fcAssert( property( @@ -94,6 +124,9 @@ describe("parsePositionalArgs properties", () => { property( array(nonEmptyStringArb, { minLength: 1, maxLength: 3 }), (args) => { + // Skip single-arg with slashes — those throw ContextError (tested separately) + pre(args.length > 1 || !args[0]?.includes("/")); + const result1 = parsePositionalArgs(args); const result2 = parsePositionalArgs(args); expect(result1).toEqual(result2); @@ -112,6 +145,9 @@ describe("parsePositionalArgs properties", () => { property( array(nonEmptyStringArb, { minLength: 1, maxLength: 3 }), (args) => { + // Skip single-arg with slashes — those throw ContextError (tested separately) + pre(args.length > 1 || !args[0]?.includes("/")); + const result = parsePositionalArgs(args); expect(result.traceId).toBeDefined(); expect(typeof result.traceId).toBe("string"); diff --git a/test/commands/trace/view.test.ts b/test/commands/trace/view.test.ts index 071b701c5a..f3d3df7327 100644 --- a/test/commands/trace/view.test.ts +++ b/test/commands/trace/view.test.ts @@ -58,6 +58,42 @@ describe("parsePositionalArgs", () => { }); }); + describe("slash-separated org/project/traceId (single arg)", () => { + test("parses org/project/traceId as target + trace ID", () => { + const result = parsePositionalArgs([ + "sentry/cli/aaaa1111bbbb2222cccc3333dddd4444", + ]); + expect(result.targetArg).toBe("sentry/cli"); + expect(result.traceId).toBe("aaaa1111bbbb2222cccc3333dddd4444"); + }); + + test("handles hyphenated org and project slugs", () => { + const result = parsePositionalArgs([ + "my-org/my-project/deadbeef12345678", + ]); + expect(result.targetArg).toBe("my-org/my-project"); + expect(result.traceId).toBe("deadbeef12345678"); + }); + + test("one slash (org/project, missing trace ID) throws ContextError", () => { + expect(() => parsePositionalArgs(["sentry/cli"])).toThrow(ContextError); + }); + + test("trailing slash (org/project/) throws ContextError", () => { + expect(() => parsePositionalArgs(["sentry/cli/"])).toThrow(ContextError); + }); + + test("one-slash ContextError mentions Trace ID", () => { + try { + parsePositionalArgs(["sentry/cli"]); + expect.unreachable("Should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ContextError); + expect((error as ContextError).message).toContain("Trace ID"); + } + }); + }); + describe("edge cases", () => { test("handles more than two args (ignores extras)", () => { const result = parsePositionalArgs([ From f948e3f7409deefe2550b2eac68aaa1be896d7b6 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Wed, 18 Feb 2026 11:41:08 +0000 Subject: [PATCH 2/3] refactor(arg-parsing): extract shared parseSlashSeparatedArg helper The slash-splitting logic was duplicated across event/view.ts, trace/view.ts, and log/view.ts. Centralise it in src/lib/arg-parsing.ts so future bug fixes only need to be made in one place. --- src/commands/event/view.ts | 30 +++++----------------- src/commands/log/view.ts | 34 +++++++------------------ src/commands/trace/view.ts | 35 ++++++++------------------ src/lib/arg-parsing.ts | 51 +++++++++++++++++++++++++++++++++++++- 4 files changed, 75 insertions(+), 75 deletions(-) diff --git a/src/commands/event/view.ts b/src/commands/event/view.ts index 7dae500d5e..a7244cf02c 100644 --- a/src/commands/event/view.ts +++ b/src/commands/event/view.ts @@ -9,6 +9,7 @@ import { getEvent } from "../../lib/api-client.js"; import { ProjectSpecificationType, parseOrgProjectArg, + parseSlashSeparatedArg, spansFlag, } from "../../lib/arg-parsing.js"; import { openInBrowser } from "../../lib/browser.js"; @@ -113,30 +114,11 @@ export function parsePositionalArgs(args: string[]): { } if (args.length === 1) { - const slashIdx = first.indexOf("/"); - - if (slashIdx === -1) { - // No slashes — plain event ID - return { eventId: first, targetArg: undefined }; - } - - // Event IDs are hex and never contain "/" — this must be a structured - // "org/project/eventId" or "org/project" (missing event ID) - const lastSlashIdx = first.lastIndexOf("/"); - - if (slashIdx === lastSlashIdx) { - // Exactly one slash: "org/project" without event ID - throw new ContextError("Event ID", USAGE_HINT); - } - - // Two+ slashes: split on last "/" → target + eventId - const targetArg = first.slice(0, lastSlashIdx); - const eventId = first.slice(lastSlashIdx + 1); - - if (!eventId) { - throw new ContextError("Event ID", USAGE_HINT); - } - + const { id: eventId, targetArg } = parseSlashSeparatedArg( + first, + "Event ID", + USAGE_HINT + ); return { eventId, targetArg }; } diff --git a/src/commands/log/view.ts b/src/commands/log/view.ts index 1981f2562d..71bafe360c 100644 --- a/src/commands/log/view.ts +++ b/src/commands/log/view.ts @@ -7,7 +7,10 @@ import { buildCommand } from "@stricli/core"; import type { SentryContext } from "../../context.js"; import { getLog } from "../../lib/api-client.js"; -import { parseOrgProjectArg } from "../../lib/arg-parsing.js"; +import { + parseOrgProjectArg, + parseSlashSeparatedArg, +} from "../../lib/arg-parsing.js"; import { openInBrowser } from "../../lib/browser.js"; import { ContextError, ValidationError } from "../../lib/errors.js"; import { formatLogDetails, writeJson } from "../../lib/formatters/index.js"; @@ -48,30 +51,11 @@ export function parsePositionalArgs(args: string[]): { } if (args.length === 1) { - const slashIdx = first.indexOf("/"); - - if (slashIdx === -1) { - // No slashes — plain log ID - return { logId: first, targetArg: undefined }; - } - - // Log IDs are hex and never contain "/" — this must be a structured - // "org/project/logId" or "org/project" (missing log ID) - const lastSlashIdx = first.lastIndexOf("/"); - - if (slashIdx === lastSlashIdx) { - // Exactly one slash: "org/project" without log ID - throw new ContextError("Log ID", USAGE_HINT); - } - - // Two+ slashes: split on last "/" → target + logId - const targetArg = first.slice(0, lastSlashIdx); - const logId = first.slice(lastSlashIdx + 1); - - if (!logId) { - throw new ContextError("Log ID", USAGE_HINT); - } - + const { id: logId, targetArg } = parseSlashSeparatedArg( + first, + "Log ID", + USAGE_HINT + ); return { logId, targetArg }; } diff --git a/src/commands/trace/view.ts b/src/commands/trace/view.ts index 3e6a20d74a..c882e3981d 100644 --- a/src/commands/trace/view.ts +++ b/src/commands/trace/view.ts @@ -7,7 +7,11 @@ import { buildCommand } from "@stricli/core"; import type { SentryContext } from "../../context.js"; import { getDetailedTrace } from "../../lib/api-client.js"; -import { parseOrgProjectArg, spansFlag } from "../../lib/arg-parsing.js"; +import { + parseOrgProjectArg, + parseSlashSeparatedArg, + spansFlag, +} from "../../lib/arg-parsing.js"; import { openInBrowser } from "../../lib/browser.js"; import { ContextError, ValidationError } from "../../lib/errors.js"; import { @@ -55,30 +59,11 @@ export function parsePositionalArgs(args: string[]): { } if (args.length === 1) { - const slashIdx = first.indexOf("/"); - - if (slashIdx === -1) { - // No slashes — plain trace ID - return { traceId: first, targetArg: undefined }; - } - - // Trace IDs are hex and never contain "/" — this must be a structured - // "org/project/traceId" or "org/project" (missing trace ID) - const lastSlashIdx = first.lastIndexOf("/"); - - if (slashIdx === lastSlashIdx) { - // Exactly one slash: "org/project" without trace ID - throw new ContextError("Trace ID", USAGE_HINT); - } - - // Two+ slashes: split on last "/" → target + traceId - const targetArg = first.slice(0, lastSlashIdx); - const traceId = first.slice(lastSlashIdx + 1); - - if (!traceId) { - throw new ContextError("Trace ID", USAGE_HINT); - } - + const { id: traceId, targetArg } = parseSlashSeparatedArg( + first, + "Trace ID", + USAGE_HINT + ); return { traceId, targetArg }; } diff --git a/src/lib/arg-parsing.ts b/src/lib/arg-parsing.ts index b2bbd3a362..157b52b337 100644 --- a/src/lib/arg-parsing.ts +++ b/src/lib/arg-parsing.ts @@ -6,7 +6,7 @@ * project list) and single-item commands (issue view, explain, plan). */ -import { ValidationError } from "./errors.js"; +import { ContextError, ValidationError } from "./errors.js"; import type { ParsedSentryUrl } from "./sentry-url-parser.js"; import { applySentryUrlContext, parseSentryUrl } from "./sentry-url-parser.js"; import { isAllDigits } from "./utils.js"; @@ -345,6 +345,55 @@ function parseWithDash(arg: string): ParsedIssueArg { return { type: "project-search", projectSlug, suffix }; } +/** + * Parse a single positional arg that may be a plain hex ID or a slash-separated + * `org/project/id` pattern. + * + * Used by commands whose IDs are hex strings that never contain `/` + * (event, trace, log), making the pattern unambiguous: + * - No slashes → plain ID, no target + * - Exactly one slash → `org/project` without ID → throws {@link ContextError} + * - Two or more slashes → splits on last `/` → `targetArg` + `id` + * + * @param arg - The raw single positional argument + * @param idLabel - Human-readable ID label for error messages (e.g. `"Event ID"`) + * @param usageHint - Usage example shown in error messages + * @returns Parsed `{ id, targetArg }` — `targetArg` is `undefined` for plain IDs + * @throws {ContextError} When the arg contains exactly one slash (missing ID) + * or ends with a trailing slash (empty ID segment) + */ +export function parseSlashSeparatedArg( + arg: string, + idLabel: string, + usageHint: string +): { id: string; targetArg: string | undefined } { + const slashIdx = arg.indexOf("/"); + + if (slashIdx === -1) { + // No slashes — plain ID + return { id: arg, targetArg: undefined }; + } + + // IDs are hex and never contain "/" — this must be a structured + // "org/project/id" or "org/project" (missing ID) + const lastSlashIdx = arg.lastIndexOf("/"); + + if (slashIdx === lastSlashIdx) { + // Exactly one slash: "org/project" without ID + throw new ContextError(idLabel, usageHint); + } + + // Two+ slashes: split on last "/" → target + id + const targetArg = arg.slice(0, lastSlashIdx); + const id = arg.slice(lastSlashIdx + 1); + + if (!id) { + throw new ContextError(idLabel, usageHint); + } + + return { id, targetArg }; +} + export function parseIssueArg(arg: string): ParsedIssueArg { // 0. URL detection — extract issue ID from Sentry web URLs const urlParsed = parseSentryUrl(arg); From a50d4b0ffa3b1d07e3ed854b08a4f2321e6193a3 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Mon, 23 Feb 2026 10:38:20 +0000 Subject: [PATCH 3/3] fix(help): document target patterns and trailing-slash significance (#267) Renames the positional placeholder from `` to `` so the usage line is self-documenting. Adds a prose explanation of trailing-slash semantics to the fullDescription of every command that uses parseOrgProjectArg, and improves the --cursor rejection error in project list to include a contextual suggestion when a bare slug was given. --- src/commands/issue/list.ts | 13 +++++++++---- src/commands/log/list.ts | 8 +++++--- src/commands/project/list.ts | 26 ++++++++++++++++++-------- src/commands/project/view.ts | 8 +++++--- src/commands/trace/list.ts | 8 +++++--- 5 files changed, 42 insertions(+), 21 deletions(-) diff --git a/src/commands/issue/list.ts b/src/commands/issue/list.ts index e1bba9b1cb..af897b420c 100644 --- a/src/commands/issue/list.ts +++ b/src/commands/issue/list.ts @@ -385,11 +385,15 @@ export const listCommand = buildCommand({ brief: "List issues in a project", fullDescription: "List issues from Sentry projects.\n\n" + - "Target specification:\n" + + "Target patterns:\n" + " sentry issue list # auto-detect from DSN or config\n" + " sentry issue list / # explicit org and project\n" + - " sentry issue list / # all projects in org\n" + + " sentry issue list / # all projects in org (trailing / required)\n" + " sentry issue list # find project across all orgs\n\n" + + "The trailing slash on / is significant — without it, the argument\n" + + "is treated as a project name search (e.g., 'sentry' searches for a\n" + + "project named 'sentry', while 'sentry/' lists all projects in the\n" + + "'sentry' org).\n\n" + "In monorepos with multiple Sentry projects, shows issues from all detected projects.", }, parameters: { @@ -397,8 +401,9 @@ export const listCommand = buildCommand({ kind: "tuple", parameters: [ { - placeholder: "target", - brief: "Target: /, /, or ", + placeholder: "org/project", + brief: + "/, / (all projects), or (search)", parse: String, optional: true, }, diff --git a/src/commands/log/list.ts b/src/commands/log/list.ts index 17b6156289..99de2e30e0 100644 --- a/src/commands/log/list.ts +++ b/src/commands/log/list.ts @@ -315,10 +315,12 @@ export const listCommand = buildCommand({ brief: "List logs from a project", fullDescription: "List and stream logs from Sentry projects.\n\n" + - "Target specification:\n" + + "Target patterns:\n" + " sentry log list # auto-detect from DSN or config\n" + " sentry log list / # explicit org and project\n" + " sentry log list # find project across all orgs\n\n" + + "A bare name (no slash) is treated as a project search. Use /\n" + + "for an explicit target.\n\n" + "Examples:\n" + " sentry log list # List last 100 logs\n" + " sentry log list -f # Stream logs (2s poll interval)\n" + @@ -331,8 +333,8 @@ export const listCommand = buildCommand({ kind: "tuple", parameters: [ { - placeholder: "target", - brief: "Target: / or ", + placeholder: "org/project", + brief: "/ or (search)", parse: String, optional: true, }, diff --git a/src/commands/project/list.ts b/src/commands/project/list.ts index 2f96b2db6b..9124c869f3 100644 --- a/src/commands/project/list.ts +++ b/src/commands/project/list.ts @@ -609,13 +609,17 @@ export const listCommand = buildCommand({ brief: "List projects", fullDescription: "List projects in an organization.\n\n" + - "Target specification:\n" + + "Target patterns:\n" + " sentry project list # auto-detect from DSN or config\n" + - " sentry project list / # list all projects in org (paginated)\n" + + " sentry project list / # all projects in org (paginated)\n" + " sentry project list / # show specific project\n" + " sentry project list # find project across all orgs\n\n" + + "The trailing slash on / is significant — without it, the argument\n" + + "is treated as a project name search (e.g., 'sentry' searches for a\n" + + "project named 'sentry', while 'sentry/' lists all projects in the\n" + + "'sentry' org). Cursor pagination (--cursor) requires the / form.\n\n" + "Pagination:\n" + - " sentry project list / -c last # continue from last page\n" + + " sentry project list / -c last # continue from last page\n" + " sentry project list / -c # resume at specific cursor\n\n" + "Filtering and output:\n" + " sentry project list --platform javascript # filter by platform\n" + @@ -627,8 +631,9 @@ export const listCommand = buildCommand({ kind: "tuple", parameters: [ { - placeholder: "target", - brief: "Target: /, /, or ", + placeholder: "org/project", + brief: + "/ (all projects), /, or (search)", parse: String, optional: true, }, @@ -673,10 +678,15 @@ export const listCommand = buildCommand({ // Cursor pagination is only supported in org-all mode — check before resolving if (flags.cursor && parsed.type !== "org-all") { + const hint = + parsed.type === "project-search" + ? `\n\nDid you mean 'sentry project list ${parsed.projectSlug}/'? ` + + "A bare name searches for a project — add a trailing slash to list an org's projects." + : ""; throw new ValidationError( - "The --cursor flag is only supported when listing projects for a specific organization " + - "(e.g., sentry project list /). " + - "Use 'sentry project list /' for paginated results.", + "The --cursor flag requires the / pattern " + + "(e.g., sentry project list my-org/)." + + hint, "cursor" ); } diff --git a/src/commands/project/view.ts b/src/commands/project/view.ts index 8d24db38f8..5bf398a867 100644 --- a/src/commands/project/view.ts +++ b/src/commands/project/view.ts @@ -201,10 +201,12 @@ export const viewCommand = buildCommand({ brief: "View details of a project", fullDescription: "View detailed information about Sentry projects.\n\n" + - "Target specification:\n" + + "Target patterns:\n" + " sentry project view # auto-detect from DSN or config\n" + " sentry project view / # explicit org and project\n" + " sentry project view # find project across all orgs\n\n" + + "A bare name (no slash) is treated as a project search. Use /\n" + + "for an explicit target.\n\n" + "In monorepos with multiple Sentry projects, shows details for all detected projects.", }, parameters: { @@ -212,8 +214,8 @@ export const viewCommand = buildCommand({ kind: "tuple", parameters: [ { - placeholder: "target", - brief: "Target: /, , or omit for auto-detect", + placeholder: "org/project", + brief: "/, (search), or omit for auto-detect", parse: String, optional: true, }, diff --git a/src/commands/trace/list.ts b/src/commands/trace/list.ts index 9c03dc180e..2513259b87 100644 --- a/src/commands/trace/list.ts +++ b/src/commands/trace/list.ts @@ -152,10 +152,12 @@ export const listCommand = buildCommand({ brief: "List recent traces in a project", fullDescription: "List recent traces from Sentry projects.\n\n" + - "Target specification:\n" + + "Target patterns:\n" + " sentry trace list # auto-detect from DSN or config\n" + " sentry trace list / # explicit org and project\n" + " sentry trace list # find project across all orgs\n\n" + + "A bare name (no slash) is treated as a project search. Use /\n" + + "for an explicit target.\n\n" + "Examples:\n" + " sentry trace list # List last 10 traces\n" + " sentry trace list --limit 50 # Show more traces\n" + @@ -167,8 +169,8 @@ export const listCommand = buildCommand({ kind: "tuple", parameters: [ { - placeholder: "target", - brief: "Target: / or ", + placeholder: "org/project", + brief: "/ or (search)", parse: String, optional: true, },