diff --git a/docs/src/content/docs/commands/index.md b/docs/src/content/docs/commands/index.md index 14576cdd..9decfeb0 100644 --- a/docs/src/content/docs/commands/index.md +++ b/docs/src/content/docs/commands/index.md @@ -13,6 +13,7 @@ The Sentry CLI provides commands for interacting with various Sentry resources. | [`cli`](./cli/) | CLI-related commands (feedback, upgrade) | | [`org`](./org/) | Organization operations | | [`project`](./project/) | Project operations | +| [`team`](./team/) | Team operations | | [`issue`](./issue/) | Issue tracking | | [`event`](./event/) | Event inspection | | [`log`](./log/) | Log viewing and streaming | diff --git a/docs/src/content/docs/commands/team.md b/docs/src/content/docs/commands/team.md new file mode 100644 index 00000000..bf562793 --- /dev/null +++ b/docs/src/content/docs/commands/team.md @@ -0,0 +1,62 @@ +--- +title: team +description: Team commands for the Sentry CLI +--- + +Manage Sentry teams. + +## Commands + +### `sentry team list` + +List teams in an organization. + +```bash +# Auto-detect organization or list all +sentry team list + +# List teams in a specific organization +sentry team list + +# Limit results +sentry team list --limit 10 +``` + +**Arguments:** + +| Argument | Description | +|----------|-------------| +| `[org-slug]` | Optional organization slug to filter by | + +**Options:** + +| Option | Description | +|--------|-------------| +| `-n, --limit ` | Maximum number of teams to list (default: 30) | +| `--json` | Output as JSON | + +**Example output:** + +``` +ORG SLUG NAME MEMBERS +my-org backend Backend Team 8 +my-org frontend Frontend Team 5 +my-org mobile Mobile Team 3 +``` + +**JSON output:** + +```bash +sentry team list --json +``` + +```json +[ + { + "id": "100", + "slug": "backend", + "name": "Backend Team", + "memberCount": 8 + } +] +``` diff --git a/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index 0a00b3c8..12aeb8c3 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -440,6 +440,33 @@ List repositories - `-n, --limit - Maximum number of repositories to list - (default: "30")` - `--json - Output JSON` +### Team + +Work with Sentry teams + +#### `sentry team list ` + +List teams + +**Flags:** +- `-n, --limit - Maximum number of teams to list - (default: "30")` +- `--json - Output JSON` + +**Examples:** + +```bash +# Auto-detect organization or list all +sentry team list + +# List teams in a specific organization +sentry team list + +# Limit results +sentry team list --limit 10 + +sentry team list --json +``` + ### Log View Sentry logs @@ -594,6 +621,18 @@ List repositories - `-n, --limit - Maximum number of repositories to list - (default: "30")` - `--json - Output JSON` +### Teams + +List teams + +#### `sentry teams ` + +List teams + +**Flags:** +- `-n, --limit - Maximum number of teams to list - (default: "30")` +- `--json - Output JSON` + ### Logs List logs from a project diff --git a/src/app.ts b/src/app.ts index ab5b6f40..91d2a302 100644 --- a/src/app.ts +++ b/src/app.ts @@ -21,6 +21,8 @@ import { projectRoute } from "./commands/project/index.js"; import { listCommand as projectListCommand } from "./commands/project/list.js"; import { repoRoute } from "./commands/repo/index.js"; import { listCommand as repoListCommand } from "./commands/repo/list.js"; +import { teamRoute } from "./commands/team/index.js"; +import { listCommand as teamListCommand } from "./commands/team/list.js"; import { traceRoute } from "./commands/trace/index.js"; import { listCommand as traceListCommand } from "./commands/trace/list.js"; import { CLI_VERSION } from "./lib/constants.js"; @@ -36,6 +38,7 @@ export const routes = buildRouteMap({ org: orgRoute, project: projectRoute, repo: repoRoute, + team: teamRoute, issue: issueRoute, event: eventRoute, log: logRoute, @@ -45,6 +48,7 @@ export const routes = buildRouteMap({ orgs: orgListCommand, projects: projectListCommand, repos: repoListCommand, + teams: teamListCommand, logs: logListCommand, traces: traceListCommand, }, diff --git a/src/commands/team/index.ts b/src/commands/team/index.ts new file mode 100644 index 00000000..627644c0 --- /dev/null +++ b/src/commands/team/index.ts @@ -0,0 +1,13 @@ +import { buildRouteMap } from "@stricli/core"; +import { listCommand } from "./list.js"; + +export const teamRoute = buildRouteMap({ + routes: { + list: listCommand, + }, + docs: { + brief: "Work with Sentry teams", + fullDescription: "List and manage teams in your Sentry organizations.", + hideRoute: {}, + }, +}); diff --git a/src/commands/team/list.ts b/src/commands/team/list.ts new file mode 100644 index 00000000..e56588fd --- /dev/null +++ b/src/commands/team/list.ts @@ -0,0 +1,292 @@ +/** + * sentry team list + * + * List teams in an organization. + */ + +import type { SentryContext } from "../../context.js"; +import { listOrganizations, listTeams } from "../../lib/api-client.js"; +import { buildCommand, numberParser } from "../../lib/command.js"; +import { getDefaultOrganization } from "../../lib/db/defaults.js"; +import { AuthError } from "../../lib/errors.js"; +import { writeFooter, writeJson } from "../../lib/formatters/index.js"; +import { resolveAllTargets } from "../../lib/resolve-target.js"; +import type { SentryTeam, Writer } from "../../types/index.js"; + +type ListFlags = { + readonly limit: number; + readonly json: boolean; +}; + +/** Team with its organization context for display */ +type TeamWithOrg = SentryTeam & { orgSlug?: string }; + +/** + * Fetch teams for a single organization. + * + * @param orgSlug - Organization slug to fetch teams from + * @returns Teams with org context attached + */ +async function fetchOrgTeams(orgSlug: string): Promise { + const teams = await listTeams(orgSlug); + return teams.map((t) => ({ ...t, orgSlug })); +} + +/** + * Fetch teams for a single org, returning empty array on non-auth errors. + * Auth errors propagate so user sees "please log in" message. + */ +async function fetchOrgTeamsSafe(orgSlug: string): Promise { + try { + return await fetchOrgTeams(orgSlug); + } catch (error) { + if (error instanceof AuthError) { + throw error; + } + return []; + } +} + +/** + * Fetch teams from all accessible organizations. + * Skips orgs where the user lacks access. + * + * @returns Combined list of teams from all accessible orgs + */ +async function fetchAllOrgTeams(): Promise { + const orgs = await listOrganizations(); + const results: TeamWithOrg[] = []; + + for (const org of orgs) { + try { + const teams = await fetchOrgTeams(org.slug); + results.push(...teams); + } catch (error) { + if (error instanceof AuthError) { + throw error; + } + // User may lack access to some orgs + } + } + + return results; +} + +/** Column widths for team list display */ +type ColumnWidths = { + orgWidth: number; + slugWidth: number; + nameWidth: number; + membersWidth: number; +}; + +/** + * Calculate column widths for team list display. + */ +function calculateColumnWidths(teams: TeamWithOrg[]): ColumnWidths { + const orgWidth = Math.max(...teams.map((t) => (t.orgSlug || "").length), 3); + const slugWidth = Math.max(...teams.map((t) => t.slug.length), 4); + const nameWidth = Math.max(...teams.map((t) => t.name.length), 4); + const membersWidth = Math.max( + ...teams.map((t) => String(t.memberCount ?? "").length), + 7 + ); + return { orgWidth, slugWidth, nameWidth, membersWidth }; +} + +/** + * Write the column header row for team list output. + */ +function writeHeader(stdout: Writer, widths: ColumnWidths): void { + const { orgWidth, slugWidth, nameWidth, membersWidth } = widths; + const org = "ORG".padEnd(orgWidth); + const slug = "SLUG".padEnd(slugWidth); + const name = "NAME".padEnd(nameWidth); + const members = "MEMBERS".padStart(membersWidth); + stdout.write(`${org} ${slug} ${name} ${members}\n`); +} + +type WriteRowsOptions = ColumnWidths & { + stdout: Writer; + teams: TeamWithOrg[]; +}; + +/** + * Write formatted team rows to stdout. + */ +function writeRows(options: WriteRowsOptions): void { + const { stdout, teams, orgWidth, slugWidth, nameWidth, membersWidth } = + options; + for (const team of teams) { + const org = (team.orgSlug || "").padEnd(orgWidth); + const slug = team.slug.padEnd(slugWidth); + const name = team.name.padEnd(nameWidth); + const members = String(team.memberCount ?? "").padStart(membersWidth); + stdout.write(`${org} ${slug} ${name} ${members}\n`); + } +} + +/** Result of resolving organizations to fetch teams from */ +type OrgResolution = { + orgs: string[]; + footer?: string; + skippedSelfHosted?: number; +}; + +/** + * Resolve which organizations to fetch teams from. + * Uses CLI flag, config defaults, or DSN auto-detection. + */ +async function resolveOrgsToFetch( + orgFlag: string | undefined, + cwd: string +): Promise { + // 1. If positional org provided, use it directly + if (orgFlag) { + return { orgs: [orgFlag] }; + } + + // 2. Check config defaults + const defaultOrg = await getDefaultOrganization(); + if (defaultOrg) { + return { orgs: [defaultOrg] }; + } + + // 3. Auto-detect from DSNs (may find multiple in monorepos) + try { + const { targets, footer, skippedSelfHosted } = await resolveAllTargets({ + cwd, + }); + + if (targets.length > 0) { + const uniqueOrgs = [...new Set(targets.map((t) => t.org))]; + return { + orgs: uniqueOrgs, + footer, + skippedSelfHosted, + }; + } + + // No resolvable targets, but may have self-hosted DSNs + return { orgs: [], skippedSelfHosted }; + } catch (error) { + // Auth errors should propagate - user needs to log in + if (error instanceof AuthError) { + throw error; + } + // Fall through to empty orgs for other errors (network, etc.) + } + + return { orgs: [] }; +} + +export const listCommand = buildCommand({ + docs: { + brief: "List teams", + fullDescription: + "List teams in an organization. If no organization is specified, " + + "uses the default organization or lists teams from all accessible organizations.\n\n" + + "Examples:\n" + + " sentry team list # auto-detect or list all\n" + + " sentry team list my-org # list teams in my-org\n" + + " sentry team list --limit 10\n" + + " sentry team list --json", + }, + parameters: { + positional: { + kind: "tuple", + parameters: [ + { + placeholder: "org", + brief: "Organization slug (optional)", + parse: String, + optional: true, + }, + ], + }, + flags: { + limit: { + kind: "parsed", + parse: numberParser, + brief: "Maximum number of teams to list", + default: "30", + }, + json: { + kind: "boolean", + brief: "Output JSON", + default: false, + }, + }, + aliases: { n: "limit" }, + }, + async func( + this: SentryContext, + flags: ListFlags, + org?: string + ): Promise { + const { stdout, cwd } = this; + + // Resolve which organizations to fetch from + const { + orgs: orgsToFetch, + footer, + skippedSelfHosted, + } = await resolveOrgsToFetch(org, cwd); + + // Fetch teams from resolved orgs (or all accessible if none detected) + let allTeams: TeamWithOrg[]; + if (orgsToFetch.length > 0) { + const results = await Promise.all(orgsToFetch.map(fetchOrgTeamsSafe)); + allTeams = results.flat(); + } else { + allTeams = await fetchAllOrgTeams(); + } + + // Apply limit (scale limit when multiple orgs) + const limitCount = + orgsToFetch.length > 1 ? flags.limit * orgsToFetch.length : flags.limit; + const limited = allTeams.slice(0, limitCount); + + if (flags.json) { + writeJson(stdout, limited); + return; + } + + if (limited.length === 0) { + const msg = + orgsToFetch.length === 1 + ? `No teams found in organization '${orgsToFetch[0]}'.\n` + : "No teams found.\n"; + stdout.write(msg); + return; + } + + const widths = calculateColumnWidths(limited); + writeHeader(stdout, widths); + writeRows({ + stdout, + teams: limited, + ...widths, + }); + + if (allTeams.length > limited.length) { + stdout.write(`\nShowing ${limited.length} of ${allTeams.length} teams\n`); + } + + if (footer) { + stdout.write(`\n${footer}\n`); + } + + if (skippedSelfHosted) { + stdout.write( + `\nNote: ${skippedSelfHosted} DSN(s) could not be resolved. ` + + "Specify the organization explicitly: sentry team list \n" + ); + } + + writeFooter( + stdout, + "Tip: Use 'sentry team list ' to filter by organization" + ); + }, +}); diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index e2146845..38f3528f 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -27,6 +27,8 @@ import { SentryProjectSchema, type SentryRepository, SentryRepositorySchema, + type SentryTeam, + SentryTeamSchema, type SentryUser, SentryUserSchema, type TraceSpan, @@ -602,6 +604,16 @@ export function listRepositories(orgSlug: string): Promise { ); } +/** + * List teams in an organization. + * Uses region-aware routing for multi-region support. + */ +export function listTeams(orgSlug: string): Promise { + return orgScopedRequest(`/organizations/${orgSlug}/teams/`, { + schema: z.array(SentryTeamSchema), + }); +} + /** * Search for projects matching a slug across all accessible organizations. * diff --git a/src/types/index.ts b/src/types/index.ts index 229f66a1..ccb9fd16 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -74,6 +74,7 @@ export type { SentryOrganization, SentryProject, SentryRepository, + SentryTeam, SentryUser, Span, StackFrame, @@ -114,6 +115,7 @@ export { SentryOrganizationSchema, SentryProjectSchema, SentryRepositorySchema, + SentryTeamSchema, SentryUserSchema, SpanSchema, StackFrameSchema, diff --git a/src/types/sentry.ts b/src/types/sentry.ts index 28b7be60..ffea05e7 100644 --- a/src/types/sentry.ts +++ b/src/types/sentry.ts @@ -810,3 +810,22 @@ export const SentryRepositorySchema = z .passthrough(); export type SentryRepository = z.infer; + +// Team + +/** A team in a Sentry organization */ +export const SentryTeamSchema = z + .object({ + // Core identifiers (required) + id: z.string(), + slug: z.string(), + name: z.string(), + // Optional metadata + dateCreated: z.string().optional(), + isMember: z.boolean().optional(), + teamRole: z.string().nullable().optional(), + memberCount: z.number().optional(), + }) + .passthrough(); + +export type SentryTeam = z.infer; diff --git a/test/commands/team/list.test.ts b/test/commands/team/list.test.ts new file mode 100644 index 00000000..2b542211 --- /dev/null +++ b/test/commands/team/list.test.ts @@ -0,0 +1,204 @@ +/** + * Team List Command Tests + * + * Tests for the team list command in src/commands/team/list.ts. + * Uses spyOn to mock api-client and resolve-target to test + * the func() body without real HTTP calls or database access. + */ + +import { + afterEach, + beforeEach, + describe, + expect, + mock, + spyOn, + test, +} from "bun:test"; +import { listCommand } from "../../../src/commands/team/list.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as apiClient from "../../../src/lib/api-client.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as defaults from "../../../src/lib/db/defaults.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as resolveTarget from "../../../src/lib/resolve-target.js"; +import type { SentryTeam } from "../../../src/types/sentry.js"; + +// Sample test data +const sampleTeams: SentryTeam[] = [ + { + id: "100", + slug: "backend", + name: "Backend Team", + memberCount: 8, + isMember: true, + teamRole: null, + dateCreated: "2024-01-10T09:00:00Z", + }, + { + id: "101", + slug: "frontend", + name: "Frontend Team", + memberCount: 5, + isMember: false, + teamRole: null, + dateCreated: "2024-02-15T14:00:00Z", + }, +]; + +function createMockContext() { + const stdoutWrite = mock(() => true); + return { + context: { + stdout: { write: stdoutWrite }, + stderr: { write: mock(() => true) }, + cwd: "/tmp", + setContext: mock(() => { + // no-op for test + }), + }, + stdoutWrite, + }; +} + +describe("listCommand.func", () => { + let listTeamsSpy: ReturnType; + let listOrganizationsSpy: ReturnType; + let getDefaultOrganizationSpy: ReturnType; + let resolveAllTargetsSpy: ReturnType; + + beforeEach(() => { + listTeamsSpy = spyOn(apiClient, "listTeams"); + listOrganizationsSpy = spyOn(apiClient, "listOrganizations"); + getDefaultOrganizationSpy = spyOn(defaults, "getDefaultOrganization"); + resolveAllTargetsSpy = spyOn(resolveTarget, "resolveAllTargets"); + + // Default: no default org, no DSN detection + getDefaultOrganizationSpy.mockResolvedValue(null); + resolveAllTargetsSpy.mockResolvedValue({ targets: [] }); + }); + + afterEach(() => { + listTeamsSpy.mockRestore(); + listOrganizationsSpy.mockRestore(); + getDefaultOrganizationSpy.mockRestore(); + resolveAllTargetsSpy.mockRestore(); + }); + + test("outputs JSON array when --json flag is set", async () => { + listTeamsSpy.mockResolvedValue(sampleTeams); + + const { context, stdoutWrite } = createMockContext(); + const func = await listCommand.loader(); + await func.call(context, { limit: 30, json: true }, "test-org"); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + const parsed = JSON.parse(output); + expect(Array.isArray(parsed)).toBe(true); + expect(parsed).toHaveLength(2); + expect(parsed[0].slug).toBe("backend"); + expect(parsed[1].slug).toBe("frontend"); + }); + + test("outputs empty JSON array when no teams found with --json", async () => { + listTeamsSpy.mockResolvedValue([]); + + const { context, stdoutWrite } = createMockContext(); + const func = await listCommand.loader(); + await func.call(context, { limit: 30, json: true }, "test-org"); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + expect(JSON.parse(output)).toEqual([]); + }); + + test("writes 'No teams found' when empty without --json", async () => { + listTeamsSpy.mockResolvedValue([]); + + const { context, stdoutWrite } = createMockContext(); + const func = await listCommand.loader(); + await func.call(context, { limit: 30, json: false }, "test-org"); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + expect(output).toContain("No teams found"); + }); + + test("writes header, rows, and footer for human output", async () => { + listTeamsSpy.mockResolvedValue(sampleTeams); + + const { context, stdoutWrite } = createMockContext(); + const func = await listCommand.loader(); + await func.call(context, { limit: 30, json: false }, "test-org"); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + // Check header + expect(output).toContain("ORG"); + expect(output).toContain("SLUG"); + expect(output).toContain("NAME"); + expect(output).toContain("MEMBERS"); + // Check data + expect(output).toContain("backend"); + expect(output).toContain("Backend Team"); + expect(output).toContain("8"); + expect(output).toContain("frontend"); + expect(output).toContain("Frontend Team"); + expect(output).toContain("5"); + // Check footer + expect(output).toContain("sentry team list"); + }); + + test("shows count when results exceed limit", async () => { + const manyTeams = Array.from({ length: 10 }, (_, i) => ({ + ...sampleTeams[0], + id: String(i), + slug: `team-${i}`, + name: `Team ${i}`, + })); + listTeamsSpy.mockResolvedValue(manyTeams); + + const { context, stdoutWrite } = createMockContext(); + const func = await listCommand.loader(); + await func.call(context, { limit: 5, json: false }, "test-org"); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + expect(output).toContain("Showing 5 of 10 teams"); + }); + + test("uses default organization when no org provided", async () => { + getDefaultOrganizationSpy.mockResolvedValue("default-org"); + listTeamsSpy.mockResolvedValue(sampleTeams); + + const { context } = createMockContext(); + const func = await listCommand.loader(); + await func.call(context, { limit: 30, json: false }, undefined); + + expect(listTeamsSpy).toHaveBeenCalledWith("default-org"); + }); + + test("uses DSN auto-detection when no org and no default", async () => { + resolveAllTargetsSpy.mockResolvedValue({ + targets: [{ org: "detected-org", project: "some-project" }], + }); + listTeamsSpy.mockResolvedValue(sampleTeams); + + const { context } = createMockContext(); + const func = await listCommand.loader(); + await func.call(context, { limit: 30, json: false }, undefined); + + expect(listTeamsSpy).toHaveBeenCalledWith("detected-org"); + }); + + test("falls back to all orgs when no org specified and no detection", async () => { + listOrganizationsSpy.mockResolvedValue([ + { id: "1", slug: "org-a", name: "Org A" }, + { id: "2", slug: "org-b", name: "Org B" }, + ]); + listTeamsSpy.mockResolvedValue(sampleTeams); + + const { context } = createMockContext(); + const func = await listCommand.loader(); + await func.call(context, { limit: 30, json: false }, undefined); + + // Should have called listOrganizations and then listTeams for each + expect(listOrganizationsSpy).toHaveBeenCalled(); + }); +});