diff --git a/AGENTS.md b/AGENTS.md index 4cce1fa9..270c472f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -292,6 +292,9 @@ return result // return result await deleteUserData(userId) ``` +### Prohibited Comment Styles +- **ASCII art section dividers** - Do not use decorative box-drawing characters like `─────────` to create section headers. Use standard JSDoc comments or simple `// Section Name` comments instead. + ### Goal Minimal comments, maximum clarity. Comments explain **intent and reasoning**, not syntax. diff --git a/docs/src/content/docs/commands/issue.md b/docs/src/content/docs/commands/issue.md index e4ceb4a7..ef5011b2 100644 --- a/docs/src/content/docs/commands/issue.md +++ b/docs/src/content/docs/commands/issue.md @@ -12,24 +12,41 @@ Track and manage Sentry issues. List issues in a project. ```bash -sentry issue list --org --project +# Explicit org and project +sentry issue list / + +# All projects in an organization +sentry issue list / + +# Search for project across all accessible orgs +sentry issue list + +# Auto-detect from DSN or config +sentry issue list ``` +**Arguments:** + +| Argument | Description | +|----------|-------------| +| `/` | Explicit organization and project (e.g., `my-org/frontend`) | +| `/` | All projects in the specified organization | +| `` | Search for project by name across all accessible organizations | + **Options:** | Option | Description | |--------|-------------| -| `--org ` | Organization slug (required) | -| `--project ` | Project slug (required) | -| `--query ` | Search query | -| `--status ` | Filter by status (unresolved, resolved, ignored) | -| `--limit ` | Maximum number of issues to return | +| `--query ` | Search query (Sentry search syntax) | +| `--sort ` | Sort by: date, new, freq, user (default: date) | +| `--limit ` | Maximum number of issues to return (default: 10) | | `--json` | Output as JSON | -**Example:** +**Examples:** ```bash -sentry issue list --org my-org --project frontend +# List issues in a specific project +sentry issue list my-org/frontend ``` ``` @@ -38,10 +55,41 @@ ID SHORT ID TITLE COUNT USERS 987654321 FRONT-DEF ReferenceError: x is not de... 456 89 ``` +**List issues from all projects in an org:** + +```bash +sentry issue list my-org/ +``` + +**Search for a project across organizations:** + +```bash +sentry issue list frontend +``` + **With search query:** ```bash -sentry issue list --org my-org --project frontend --query "TypeError" +sentry issue list my-org/frontend --query "TypeError" +``` + +**Sort by frequency:** + +```bash +sentry issue list my-org/frontend --sort freq --limit 20 +``` + +**Filter by status:** + +```bash +# Show only unresolved issues +sentry issue list my-org/frontend --query "is:unresolved" + +# Show resolved issues +sentry issue list my-org/frontend --query "is:resolved" + +# Combine with other search terms +sentry issue list my-org/frontend --query "is:unresolved TypeError" ``` ### `sentry issue view` diff --git a/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index 0633a528..77166bf2 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -110,7 +110,7 @@ sentry org list sentry org list --json ``` -#### `sentry org view ` +#### `sentry org view ` View details of an organization @@ -132,15 +132,14 @@ sentry org view my-org -w Work with Sentry projects -#### `sentry project list` +#### `sentry project list ` List projects **Flags:** -- `--org - Organization slug` -- `--limit - Maximum number of projects to list - (default: "30")` +- `-n, --limit - Maximum number of projects to list - (default: "30")` - `--json - Output JSON` -- `--platform - Filter by platform (e.g., javascript, python)` +- `-p, --platform - Filter by platform (e.g., javascript, python)` **Examples:** @@ -155,7 +154,7 @@ sentry project list sentry project list --platform javascript ``` -#### `sentry project view ` +#### `sentry project view ` View details of a project @@ -178,29 +177,53 @@ sentry project view frontend -w Manage Sentry issues -#### `sentry issue list` +#### `sentry issue list ` List issues in a project **Flags:** -- `--org - Organization slug` -- `--project - Project slug` -- `--query - Search query (Sentry search syntax)` -- `--limit - Maximum number of issues to return - (default: "10")` -- `--sort - Sort by: date, new, freq, user - (default: "date")` +- `-q, --query - Search query (Sentry search syntax)` +- `-n, --limit - Maximum number of issues to return - (default: "10")` +- `-s, --sort - Sort by: date, new, freq, user - (default: "date")` - `--json - Output as JSON` **Examples:** ```bash -sentry issue list --org --project +# Explicit org and project +sentry issue list / + +# All projects in an organization +sentry issue list / + +# Search for project across all accessible orgs +sentry issue list + +# Auto-detect from DSN or config +sentry issue list + +# List issues in a specific project +sentry issue list my-org/frontend + +sentry issue list my-org/ + +sentry issue list frontend + +sentry issue list my-org/frontend --query "TypeError" + +sentry issue list my-org/frontend --sort freq --limit 20 + +# Show only unresolved issues +sentry issue list my-org/frontend --query "is:unresolved" -sentry issue list --org my-org --project frontend +# Show resolved issues +sentry issue list my-org/frontend --query "is:resolved" -sentry issue list --org my-org --project frontend --query "TypeError" +# Combine with other search terms +sentry issue list my-org/frontend --query "is:unresolved TypeError" ``` -#### `sentry issue explain ` +#### `sentry issue explain ` Analyze an issue's root cause using Seer AI @@ -228,7 +251,7 @@ sentry issue explain G --org my-org --project my-project sentry issue explain 123456789 --force ``` -#### `sentry issue plan ` +#### `sentry issue plan ` Generate a solution plan using Seer AI @@ -253,7 +276,7 @@ sentry issue plan 123456789 --cause 0 sentry issue plan MYPROJECT-ABC --org my-org --cause 1 ``` -#### `sentry issue view ` +#### `sentry issue view ` View details of a specific issue @@ -282,7 +305,7 @@ sentry issue view FRONT-ABC -w View Sentry events -#### `sentry event view ` +#### `sentry event view ` View details of a specific event diff --git a/src/commands/event/view.ts b/src/commands/event/view.ts index 60e008b0..69231937 100644 --- a/src/commands/event/view.ts +++ b/src/commands/event/view.ts @@ -72,6 +72,7 @@ export const viewCommand = buildCommand({ kind: "tuple", parameters: [ { + placeholder: "event-id", brief: "Event ID (hexadecimal, e.g., 9999aaaaca8b46d797c23c6077c6ff01)", parse: String, diff --git a/src/commands/issue/list.ts b/src/commands/issue/list.ts index 70824ce0..0d591feb 100644 --- a/src/commands/issue/list.ts +++ b/src/commands/issue/list.ts @@ -8,7 +8,11 @@ import { buildCommand, numberParser } from "@stricli/core"; import type { SentryContext } from "../../context.js"; import { buildOrgAwareAliases } from "../../lib/alias.js"; -import { listIssues } from "../../lib/api-client.js"; +import { + findProjectsBySlug, + listIssues, + listProjects, +} from "../../lib/api-client.js"; import { clearProjectAliases, setProjectAliases, @@ -24,6 +28,7 @@ import { writeJson, } from "../../lib/formatters/index.js"; import { + parseOrgProjectArg, type ResolvedTarget, resolveAllTargets, } from "../../lib/resolve-target.js"; @@ -34,8 +39,6 @@ import type { } from "../../types/index.js"; type ListFlags = { - readonly org?: string; - readonly project?: string; readonly query?: string; readonly limit: number; readonly sort: "date" | "new" | "freq" | "user"; @@ -47,7 +50,7 @@ type SortValue = "date" | "new" | "freq" | "user"; const VALID_SORT_VALUES: SortValue[] = ["date", "new", "freq", "user"]; /** Usage hint for ContextError messages */ -const USAGE_HINT = "sentry issue list --org --project "; +const USAGE_HINT = "sentry issue list /"; /** Error type classification for fetch failures */ type FetchErrorType = "permission" | "network" | "unknown"; @@ -147,7 +150,7 @@ type AliasMapResult = { * frontend, functions, backend → fr, fu, b * * Cross-org collision example: - * org1:dashboard, org2:dashboard → o1:d, o2:d + * org1/dashboard, org2/dashboard → o1/d, o2/d */ function buildProjectAliasMap(results: IssueListResult[]): AliasMapResult { const entries: Record = {}; @@ -161,7 +164,7 @@ function buildProjectAliasMap(results: IssueListResult[]): AliasMapResult { // Build entries record for storage for (const result of results) { - const key = `${result.target.org}:${result.target.project}`; + const key = `${result.target.org}/${result.target.project}`; const alias = aliasMap.get(key); if (alias) { entries[alias] = { @@ -188,7 +191,7 @@ function attachFormatOptions( ): IssueWithOptions[] { return results.flatMap((result) => result.issues.map((issue) => { - const key = `${result.target.org}:${result.target.project}`; + const key = `${result.target.org}/${result.target.project}`; const alias = aliasMap.get(key); return { issue, @@ -240,6 +243,107 @@ type FetchResult = | { success: true; data: IssueListResult } | { success: false; errorType: FetchErrorType }; +/** Result of resolving targets from parsed argument */ +type TargetResolutionResult = { + targets: ResolvedTarget[]; + footer?: string; + skippedSelfHosted?: number; + detectedDsns?: import("../../lib/dsn/index.js").DetectedDsn[]; +}; + +/** + * Resolve targets based on parsed org/project argument. + * + * Handles all four cases: + * - auto-detect: Use DSN detection / config defaults + * - explicit: Single org/project target + * - org-all: All projects in specified org + * - project-search: Find project across all orgs + */ +async function resolveTargetsFromParsedArg( + parsed: ReturnType, + cwd: string +): Promise { + switch (parsed.type) { + case "auto-detect": + // Use existing resolution logic (DSN detection, config defaults) + return resolveAllTargets({ cwd, usageHint: USAGE_HINT }); + + case "explicit": + // Single explicit target + return { + targets: [ + { + org: parsed.org, + project: parsed.project, + orgDisplay: parsed.org, + projectDisplay: parsed.project, + }, + ], + }; + + case "org-all": { + // List all projects in the specified org + const projects = await listProjects(parsed.org); + const targets: ResolvedTarget[] = projects.map((p) => ({ + org: parsed.org, + project: p.slug, + orgDisplay: parsed.org, + projectDisplay: p.name, + })); + + if (targets.length === 0) { + throw new ContextError( + "Projects", + `No projects found in organization '${parsed.org}'.` + ); + } + + return { + targets, + footer: + targets.length > 1 + ? `Showing issues from ${targets.length} projects in ${parsed.org}` + : undefined, + }; + } + + case "project-search": { + // Find project across all orgs + const matches = await findProjectsBySlug(parsed.projectSlug); + + if (matches.length === 0) { + throw new ContextError( + "Project", + `No project '${parsed.projectSlug}' found in any accessible organization.\n\n` + + `Try: sentry issue list /${parsed.projectSlug}` + ); + } + + const targets: ResolvedTarget[] = matches.map((m) => ({ + org: m.orgSlug, + project: m.slug, + orgDisplay: m.orgSlug, + projectDisplay: m.name, + })); + + return { + targets, + footer: + matches.length > 1 + ? `Found '${parsed.projectSlug}' in ${matches.length} organizations` + : undefined, + }; + } + + default: { + // TypeScript exhaustiveness check - this should never be reached + const _exhaustiveCheck: never = parsed; + throw new Error(`Unexpected parsed type: ${_exhaustiveCheck}`); + } + } +} + /** * Fetch issues for a single target project. * @@ -280,24 +384,27 @@ export const listCommand = buildCommand({ docs: { brief: "List issues in a project", fullDescription: - "List issues from Sentry projects. Use --org and --project to specify " + - "the target, or set defaults with 'sentry config set'.\n\n" + + "List issues from Sentry projects.\n\n" + + "Target specification:\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 # find project across all orgs\n\n" + "In monorepos with multiple Sentry projects, shows issues from all detected projects.", }, parameters: { + positional: { + kind: "tuple", + parameters: [ + { + placeholder: "target", + brief: "Target: /, /, or ", + parse: String, + optional: true, + }, + ], + }, flags: { - org: { - kind: "parsed", - parse: String, - brief: "Organization slug", - optional: true, - }, - project: { - kind: "parsed", - parse: String, - brief: "Project slug", - optional: true, - }, query: { kind: "parsed", parse: String, @@ -323,19 +430,22 @@ export const listCommand = buildCommand({ default: false, }, }, + aliases: { q: "query", s: "sort", n: "limit" }, }, // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: command entry point with inherent complexity - async func(this: SentryContext, flags: ListFlags): Promise { + async func( + this: SentryContext, + flags: ListFlags, + target?: string + ): Promise { const { stdout, cwd, setContext } = this; - // Resolve targets (may find multiple in monorepos) + // Parse positional argument to determine resolution strategy + const parsed = parseOrgProjectArg(target); + + // Resolve targets based on parsed argument type const { targets, footer, skippedSelfHosted, detectedDsns } = - await resolveAllTargets({ - org: flags.org, - project: flags.project, - cwd, - usageHint: USAGE_HINT, - }); + await resolveTargetsFromParsedArg(parsed, cwd); // Set telemetry context with unique orgs and projects const orgs = [...new Set(targets.map((t) => t.org))]; @@ -348,7 +458,7 @@ export const listCommand = buildCommand({ "Organization and project", `${USAGE_HINT}\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 you can specify the target explicitly." ); } throw new ContextError("Organization and project", USAGE_HINT); @@ -356,8 +466,8 @@ export const listCommand = buildCommand({ // Fetch issues from all targets in parallel const results = await Promise.all( - targets.map((target) => - fetchIssuesForTarget(target, { + targets.map((t) => + fetchIssuesForTarget(t, { query: flags.query, limit: flags.limit, sort: flags.sort, diff --git a/src/commands/issue/utils.ts b/src/commands/issue/utils.ts index 1c1cd107..fc7e3262 100644 --- a/src/commands/issue/utils.ts +++ b/src/commands/issue/utils.ts @@ -52,6 +52,7 @@ export const issueIdPositional = { kind: "tuple", parameters: [ { + placeholder: "issue-id", brief: "Issue ID, short ID, suffix, or alias-suffix (e.g., 123456, CRAFT-G, G, or f-g)", parse: String, diff --git a/src/commands/org/view.ts b/src/commands/org/view.ts index 89a49592..d1117dff 100644 --- a/src/commands/org/view.ts +++ b/src/commands/org/view.ts @@ -33,6 +33,7 @@ export const viewCommand = buildCommand({ kind: "tuple", parameters: [ { + placeholder: "org", brief: "Organization slug (optional if auto-detected)", parse: String, optional: true, diff --git a/src/commands/project/list.ts b/src/commands/project/list.ts index 84ccefbd..39aef7bc 100644 --- a/src/commands/project/list.ts +++ b/src/commands/project/list.ts @@ -19,7 +19,6 @@ import { resolveAllTargets } from "../../lib/resolve-target.js"; import type { SentryProject, Writer } from "../../types/index.js"; type ListFlags = { - readonly org?: string; readonly limit: number; readonly json: boolean; readonly platform?: string; @@ -197,20 +196,25 @@ export const listCommand = buildCommand({ "List projects in an organization. If no organization is specified, " + "uses the default organization or lists projects from all accessible organizations.\n\n" + "Examples:\n" + - " sentry project list\n" + - " sentry project list --org my-org\n" + + " sentry project list # auto-detect or list all\n" + + " sentry project list my-org # list projects in my-org\n" + " sentry project list --limit 10\n" + " sentry project list --json\n" + " sentry project list --platform javascript", }, parameters: { + positional: { + kind: "tuple", + parameters: [ + { + placeholder: "org", + brief: "Organization slug (optional)", + parse: String, + optional: true, + }, + ], + }, flags: { - org: { - kind: "parsed", - parse: String, - brief: "Organization slug", - optional: true, - }, limit: { kind: "parsed", parse: numberParser, @@ -230,8 +234,13 @@ export const listCommand = buildCommand({ optional: true, }, }, + aliases: { n: "limit", p: "platform" }, }, - async func(this: SentryContext, flags: ListFlags): Promise { + async func( + this: SentryContext, + flags: ListFlags, + org?: string + ): Promise { const { stdout, cwd } = this; // Resolve which organizations to fetch from @@ -239,14 +248,12 @@ export const listCommand = buildCommand({ orgs: orgsToFetch, footer, skippedSelfHosted, - } = await resolveOrgsToFetch(flags.org, cwd); + } = await resolveOrgsToFetch(org, cwd); // Fetch projects from all orgs (or all accessible if none detected) let allProjects: ProjectWithOrg[]; if (orgsToFetch.length > 0) { - const results = await Promise.all( - orgsToFetch.map((org) => fetchOrgProjectsSafe(org)) - ); + const results = await Promise.all(orgsToFetch.map(fetchOrgProjectsSafe)); allProjects = results.flat(); } else { allProjects = await fetchAllOrgProjects(); @@ -296,7 +303,7 @@ export const listCommand = buildCommand({ if (skippedSelfHosted) { stdout.write( `\nNote: ${skippedSelfHosted} DSN(s) could not be resolved. ` + - "Use --org to specify organization explicitly.\n" + "Specify the organization explicitly: sentry project list \n" ); } diff --git a/src/commands/project/view.ts b/src/commands/project/view.ts index 12ecf5fa..90670d07 100644 --- a/src/commands/project/view.ts +++ b/src/commands/project/view.ts @@ -207,6 +207,7 @@ export const viewCommand = buildCommand({ kind: "tuple", parameters: [ { + placeholder: "project", brief: "Project slug (optional if auto-detected)", parse: String, optional: true, diff --git a/src/lib/alias.ts b/src/lib/alias.ts index 20f74306..3cd77d23 100644 --- a/src/lib/alias.ts +++ b/src/lib/alias.ts @@ -118,7 +118,7 @@ export type OrgProjectPair = { /** Result of org-aware alias generation */ export type OrgAwareAliasResult = { - /** Map from "org:project" key to alias string */ + /** Map from "org/project" key to alias string */ aliasMap: Map; }; @@ -178,7 +178,7 @@ function processUniqueSlugs( const remainder = slugToRemainder.get(project) ?? project; const alias = uniquePrefixes.get(remainder) ?? remainder.charAt(0).toLowerCase(); - aliasMap.set(`${org}:${project}`, alias); + aliasMap.set(`${org}/${project}`, alias); } } @@ -216,7 +216,7 @@ function processCollidingSlugs( for (const org of orgs) { const orgPrefix = orgPrefixes.get(org) ?? org.charAt(0).toLowerCase(); - aliasMap.set(`${org}:${slug}`, `${orgPrefix}:${projectPrefix}`); + aliasMap.set(`${org}/${slug}`, `${orgPrefix}/${projectPrefix}`); } } } @@ -225,13 +225,13 @@ function processCollidingSlugs( * Build aliases for org/project pairs, handling cross-org slug collisions. * * - Unique project slugs → shortest unique prefix of project slug - * - Colliding slugs (same project in multiple orgs) → "{orgPrefix}:{projectPrefix}" + * - Colliding slugs (same project in multiple orgs) → "{orgPrefix}/{projectPrefix}" * * Common word prefixes (like "spotlight-" in "spotlight-electron") are stripped * before computing project prefixes to keep aliases short. * * @param pairs - Array of org/project pairs to generate aliases for - * @returns Map from "org:project" key to alias string + * @returns Map from "org/project" key to alias string * * @example * // No collision - same as existing behavior @@ -239,7 +239,7 @@ function processCollidingSlugs( * { org: "acme", project: "frontend" }, * { org: "acme", project: "backend" } * ]) - * // { aliasMap: Map { "acme:frontend" => "f", "acme:backend" => "b" } } + * // { aliasMap: Map { "acme/frontend" => "f", "acme/backend" => "b" } } * * @example * // Collision: same project slug in different orgs @@ -247,7 +247,7 @@ function processCollidingSlugs( * { org: "org1", project: "dashboard" }, * { org: "org2", project: "dashboard" } * ]) - * // { aliasMap: Map { "org1:dashboard" => "o1:d", "org2:dashboard" => "o2:d" } } + * // { aliasMap: Map { "org1/dashboard" => "o1/d", "org2/dashboard" => "o2/d" } } */ export function buildOrgAwareAliases( pairs: OrgProjectPair[] diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index 0b0129a3..a0fc0fb0 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -572,6 +572,50 @@ export function listProjects(orgSlug: string): Promise { ); } +/** Project with its organization context */ +export type ProjectWithOrg = SentryProject & { + /** Organization slug the project belongs to */ + orgSlug: string; +}; + +/** + * Search for projects matching a slug across all accessible organizations. + * + * Used for `sentry issue list ` when no org is specified. + * Searches all orgs the user has access to and returns matches. + * + * @param projectSlug - Project slug to search for (exact match) + * @returns Array of matching projects with their org context + */ +export async function findProjectsBySlug( + projectSlug: string +): Promise { + const orgs = await listOrganizations(); + + // Search in parallel for performance + const searchResults = await Promise.all( + orgs.map(async (org) => { + try { + const projects = await listProjects(org.slug); + const match = projects.find((p) => p.slug === projectSlug); + if (match) { + return { ...match, orgSlug: org.slug }; + } + return null; + } catch (error) { + // Re-throw auth errors - user needs to login + if (error instanceof AuthError) { + throw error; + } + // Skip orgs where user lacks access (permission errors, etc.) + return null; + } + }) + ); + + return searchResults.filter((r): r is ProjectWithOrg => r !== null); +} + /** * Find a project by DSN public key. * diff --git a/src/lib/resolve-target.ts b/src/lib/resolve-target.ts index 7d1827de..e82caba2 100644 --- a/src/lib/resolve-target.ts +++ b/src/lib/resolve-target.ts @@ -27,10 +27,6 @@ import { } from "./dsn/index.js"; import { AuthError, ContextError } from "./errors.js"; -// ───────────────────────────────────────────────────────────────────────────── -// Types -// ───────────────────────────────────────────────────────────────────────────── - /** * Resolved organization and project target for API calls. */ @@ -97,10 +93,6 @@ export type ResolveOrgOptions = { cwd: string; }; -// ───────────────────────────────────────────────────────────────────────────── -// DSN-based Resolution -// ───────────────────────────────────────────────────────────────────────────── - /** * Resolve organization and project from DSN detection. * Uses cached project info when available, otherwise fetches and caches it. @@ -439,10 +431,6 @@ export async function resolveAllTargets( }; } -// ───────────────────────────────────────────────────────────────────────────── -// Full Resolution Chain -// ───────────────────────────────────────────────────────────────────────────── - /** * Resolve organization and project from multiple sources. * @@ -532,3 +520,82 @@ export async function resolveOrg( return null; } } + +/** + * Discriminated union type values for `ParsedOrgProject`. + * Use these constants instead of string literals for type safety. + */ +export const ProjectSpecificationType = { + /** Explicit org/project provided (e.g., "sentry/cli") */ + Explicit: "explicit", + /** Org with trailing slash for all projects (e.g., "sentry/") */ + OrgAll: "org-all", + /** Project slug only, search across all orgs (e.g., "cli") */ + ProjectSearch: "project-search", + /** No input, auto-detect from DSN/config */ + AutoDetect: "auto-detect", +} as const; + +/** + * Parsed result from an org/project positional argument. + * Discriminated union based on the `type` field. + */ +export type ParsedOrgProject = + | { + type: typeof ProjectSpecificationType.Explicit; + org: string; + project: string; + } + | { type: typeof ProjectSpecificationType.OrgAll; org: string } + | { type: typeof ProjectSpecificationType.ProjectSearch; projectSlug: string } + | { type: typeof ProjectSpecificationType.AutoDetect }; + +/** + * Parse an org/project positional argument string. + * + * Supports the following patterns: + * - `undefined` or empty → auto-detect from DSN/config + * - `sentry/cli` → explicit org and project + * - `sentry/` → org with all projects + * - `/cli` → search for project across all orgs (leading slash) + * - `cli` → search for project across all orgs + * + * @param arg - Input string from CLI positional argument + * @returns Parsed result with type discrimination + * + * @example + * parseOrgProjectArg(undefined) // { type: "auto-detect" } + * parseOrgProjectArg("sentry/cli") // { type: "explicit", org: "sentry", project: "cli" } + * parseOrgProjectArg("sentry/") // { type: "org-all", org: "sentry" } + * parseOrgProjectArg("/cli") // { type: "project-search", projectSlug: "cli" } + * parseOrgProjectArg("cli") // { type: "project-search", projectSlug: "cli" } + */ +export function parseOrgProjectArg(arg: string | undefined): ParsedOrgProject { + if (!arg || arg.trim() === "") { + return { type: "auto-detect" }; + } + + const trimmed = arg.trim(); + + if (trimmed.includes("/")) { + const slashIndex = trimmed.indexOf("/"); + const org = trimmed.slice(0, slashIndex); + const project = trimmed.slice(slashIndex + 1); + + if (!org) { + // "/cli" → search for project across all orgs + return { type: "project-search", projectSlug: project }; + } + + if (!project) { + // "sentry/" → list all projects in org + return { type: "org-all", org }; + } + + // "sentry/cli" → explicit org and project + return { type: "explicit", org, project }; + } + + // No slash → search for project across all orgs + return { type: "project-search", projectSlug: trimmed }; +} diff --git a/test/commands/issue/utils.test.ts b/test/commands/issue/utils.test.ts index 1e2b2857..a5c585ce 100644 --- a/test/commands/issue/utils.test.ts +++ b/test/commands/issue/utils.test.ts @@ -207,14 +207,14 @@ describe("resolveOrgAndIssueId", () => { expect(result.issueId).toBe("111222333"); }); - test("resolves org-aware alias format (e.g., 'o1:d-4y') for cross-org collisions", async () => { + test("resolves org-aware alias format (e.g., 'o1/d-4y') for cross-org collisions", async () => { const { setProjectAliases } = await import( "../../../src/lib/db/project-aliases.js" ); await setProjectAliases( { - "o1:d": { orgSlug: "org1", projectSlug: "dashboard" }, - "o2:d": { orgSlug: "org2", projectSlug: "dashboard" }, + "o1/d": { orgSlug: "org1", projectSlug: "dashboard" }, + "o2/d": { orgSlug: "org2", projectSlug: "dashboard" }, }, "" ); @@ -248,9 +248,9 @@ describe("resolveOrgAndIssueId", () => { }; const result = await resolveOrgAndIssueId({ - issueId: "o1:d-4y", + issueId: "o1/d-4y", cwd: testConfigDir, - commandHint: "sentry issue explain o1:d-4y", + commandHint: "sentry issue explain o1/d-4y", }); expect(result.org).toBe("org1"); diff --git a/test/e2e/issue.test.ts b/test/e2e/issue.test.ts index 8f641e68..b533aa4a 100644 --- a/test/e2e/issue.test.ts +++ b/test/e2e/issue.test.ts @@ -50,26 +50,20 @@ describe("sentry issue list", () => { const result = await ctx.run([ "issue", "list", - "--org", - "test-org", - "--project", - "test-project", + `${TEST_ORG}/${TEST_PROJECT}`, ]); expect(result.exitCode).toBe(1); expect(result.stderr + result.stdout).toMatch(/not authenticated|login/i); }); - test("lists issues with valid auth", async () => { + test("lists issues with valid auth using positional arg", async () => { await ctx.setAuthToken(TEST_TOKEN); const result = await ctx.run([ "issue", "list", - "--org", - TEST_ORG, - "--project", - TEST_PROJECT, + `${TEST_ORG}/${TEST_PROJECT}`, ]); // Should succeed (may have 0 issues, that's fine) @@ -82,10 +76,7 @@ describe("sentry issue list", () => { const result = await ctx.run([ "issue", "list", - "--org", - TEST_ORG, - "--project", - TEST_PROJECT, + `${TEST_ORG}/${TEST_PROJECT}`, "--json", ]); @@ -94,6 +85,32 @@ describe("sentry issue list", () => { const data = JSON.parse(result.stdout); expect(Array.isArray(data)).toBe(true); }); + + test("lists all projects in org with trailing slash", async () => { + await ctx.setAuthToken(TEST_TOKEN); + + const result = await ctx.run(["issue", "list", `${TEST_ORG}/`, "--json"]); + + expect(result.exitCode).toBe(0); + // Should be valid JSON array (issues from all projects in org) + const data = JSON.parse(result.stdout); + expect(Array.isArray(data)).toBe(true); + }); + + test("searches for project across orgs with project-only arg", async () => { + await ctx.setAuthToken(TEST_TOKEN); + + const result = await ctx.run(["issue", "list", TEST_PROJECT, "--json"]); + + // Should succeed if project exists in any accessible org + // or fail with a "not found" error if not + if (result.exitCode === 0) { + const data = JSON.parse(result.stdout); + expect(Array.isArray(data)).toBe(true); + } else { + expect(result.stderr + result.stdout).toMatch(/not found|no project/i); + } + }); }); describe("sentry issue view", () => { diff --git a/test/e2e/multiregion.test.ts b/test/e2e/multiregion.test.ts index 888b95ed..7ee701a6 100644 --- a/test/e2e/multiregion.test.ts +++ b/test/e2e/multiregion.test.ts @@ -163,7 +163,7 @@ describe("multi-region", () => { // First list orgs to populate region cache await ctx.run(["org", "list"]); - const result = await ctx.run(["project", "list", "--org", "acme-corp"]); + const result = await ctx.run(["project", "list", "acme-corp"]); expect(result.exitCode).toBe(0); // Should contain US projects for acme-corp @@ -182,7 +182,7 @@ describe("multi-region", () => { // First list orgs to populate region cache await ctx.run(["org", "list"]); - const result = await ctx.run(["project", "list", "--org", "euro-gmbh"]); + const result = await ctx.run(["project", "list", "euro-gmbh"]); expect(result.exitCode).toBe(0); // Should contain EU projects for euro-gmbh @@ -204,7 +204,6 @@ describe("multi-region", () => { const result = await ctx.run([ "project", "list", - "--org", "berlin-startup", "--json", ]); @@ -234,10 +233,7 @@ describe("multi-region", () => { const result = await ctx.run([ "issue", "list", - "--org", - "acme-corp", - "--project", - "acme-frontend", + "acme-corp/acme-frontend", ]); expect(result.exitCode).toBe(0); @@ -258,10 +254,7 @@ describe("multi-region", () => { const result = await ctx.run([ "issue", "list", - "--org", - "euro-gmbh", - "--project", - "euro-portal", + "euro-gmbh/euro-portal", ]); expect(result.exitCode).toBe(0); @@ -282,10 +275,7 @@ describe("multi-region", () => { const result = await ctx.run([ "issue", "list", - "--org", - "berlin-startup", - "--project", - "berlin-app", + "berlin-startup/berlin-app", "--json", ]); diff --git a/test/e2e/project.test.ts b/test/e2e/project.test.ts index 8c58b1a0..68eabe4d 100644 --- a/test/e2e/project.test.ts +++ b/test/e2e/project.test.ts @@ -93,15 +93,14 @@ describe("sentry project list", () => { }); test( - "lists projects with valid auth and org filter", + "lists projects with valid auth using positional org arg", async () => { await ctx.setAuthToken(TEST_TOKEN); - // Use --org flag to filter by organization + // Use positional argument for organization const result = await ctx.run([ "project", "list", - "--org", TEST_ORG, "--limit", "5", @@ -117,11 +116,10 @@ describe("sentry project list", () => { async () => { await ctx.setAuthToken(TEST_TOKEN); - // Use --org flag to filter by organization + // Use positional argument for organization const result = await ctx.run([ "project", "list", - "--org", TEST_ORG, "--json", "--limit", diff --git a/test/lib/alias.test.ts b/test/lib/alias.test.ts index 840ad014..2b134fce 100644 --- a/test/lib/alias.test.ts +++ b/test/lib/alias.test.ts @@ -167,8 +167,8 @@ describe("buildOrgAwareAliases", () => { { org: "acme", project: "frontend" }, { org: "acme", project: "backend" }, ]); - expect(result.aliasMap.get("acme:frontend")).toBe("f"); - expect(result.aliasMap.get("acme:backend")).toBe("b"); + expect(result.aliasMap.get("acme/frontend")).toBe("f"); + expect(result.aliasMap.get("acme/backend")).toBe("b"); }); test("multiple orgs with unique project slugs - no collision", () => { @@ -176,8 +176,8 @@ describe("buildOrgAwareAliases", () => { { org: "org1", project: "frontend" }, { org: "org2", project: "backend" }, ]); - expect(result.aliasMap.get("org1:frontend")).toBe("f"); - expect(result.aliasMap.get("org2:backend")).toBe("b"); + expect(result.aliasMap.get("org1/frontend")).toBe("f"); + expect(result.aliasMap.get("org2/backend")).toBe("b"); }); test("same project slug in different orgs - collision", () => { @@ -186,19 +186,19 @@ describe("buildOrgAwareAliases", () => { { org: "org2", project: "dashboard" }, ]); - const alias1 = result.aliasMap.get("org1:dashboard"); - const alias2 = result.aliasMap.get("org2:dashboard"); + const alias1 = result.aliasMap.get("org1/dashboard"); + const alias2 = result.aliasMap.get("org2/dashboard"); - // Both should have org-prefixed format with colon - expect(alias1).toContain(":"); - expect(alias2).toContain(":"); + // Both should have org-prefixed format with slash + expect(alias1).toContain("/"); + expect(alias2).toContain("/"); // Must be different aliases expect(alias1).not.toBe(alias2); - // Should follow pattern: orgPrefix:projectPrefix - expect(alias1).toMatch(/^o.*:d$/); - expect(alias2).toMatch(/^o.*:d$/); + // Should follow pattern: orgPrefix/projectPrefix + expect(alias1).toMatch(/^o.*\/d$/); + expect(alias2).toMatch(/^o.*\/d$/); }); test("collision with distinct org names", () => { @@ -207,12 +207,12 @@ describe("buildOrgAwareAliases", () => { { org: "bigco", project: "api" }, ]); - const alias1 = result.aliasMap.get("acme-corp:api"); - const alias2 = result.aliasMap.get("bigco:api"); + const alias1 = result.aliasMap.get("acme-corp/api"); + const alias2 = result.aliasMap.get("bigco/api"); // Org prefixes should be unique: "a" vs "b" - expect(alias1).toBe("a:a"); - expect(alias2).toBe("b:a"); + expect(alias1).toBe("a/a"); + expect(alias2).toBe("b/a"); }); test("mixed - some colliding, some unique project slugs", () => { @@ -222,15 +222,15 @@ describe("buildOrgAwareAliases", () => { { org: "org1", project: "backend" }, ]); - // dashboard collides → org-prefixed aliases with colon - const dashAlias1 = result.aliasMap.get("org1:dashboard"); - const dashAlias2 = result.aliasMap.get("org2:dashboard"); - expect(dashAlias1).toContain(":"); - expect(dashAlias2).toContain(":"); + // dashboard collides → org-prefixed aliases with slash + const dashAlias1 = result.aliasMap.get("org1/dashboard"); + const dashAlias2 = result.aliasMap.get("org2/dashboard"); + expect(dashAlias1).toContain("/"); + expect(dashAlias2).toContain("/"); expect(dashAlias1).not.toBe(dashAlias2); // backend is unique → simple alias - const backendAlias = result.aliasMap.get("org1:backend"); + const backendAlias = result.aliasMap.get("org1/backend"); expect(backendAlias).toBe("b"); }); @@ -240,13 +240,13 @@ describe("buildOrgAwareAliases", () => { { org: "acme", project: "spotlight-website" }, ]); // Common prefix "spotlight-" is stripped internally, resulting in short aliases - expect(result.aliasMap.get("acme:spotlight-electron")).toBe("e"); - expect(result.aliasMap.get("acme:spotlight-website")).toBe("w"); + expect(result.aliasMap.get("acme/spotlight-electron")).toBe("e"); + expect(result.aliasMap.get("acme/spotlight-website")).toBe("w"); }); test("handles single project", () => { const result = buildOrgAwareAliases([{ org: "acme", project: "frontend" }]); - expect(result.aliasMap.get("acme:frontend")).toBe("f"); + expect(result.aliasMap.get("acme/frontend")).toBe("f"); }); test("collision with similar org names uses longer prefixes", () => { @@ -255,14 +255,14 @@ describe("buildOrgAwareAliases", () => { { org: "organization2", project: "app" }, ]); - const alias1 = result.aliasMap.get("organization1:app"); - const alias2 = result.aliasMap.get("organization2:app"); + const alias1 = result.aliasMap.get("organization1/app"); + const alias2 = result.aliasMap.get("organization2/app"); // Both orgs start with "organization", so prefixes need to be longer expect(alias1).not.toBe(alias2); // Should include enough of the org to be unique - expect(alias1).toMatch(/:a$/); // ends with project prefix - expect(alias2).toMatch(/:a$/); + expect(alias1).toMatch(/\/a$/); // ends with project prefix + expect(alias2).toMatch(/\/a$/); }); test("multiple collisions across same orgs", () => { @@ -273,11 +273,11 @@ describe("buildOrgAwareAliases", () => { { org: "org2", project: "web" }, ]); - // All four should have org-prefixed aliases with colon - expect(result.aliasMap.get("org1:api")).toContain(":"); - expect(result.aliasMap.get("org2:api")).toContain(":"); - expect(result.aliasMap.get("org1:web")).toContain(":"); - expect(result.aliasMap.get("org2:web")).toContain(":"); + // All four should have org-prefixed aliases with slash + expect(result.aliasMap.get("org1/api")).toContain("/"); + expect(result.aliasMap.get("org2/api")).toContain("/"); + expect(result.aliasMap.get("org1/web")).toContain("/"); + expect(result.aliasMap.get("org2/web")).toContain("/"); // All should be unique const aliases = [...result.aliasMap.values()]; @@ -300,13 +300,13 @@ describe("buildOrgAwareAliases", () => { expect(uniqueAliases.size).toBe(4); // api and app should have different project prefixes (not both "a") - const org1Api = result.aliasMap.get("org1:api"); - const org1App = result.aliasMap.get("org1:app"); + const org1Api = result.aliasMap.get("org1/api"); + const org1App = result.aliasMap.get("org1/app"); expect(org1Api).not.toBe(org1App); // Project prefixes should distinguish api vs app - // e.g., "o1:api" vs "o1:app" - expect(org1Api).toMatch(/^o.*:api$/); - expect(org1App).toMatch(/^o.*:app$/); + // e.g., "o1/api" vs "o1/app" + expect(org1Api).toMatch(/^o.*\/api$/); + expect(org1App).toMatch(/^o.*\/app$/); }); }); diff --git a/test/lib/api-client.test.ts b/test/lib/api-client.test.ts index 5a51ab91..18838a09 100644 --- a/test/lib/api-client.test.ts +++ b/test/lib/api-client.test.ts @@ -1,7 +1,7 @@ /** * API Client Tests * - * Tests for the Sentry API client 401 retry behavior. + * Tests for the Sentry API client 401 retry behavior and utility functions. * Uses manual fetch mocking to avoid polluting the module cache. */ @@ -564,3 +564,176 @@ describe("rawApiRequest", () => { expect(result.headers.get("X-Request-Id")).toBe("abc123"); }); }); + +describe("findProjectsBySlug", () => { + test("returns matching projects from multiple orgs", async () => { + // Import dynamically inside test to allow mocking + const { findProjectsBySlug } = await import("../../src/lib/api-client.js"); + const requests: Request[] = []; + + // Mock the regions endpoint first, then org/project requests + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + requests.push(req); + const url = req.url; + + // Regions endpoint - return single region to simplify test + if (url.includes("/users/me/regions/")) { + return new Response( + JSON.stringify({ + regions: [{ name: "us", url: "https://us.sentry.io" }], + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + + // Organizations list + if (url.includes("/organizations/") && !url.includes("/projects/")) { + return new Response( + JSON.stringify([ + { id: "1", slug: "acme", name: "Acme Corp" }, + { id: "2", slug: "beta", name: "Beta Inc" }, + ]), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + + // Projects for acme org - has matching project + if (url.includes("/organizations/acme/projects/")) { + return new Response( + JSON.stringify([ + { id: "101", slug: "frontend", name: "Frontend" }, + { id: "102", slug: "backend", name: "Backend" }, + ]), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + + // Projects for beta org - also has matching project + if (url.includes("/organizations/beta/projects/")) { + return new Response( + JSON.stringify([ + { id: "201", slug: "frontend", name: "Beta Frontend" }, + { id: "202", slug: "api", name: "API" }, + ]), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + + // Default response + return new Response(JSON.stringify([]), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }; + + const results = await findProjectsBySlug("frontend"); + + expect(results).toHaveLength(2); + expect(results[0].slug).toBe("frontend"); + expect(results[0].orgSlug).toBe("acme"); + expect(results[1].slug).toBe("frontend"); + expect(results[1].orgSlug).toBe("beta"); + }); + + test("returns empty array when no projects match", async () => { + const { findProjectsBySlug } = await import("../../src/lib/api-client.js"); + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + const url = req.url; + + // Regions endpoint + if (url.includes("/users/me/regions/")) { + return new Response( + JSON.stringify({ + regions: [{ name: "us", url: "https://us.sentry.io" }], + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + + // Organizations list + if (url.includes("/organizations/") && !url.includes("/projects/")) { + return new Response( + JSON.stringify([{ id: "1", slug: "acme", name: "Acme Corp" }]), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + + // Projects - no match + if (url.includes("/organizations/acme/projects/")) { + return new Response( + JSON.stringify([{ id: "101", slug: "backend", name: "Backend" }]), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + + return new Response(JSON.stringify([]), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }; + + const results = await findProjectsBySlug("nonexistent"); + + expect(results).toHaveLength(0); + }); + + test("skips orgs where user lacks access (403)", async () => { + const { findProjectsBySlug } = await import("../../src/lib/api-client.js"); + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + const url = req.url; + + // Regions endpoint + if (url.includes("/users/me/regions/")) { + return new Response( + JSON.stringify({ + regions: [{ name: "us", url: "https://us.sentry.io" }], + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + + // Organizations list + if (url.includes("/organizations/") && !url.includes("/projects/")) { + return new Response( + JSON.stringify([ + { id: "1", slug: "acme", name: "Acme Corp" }, + { id: "2", slug: "restricted", name: "Restricted Org" }, + ]), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + + // Projects for acme - success + if (url.includes("/organizations/acme/projects/")) { + return new Response( + JSON.stringify([{ id: "101", slug: "frontend", name: "Frontend" }]), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + + // Projects for restricted org - 403 forbidden + if (url.includes("/organizations/restricted/projects/")) { + return new Response(JSON.stringify({ detail: "Forbidden" }), { + status: 403, + headers: { "Content-Type": "application/json" }, + }); + } + + return new Response(JSON.stringify([]), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }; + + // Should not throw, should just skip the restricted org + const results = await findProjectsBySlug("frontend"); + + expect(results).toHaveLength(1); + expect(results[0].orgSlug).toBe("acme"); + }); +}); diff --git a/test/lib/resolve-target.test.ts b/test/lib/resolve-target.test.ts new file mode 100644 index 00000000..c1ad5d5c --- /dev/null +++ b/test/lib/resolve-target.test.ts @@ -0,0 +1,107 @@ +/** + * Tests for resolve-target utilities + */ + +import { describe, expect, test } from "bun:test"; +import { parseOrgProjectArg } from "../../src/lib/resolve-target.js"; + +describe("parseOrgProjectArg", () => { + test("returns auto-detect for undefined", () => { + const result = parseOrgProjectArg(undefined); + expect(result).toEqual({ type: "auto-detect" }); + }); + + test("returns auto-detect for empty string", () => { + const result = parseOrgProjectArg(""); + expect(result).toEqual({ type: "auto-detect" }); + }); + + test("returns auto-detect for whitespace-only string", () => { + const result = parseOrgProjectArg(" "); + expect(result).toEqual({ type: "auto-detect" }); + }); + + test("returns explicit for org/project pattern", () => { + const result = parseOrgProjectArg("sentry/cli"); + expect(result).toEqual({ + type: "explicit", + org: "sentry", + project: "cli", + }); + }); + + test("returns explicit with trimmed whitespace", () => { + const result = parseOrgProjectArg(" sentry/cli "); + expect(result).toEqual({ + type: "explicit", + org: "sentry", + project: "cli", + }); + }); + + test("returns org-all for org/ pattern (trailing slash)", () => { + const result = parseOrgProjectArg("sentry/"); + expect(result).toEqual({ + type: "org-all", + org: "sentry", + }); + }); + + test("returns org-all for org/ with whitespace", () => { + const result = parseOrgProjectArg(" my-org/ "); + expect(result).toEqual({ + type: "org-all", + org: "my-org", + }); + }); + + test("returns project-search for simple project name", () => { + const result = parseOrgProjectArg("cli"); + expect(result).toEqual({ + type: "project-search", + projectSlug: "cli", + }); + }); + + test("returns project-search for project name with hyphens", () => { + const result = parseOrgProjectArg("my-awesome-project"); + expect(result).toEqual({ + type: "project-search", + projectSlug: "my-awesome-project", + }); + }); + + test("returns project-search for /project pattern (leading slash)", () => { + // "/cli" → search for project across all orgs + const result = parseOrgProjectArg("/cli"); + expect(result).toEqual({ type: "project-search", projectSlug: "cli" }); + }); + + test("handles only first slash for patterns with multiple slashes", () => { + // This is an edge case - "org/proj/extra" should parse as org="org", project="proj/extra" + const result = parseOrgProjectArg("org/proj/extra"); + expect(result).toEqual({ + type: "explicit", + org: "org", + project: "proj/extra", + }); + }); + + test("handles numeric org and project names", () => { + const result = parseOrgProjectArg("123/456"); + expect(result).toEqual({ + type: "explicit", + org: "123", + project: "456", + }); + }); + + test("handles underscore in names", () => { + const result = parseOrgProjectArg("my_org/my_project"); + expect(result).toEqual({ + type: "explicit", + org: "my_org", + project: "my_project", + }); + }); +});