diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..d845fad7a --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,92 @@ +# Contributing to Sentry CLI + +This guide documents the patterns and conventions used in this CLI for consistency. + +## Command Patterns + +We follow [gh CLI](https://cli.github.com/) conventions for best-in-class developer experience. + +### List Commands + +List commands use **flags only** for context (no positional arguments). + +```bash +sentry org list [--limit N] [--json] +sentry project list [--org ORG] [--limit N] [--json] +sentry issue list [--org ORG] [--project PROJECT] [--json] +``` + +**Rationale**: Flags are self-documenting and avoid ambiguity when multiple identifiers are needed. + +### Get Commands + +Get commands use **optional positional arguments** for the primary identifier, supporting auto-detection when omitted. + +```bash +sentry org get [org-slug] [--json] # works with DSN if no arg +sentry project get [project-slug] [--org ORG] [--json] # works with DSN if no arg +sentry issue get [--org ORG] [--json] # issue ID required +sentry event get [--org ORG] [--project PROJECT] [--json] +``` + +**Key insight**: `org get` and `project get` mirror `gh repo view` - works in context (DSN) or with explicit arg. + +## Context Resolution + +Context (org, project) is resolved in this priority order: + +1. **CLI flags** (`--org`, `--project`) - explicit, always wins +2. **Config defaults** - set via `sentry config set` +3. **DSN auto-detection** - from `SENTRY_DSN` env var or source code + +## Common Flags + +| Flag | Description | Used In | +|------|-------------|---------| +| `--org` | Organization slug | Most commands | +| `--project` | Project slug | Project/issue/event commands | +| `--json` | Output as JSON | All get/list commands | +| `--limit` | Max items to return | List commands | + +## Error Handling + +Use `ContextError` for missing required context. This provides consistent formatting: + +```typescript +import { ContextError } from "../../lib/errors.js"; + +if (!resolved) { + throw new ContextError( + "Organization", // What is required + "sentry org get ", // Primary usage + ["Set SENTRY_DSN for auto-detection"] // Alternatives + ); +} +``` + +This produces: + +``` +Organization is required. + +Specify it using: + sentry org get + +Or: + - Set SENTRY_DSN for auto-detection +``` + +## Adding New Commands + +1. **Choose the right pattern**: list (flags only) or get (optional positional) +2. **Use existing utilities**: `resolveOrg()`, `resolveOrgAndProject()` from `lib/resolve-target.ts` +3. **Support JSON output**: All commands should have `--json` flag +4. **Use ContextError**: For missing context errors, use `ContextError` class +5. **Add tests**: E2E tests in `test/e2e/` directory + +## Code Style + +- Use TypeScript strict mode +- Prefer explicit types over inference for public APIs +- Document functions with JSDoc comments +- Keep functions small and focused diff --git a/packages/cli/src/commands/event/get.ts b/packages/cli/src/commands/event/get.ts index 5dd819b20..b8aa7b421 100644 --- a/packages/cli/src/commands/event/get.ts +++ b/packages/cli/src/commands/event/get.ts @@ -7,6 +7,7 @@ import { buildCommand } from "@stricli/core"; import type { SentryContext } from "../../context.js"; import { getEvent } from "../../lib/api-client.js"; +import { ContextError } from "../../lib/errors.js"; import { formatEventDetails, writeJson } from "../../lib/formatters/index.js"; import { resolveOrgAndProject } from "../../lib/resolve-target.js"; import type { SentryEvent, Writer } from "../../types/index.js"; @@ -95,11 +96,9 @@ export const getCommand = buildCommand({ }); if (!target) { - throw new Error( - "Organization and project are required to fetch an event.\n\n" + - "Please specify them using:\n" + - ` sentry event get ${eventId} --org --project \n\n` + - "Or set SENTRY_DSN environment variable for automatic detection." + throw new ContextError( + "Organization and project", + `sentry event get ${eventId} --org --project ` ); } diff --git a/packages/cli/src/commands/issue/get.ts b/packages/cli/src/commands/issue/get.ts index 120d13705..b4024f4e5 100644 --- a/packages/cli/src/commands/issue/get.ts +++ b/packages/cli/src/commands/issue/get.ts @@ -12,6 +12,7 @@ import { getLatestEvent, isShortId, } from "../../lib/api-client.js"; +import { ContextError } from "../../lib/errors.js"; import { formatEventDetails, formatIssueDetails, @@ -106,11 +107,9 @@ export const getCommand = buildCommand({ // Short ID requires organization context const resolved = await resolveOrg({ org: flags.org, cwd }); if (!resolved) { - throw new Error( - "Organization is required for short ID lookup.\n\n" + - "Please specify it using:\n" + - ` sentry issue get ${issueId} --org \n\n` + - "Or set SENTRY_DSN environment variable for automatic detection." + throw new ContextError( + "Organization", + `sentry issue get ${issueId} --org ` ); } issue = await getIssueByShortId(resolved.org, issueId); diff --git a/packages/cli/src/commands/issue/list.ts b/packages/cli/src/commands/issue/list.ts index a9672b1cd..42ba61ceb 100644 --- a/packages/cli/src/commands/issue/list.ts +++ b/packages/cli/src/commands/issue/list.ts @@ -6,11 +6,8 @@ import { buildCommand, numberParser } from "@stricli/core"; import type { SentryContext } from "../../context.js"; -import { - listIssues, - listOrganizations, - listProjects, -} from "../../lib/api-client.js"; +import { listIssues } from "../../lib/api-client.js"; +import { ContextError } from "../../lib/errors.js"; import { divider, formatIssueRow, @@ -79,67 +76,6 @@ function writeListFooter(stdout: Writer): void { ); } -/** Minimal project reference for error message display */ -type ProjectRef = { - orgSlug: string; - projectSlug: string; -}; - -/** - * Fetch all projects from all accessible organizations. - * Used to show available options when no project is specified. - * - * @returns List of org/project slug pairs - */ -async function fetchAllProjects(): Promise { - const orgs = await listOrganizations(); - const results: ProjectRef[] = []; - - for (const org of orgs) { - try { - const projects = await listProjects(org.slug); - for (const project of projects) { - results.push({ - orgSlug: org.slug, - projectSlug: project.slug, - }); - } - } catch { - // User may lack access to some orgs - } - } - - return results; -} - -/** - * Build a helpful error message listing all available projects. - * Fetches projects from all accessible organizations. - * - * @returns Formatted error message with project list and usage instructions - */ -async function buildNoProjectError(): Promise { - const projects = await fetchAllProjects(); - - const lines: string[] = ["No project specified.", ""]; - - if (projects.length > 0) { - lines.push("Available projects:"); - lines.push(""); - for (const p of projects) { - lines.push(` ${p.orgSlug}/${p.projectSlug}`); - } - lines.push(""); - } - - lines.push("Specify a project using:"); - lines.push(" sentry issue list --org --project "); - lines.push(""); - lines.push("Or set SENTRY_DSN in your environment for automatic detection."); - - return lines.join("\n"); -} - export const listCommand = buildCommand({ docs: { brief: "List issues in a project", @@ -196,8 +132,10 @@ export const listCommand = buildCommand({ }); if (!target) { - const errorMessage = await buildNoProjectError(); - throw new Error(errorMessage); + throw new ContextError( + "Organization and project", + "sentry issue list --org --project " + ); } const issues = await listIssues(target.org, target.project, { diff --git a/packages/cli/src/commands/org/get.ts b/packages/cli/src/commands/org/get.ts index f5901e82b..7bf32d333 100644 --- a/packages/cli/src/commands/org/get.ts +++ b/packages/cli/src/commands/org/get.ts @@ -7,6 +7,7 @@ import { buildCommand } from "@stricli/core"; import type { SentryContext } from "../../context.js"; import { getOrganization } from "../../lib/api-client.js"; +import { ContextError } from "../../lib/errors.js"; import { formatOrgDetails, writeOutput } from "../../lib/formatters/index.js"; import { resolveOrg } from "../../lib/resolve-target.js"; @@ -53,12 +54,7 @@ export const getCommand = buildCommand({ const resolved = await resolveOrg({ org: orgSlug, cwd }); if (!resolved) { - throw new Error( - "Organization is required.\n\n" + - "Please specify it using:\n" + - " sentry org get \n\n" + - "Or set SENTRY_DSN environment variable for automatic detection." - ); + throw new ContextError("Organization", "sentry org get "); } const org = await getOrganization(resolved.org); diff --git a/packages/cli/src/commands/project/get.ts b/packages/cli/src/commands/project/get.ts index 7377d67cb..3c92220b6 100644 --- a/packages/cli/src/commands/project/get.ts +++ b/packages/cli/src/commands/project/get.ts @@ -7,6 +7,7 @@ import { buildCommand } from "@stricli/core"; import type { SentryContext } from "../../context.js"; import { getProject } from "../../lib/api-client.js"; +import { ContextError } from "../../lib/errors.js"; import { formatProjectDetails, writeOutput, @@ -67,11 +68,9 @@ export const getCommand = buildCommand({ }); if (!resolved) { - throw new Error( - "Organization and project are required.\n\n" + - "Please specify them using:\n" + - " sentry project get --org \n\n" + - "Or set SENTRY_DSN environment variable for automatic detection." + throw new ContextError( + "Organization and project", + "sentry project get --org " ); } diff --git a/packages/cli/src/commands/project/list.ts b/packages/cli/src/commands/project/list.ts index 42e5c3b15..415c29d6b 100644 --- a/packages/cli/src/commands/project/list.ts +++ b/packages/cli/src/commands/project/list.ts @@ -145,16 +145,6 @@ export const listCommand = buildCommand({ " sentry project list --platform javascript", }, parameters: { - positional: { - kind: "tuple", - parameters: [ - { - brief: "Organization slug (optional)", - parse: String, - optional: true, - }, - ], - }, flags: { org: { kind: "parsed", @@ -181,15 +171,11 @@ export const listCommand = buildCommand({ }, }, }, - async func( - this: SentryContext, - flags: ListFlags, - orgArg?: string - ): Promise { + async func(this: SentryContext, flags: ListFlags): Promise { const { stdout, cwd } = this; // Resolve organization from multiple sources - let orgSlug = orgArg ?? flags.org ?? (await getDefaultOrganization()); + let orgSlug = flags.org ?? (await getDefaultOrganization()); let detectedFrom: string | undefined; // Try DSN auto-detection if no org specified diff --git a/packages/cli/src/lib/errors.ts b/packages/cli/src/lib/errors.ts index 5badc9a29..4b26fb05f 100644 --- a/packages/cli/src/lib/errors.ts +++ b/packages/cli/src/lib/errors.ts @@ -126,6 +126,58 @@ export class ConfigError extends CliError { } } +// ───────────────────────────────────────────────────────────────────────────── +// Context Errors (Missing Required Context) +// ───────────────────────────────────────────────────────────────────────────── + +const DEFAULT_CONTEXT_ALTERNATIVES = [ + "Run from a directory with a Sentry-configured project", + "Set SENTRY_DSN environment variable", +] as const; + +/** + * Missing required context errors (org, project, etc). + * + * Provides consistent error formatting with usage hints and alternatives. + * + * @param resource - What is required (e.g., "Organization", "Organization and project") + * @param command - Primary usage example (e.g., "sentry org get ") + * @param alternatives - Alternative ways to resolve (defaults to DSN/project detection hints) + */ +export class ContextError extends CliError { + readonly resource: string; + readonly command: string; + readonly alternatives: string[]; + + constructor( + resource: string, + command: string, + alternatives: string[] = [...DEFAULT_CONTEXT_ALTERNATIVES] + ) { + super(`${resource} is required.`); + this.name = "ContextError"; + this.resource = resource; + this.command = command; + this.alternatives = alternatives; + } + + override format(): string { + const lines = [ + `${this.resource} is required.`, + "", + "Specify it using:", + ` ${this.command}`, + ]; + if (this.alternatives.length > 0) { + lines.push("", "Or:"); + for (const alt of this.alternatives) { + lines.push(` - ${alt}`); + } + } + return lines.join("\n"); + } +} + // ───────────────────────────────────────────────────────────────────────────── // Validation Errors // ───────────────────────────────────────────────────────────────────────────── diff --git a/packages/cli/test/e2e/project.test.ts b/packages/cli/test/e2e/project.test.ts index 534d28c77..f7207ba78 100644 --- a/packages/cli/test/e2e/project.test.ts +++ b/packages/cli/test/e2e/project.test.ts @@ -95,10 +95,13 @@ describe("sentry project list", () => { test("lists projects with valid auth and org filter", async () => { await setAuthToken(TEST_TOKEN); - // Use org filter to avoid timeout from listing all projects - const result = await runCli(["project", "list", TEST_ORG, "--limit", "5"], { - env: { SENTRY_CLI_CONFIG_DIR: testConfigDir }, - }); + // Use --org flag to filter by organization + const result = await runCli( + ["project", "list", "--org", TEST_ORG, "--limit", "5"], + { + env: { SENTRY_CLI_CONFIG_DIR: testConfigDir }, + } + ); expect(result.exitCode).toBe(0); }); @@ -106,9 +109,9 @@ describe("sentry project list", () => { test("supports --json output", async () => { await setAuthToken(TEST_TOKEN); - // Use org filter to avoid timeout + // Use --org flag to filter by organization const result = await runCli( - ["project", "list", TEST_ORG, "--json", "--limit", "5"], + ["project", "list", "--org", TEST_ORG, "--json", "--limit", "5"], { env: { SENTRY_CLI_CONFIG_DIR: testConfigDir }, }