diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 18b41788..154407f4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,15 +8,21 @@ We follow [gh CLI](https://cli.github.com/) conventions for best-in-class develo ### List Commands -List commands use **flags only** for context (no positional arguments). +List commands use **optional positional arguments** for context with smart auto-detection. ```bash sentry org list [--limit N] [--json] -sentry project list [--org ORG] [--limit N] [--json] -sentry issue list [--org ORG] [--project PROJECT] [--json] +sentry project list [org] [--limit N] [--json] +sentry issue list [/] [--json] ``` -**Rationale**: Flags are self-documenting and avoid ambiguity when multiple identifiers are needed. +**Target syntax**: +- `/` - Explicit organization and project (e.g., `my-org/frontend`) +- `/` - All projects in the specified organization +- `` - Search for project by name across all accessible organizations +- *(omit)* - Auto-detect from DSN or config + +**Rationale**: Positional arguments follow `gh` CLI conventions and are more concise than flags. ### View Commands @@ -24,9 +30,9 @@ View commands use **optional positional arguments** for the primary identifier, ```bash sentry org view [org-slug] [--json] [-w] # works with DSN if no arg -sentry project view [project-slug] [--org ORG] [--json] [-w] # works with DSN if no arg -sentry issue view [--org ORG] [--json] [-w] # issue ID required -sentry event view [--org ORG] [--project PROJECT] [--json] [-w] +sentry project view [/] [--json] [-w] # works with DSN if no arg +sentry issue view [--json] [-w] # issue ID required +sentry event view [/] [--json] [-w] # event ID required ``` **Key insight**: `org view` and `project view` mirror `gh repo view` - works in context (DSN) or with explicit arg. @@ -37,7 +43,7 @@ sentry event view [--org ORG] [--project PROJECT] [--json] [-w] Context (org, project) is resolved in this priority order: -1. **CLI flags** (`--org`, `--project`) - explicit, always wins +1. **Positional arguments** (`/`) - explicit, always wins 2. **Config defaults** - set via `sentry config set` 3. **DSN auto-detection** - from `SENTRY_DSN` env var or source code @@ -45,8 +51,6 @@ Context (org, project) is resolved in this priority order: | Flag | Description | Used In | |------|-------------|---------| -| `--org` | Organization slug | Most commands | -| `--project` | Project slug | Project/issue/event commands | | `--json` | Output as JSON | All view/list commands | | `-w`, `--web` | Open in browser | All view commands | | `--limit` | Max items to return | List commands | diff --git a/docs/src/content/docs/commands/issue.md b/docs/src/content/docs/commands/issue.md index ef5011b2..735db697 100644 --- a/docs/src/content/docs/commands/issue.md +++ b/docs/src/content/docs/commands/issue.md @@ -169,8 +169,6 @@ The analysis may take a few minutes for new issues. | Option | Description | |--------|-------------| -| `--org ` | Organization slug (required for short IDs if not auto-detected) | -| `--project ` | Project slug (required for short suffixes if not auto-detected) | | `--force` | Force new analysis even if one already exists | | `--json` | Output as JSON | @@ -180,11 +178,11 @@ The analysis may take a few minutes for new issues. # By numeric issue ID sentry issue explain 123456789 -# By short ID -sentry issue explain MYPROJECT-ABC --org my-org +# By short ID with org prefix +sentry issue explain my-org/MYPROJECT-ABC -# By short suffix (requires project context) -sentry issue explain G --org my-org --project my-project +# By project-suffix format +sentry issue explain myproject-G # Force a fresh analysis sentry issue explain 123456789 --force @@ -216,8 +214,6 @@ This command requires that `sentry issue explain` has been run first to identify | Option | Description | |--------|-------------| -| `--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 were identified) | | `--json` | Output as JSON | @@ -230,8 +226,11 @@ sentry issue plan 123456789 # Specify which root cause to plan for (if multiple were found) sentry issue plan 123456789 --cause 0 -# By short ID -sentry issue plan MYPROJECT-ABC --org my-org --cause 1 +# By short ID with org prefix +sentry issue plan my-org/MYPROJECT-ABC --cause 1 + +# By project-suffix format +sentry issue plan myproject-G --cause 0 ``` **Requirements:** diff --git a/docs/src/content/docs/features.md b/docs/src/content/docs/features.md index 0e12be8d..158e1139 100644 --- a/docs/src/content/docs/features.md +++ b/docs/src/content/docs/features.md @@ -7,7 +7,7 @@ The Sentry CLI includes several features designed to streamline your workflow, e ## DSN Auto-Detection -The CLI automatically detects your Sentry project from your codebase, eliminating the need to specify `--org` and `--project` flags for every command. +The CLI automatically detects your Sentry project from your codebase, eliminating the need to specify the target for every command. ### How It Works @@ -47,7 +47,7 @@ Once your project has a DSN configured, commands automatically use it: ```bash # Instead of: -sentry issue list --org my-org --project my-project +sentry issue list my-org/my-project # Just run: sentry issue list @@ -151,10 +151,10 @@ sentry issue explain FRONTEND-XYZ ### Short Suffix -Just the suffix portion when `--project` context is provided: +Just the suffix portion when project context is provided via the `/` prefix: ```bash -sentry issue view ABC --org my-org --project myproject +sentry issue view my-org/myproject-ABC ``` ### Alias-Suffix diff --git a/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index a95287cb..df416c56 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -239,11 +239,11 @@ sentry issue explain # By numeric issue ID sentry issue explain 123456789 -# By short ID -sentry issue explain MYPROJECT-ABC --org my-org +# By short ID with org prefix +sentry issue explain my-org/MYPROJECT-ABC -# By short suffix (requires project context) -sentry issue explain G --org my-org --project my-project +# By project-suffix format +sentry issue explain myproject-G # Force a fresh analysis sentry issue explain 123456789 --force @@ -269,8 +269,11 @@ sentry issue plan 123456789 # Specify which root cause to plan for (if multiple were found) sentry issue plan 123456789 --cause 0 -# By short ID -sentry issue plan MYPROJECT-ABC --org my-org --cause 1 +# By short ID with org prefix +sentry issue plan my-org/MYPROJECT-ABC --cause 1 + +# By project-suffix format +sentry issue plan myproject-G --cause 0 ``` #### `sentry issue view ` @@ -300,13 +303,11 @@ sentry issue view FRONT-ABC -w View Sentry events -#### `sentry event view ` +#### `sentry event view ` View details of a specific event **Flags:** -- `--org - Organization slug` -- `--project - Project slug` - `--json - Output as JSON` - `-w, --web - Open in browser` - `--spans - Span tree depth limit (number, "all" for unlimited, "no" to disable) - (default: "3")` diff --git a/src/commands/event/view.ts b/src/commands/event/view.ts index b5a97eea..f04af33c 100644 --- a/src/commands/event/view.ts +++ b/src/commands/event/view.ts @@ -6,10 +6,14 @@ import { buildCommand } from "@stricli/core"; import type { SentryContext } from "../../context.js"; -import { getEvent } from "../../lib/api-client.js"; -import { spansFlag } from "../../lib/arg-parsing.js"; +import { findProjectsBySlug, getEvent } from "../../lib/api-client.js"; +import { + ProjectSpecificationType, + parseOrgProjectArg, + spansFlag, +} from "../../lib/arg-parsing.js"; import { openInBrowser } from "../../lib/browser.js"; -import { ContextError } from "../../lib/errors.js"; +import { ContextError, ValidationError } from "../../lib/errors.js"; import { formatEventDetails, writeJson } from "../../lib/formatters/index.js"; import { resolveOrgAndProject } from "../../lib/resolve-target.js"; import { buildEventSearchUrl } from "../../lib/sentry-urls.js"; @@ -17,8 +21,6 @@ import { getSpanTreeLines } from "../../lib/span-tree.js"; import type { SentryEvent, Writer } from "../../types/index.js"; type ViewFlags = { - readonly org?: string; - readonly project?: string; readonly json: boolean; readonly web: boolean; readonly spans: number; @@ -54,41 +56,104 @@ function writeHumanOutput(stdout: Writer, options: HumanOutputOptions): void { } } +/** Usage hint for ContextError messages */ +const USAGE_HINT = "sentry event view / "; + +/** + * Parse positional arguments for event view. + * Handles: `` or ` ` + * + * @returns Parsed event ID and optional target arg + */ +export function parsePositionalArgs(args: string[]): { + eventId: string; + targetArg: string | undefined; +} { + if (args.length === 0) { + throw new ContextError("Event ID", USAGE_HINT); + } + + const first = args[0]; + if (first === undefined) { + throw new ContextError("Event ID", USAGE_HINT); + } + + if (args.length === 1) { + // Single arg - must be event ID + return { eventId: first, targetArg: undefined }; + } + + const second = args[1]; + if (second === undefined) { + // Should not happen given length check, but TypeScript needs this + return { eventId: first, targetArg: undefined }; + } + + // Two or more args - first is target, second is event ID + return { eventId: second, targetArg: first }; +} + +/** Resolved target type for internal use */ +type ResolvedEventTarget = { + org: string; + project: string; + orgDisplay: string; + projectDisplay: string; + detectedFrom?: string; +}; + +/** + * Resolve target from a project search result. + */ +async function resolveFromProjectSearch( + projectSlug: string, + eventId: string +): Promise { + const found = await findProjectsBySlug(projectSlug); + if (found.length === 0) { + throw new ContextError(`Project "${projectSlug}"`, USAGE_HINT, [ + "Check that you have access to a project with this slug", + ]); + } + if (found.length > 1) { + const orgList = found.map((p) => ` ${p.orgSlug}/${p.slug}`).join("\n"); + throw new ValidationError( + `Project "${projectSlug}" exists in multiple organizations.\n\n` + + `Specify the organization:\n${orgList}\n\n` + + `Example: sentry event view /${projectSlug} ${eventId}` + ); + } + // Safe assertion: length is exactly 1 after the checks above + const foundProject = found[0] as (typeof found)[0]; + return { + org: foundProject.orgSlug, + project: foundProject.slug, + orgDisplay: foundProject.orgSlug, + projectDisplay: foundProject.slug, + }; +} + export const viewCommand = buildCommand({ docs: { brief: "View details of a specific event", fullDescription: "View detailed information about a Sentry event by its ID.\n\n" + - "The organization and project are resolved from:\n" + - " 1. --org and --project flags\n" + - " 2. Config defaults\n" + - " 3. SENTRY_DSN environment variable or source code detection", + "Target specification:\n" + + " sentry event view # auto-detect from DSN or config\n" + + " sentry event view / # explicit org and project\n" + + " sentry event view # find project across all orgs", }, parameters: { positional: { - kind: "tuple", - parameters: [ - { - placeholder: "event-id", - brief: - "Event ID (hexadecimal, e.g., 9999aaaaca8b46d797c23c6077c6ff01)", - parse: String, - }, - ], - }, - flags: { - org: { - kind: "parsed", - parse: String, - brief: "Organization slug", - optional: true, - }, - project: { - kind: "parsed", + kind: "array", + parameter: { + placeholder: "args", + brief: + "[/] - Target (optional) and event ID (required)", parse: String, - brief: "Project slug", - optional: true, }, + }, + flags: { json: { kind: "boolean", brief: "Output as JSON", @@ -106,22 +171,44 @@ export const viewCommand = buildCommand({ async func( this: SentryContext, flags: ViewFlags, - eventId: string + ...args: string[] ): Promise { const { stdout, cwd } = this; - const target = await resolveOrgAndProject({ - org: flags.org, - project: flags.project, - cwd, - usageHint: `sentry event view ${eventId} --org --project `, - }); + // Parse positional args + const { eventId, targetArg } = parsePositionalArgs(args); + const parsed = parseOrgProjectArg(targetArg); + + let target: ResolvedEventTarget | null = null; + + switch (parsed.type) { + case ProjectSpecificationType.Explicit: + target = { + org: parsed.org, + project: parsed.project, + orgDisplay: parsed.org, + projectDisplay: parsed.project, + }; + break; + + case ProjectSpecificationType.ProjectSearch: + target = await resolveFromProjectSearch(parsed.projectSlug, eventId); + break; + + case ProjectSpecificationType.OrgAll: + throw new ContextError("Specific project", USAGE_HINT); + + case ProjectSpecificationType.AutoDetect: + target = await resolveOrgAndProject({ cwd, usageHint: USAGE_HINT }); + break; + + default: + // Exhaustive check - should never reach here + throw new ValidationError("Invalid target specification"); + } if (!target) { - throw new ContextError( - "Organization and project", - `sentry event view ${eventId} --org --project ` - ); + throw new ContextError("Organization and project", USAGE_HINT); } if (flags.web) { diff --git a/src/commands/project/view.ts b/src/commands/project/view.ts index 90670d07..edd9ca5b 100644 --- a/src/commands/project/view.ts +++ b/src/commands/project/view.ts @@ -40,7 +40,7 @@ function buildContextError(skippedSelfHosted?: number): ContextError { "Organization and project", `${usageHint}\n\n` + `Note: Found ${skippedSelfHosted} DSN(s) that could not be resolved.\n` + - "You may not have access to these projects, or you can specify --org and --project explicitly." + "You may not have access to these projects, or specify the target explicitly." ); } diff --git a/src/lib/dsn/errors.ts b/src/lib/dsn/errors.ts index 6db09610..19cf4c4f 100644 --- a/src/lib/dsn/errors.ts +++ b/src/lib/dsn/errors.ts @@ -31,7 +31,7 @@ export function formatConflictError(result: DsnDetectionResult): string { }); lines.push("To resolve, specify which project to use:"); - lines.push(" sentry issue list --org --project "); + lines.push(" sentry /"); lines.push(""); lines.push("Or set a default project:"); lines.push(" sentry config set defaults.org "); @@ -91,7 +91,7 @@ export async function formatNoDsnError( ); lines.push(""); lines.push("3. Specify project explicitly:"); - lines.push(" sentry issue list --org --project "); + lines.push(" sentry /"); lines.push(""); lines.push("4. Set default project:"); lines.push(" sentry config set defaults.org "); @@ -119,7 +119,7 @@ export function formatResolutionError(error: Error, dsnRaw: string): string { " - The DSN is invalid or expired", "", "Try specifying the project explicitly:", - " sentry issue list --org --project ", + " sentry /", ]; return lines.join("\n"); @@ -147,7 +147,7 @@ type ProjectInfo = { * Found 2 Sentry projects: * • my-org / frontend (from packages/frontend/.env) * • my-org / backend (from src/sentry.ts) - * Use --org and --project to target a specific project. + * Use / to target a specific project. * ``` */ export function formatMultipleProjectsFooter(projects: ProjectInfo[]): string { @@ -162,7 +162,7 @@ export function formatMultipleProjectsFooter(projects: ProjectInfo[]): string { lines.push(` • ${p.orgDisplay} / ${p.projectDisplay}${source}`); } - lines.push("Use --org and --project to target a specific project."); + lines.push("Use / to target a specific project."); return lines.join("\n"); } diff --git a/src/lib/dsn/resolver.ts b/src/lib/dsn/resolver.ts index 8b36d9bc..2f183f13 100644 --- a/src/lib/dsn/resolver.ts +++ b/src/lib/dsn/resolver.ts @@ -59,7 +59,7 @@ export async function resolveProject( if (!project?.organization) { throw new Error( "Cannot resolve project: DSN could not be matched to any accessible project. " + - "You may not have access, or please specify --org and --project explicitly." + "You may not have access, or specify the target explicitly: sentry /" ); } diff --git a/src/lib/resolve-target.ts b/src/lib/resolve-target.ts index 232924ad..3c503f3f 100644 --- a/src/lib/resolve-target.ts +++ b/src/lib/resolve-target.ts @@ -466,14 +466,14 @@ async function inferFromDirectoryName(cwd: string): Promise { * and returns a footer message for display. * * Resolution priority: - * 1. CLI flags (--org and --project) - returns single target + * 1. Explicit org and project - returns single target * 2. Config defaults - returns single target * 3. DSN auto-detection - may return multiple targets * 4. Directory name inference - matches project slugs with word boundaries * - * @param options - Resolution options with flags and cwd + * @param options - Resolution options with org, project, and cwd * @returns All resolved targets and optional footer message - * @throws Error if only one of org/project flags is provided + * @throws Error if only one of org/project is provided */ export async function resolveAllTargets( options: ResolveOptions @@ -498,7 +498,7 @@ export async function resolveAllTargets( if (org || project) { throw new ContextError( "Organization and project", - options.usageHint ?? "sentry --org --project " + options.usageHint ?? "sentry /" ); } @@ -573,14 +573,14 @@ export async function resolveAllTargets( * Resolve organization and project from multiple sources. * * Resolution priority: - * 1. CLI flags (--org and --project) - both must be provided together + * 1. Explicit org and project - both must be provided together * 2. Config defaults * 3. DSN auto-detection * 4. Directory name inference - matches project slugs with word boundaries * - * @param options - Resolution options with flags and cwd + * @param options - Resolution options with org, project, and cwd * @returns Resolved target, or null if resolution failed - * @throws Error if only one of org/project flags is provided + * @throws Error if only one of org/project is provided */ export async function resolveOrgAndProject( options: ResolveOptions @@ -601,7 +601,7 @@ export async function resolveOrgAndProject( if (org || project) { throw new ContextError( "Organization and project", - options.usageHint ?? "sentry --org --project " + options.usageHint ?? "sentry /" ); } diff --git a/test/commands/event/view.test.ts b/test/commands/event/view.test.ts new file mode 100644 index 00000000..d86e49c4 --- /dev/null +++ b/test/commands/event/view.test.ts @@ -0,0 +1,87 @@ +/** + * Event View Command Tests + * + * Tests for positional argument parsing in src/commands/event/view.ts + */ + +import { describe, expect, test } from "bun:test"; +import { parsePositionalArgs } from "../../../src/commands/event/view.js"; +import { ContextError } from "../../../src/lib/errors.js"; + +describe("parsePositionalArgs", () => { + describe("single argument (event ID only)", () => { + test("parses single arg as event ID", () => { + const result = parsePositionalArgs(["abc123def456"]); + expect(result.eventId).toBe("abc123def456"); + expect(result.targetArg).toBeUndefined(); + }); + + test("parses UUID-like event ID", () => { + const result = parsePositionalArgs([ + "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + ]); + expect(result.eventId).toBe("a1b2c3d4-e5f6-7890-abcd-ef1234567890"); + expect(result.targetArg).toBeUndefined(); + }); + + test("parses short event ID", () => { + const result = parsePositionalArgs(["abc"]); + expect(result.eventId).toBe("abc"); + expect(result.targetArg).toBeUndefined(); + }); + }); + + describe("two arguments (target + event ID)", () => { + test("parses org/project target and event ID", () => { + const result = parsePositionalArgs(["my-org/frontend", "abc123def456"]); + expect(result.targetArg).toBe("my-org/frontend"); + expect(result.eventId).toBe("abc123def456"); + }); + + test("parses project-only target and event ID", () => { + const result = parsePositionalArgs(["frontend", "abc123def456"]); + expect(result.targetArg).toBe("frontend"); + expect(result.eventId).toBe("abc123def456"); + }); + + test("parses org/ target (all projects) and event ID", () => { + const result = parsePositionalArgs(["my-org/", "abc123def456"]); + expect(result.targetArg).toBe("my-org/"); + expect(result.eventId).toBe("abc123def456"); + }); + }); + + describe("error cases", () => { + test("throws ContextError for empty args", () => { + expect(() => parsePositionalArgs([])).toThrow(ContextError); + }); + + test("throws ContextError with usage hint", () => { + try { + parsePositionalArgs([]); + 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([ + "my-org/frontend", + "abc123", + "extra-arg", + ]); + expect(result.targetArg).toBe("my-org/frontend"); + expect(result.eventId).toBe("abc123"); + }); + + test("handles empty string event ID in two-arg case", () => { + const result = parsePositionalArgs(["my-org/frontend", ""]); + expect(result.targetArg).toBe("my-org/frontend"); + expect(result.eventId).toBe(""); + }); + }); +}); diff --git a/test/e2e/event.test.ts b/test/e2e/event.test.ts index b7d4d881..1167ff07 100644 --- a/test/e2e/event.test.ts +++ b/test/e2e/event.test.ts @@ -47,14 +47,12 @@ afterEach(async () => { describe("sentry event view", () => { test("requires authentication", async () => { + // Use positional arg format: / const result = await ctx.run([ "event", "view", + `${TEST_ORG}/${TEST_PROJECT}`, "abc123", - "--org", - TEST_ORG, - "--project", - TEST_PROJECT, ]); expect(result.exitCode).toBe(1); @@ -73,14 +71,12 @@ describe("sentry event view", () => { test("handles non-existent event", async () => { await ctx.setAuthToken(TEST_TOKEN); + // Use positional arg format: / const result = await ctx.run([ "event", "view", + `${TEST_ORG}/${TEST_PROJECT}`, "nonexistent123", - "--org", - TEST_ORG, - "--project", - TEST_PROJECT, ]); expect(result.exitCode).toBe(1); diff --git a/test/lib/dsn/errors.test.ts b/test/lib/dsn/errors.test.ts index 08d4640e..c50caa6b 100644 --- a/test/lib/dsn/errors.test.ts +++ b/test/lib/dsn/errors.test.ts @@ -59,9 +59,7 @@ describe("formatConflictError", () => { expect(error).toContain("def456@o789.ingest.sentry.io/101112"); expect(error).toContain("Project ID: 456"); expect(error).toContain("Project ID: 101112"); - expect(error).toContain( - "sentry issue list --org --project " - ); + expect(error).toContain("sentry /"); expect(error).toContain("sentry config set defaults.org"); }); @@ -185,7 +183,7 @@ describe("formatNoDsnError", () => { expect(error).toContain(".env files"); expect(error).toContain("JavaScript/TypeScript source code"); expect(error).toContain("export SENTRY_DSN="); - expect(error).toContain("sentry issue list --org"); + expect(error).toContain("sentry /"); expect(error).toContain("sentry config set defaults.org"); }); @@ -247,9 +245,7 @@ describe("formatResolutionError", () => { expect(formatted).toContain("You don't have access to this project"); expect(formatted).toContain("self-hosted Sentry instance"); expect(formatted).toContain("invalid or expired"); - expect(formatted).toContain( - "sentry issue list --org --project " - ); + expect(formatted).toContain("sentry /"); }); test("formats error with access denied message", () => { @@ -306,7 +302,7 @@ describe("formatMultipleProjectsFooter", () => { ); expect(footer).toContain("• my-org / backend (from src/sentry.ts)"); expect(footer).toContain( - "Use --org and --project to target a specific project" + "Use / to target a specific project." ); });