Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 92 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -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 <issue-id> [--org ORG] [--json] # issue ID required
sentry event get <event-id> [--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 <org-slug>", // Primary usage
["Set SENTRY_DSN for auto-detection"] // Alternatives
);
}
```

This produces:

```
Organization is required.

Specify it using:
sentry org get <org-slug>

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
9 changes: 4 additions & 5 deletions packages/cli/src/commands/event/get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 <org-slug> --project <project-slug>\n\n` +
"Or set SENTRY_DSN environment variable for automatic detection."
throw new ContextError(
"Organization and project",
`sentry event get ${eventId} --org <org-slug> --project <project-slug>`
);
}

Expand Down
9 changes: 4 additions & 5 deletions packages/cli/src/commands/issue/get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
getLatestEvent,
isShortId,
} from "../../lib/api-client.js";
import { ContextError } from "../../lib/errors.js";
import {
formatEventDetails,
formatIssueDetails,
Expand Down Expand Up @@ -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 <org-slug>\n\n` +
"Or set SENTRY_DSN environment variable for automatic detection."
throw new ContextError(
"Organization",
`sentry issue get ${issueId} --org <org-slug>`
);
}
issue = await getIssueByShortId(resolved.org, issueId);
Expand Down
74 changes: 6 additions & 68 deletions packages/cli/src/commands/issue/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<ProjectRef[]> {
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<string> {
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 <org-slug> --project <project-slug>");
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",
Expand Down Expand Up @@ -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 <org-slug> --project <project-slug>"
);
}

const issues = await listIssues(target.org, target.project, {
Expand Down
8 changes: 2 additions & 6 deletions packages/cli/src/commands/org/get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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 <org-slug>\n\n" +
"Or set SENTRY_DSN environment variable for automatic detection."
);
throw new ContextError("Organization", "sentry org get <org-slug>");
}

const org = await getOrganization(resolved.org);
Expand Down
9 changes: 4 additions & 5 deletions packages/cli/src/commands/project/get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 <project-slug> --org <org-slug>\n\n" +
"Or set SENTRY_DSN environment variable for automatic detection."
throw new ContextError(
"Organization and project",
"sentry project get <project-slug> --org <org-slug>"
);
}

Expand Down
18 changes: 2 additions & 16 deletions packages/cli/src/commands/project/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -181,15 +171,11 @@ export const listCommand = buildCommand({
},
},
},
async func(
this: SentryContext,
flags: ListFlags,
orgArg?: string
): Promise<void> {
async func(this: SentryContext, flags: ListFlags): Promise<void> {
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
Expand Down
52 changes: 52 additions & 0 deletions packages/cli/src/lib/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <org-slug>")
* @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
// ─────────────────────────────────────────────────────────────────────────────
Expand Down
Loading