From 44c692c94ec168f2a57845d813af477c3353798c Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Mon, 2 Feb 2026 17:30:28 +0000 Subject: [PATCH 1/6] feat(commands): use positional args for org/project selection Replace --org and --project flags with GitHub-style positional arguments for issue list and project list commands. New syntax: - `sentry issue list /` - explicit target - `sentry issue list /` - all projects in org - `sentry issue list ` - find project across orgs - `sentry project list ` - list projects in org Also changes cross-org collision aliases from colon to slash separator (e.g., `o1/d` instead of `o1:d`) for consistency. BREAKING CHANGE: --org and --project flags removed from issue list and --org flag removed from project list commands. --- src/commands/issue/list.ts | 168 ++++++++++++++++++++++++------ src/commands/project/list.ts | 35 ++++--- src/lib/alias.ts | 6 +- src/lib/api-client.ts | 52 +++++++++ src/lib/resolve-target.ts | 62 +++++++++++ test/commands/issue/utils.test.ts | 10 +- test/lib/alias.test.ts | 42 ++++---- test/lib/resolve-target.test.ts | 107 +++++++++++++++++++ 8 files changed, 409 insertions(+), 73 deletions(-) create mode 100644 test/lib/resolve-target.test.ts diff --git a/src/commands/issue/list.ts b/src/commands/issue/list.ts index 70824ce0..8e5aaadb 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 = {}; @@ -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,26 @@ 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: [ + { + 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, @@ -325,17 +431,19 @@ export const listCommand = buildCommand({ }, }, // 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 +456,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 +464,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/project/list.ts b/src/commands/project/list.ts index 84ccefbd..d34b5885 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,24 @@ 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: [ + { + brief: "Organization slug (optional)", + parse: String, + optional: true, + }, + ], + }, flags: { - org: { - kind: "parsed", - parse: String, - brief: "Organization slug", - optional: true, - }, limit: { kind: "parsed", parse: numberParser, @@ -231,7 +234,11 @@ export const listCommand = buildCommand({ }, }, }, - 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,13 +246,13 @@ 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)) + orgsToFetch.map((o) => fetchOrgProjectsSafe(o)) ); allProjects = results.flat(); } else { @@ -296,13 +303,13 @@ 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" ); } writeFooter( stdout, - "Tip: Use 'sentry project view --org ' for details" + "Tip: Use 'sentry project view /' for details" ); }, }); diff --git a/src/lib/alias.ts b/src/lib/alias.ts index 20f74306..629c0820 100644 --- a/src/lib/alias.ts +++ b/src/lib/alias.ts @@ -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,7 +225,7 @@ 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. @@ -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..d2e5e2bb 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -572,6 +572,58 @@ 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(); + const results: ProjectWithOrg[] = []; + + // 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; + } + }) + ); + + // Filter out nulls + for (const result of searchResults) { + if (result) { + results.push(result); + } + } + + return results; +} + /** * Find a project by DSN public key. * diff --git a/src/lib/resolve-target.ts b/src/lib/resolve-target.ts index 7d1827de..4abd930e 100644 --- a/src/lib/resolve-target.ts +++ b/src/lib/resolve-target.ts @@ -532,3 +532,65 @@ export async function resolveOrg( return null; } } + +// ───────────────────────────────────────────────────────────────────────────── +// Positional Argument Parsing +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Parsed result from an org/project positional argument. + * Discriminated union based on the `type` field. + */ +export type ParsedOrgProject = + | { type: "explicit"; org: string; project: string } + | { type: "org-all"; org: string } + | { type: "project-search"; projectSlug: string } + | { type: "auto-detect" }; + +/** + * 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 + * + * @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" } + */ +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" is invalid - treat as auto-detect with warning + return { type: "auto-detect" }; + } + + 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/lib/alias.test.ts b/test/lib/alias.test.ts index 840ad014..c9f36b88 100644 --- a/test/lib/alias.test.ts +++ b/test/lib/alias.test.ts @@ -189,16 +189,16 @@ describe("buildOrgAwareAliases", () => { 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", () => { @@ -211,8 +211,8 @@ describe("buildOrgAwareAliases", () => { 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,11 +222,11 @@ describe("buildOrgAwareAliases", () => { { org: "org1", project: "backend" }, ]); - // dashboard collides → org-prefixed aliases with colon + // 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).toContain("/"); + expect(dashAlias2).toContain("/"); expect(dashAlias1).not.toBe(dashAlias2); // backend is unique → simple alias @@ -261,8 +261,8 @@ describe("buildOrgAwareAliases", () => { // 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()]; @@ -305,8 +305,8 @@ describe("buildOrgAwareAliases", () => { 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/resolve-target.test.ts b/test/lib/resolve-target.test.ts new file mode 100644 index 00000000..6c3e5d59 --- /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 auto-detect for /project pattern (no org)", () => { + // "/cli" is invalid - no org specified before the slash + const result = parseOrgProjectArg("/cli"); + expect(result).toEqual({ type: "auto-detect" }); + }); + + 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", + }); + }); +}); From 4b8e3eb6d5bcce1d072110900415ef25572277c1 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Mon, 2 Feb 2026 19:03:43 +0000 Subject: [PATCH 2/6] Address PR review comments - Fix JSDoc cross-org example notation (org1:dashboard -> org1/dashboard) - Add -n short-hand flag for --limit in project list - Simplify map callback in project list - Change internal alias map key notation from : to / - Simplify findProjectsBySlug filter pattern - Remove ASCII art section dividers from resolve-target.ts - Add ProjectSpecificationType constant for discriminated union - Change /cli handling from auto-detect to project-search - Add prohibited comment styles rule to AGENTS.md --- AGENTS.md | 3 ++ docs/src/content/docs/commands/issue.md | 53 ++++++++++++++++++++----- src/commands/issue/list.ts | 6 +-- src/commands/project/list.ts | 5 +-- src/lib/alias.ts | 12 +++--- src/lib/api-client.ts | 10 +---- src/lib/resolve-target.ts | 47 ++++++++++++---------- test/e2e/issue.test.ts | 43 ++++++++++++++------ test/e2e/project.test.ts | 8 ++-- test/lib/alias.test.ts | 44 ++++++++++---------- test/lib/api-client.test.ts | 6 ++- test/lib/resolve-target.test.ts | 6 +-- 12 files changed, 148 insertions(+), 95 deletions(-) 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..09f7b7d3 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,28 @@ 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 ``` ### `sentry issue view` diff --git a/src/commands/issue/list.ts b/src/commands/issue/list.ts index 8e5aaadb..96ac0d7e 100644 --- a/src/commands/issue/list.ts +++ b/src/commands/issue/list.ts @@ -150,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 = {}; @@ -164,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] = { @@ -191,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, diff --git a/src/commands/project/list.ts b/src/commands/project/list.ts index d34b5885..276298e3 100644 --- a/src/commands/project/list.ts +++ b/src/commands/project/list.ts @@ -233,6 +233,7 @@ export const listCommand = buildCommand({ optional: true, }, }, + aliases: { n: "limit" }, }, async func( this: SentryContext, @@ -251,9 +252,7 @@ export const listCommand = buildCommand({ // 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((o) => fetchOrgProjectsSafe(o)) - ); + const results = await Promise.all(orgsToFetch.map(fetchOrgProjectsSafe)); allProjects = results.flat(); } else { allProjects = await fetchAllOrgProjects(); diff --git a/src/lib/alias.ts b/src/lib/alias.ts index 629c0820..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}`); } } } @@ -231,7 +231,7 @@ function processCollidingSlugs( * 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 d2e5e2bb..a0fc0fb0 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -591,7 +591,6 @@ export async function findProjectsBySlug( projectSlug: string ): Promise { const orgs = await listOrganizations(); - const results: ProjectWithOrg[] = []; // Search in parallel for performance const searchResults = await Promise.all( @@ -614,14 +613,7 @@ export async function findProjectsBySlug( }) ); - // Filter out nulls - for (const result of searchResults) { - if (result) { - results.push(result); - } - } - - return results; + return searchResults.filter((r): r is ProjectWithOrg => r !== null); } /** diff --git a/src/lib/resolve-target.ts b/src/lib/resolve-target.ts index 4abd930e..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. * @@ -533,19 +521,34 @@ export async function resolveOrg( } } -// ───────────────────────────────────────────────────────────────────────────── -// Positional Argument Parsing -// ───────────────────────────────────────────────────────────────────────────── +/** + * 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: "explicit"; org: string; project: string } - | { type: "org-all"; org: string } - | { type: "project-search"; projectSlug: string } - | { type: "auto-detect" }; + | { + 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. @@ -554,6 +557,7 @@ export type ParsedOrgProject = * - `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 @@ -563,6 +567,7 @@ export type ParsedOrgProject = * 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 { @@ -578,8 +583,8 @@ export function parseOrgProjectArg(arg: string | undefined): ParsedOrgProject { const project = trimmed.slice(slashIndex + 1); if (!org) { - // "/cli" is invalid - treat as auto-detect with warning - return { type: "auto-detect" }; + // "/cli" → search for project across all orgs + return { type: "project-search", projectSlug: project }; } if (!project) { 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/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 c9f36b88..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,8 +186,8 @@ 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 slash expect(alias1).toContain("/"); @@ -207,8 +207,8 @@ 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"); @@ -223,14 +223,14 @@ describe("buildOrgAwareAliases", () => { ]); // dashboard collides → org-prefixed aliases with slash - const dashAlias1 = result.aliasMap.get("org1:dashboard"); - const dashAlias2 = result.aliasMap.get("org2:dashboard"); + 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,8 +255,8 @@ 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); @@ -274,10 +274,10 @@ describe("buildOrgAwareAliases", () => { ]); // 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("/"); + 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,8 +300,8 @@ 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 diff --git a/test/lib/api-client.test.ts b/test/lib/api-client.test.ts index 5a51ab91..45825900 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,7 @@ describe("rawApiRequest", () => { expect(result.headers.get("X-Request-Id")).toBe("abc123"); }); }); + +// Note: findProjectsBySlug() is tested via E2E tests in test/e2e/issue.test.ts +// since it requires complex multi-region API mocking that is better handled +// by the mock server infrastructure. diff --git a/test/lib/resolve-target.test.ts b/test/lib/resolve-target.test.ts index 6c3e5d59..c1ad5d5c 100644 --- a/test/lib/resolve-target.test.ts +++ b/test/lib/resolve-target.test.ts @@ -71,10 +71,10 @@ describe("parseOrgProjectArg", () => { }); }); - test("returns auto-detect for /project pattern (no org)", () => { - // "/cli" is invalid - no org specified before the slash + test("returns project-search for /project pattern (leading slash)", () => { + // "/cli" → search for project across all orgs const result = parseOrgProjectArg("/cli"); - expect(result).toEqual({ type: "auto-detect" }); + expect(result).toEqual({ type: "project-search", projectSlug: "cli" }); }); test("handles only first slash for patterns with multiple slashes", () => { From 3011567971e291d12885951d1398eb650db76983 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Mon, 2 Feb 2026 19:07:35 +0000 Subject: [PATCH 3/6] fix: update multiregion E2E tests to use positional args - Update project list tests to use positional org arg instead of --org flag - Update issue list tests to use org/project positional format - Regenerate SKILL.md --- plugins/sentry-cli/skills/sentry-cli/SKILL.md | 32 +++++++++++++------ test/e2e/multiregion.test.ts | 20 +++--------- 2 files changed, 28 insertions(+), 24 deletions(-) diff --git a/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index 0633a528..ed7f1a0b 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -132,13 +132,12 @@ 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)` @@ -178,13 +177,11 @@ 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")` @@ -193,11 +190,28 @@ List issues in a project **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 --org my-org --project frontend +sentry issue list my-org/frontend --query "TypeError" -sentry issue list --org my-org --project frontend --query "TypeError" +sentry issue list my-org/frontend --sort freq --limit 20 ``` #### `sentry issue explain ` 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", ]); From 0464bc96d7e1416bc16b5ab5fa9fa9477ac7a5f0 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Mon, 2 Feb 2026 20:32:38 +0000 Subject: [PATCH 4/6] fix: address remaining PR comments - Add placeholder properties to positional args for better help text - Add shortcut aliases: -q for query, -s for sort, -n for limit (issue list) - Add shortcut aliases: -p for platform (project list) - Add status filtering examples to issue docs (via --query) - Regenerate SKILL.md with proper argument names --- docs/src/content/docs/commands/issue.md | 13 ++++++++ plugins/sentry-cli/skills/sentry-cli/SKILL.md | 33 ++++++++++++------- src/commands/event/view.ts | 1 + src/commands/issue/list.ts | 2 ++ src/commands/issue/utils.ts | 1 + src/commands/org/view.ts | 1 + src/commands/project/list.ts | 3 +- src/commands/project/view.ts | 1 + 8 files changed, 42 insertions(+), 13 deletions(-) diff --git a/docs/src/content/docs/commands/issue.md b/docs/src/content/docs/commands/issue.md index 09f7b7d3..ef5011b2 100644 --- a/docs/src/content/docs/commands/issue.md +++ b/docs/src/content/docs/commands/issue.md @@ -79,6 +79,19 @@ sentry issue list my-org/frontend --query "TypeError" 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` View details of a specific issue. diff --git a/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index ed7f1a0b..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,14 +132,14 @@ sentry org view my-org -w Work with Sentry projects -#### `sentry project list ` +#### `sentry project list ` List projects **Flags:** - `-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:** @@ -154,7 +154,7 @@ sentry project list sentry project list --platform javascript ``` -#### `sentry project view ` +#### `sentry project view ` View details of a project @@ -177,14 +177,14 @@ sentry project view frontend -w Manage Sentry issues -#### `sentry issue list ` +#### `sentry issue list ` List issues in a project **Flags:** -- `--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:** @@ -212,9 +212,18 @@ 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" + +# 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 explain ` +#### `sentry issue explain ` Analyze an issue's root cause using Seer AI @@ -242,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 @@ -267,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 @@ -296,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 96ac0d7e..0d591feb 100644 --- a/src/commands/issue/list.ts +++ b/src/commands/issue/list.ts @@ -397,6 +397,7 @@ export const listCommand = buildCommand({ kind: "tuple", parameters: [ { + placeholder: "target", brief: "Target: /, /, or ", parse: String, optional: true, @@ -429,6 +430,7 @@ 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( 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 276298e3..af5569a4 100644 --- a/src/commands/project/list.ts +++ b/src/commands/project/list.ts @@ -207,6 +207,7 @@ export const listCommand = buildCommand({ kind: "tuple", parameters: [ { + placeholder: "org", brief: "Organization slug (optional)", parse: String, optional: true, @@ -233,7 +234,7 @@ export const listCommand = buildCommand({ optional: true, }, }, - aliases: { n: "limit" }, + aliases: { n: "limit", p: "platform" }, }, async func( this: SentryContext, 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, From 2c4122c61637ce8cd5e7af166764bb79077f8a33 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Mon, 2 Feb 2026 20:35:44 +0000 Subject: [PATCH 5/6] test: add coverage for ProjectSpecificationType and findProjectsBySlug - Add tests for ProjectSpecificationType constant values - Add tests for findProjectsBySlug function with various scenarios - Test handling of multiple orgs with matching projects - Test empty results when no projects match - Test graceful handling of 403 errors (permission denied) --- test/lib/api-client.test.ts | 175 +++++++++++++++++++++++++++++++- test/lib/resolve-target.test.ts | 25 ++++- 2 files changed, 196 insertions(+), 4 deletions(-) diff --git a/test/lib/api-client.test.ts b/test/lib/api-client.test.ts index 45825900..18838a09 100644 --- a/test/lib/api-client.test.ts +++ b/test/lib/api-client.test.ts @@ -565,6 +565,175 @@ describe("rawApiRequest", () => { }); }); -// Note: findProjectsBySlug() is tested via E2E tests in test/e2e/issue.test.ts -// since it requires complex multi-region API mocking that is better handled -// by the mock server infrastructure. +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 index c1ad5d5c..4d65626d 100644 --- a/test/lib/resolve-target.test.ts +++ b/test/lib/resolve-target.test.ts @@ -3,7 +3,30 @@ */ import { describe, expect, test } from "bun:test"; -import { parseOrgProjectArg } from "../../src/lib/resolve-target.js"; +import { + ProjectSpecificationType, + parseOrgProjectArg, +} from "../../src/lib/resolve-target.js"; + +describe("ProjectSpecificationType", () => { + test("has correct string values", () => { + expect(ProjectSpecificationType.Explicit).toBe("explicit"); + expect(ProjectSpecificationType.OrgAll).toBe("org-all"); + expect(ProjectSpecificationType.ProjectSearch).toBe("project-search"); + expect(ProjectSpecificationType.AutoDetect).toBe("auto-detect"); + }); + + test("is immutable (const assertion)", () => { + // TypeScript const assertion makes this read-only at compile time + // At runtime, we can verify the object structure + expect(Object.keys(ProjectSpecificationType)).toEqual([ + "Explicit", + "OrgAll", + "ProjectSearch", + "AutoDetect", + ]); + }); +}); describe("parseOrgProjectArg", () => { test("returns auto-detect for undefined", () => { From c08958c6190f8373a52662dad89a68867b3c6e93 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Mon, 2 Feb 2026 21:37:47 +0000 Subject: [PATCH 6/6] fix: remove useless constant tests and fix project view tip text - Remove ProjectSpecificationType tests (testing constants is useless) - Fix footer tip to show correct project view syntax --- src/commands/project/list.ts | 2 +- test/lib/resolve-target.test.ts | 25 +------------------------ 2 files changed, 2 insertions(+), 25 deletions(-) diff --git a/src/commands/project/list.ts b/src/commands/project/list.ts index af5569a4..39aef7bc 100644 --- a/src/commands/project/list.ts +++ b/src/commands/project/list.ts @@ -309,7 +309,7 @@ export const listCommand = buildCommand({ writeFooter( stdout, - "Tip: Use 'sentry project view /' for details" + "Tip: Use 'sentry project view --org ' for details" ); }, }); diff --git a/test/lib/resolve-target.test.ts b/test/lib/resolve-target.test.ts index 4d65626d..c1ad5d5c 100644 --- a/test/lib/resolve-target.test.ts +++ b/test/lib/resolve-target.test.ts @@ -3,30 +3,7 @@ */ import { describe, expect, test } from "bun:test"; -import { - ProjectSpecificationType, - parseOrgProjectArg, -} from "../../src/lib/resolve-target.js"; - -describe("ProjectSpecificationType", () => { - test("has correct string values", () => { - expect(ProjectSpecificationType.Explicit).toBe("explicit"); - expect(ProjectSpecificationType.OrgAll).toBe("org-all"); - expect(ProjectSpecificationType.ProjectSearch).toBe("project-search"); - expect(ProjectSpecificationType.AutoDetect).toBe("auto-detect"); - }); - - test("is immutable (const assertion)", () => { - // TypeScript const assertion makes this read-only at compile time - // At runtime, we can verify the object structure - expect(Object.keys(ProjectSpecificationType)).toEqual([ - "Explicit", - "OrgAll", - "ProjectSearch", - "AutoDetect", - ]); - }); -}); +import { parseOrgProjectArg } from "../../src/lib/resolve-target.js"; describe("parseOrgProjectArg", () => { test("returns auto-detect for undefined", () => {