diff --git a/AGENTS.md b/AGENTS.md index e73fba2b5f..63d14b18f5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -319,6 +319,20 @@ import type { SentryContext } from "../../context.js"; import { getAuthToken } from "../../lib/config.js"; ``` +### List Command Infrastructure + +Two abstraction levels exist for list commands: + +1. **`src/lib/list-command.ts`** — `buildOrgListCommand` factory + shared Stricli parameter constants (`LIST_TARGET_POSITIONAL`, `LIST_JSON_FLAG`, `LIST_CURSOR_FLAG`, `buildListLimitFlag`). Use this for simple entity lists like `team list` and `repo list`. + +2. **`src/lib/org-list.ts`** — `dispatchOrgScopedList` with `OrgListConfig` and a 4-mode handler map: `auto-detect`, `explicit`, `org-all`, `project-search`. Complex commands (`project list`, `issue list`) call `dispatchOrgScopedList` with an `overrides` map directly instead of using `buildOrgListCommand`. + +Key rules when writing overrides: +- Each mode handler receives a `HandlerContext` with the narrowed `parsed` plus shared I/O (`stdout`, `cwd`, `flags`). Access parsed fields via `ctx.parsed.org`, `ctx.parsed.projectSlug`, etc. — no manual `Extract<>` casts needed. +- Commands with extra fields (e.g., `stderr`, `setContext`) spread the context and add them: `(ctx) => handle({ ...ctx, flags, stderr, setContext })`. Override `ctx.flags` with the command-specific flags type when needed. +- `resolveCursor()` must be called **inside** the `org-all` override closure, not before `dispatchOrgScopedList`, so that `--cursor` validation errors fire correctly for non-org-all modes. +- `handleProjectSearch` errors must use `"Project"` as the `ContextError` resource, not `config.entityName`. + ## Commenting & Documentation (JSDoc-first) ### Default Rule diff --git a/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index a2ba5469c0..e37edef3a5 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -194,9 +194,10 @@ List issues in a project **Flags:** - `-q, --query - Search query (Sentry search syntax)` -- `-n, --limit - Maximum number of issues to return - (default: "10")` +- `-n, --limit - Maximum number of issues to list - (default: "10")` - `-s, --sort - Sort by: date, new, freq, user - (default: "date")` -- `--json - Output as JSON` +- `--json - Output JSON` +- `-c, --cursor - Pagination cursor — only for / mode (use "last" to continue)` **Examples:** @@ -433,25 +434,27 @@ Update the Sentry CLI to the latest version Work with Sentry repositories -#### `sentry repo list ` +#### `sentry repo list ` List repositories **Flags:** - `-n, --limit - Maximum number of repositories to list - (default: "30")` - `--json - Output JSON` +- `-c, --cursor - Pagination cursor (use "last" to continue from previous page)` ### Team Work with Sentry teams -#### `sentry team list ` +#### `sentry team list ` List teams **Flags:** - `-n, --limit - Maximum number of teams to list - (default: "30")` - `--json - Output JSON` +- `-c, --cursor - Pagination cursor (use "last" to continue from previous page)` **Examples:** @@ -581,9 +584,10 @@ List issues in a project **Flags:** - `-q, --query - Search query (Sentry search syntax)` -- `-n, --limit - Maximum number of issues to return - (default: "10")` +- `-n, --limit - Maximum number of issues to list - (default: "10")` - `-s, --sort - Sort by: date, new, freq, user - (default: "date")` -- `--json - Output as JSON` +- `--json - Output JSON` +- `-c, --cursor - Pagination cursor — only for / mode (use "last" to continue)` ### Orgs @@ -615,25 +619,27 @@ List projects List repositories -#### `sentry repos ` +#### `sentry repos ` List repositories **Flags:** - `-n, --limit - Maximum number of repositories to list - (default: "30")` - `--json - Output JSON` +- `-c, --cursor - Pagination cursor (use "last" to continue from previous page)` ### Teams List teams -#### `sentry teams ` +#### `sentry teams ` List teams **Flags:** - `-n, --limit - Maximum number of teams to list - (default: "30")` - `--json - Output JSON` +- `-c, --cursor - Pagination cursor (use "last" to continue from previous page)` ### Logs diff --git a/src/commands/issue/list.ts b/src/commands/issue/list.ts index 0169174269..858bca16a6 100644 --- a/src/commands/issue/list.ts +++ b/src/commands/issue/list.ts @@ -10,10 +10,17 @@ import { buildOrgAwareAliases } from "../../lib/alias.js"; import { findProjectsBySlug, listIssues, + listIssuesPaginated, listProjects, } from "../../lib/api-client.js"; import { parseOrgProjectArg } from "../../lib/arg-parsing.js"; -import { buildCommand, numberParser } from "../../lib/command.js"; +import { buildCommand } from "../../lib/command.js"; +import { + clearPaginationCursor, + escapeContextKeyValue, + resolveOrgCursor, + setPaginationCursor, +} from "../../lib/db/pagination.js"; import { clearProjectAliases, setProjectAliases, @@ -28,21 +35,37 @@ import { muted, writeJson, } from "../../lib/formatters/index.js"; +import { + buildListLimitFlag, + LIST_BASE_ALIASES, + LIST_JSON_FLAG, + LIST_TARGET_POSITIONAL, +} from "../../lib/list-command.js"; +import { + dispatchOrgScopedList, + type ListCommandMeta, + type ModeHandler, +} from "../../lib/org-list.js"; import { type ResolvedTarget, resolveAllTargets, } from "../../lib/resolve-target.js"; +import { getApiBaseUrl } from "../../lib/sentry-client.js"; import type { ProjectAliasEntry, SentryIssue, Writer, } from "../../types/index.js"; +/** Command key for pagination cursor storage */ +export const PAGINATION_KEY = "issue-list"; + type ListFlags = { readonly query?: string; readonly limit: number; readonly sort: "date" | "new" | "freq" | "user"; readonly json: boolean; + readonly cursor?: string; }; type SortValue = "date" | "new" | "freq" | "user"; @@ -369,6 +392,266 @@ async function fetchIssuesForTarget( } } +/** Build the CLI hint for fetching the next page, preserving active flags. */ +function nextPageHint(org: string, flags: ListFlags): string { + const base = `sentry issue list ${org}/ -c last`; + const parts: string[] = []; + if (flags.sort !== "date") { + parts.push(`--sort ${flags.sort}`); + } + if (flags.query) { + parts.push(`-q "${flags.query}"`); + } + return parts.length > 0 ? `${base} ${parts.join(" ")}` : base; +} + +/** Options for {@link handleOrgAllIssues}. */ +type OrgAllIssuesOptions = { + stdout: Writer; + org: string; + flags: ListFlags; + setContext: (orgs: string[], projects: string[]) => void; +}; + +/** + * Handle org-all mode for issues: cursor-paginated listing of all issues in an org. + * + * Uses a sort+query-aware context key so cursors from different searches are + * never accidentally reused. + */ +async function handleOrgAllIssues(options: OrgAllIssuesOptions): Promise { + const { stdout, org, flags, setContext } = options; + // Encode sort + query in context key so cursors from different searches don't collide. + const escapedQuery = flags.query + ? escapeContextKeyValue(flags.query) + : undefined; + const contextKey = `host:${getApiBaseUrl()}|type:org:${org}|sort:${flags.sort}${escapedQuery ? `|q:${escapedQuery}` : ""}`; + const cursor = resolveOrgCursor(flags.cursor, PAGINATION_KEY, contextKey); + + setContext([org], []); + + const response = await listIssuesPaginated(org, "", { + query: flags.query, + cursor, + perPage: flags.limit, + sort: flags.sort, + }); + + if (response.nextCursor) { + setPaginationCursor(PAGINATION_KEY, contextKey, response.nextCursor); + } else { + clearPaginationCursor(PAGINATION_KEY, contextKey); + } + + const hasMore = !!response.nextCursor; + + if (flags.json) { + const output = hasMore + ? { data: response.data, nextCursor: response.nextCursor, hasMore: true } + : { data: response.data, hasMore: false }; + writeJson(stdout, output); + return; + } + + if (response.data.length === 0) { + if (hasMore) { + stdout.write( + `No issues on this page. Try the next page: ${nextPageHint(org, flags)}\n` + ); + } else { + stdout.write(`No issues found in organization '${org}'.\n`); + } + return; + } + + // isMultiProject=true: org-all shows issues from every project, so the ALIAS + // column is needed to identify which project each issue belongs to. + writeListHeader(stdout, `Issues in ${org}`, true); + const termWidth = process.stdout.columns || 80; + const issuesWithOpts = response.data.map((issue) => ({ + issue, + formatOptions: { + projectSlug: issue.project?.slug ?? "", + isMultiProject: true, + }, + })); + writeIssueRows(stdout, issuesWithOpts, termWidth); + + if (hasMore) { + stdout.write(`\nShowing ${response.data.length} issues (more available)\n`); + stdout.write(`Next page: ${nextPageHint(org, flags)}\n`); + } else { + stdout.write(`\nShowing ${response.data.length} issues\n`); + } +} + +/** Options for {@link handleResolvedTargets}. */ +type ResolvedTargetsOptions = { + stdout: Writer; + stderr: Writer; + parsed: ReturnType; + flags: ListFlags; + cwd: string; + setContext: (orgs: string[], projects: string[]) => void; +}; + +/** + * Handle auto-detect, explicit, and project-search modes. + * + * All three share the same flow: resolve targets → fetch issues in parallel → + * merge results → display. Only the target resolution step differs. + */ +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: inherent multi-target issue resolution, error handling, and display logic +async function handleResolvedTargets( + options: ResolvedTargetsOptions +): Promise { + const { stdout, stderr, parsed, flags, cwd, setContext } = options; + + const { targets, footer, skippedSelfHosted, detectedDsns } = + await resolveTargetsFromParsedArg(parsed, cwd); + + const orgs = [...new Set(targets.map((t) => t.org))]; + const projects = [...new Set(targets.map((t) => t.project))]; + setContext(orgs, projects); + + if (targets.length === 0) { + if (skippedSelfHosted) { + throw new ContextError( + "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 the target explicitly." + ); + } + throw new ContextError("Organization and project", USAGE_HINT); + } + + const results = await Promise.all( + targets.map((t) => + fetchIssuesForTarget(t, { + query: flags.query, + limit: flags.limit, + sort: flags.sort, + }) + ) + ); + + const validResults: IssueListResult[] = []; + const failures: Error[] = []; + + for (const result of results) { + if (result.success) { + validResults.push(result.data); + } else { + failures.push(result.error); + } + } + + if (validResults.length === 0 && failures.length > 0) { + // biome-ignore lint/style/noNonNullAssertion: guarded by failures.length > 0 + const first = failures[0]!; + const prefix = `Failed to fetch issues from ${targets.length} project(s)`; + + // Propagate ApiError so telemetry sees the original status code + if (first instanceof ApiError) { + throw new ApiError( + `${prefix}: ${first.message}`, + first.status, + first.detail, + first.endpoint + ); + } + + throw new Error(`${prefix}.\n${first.message}`); + } + + const isMultiProject = validResults.length > 1; + const isSingleProject = validResults.length === 1; + const firstTarget = validResults[0]?.target; + + const { aliasMap, entries } = isMultiProject + ? buildProjectAliasMap(validResults) + : { aliasMap: new Map(), entries: {} }; + + if (isMultiProject) { + const fingerprint = createDsnFingerprint(detectedDsns ?? []); + await setProjectAliases(entries, fingerprint); + } else { + await clearProjectAliases(); + } + + const issuesWithOptions = attachFormatOptions( + validResults, + aliasMap, + isMultiProject + ); + + issuesWithOptions.sort((a, b) => getComparator(flags.sort)(a.issue, b.issue)); + + if (flags.json) { + const allIssues = issuesWithOptions.map((i) => i.issue); + if (failures.length > 0) { + writeJson(stdout, { + issues: allIssues, + errors: failures.map((e) => + e instanceof ApiError + ? { status: e.status, message: e.message } + : { message: e.message } + ), + }); + } else { + writeJson(stdout, allIssues); + } + return; + } + + if (failures.length > 0) { + stderr.write( + muted( + `\nNote: Failed to fetch issues from ${failures.length} project(s). Showing results from ${validResults.length} project(s).\n` + ) + ); + } + + if (issuesWithOptions.length === 0) { + stdout.write("No issues found.\n"); + if (footer) { + stdout.write(`\n${footer}\n`); + } + return; + } + + const title = + isSingleProject && firstTarget + ? `Issues in ${firstTarget.orgDisplay}/${firstTarget.projectDisplay}` + : `Issues from ${validResults.length} projects`; + + writeListHeader(stdout, title, isMultiProject); + + const termWidth = process.stdout.columns || 80; + writeIssueRows(stdout, issuesWithOptions, termWidth); + + let footerMode: "single" | "multi" | "none" = "none"; + if (isMultiProject) { + footerMode = "multi"; + } else if (isSingleProject) { + footerMode = "single"; + } + writeListFooter(stdout, footerMode); + + if (footer) { + stdout.write(`\n${footer}\n`); + } +} + +/** Metadata for the shared dispatch infrastructure. */ +const issueListMeta: ListCommandMeta = { + paginationKey: PAGINATION_KEY, + entityName: "issue", + entityPlural: "issues", + commandPrefix: "sentry issue list", +}; + export const listCommand = buildCommand({ docs: { brief: "List issues in a project", @@ -382,17 +665,7 @@ export const listCommand = buildCommand({ "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, - }, - ], - }, + positional: LIST_TARGET_POSITIONAL, flags: { query: { kind: "parsed", @@ -400,28 +673,25 @@ export const listCommand = buildCommand({ brief: "Search query (Sentry search syntax)", optional: true, }, - limit: { - kind: "parsed", - parse: numberParser, - brief: "Maximum number of issues to return", - // Stricli requires string defaults (raw CLI input); numberParser converts to number - default: "10", - }, + limit: buildListLimitFlag("issues", "10"), sort: { kind: "parsed", parse: parseSort, brief: "Sort by: date, new, freq, user", default: "date" as const, }, - json: { - kind: "boolean", - brief: "Output as JSON", - default: false, + json: LIST_JSON_FLAG, + cursor: { + kind: "parsed", + parse: String, + // Issue-specific cursor brief: cursor only works in / mode + brief: + 'Pagination cursor — only for / mode (use "last" to continue)', + optional: true, }, }, - aliases: { q: "query", s: "sort", n: "limit" }, + aliases: { ...LIST_BASE_ALIASES, q: "query", s: "sort" }, }, - // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: command entry point with inherent complexity async func( this: SentryContext, flags: ListFlags, @@ -429,164 +699,30 @@ export const listCommand = buildCommand({ ): Promise { const { stdout, stderr, cwd, setContext } = this; - // Parse positional argument to determine resolution strategy const parsed = parseOrgProjectArg(target); - // Resolve targets based on parsed argument type - const { targets, footer, skippedSelfHosted, detectedDsns } = - await resolveTargetsFromParsedArg(parsed, cwd); - - // Set telemetry context with unique orgs and projects - const orgs = [...new Set(targets.map((t) => t.org))]; - const projects = [...new Set(targets.map((t) => t.project))]; - setContext(orgs, projects); - - if (targets.length === 0) { - if (skippedSelfHosted) { - throw new ContextError( - "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 the target explicitly." - ); - } - throw new ContextError("Organization and project", USAGE_HINT); - } - - // Fetch issues from all targets in parallel - const results = await Promise.all( - targets.map((t) => - fetchIssuesForTarget(t, { - query: flags.query, - limit: flags.limit, - sort: flags.sort, - }) - ) - ); - - // Separate successful fetches from failures - const validResults: IssueListResult[] = []; - const failures: Error[] = []; - - for (const result of results) { - if (result.success) { - validResults.push(result.data); - } else { - failures.push(result.error); - } - } - - if (validResults.length === 0 && failures.length > 0) { - // Re-throw the first underlying error so telemetry can classify it - // correctly (e.g., ApiError → isClientApiError → suppressed from exceptions). - // Add context about how many projects failed. - // biome-ignore lint/style/noNonNullAssertion: guarded by failures.length > 0 - const first = failures[0]!; - const prefix = `Failed to fetch issues from ${targets.length} project(s)`; - - // For ApiError, propagate the original so telemetry sees the status code - if (first instanceof ApiError) { - throw new ApiError( - `${prefix}: ${first.message}`, - first.status, - first.detail, - first.endpoint - ); - } - - // For other errors, add context to the message - throw new Error(`${prefix}.\n${first.message}`); - } - - // Determine display mode - const isMultiProject = validResults.length > 1; - const isSingleProject = validResults.length === 1; - const firstTarget = validResults[0]?.target; - - // Build project alias map and cache it for multi-project mode - const { aliasMap, entries } = isMultiProject - ? buildProjectAliasMap(validResults) - : { - aliasMap: new Map(), - entries: {}, - }; - - if (isMultiProject) { - const fingerprint = createDsnFingerprint(detectedDsns ?? []); - await setProjectAliases(entries, fingerprint); - } else { - await clearProjectAliases(); - } - - // Attach formatting options to each issue - const issuesWithOptions = attachFormatOptions( - validResults, - aliasMap, - isMultiProject - ); - - // Sort by user preference - issuesWithOptions.sort((a, b) => - getComparator(flags.sort)(a.issue, b.issue) - ); - - // JSON output — include partial failure info when some projects failed - if (flags.json) { - const allIssues = issuesWithOptions.map((i) => i.issue); - if (failures.length > 0) { - writeJson(stdout, { - issues: allIssues, - errors: failures.map((e) => - e instanceof ApiError - ? { status: e.status, message: e.message } - : { message: e.message } - ), - }); - } else { - writeJson(stdout, allIssues); - } - return; - } - - // Warn on stderr about partial failures (human output only) - if (failures.length > 0) { - stderr.write( - muted( - `\nNote: Failed to fetch issues from ${failures.length} project(s). Showing results from ${validResults.length} project(s).\n` - ) - ); - } - - if (issuesWithOptions.length === 0) { - stdout.write("No issues found.\n"); - if (footer) { - stdout.write(`\n${footer}\n`); - } - return; - } - - // Header depends on single vs multiple projects - const title = - isSingleProject && firstTarget - ? `Issues in ${firstTarget.orgDisplay}/${firstTarget.projectDisplay}` - : `Issues from ${validResults.length} projects`; - - writeListHeader(stdout, title, isMultiProject); - - const termWidth = process.stdout.columns || 80; - writeIssueRows(stdout, issuesWithOptions, termWidth); - - // Footer mode - let footerMode: "single" | "multi" | "none" = "none"; - if (isMultiProject) { - footerMode = "multi"; - } else if (isSingleProject) { - footerMode = "single"; - } - writeListFooter(stdout, footerMode); - - if (footer) { - stdout.write(`\n${footer}\n`); - } + // biome-ignore lint/suspicious/noExplicitAny: shared handler accepts any mode variant + const resolveAndHandle: ModeHandler = (ctx) => + handleResolvedTargets({ ...ctx, flags, stderr, setContext }); + + await dispatchOrgScopedList({ + config: issueListMeta, + stdout, + cwd, + flags, + parsed, + overrides: { + "auto-detect": resolveAndHandle, + explicit: resolveAndHandle, + "project-search": resolveAndHandle, + "org-all": (ctx) => + handleOrgAllIssues({ + stdout: ctx.stdout, + org: ctx.parsed.org, + flags, + setContext, + }), + }, + }); }, }); diff --git a/src/commands/log/list.ts b/src/commands/log/list.ts index b9fb5054f2..0aacec2c8c 100644 --- a/src/commands/log/list.ts +++ b/src/commands/log/list.ts @@ -8,17 +8,17 @@ // biome-ignore lint/performance/noNamespaceImport: Sentry SDK recommends namespace import import * as Sentry from "@sentry/bun"; import type { SentryContext } from "../../context.js"; -import { findProjectsBySlug, listLogs } from "../../lib/api-client.js"; -import { parseOrgProjectArg } from "../../lib/arg-parsing.js"; +import { listLogs } from "../../lib/api-client.js"; +import { validateLimit } from "../../lib/arg-parsing.js"; import { buildCommand } from "../../lib/command.js"; -import { AuthError, ContextError, stringifyUnknown } from "../../lib/errors.js"; +import { AuthError, stringifyUnknown } from "../../lib/errors.js"; import { formatLogRow, formatLogsHeader, writeFooter, writeJson, } from "../../lib/formatters/index.js"; -import { resolveOrgAndProject } from "../../lib/resolve-target.js"; +import { resolveOrgProjectFromArg } from "../../lib/resolve-target.js"; import { getUpdateNotification } from "../../lib/version-check.js"; import type { SentryLog, Writer } from "../../types/index.js"; @@ -29,9 +29,6 @@ type ListFlags = { readonly json: boolean; }; -/** Usage hint for ContextError messages */ -const USAGE_HINT = "sentry log list /"; - /** Maximum allowed value for --limit flag */ const MAX_LIMIT = 1000; @@ -44,17 +41,14 @@ const DEFAULT_LIMIT = 100; /** Default poll interval in seconds for --follow mode */ const DEFAULT_POLL_INTERVAL = 2; +/** Command name used in resolver error messages */ +const COMMAND_NAME = "log list"; + /** - * Validate that --limit value is within allowed range. - * - * @throws Error if value is outside MIN_LIMIT..MAX_LIMIT range + * Parse --limit flag, delegating range validation to shared utility. */ -function validateLimit(value: string): number { - const num = Number.parseInt(value, 10); - if (Number.isNaN(num) || num < MIN_LIMIT || num > MAX_LIMIT) { - throw new Error(`--limit must be between ${MIN_LIMIT} and ${MAX_LIMIT}`); - } - return num; +function parseLimit(value: string): number { + return validateLimit(value, MIN_LIMIT, MAX_LIMIT); } /** @@ -233,83 +227,6 @@ async function executeFollowMode(options: FollowModeOptions): Promise { } } -/** Resolved org and project for log commands */ -type ResolvedLogTarget = { - org: string; - project: string; -}; - -/** - * Resolve org/project from parsed argument or auto-detection. - * - * Handles: - * - explicit: "org/project" → use directly - * - project-search: "project" → find project across all orgs - * - auto-detect: no input → use DSN detection or config defaults - * - * @throws {ContextError} When target cannot be resolved - */ -async function resolveLogTarget( - target: string | undefined, - cwd: string -): Promise { - const parsed = parseOrgProjectArg(target); - - switch (parsed.type) { - case "explicit": - return { org: parsed.org, project: parsed.project }; - - case "org-all": - throw new ContextError( - "Project", - `Please specify a project: sentry log list ${parsed.org}/` - ); - - 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 log list /${parsed.projectSlug}` - ); - } - - if (matches.length > 1) { - const options = matches - .map((m) => ` sentry log list ${m.orgSlug}/${m.slug}`) - .join("\n"); - throw new ContextError( - "Project", - `Found '${parsed.projectSlug}' in ${matches.length} organizations. Please specify:\n${options}` - ); - } - - // Safe: we checked matches.length === 1 above, so first element exists - const match = matches[0] as (typeof matches)[number]; - return { org: match.orgSlug, project: match.slug }; - } - - case "auto-detect": { - const resolved = await resolveOrgAndProject({ - cwd, - usageHint: USAGE_HINT, - }); - if (!resolved) { - throw new ContextError("Organization and project", USAGE_HINT); - } - return { org: resolved.org, project: resolved.project }; - } - - default: { - const _exhaustiveCheck: never = parsed; - throw new Error(`Unexpected parsed type: ${_exhaustiveCheck}`); - } - } -} - export const listCommand = buildCommand({ docs: { brief: "List logs from a project", @@ -341,7 +258,7 @@ export const listCommand = buildCommand({ flags: { limit: { kind: "parsed", - parse: validateLimit, + parse: parseLimit, brief: `Number of log entries (${MIN_LIMIT}-${MAX_LIMIT})`, default: String(DEFAULT_LIMIT), }, @@ -378,7 +295,11 @@ export const listCommand = buildCommand({ const { stdout, stderr, cwd, setContext } = this; // Resolve org/project from positional arg, config, or DSN auto-detection - const { org, project } = await resolveLogTarget(target, cwd); + const { org, project } = await resolveOrgProjectFromArg( + target, + cwd, + COMMAND_NAME + ); setContext([org], [project]); if (flags.follow) { diff --git a/src/commands/project/list.ts b/src/commands/project/list.ts index 2f96b2db6b..4223a3465e 100644 --- a/src/commands/project/list.ts +++ b/src/commands/project/list.ts @@ -23,20 +23,32 @@ import { type ParsedOrgProject, parseOrgProjectArg, } from "../../lib/arg-parsing.js"; -import { buildCommand, numberParser } from "../../lib/command.js"; +import { buildCommand } from "../../lib/command.js"; import { getDefaultOrganization } from "../../lib/db/defaults.js"; import { clearPaginationCursor, - getPaginationCursor, + escapeContextKeyValue, + resolveOrgCursor, setPaginationCursor, } from "../../lib/db/pagination.js"; -import { AuthError, ContextError, ValidationError } from "../../lib/errors.js"; +import { AuthError, ContextError } from "../../lib/errors.js"; import { calculateProjectColumnWidths, formatProjectRow, writeFooter, writeJson, } from "../../lib/formatters/index.js"; +import { + buildListLimitFlag, + LIST_BASE_ALIASES, + LIST_CURSOR_FLAG, + LIST_JSON_FLAG, + LIST_TARGET_POSITIONAL, +} from "../../lib/list-command.js"; +import { + dispatchOrgScopedList, + type ListCommandMeta, +} from "../../lib/org-list.js"; import { resolveAllTargets } from "../../lib/resolve-target.js"; import { getApiBaseUrl } from "../../lib/sentry-client.js"; import type { SentryProject, Writer } from "../../types/index.js"; @@ -204,36 +216,14 @@ export function buildContextKey( } } if (flags.platform) { - // Normalize to lowercase since platform filtering is case-insensitive - parts.push(`platform:${flags.platform.toLowerCase()}`); + // Normalize to lowercase since platform filtering is case-insensitive. + parts.push( + `platform:${escapeContextKeyValue(flags.platform.toLowerCase())}` + ); } return parts.join("|"); } -/** - * Resolve the cursor value from --cursor flag. - * Handles the magic "last" value by looking up the cached cursor. - */ -export function resolveCursor( - cursorFlag: string | undefined, - contextKey: string -): string | undefined { - if (!cursorFlag) { - return; - } - if (cursorFlag === "last") { - const cached = getPaginationCursor(PAGINATION_KEY, contextKey); - if (!cached) { - throw new ContextError( - "Pagination cursor", - "No saved cursor for this query. Run without --cursor first." - ); - } - return cached; - } - return cursorFlag; -} - /** Result of resolving organizations to fetch projects from */ type OrgResolution = { orgs: string[]; @@ -604,6 +594,14 @@ export function writeSelfHostedWarning( } } +/** Metadata used by the shared dispatch infrastructure for error messages and cursor keys. */ +const projectListMeta: ListCommandMeta = { + paginationKey: PAGINATION_KEY, + entityName: "project", + entityPlural: "projects", + commandPrefix: "sentry project list", +}; + export const listCommand = buildCommand({ docs: { brief: "List projects", @@ -623,36 +621,11 @@ export const listCommand = buildCommand({ " sentry project list --json # output as JSON", }, parameters: { - positional: { - kind: "tuple", - parameters: [ - { - placeholder: "target", - brief: "Target: /, /, or ", - parse: String, - optional: true, - }, - ], - }, + positional: LIST_TARGET_POSITIONAL, flags: { - limit: { - kind: "parsed", - parse: numberParser, - brief: "Maximum number of projects to list", - // Stricli requires string defaults (raw CLI input); numberParser converts to number - default: "30", - }, - json: { - kind: "boolean", - brief: "Output JSON", - default: false, - }, - cursor: { - kind: "parsed", - parse: String, - brief: 'Pagination cursor (use "last" to continue from previous page)', - optional: true, - }, + limit: buildListLimitFlag("projects"), + json: LIST_JSON_FLAG, + cursor: LIST_CURSOR_FLAG, platform: { kind: "parsed", parse: String, @@ -660,7 +633,7 @@ export const listCommand = buildCommand({ optional: true, }, }, - aliases: { n: "limit", p: "platform", c: "cursor" }, + aliases: { ...LIST_BASE_ALIASES, p: "platform" }, }, async func( this: SentryContext, @@ -671,46 +644,40 @@ export const listCommand = buildCommand({ const parsed = parseOrgProjectArg(target); - // Cursor pagination is only supported in org-all mode — check before resolving - if (flags.cursor && parsed.type !== "org-all") { - throw new ValidationError( - "The --cursor flag is only supported when listing projects for a specific organization " + - "(e.g., sentry project list /). " + - "Use 'sentry project list /' for paginated results.", - "cursor" - ); - } - - const contextKey = buildContextKey(parsed, flags, getApiBaseUrl()); - const cursor = resolveCursor(flags.cursor, contextKey); - - switch (parsed.type) { - case "auto-detect": - await handleAutoDetect(stdout, cwd, flags); - break; - - case "explicit": - await handleExplicit(stdout, parsed.org, parsed.project, flags); - break; - - case "org-all": - await handleOrgAll({ - stdout, - org: parsed.org, - flags, - contextKey, - cursor, - }); - break; - - case "project-search": - await handleProjectSearch(stdout, parsed.projectSlug, flags); - break; - - default: { - const _exhaustiveCheck: never = parsed; - throw new Error(`Unexpected parsed type: ${_exhaustiveCheck}`); - } - } + await dispatchOrgScopedList({ + config: projectListMeta, + stdout, + cwd, + flags, + parsed, + overrides: { + "auto-detect": (ctx) => handleAutoDetect(ctx.stdout, ctx.cwd, flags), + explicit: (ctx) => + handleExplicit(ctx.stdout, ctx.parsed.org, ctx.parsed.project, flags), + "org-all": (ctx) => { + // Build context key and resolve cursor only in org-all mode, after + // dispatchOrgScopedList has already validated --cursor is allowed here. + const contextKey = buildContextKey( + ctx.parsed, + flags, + getApiBaseUrl() + ); + const cursor = resolveOrgCursor( + flags.cursor, + PAGINATION_KEY, + contextKey + ); + return handleOrgAll({ + stdout: ctx.stdout, + org: ctx.parsed.org, + flags, + contextKey, + cursor, + }); + }, + "project-search": (ctx) => + handleProjectSearch(ctx.stdout, ctx.parsed.projectSlug, flags), + }, + }); }, }); diff --git a/src/commands/repo/list.ts b/src/commands/repo/list.ts index 58020d83c2..f185c101f7 100644 --- a/src/commands/repo/list.ts +++ b/src/commands/repo/list.ts @@ -1,301 +1,71 @@ /** * sentry repo list * - * List repositories in an organization. + * List repositories in an organization, with flexible targeting and cursor pagination. + * + * Supports: + * - Auto-detection from DSN/config + * - Org-scoped listing with cursor pagination (e.g., sentry/) + * - Project-scoped listing (e.g., sentry/cli) - lists repos for that project's org + * - Bare org slug (e.g., sentry) - lists repos for that org */ -import type { SentryContext } from "../../context.js"; -import { listOrganizations, listRepositories } 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 { + listRepositories, + listRepositoriesPaginated, +} from "../../lib/api-client.js"; +import { type Column, writeTable } from "../../lib/formatters/table.js"; +import { + buildOrgListCommand, + type OrgListCommandDocs, +} from "../../lib/list-command.js"; +import type { OrgListConfig } from "../../lib/org-list.js"; import type { SentryRepository, Writer } from "../../types/index.js"; -type ListFlags = { - readonly limit: number; - readonly json: boolean; -}; +/** Command key for pagination cursor storage */ +export const PAGINATION_KEY = "repo-list"; /** Repository with its organization context for display */ type RepositoryWithOrg = SentryRepository & { orgSlug?: string }; -/** - * Fetch repositories for a single organization. - * - * @param orgSlug - Organization slug to fetch repositories from - * @returns Repositories with org context attached - */ -async function fetchOrgRepositories( - orgSlug: string -): Promise { - const repos = await listRepositories(orgSlug); - return repos.map((r) => ({ ...r, orgSlug })); -} - -/** - * Fetch repositories for a single org, returning empty array on non-auth errors. - * Auth errors propagate so user sees "please log in" message. - */ -async function fetchOrgRepositoriesSafe( - orgSlug: string -): Promise { - try { - return await fetchOrgRepositories(orgSlug); - } catch (error) { - if (error instanceof AuthError) { - throw error; - } - return []; - } -} - -/** - * Fetch repositories from all accessible organizations. - * Skips orgs where the user lacks access. - * - * @returns Combined list of repositories from all accessible orgs - */ -async function fetchAllOrgRepositories(): Promise { - const orgs = await listOrganizations(); - const results: RepositoryWithOrg[] = []; - - for (const org of orgs) { - try { - const repos = await fetchOrgRepositories(org.slug); - results.push(...repos); - } catch (error) { - if (error instanceof AuthError) { - throw error; - } - // User may lack access to some orgs - } - } - - return results; -} - -/** Column widths for repository list display */ -type ColumnWidths = { - orgWidth: number; - nameWidth: number; - providerWidth: number; - statusWidth: number; -}; - -/** - * Calculate column widths for repository list display. - */ -function calculateColumnWidths(repos: RepositoryWithOrg[]): ColumnWidths { - const orgWidth = Math.max(...repos.map((r) => (r.orgSlug || "").length), 3); - const nameWidth = Math.max(...repos.map((r) => r.name.length), 4); - const providerWidth = Math.max( - ...repos.map((r) => r.provider.name.length), - 8 - ); - const statusWidth = Math.max(...repos.map((r) => r.status.length), 6); - return { orgWidth, nameWidth, providerWidth, statusWidth }; -} - -/** - * Write the column header row for repository list output. - */ -function writeHeader(stdout: Writer, widths: ColumnWidths): void { - const { orgWidth, nameWidth, providerWidth, statusWidth } = widths; - const org = "ORG".padEnd(orgWidth); - const name = "NAME".padEnd(nameWidth); - const provider = "PROVIDER".padEnd(providerWidth); - const status = "STATUS".padEnd(statusWidth); - stdout.write(`${org} ${name} ${provider} ${status} URL\n`); -} - -type WriteRowsOptions = ColumnWidths & { - stdout: Writer; - repos: RepositoryWithOrg[]; +/** Column definitions for the repository table. */ +const REPO_COLUMNS: Column[] = [ + { header: "ORG", value: (r) => r.orgSlug || "", minWidth: 3 }, + { header: "NAME", value: (r) => r.name, minWidth: 4 }, + { header: "PROVIDER", value: (r) => r.provider.name, minWidth: 8 }, + { header: "STATUS", value: (r) => r.status, minWidth: 6 }, + { header: "URL", value: (r) => r.url || "" }, +]; + +/** Shared config that plugs into the org-list framework. */ +const repoListConfig: OrgListConfig = { + paginationKey: PAGINATION_KEY, + entityName: "repository", + entityPlural: "repositories", + commandPrefix: "sentry repo list", + listForOrg: (org) => listRepositories(org), + listPaginated: (org, opts) => listRepositoriesPaginated(org, opts), + withOrg: (repo, orgSlug) => ({ ...repo, orgSlug }), + displayTable: (stdout: Writer, repos: RepositoryWithOrg[]) => + writeTable(stdout, repos, REPO_COLUMNS), }; -/** - * Write formatted repository rows to stdout. - */ -function writeRows(options: WriteRowsOptions): void { - const { stdout, repos, orgWidth, nameWidth, providerWidth, statusWidth } = - options; - for (const repo of repos) { - const org = (repo.orgSlug || "").padEnd(orgWidth); - const name = repo.name.padEnd(nameWidth); - const provider = repo.provider.name.padEnd(providerWidth); - const status = repo.status.padEnd(statusWidth); - const url = repo.url || ""; - stdout.write(`${org} ${name} ${provider} ${status} ${url}\n`); - } -} - -/** Result of resolving organizations to fetch repositories from */ -type OrgResolution = { - orgs: string[]; - footer?: string; - skippedSelfHosted?: number; +const docs: OrgListCommandDocs = { + brief: "List repositories", + fullDescription: + "List repositories connected to an organization.\n\n" + + "Target specification:\n" + + " sentry repo list # auto-detect from DSN or config\n" + + " sentry repo list / # list all repos in org (paginated)\n" + + " sentry repo list / # list repos in org (project context)\n" + + " sentry repo list # list repos in org\n\n" + + "Pagination:\n" + + " sentry repo list / -c last # continue from last page\n\n" + + "Examples:\n" + + " sentry repo list # auto-detect or list all\n" + + " sentry repo list my-org/ # list repositories in my-org (paginated)\n" + + " sentry repo list --limit 10\n" + + " sentry repo list --json", }; -/** - * Resolve which organizations to fetch repositories 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 repositories", - fullDescription: - "List repositories connected to an organization. If no organization is specified, " + - "uses the default organization or lists repositories from all accessible organizations.\n\n" + - "Examples:\n" + - " sentry repo list # auto-detect or list all\n" + - " sentry repo list my-org # list repositories in my-org\n" + - " sentry repo list --limit 10\n" + - " sentry repo 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 repositories 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 repositories from all orgs (or all accessible if none detected) - let allRepos: RepositoryWithOrg[]; - if (orgsToFetch.length > 0) { - const results = await Promise.all( - orgsToFetch.map(fetchOrgRepositoriesSafe) - ); - allRepos = results.flat(); - } else { - allRepos = await fetchAllOrgRepositories(); - } - - // Apply limit (limit is per-org when multiple orgs) - const limitCount = - orgsToFetch.length > 1 ? flags.limit * orgsToFetch.length : flags.limit; - const limited = allRepos.slice(0, limitCount); - - if (flags.json) { - writeJson(stdout, limited); - return; - } - - if (limited.length === 0) { - const msg = - orgsToFetch.length === 1 - ? `No repositories found in organization '${orgsToFetch[0]}'.\n` - : "No repositories found.\n"; - stdout.write(msg); - return; - } - - const widths = calculateColumnWidths(limited); - writeHeader(stdout, widths); - writeRows({ - stdout, - repos: limited, - ...widths, - }); - - if (allRepos.length > limited.length) { - stdout.write( - `\nShowing ${limited.length} of ${allRepos.length} repositories\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 repo list \n" - ); - } - - writeFooter( - stdout, - "Tip: Use 'sentry repo list ' to filter by organization" - ); - }, -}); +export const listCommand = buildOrgListCommand(repoListConfig, docs); diff --git a/src/commands/team/list.ts b/src/commands/team/list.ts index e56588fd69..a09d159984 100644 --- a/src/commands/team/list.ts +++ b/src/commands/team/list.ts @@ -1,292 +1,77 @@ /** * sentry team list * - * List teams in an organization. + * List teams in an organization, with flexible targeting and cursor pagination. + * + * Supports: + * - Auto-detection from DSN/config + * - Org-scoped listing with cursor pagination (e.g., sentry/) + * - Project-scoped listing (e.g., sentry/cli) - lists teams for that project's org + * - Cross-org project search (e.g., sentry) */ -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 { + listProjectTeams, + listTeams, + listTeamsPaginated, +} from "../../lib/api-client.js"; +import { type Column, writeTable } from "../../lib/formatters/table.js"; +import { + buildOrgListCommand, + type OrgListCommandDocs, +} from "../../lib/list-command.js"; +import type { OrgListConfig } from "../../lib/org-list.js"; import type { SentryTeam, Writer } from "../../types/index.js"; -type ListFlags = { - readonly limit: number; - readonly json: boolean; -}; +/** Command key for pagination cursor storage */ +export const PAGINATION_KEY = "team-list"; /** 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[]; +/** Column definitions for the team table. */ +const TEAM_COLUMNS: Column[] = [ + { header: "ORG", value: (t) => t.orgSlug || "", minWidth: 3 }, + { header: "SLUG", value: (t) => t.slug, minWidth: 4 }, + { header: "NAME", value: (t) => t.name, minWidth: 4 }, + { + header: "MEMBERS", + value: (t) => String(t.memberCount ?? ""), + align: "right", + minWidth: 7, + }, +]; + +/** Shared config that plugs into the org-list framework. */ +const teamListConfig: OrgListConfig = { + paginationKey: PAGINATION_KEY, + entityName: "team", + entityPlural: "teams", + commandPrefix: "sentry team list", + listForOrg: (org) => listTeams(org), + listPaginated: (org, opts) => listTeamsPaginated(org, opts), + withOrg: (team, orgSlug) => ({ ...team, orgSlug }), + displayTable: (stdout: Writer, teams: TeamWithOrg[]) => + writeTable(stdout, teams, TEAM_COLUMNS), + listForProject: (org, project) => listProjectTeams(org, project), }; -/** - * 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; +const docs: OrgListCommandDocs = { + brief: "List teams", + fullDescription: + "List teams in an organization.\n\n" + + "Target specification:\n" + + " sentry team list # auto-detect from DSN or config\n" + + " sentry team list / # list all teams in org (paginated)\n" + + " sentry team list / # list teams in org (project context)\n" + + " sentry team list # list teams in org\n\n" + + "Pagination:\n" + + " sentry team list / -c last # continue from last page\n\n" + + "Examples:\n" + + " sentry team list # auto-detect or list all\n" + + " sentry team list my-org/ # list teams in my-org (paginated)\n" + + " sentry team list --limit 10\n" + + " sentry team list --json", }; -/** - * 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" - ); - }, -}); +export const listCommand = buildOrgListCommand(teamListConfig, docs); diff --git a/src/commands/trace/list.ts b/src/commands/trace/list.ts index 9c03dc180e..d3cf2b66c6 100644 --- a/src/commands/trace/list.ts +++ b/src/commands/trace/list.ts @@ -6,16 +6,15 @@ import { buildCommand } from "@stricli/core"; import type { SentryContext } from "../../context.js"; -import { findProjectsBySlug, listTransactions } from "../../lib/api-client.js"; -import { parseOrgProjectArg } from "../../lib/arg-parsing.js"; -import { ContextError } from "../../lib/errors.js"; +import { listTransactions } from "../../lib/api-client.js"; +import { validateLimit } from "../../lib/arg-parsing.js"; import { formatTraceRow, formatTracesHeader, writeFooter, writeJson, } from "../../lib/formatters/index.js"; -import { resolveOrgAndProject } from "../../lib/resolve-target.js"; +import { resolveOrgProjectFromArg } from "../../lib/resolve-target.js"; type ListFlags = { readonly limit: number; @@ -29,9 +28,6 @@ type SortValue = "date" | "duration"; /** Accepted values for the --sort flag */ const VALID_SORT_VALUES: SortValue[] = ["date", "duration"]; -/** Usage hint for ContextError messages */ -const USAGE_HINT = "sentry trace list /"; - /** Maximum allowed value for --limit flag */ const MAX_LIMIT = 1000; @@ -41,18 +37,14 @@ const MIN_LIMIT = 1; /** Default number of traces to show */ const DEFAULT_LIMIT = 20; +/** Command name used in resolver error messages */ +const COMMAND_NAME = "trace list"; + /** - * Validate that --limit value is within allowed range. - * - * @throws Error if value is outside MIN_LIMIT..MAX_LIMIT range - * @internal Exported for testing + * Parse --limit flag, delegating range validation to shared utility. */ -export function validateLimit(value: string): number { - const num = Number.parseInt(value, 10); - if (Number.isNaN(num) || num < MIN_LIMIT || num > MAX_LIMIT) { - throw new Error(`--limit must be between ${MIN_LIMIT} and ${MAX_LIMIT}`); - } - return num; +function parseLimit(value: string): number { + return validateLimit(value, MIN_LIMIT, MAX_LIMIT); } /** @@ -70,83 +62,6 @@ export function parseSort(value: string): SortValue { return value as SortValue; } -/** Resolved org and project for trace commands */ -type ResolvedTraceTarget = { - org: string; - project: string; -}; - -/** - * Resolve org/project from parsed argument or auto-detection. - * - * Handles: - * - explicit: "org/project" -> use directly - * - project-search: "project" -> find project across all orgs - * - auto-detect: no input -> use DSN detection or config defaults - * - * @throws {ContextError} When target cannot be resolved - * @internal Exported for testing - */ -export async function resolveTraceTarget( - target: string | undefined, - cwd: string -): Promise { - const parsed = parseOrgProjectArg(target); - - switch (parsed.type) { - case "explicit": - return { org: parsed.org, project: parsed.project }; - - case "org-all": - throw new ContextError( - "Project", - `Please specify a project: sentry trace list ${parsed.org}/` - ); - - case "project-search": { - 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 trace list /${parsed.projectSlug}` - ); - } - - if (matches.length > 1) { - const options = matches - .map((m) => ` sentry trace list ${m.orgSlug}/${m.slug}`) - .join("\n"); - throw new ContextError( - "Project", - `Found '${parsed.projectSlug}' in ${matches.length} organizations. Please specify:\n${options}` - ); - } - - // Safe: we checked matches.length === 1 above, so first element exists - const match = matches[0] as (typeof matches)[number]; - return { org: match.orgSlug, project: match.slug }; - } - - case "auto-detect": { - const resolved = await resolveOrgAndProject({ - cwd, - usageHint: USAGE_HINT, - }); - if (!resolved) { - throw new ContextError("Organization and project", USAGE_HINT); - } - return { org: resolved.org, project: resolved.project }; - } - - default: { - const _exhaustiveCheck: never = parsed; - throw new Error(`Unexpected parsed type: ${_exhaustiveCheck}`); - } - } -} - export const listCommand = buildCommand({ docs: { brief: "List recent traces in a project", @@ -177,7 +92,7 @@ export const listCommand = buildCommand({ flags: { limit: { kind: "parsed", - parse: validateLimit, + parse: parseLimit, brief: `Number of traces (${MIN_LIMIT}-${MAX_LIMIT})`, default: String(DEFAULT_LIMIT), }, @@ -209,7 +124,11 @@ export const listCommand = buildCommand({ const { stdout, cwd, setContext } = this; // Resolve org/project from positional arg, config, or DSN auto-detection - const { org, project } = await resolveTraceTarget(target, cwd); + const { org, project } = await resolveOrgProjectFromArg( + target, + cwd, + COMMAND_NAME + ); setContext([org], [project]); const traces = await listTransactions(org, project, { diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index 0aec7b3ebc..4d96155d9b 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -12,6 +12,7 @@ import { listAnOrganization_sIssues, listAnOrganization_sTeams, listAProject_sClientKeys, + listAProject_sTeams, queryExploreEventsInTableFormat, resolveAShortId, retrieveAnEventForAProject, @@ -685,6 +686,78 @@ export async function listTeams(orgSlug: string): Promise { return data as unknown as SentryTeam[]; } +/** + * List teams in an organization with pagination control. + * Returns a single page of results with cursor metadata. + * + * @param orgSlug - Organization slug + * @param options - Pagination options + * @returns Single page of teams with cursor metadata + */ +export function listTeamsPaginated( + orgSlug: string, + options: { cursor?: string; perPage?: number } = {} +): Promise> { + return orgScopedRequestPaginated( + `/organizations/${orgSlug}/teams/`, + { + params: { + per_page: options.perPage ?? 25, + cursor: options.cursor, + }, + } + ); +} + +/** + * List teams that have access to a specific project. + * + * Uses the project-scoped endpoint (`/projects/{org}/{project}/teams/`) which + * returns only the teams with access to that project, not all teams in the org. + * + * @param orgSlug - Organization slug + * @param projectSlug - Project slug + * @returns Teams with access to the project + */ +export async function listProjectTeams( + orgSlug: string, + projectSlug: string +): Promise { + const config = await getOrgSdkConfig(orgSlug); + const result = await listAProject_sTeams({ + ...config, + path: { + organization_id_or_slug: orgSlug, + project_id_or_slug: projectSlug, + }, + }); + const data = unwrapResult(result, "Failed to list project teams"); + return data as unknown as SentryTeam[]; +} + +/** + * List repositories in an organization with pagination control. + * Returns a single page of results with cursor metadata. + * + * @param orgSlug - Organization slug + * @param options - Pagination options + * @returns Single page of repositories with cursor metadata + */ +export function listRepositoriesPaginated( + orgSlug: string, + options: { cursor?: string; perPage?: number } = {} +): Promise> { + return orgScopedRequestPaginated( + `/organizations/${orgSlug}/repos/`, + { + params: { + per_page: options.perPage ?? 25, + cursor: options.cursor, + }, + } + ); +} + /** * Search for projects matching a slug across all accessible organizations. * @@ -933,6 +1006,50 @@ export async function listIssues( return data as unknown as SentryIssue[]; } +/** + * List issues for a project with pagination control. + * Returns a single page of results with cursor metadata for manual pagination. + * Uses the org-scoped endpoint with a `project:{slug}` filter. + * + * @param orgSlug - Organization slug + * @param projectSlug - Project slug + * @param options - Query and pagination options + * @returns Single page of issues with cursor metadata + */ +export function listIssuesPaginated( + orgSlug: string, + projectSlug: string, + options: { + query?: string; + cursor?: string; + perPage?: number; + sort?: "date" | "new" | "freq" | "user"; + statsPeriod?: string; + } = {} +): Promise> { + // Only add project filter when projectSlug is non-empty; an empty slug would + // produce "project:" (a truthy string that .filter(Boolean) won't remove), + // sending a malformed query to the API for org-wide listing. + const projectFilter = projectSlug ? `project:${projectSlug}` : ""; + const fullQuery = [projectFilter, options.query].filter(Boolean).join(" "); + + return orgScopedRequestPaginated( + `/organizations/${orgSlug}/issues/`, + { + params: { + // Convert empty string to undefined so ky omits the param entirely; + // sending `query=` causes the Sentry API to behave differently than + // omitting the parameter. + query: fullQuery || undefined, + cursor: options.cursor, + per_page: options.perPage ?? 25, + sort: options.sort, + statsPeriod: options.statsPeriod, + }, + } + ); +} + /** * Get a specific issue by numeric ID. */ @@ -1114,6 +1231,9 @@ export async function listTransactions( dataset: "transactions", field: TRANSACTION_FIELDS, project: isNumericProject ? projectSlug : undefined, + // Convert empty string to undefined so ky omits the param entirely; + // sending `query=` causes the Sentry API to behave differently than + // omitting the parameter. query: fullQuery || undefined, per_page: options.limit || 10, statsPeriod: options.statsPeriod ?? "7d", diff --git a/src/lib/arg-parsing.ts b/src/lib/arg-parsing.ts index 157b52b337..adeab43082 100644 --- a/src/lib/arg-parsing.ts +++ b/src/lib/arg-parsing.ts @@ -11,6 +11,31 @@ import type { ParsedSentryUrl } from "./sentry-url-parser.js"; import { applySentryUrlContext, parseSentryUrl } from "./sentry-url-parser.js"; import { isAllDigits } from "./utils.js"; +/** + * Validate that a CLI --limit flag value is within an allowed range. + * + * Used by commands that need API-side limiting (trace list, log list) where + * the value is passed directly to the API as `per_page`. + * + * @param value - Raw string input from CLI flag + * @param min - Minimum allowed value (inclusive) + * @param max - Maximum allowed value (inclusive) + * @returns Parsed integer + * @throws {Error} If value is NaN or outside [min, max] + * + * @example + * validateLimit("50", 1, 1000) // 50 + * validateLimit("0", 1, 1000) // throws + * validateLimit("abc", 1, 1000) // throws + */ +export function validateLimit(value: string, min: number, max: number): number { + const num = Number.parseInt(value, 10); + if (Number.isNaN(num) || num < min || num > max) { + throw new Error(`--limit must be between ${min} and ${max}`); + } + return num; +} + /** Default span depth when no value is provided */ const DEFAULT_SPAN_DEPTH = 3; diff --git a/src/lib/db/pagination.ts b/src/lib/db/pagination.ts index 06d2668018..403bdd4666 100644 --- a/src/lib/db/pagination.ts +++ b/src/lib/db/pagination.ts @@ -5,8 +5,13 @@ * using a composite primary key so different contexts (e.g., different orgs) * maintain independent cursors. * Cursors expire after a short TTL to prevent stale pagination. + * + * Also exports shared helpers for building context keys and resolving cursor + * flags, used by list commands that support cursor-based pagination. */ +import { ContextError } from "../errors.js"; +import { getApiBaseUrl } from "../sentry-client.js"; import { getDatabase } from "./index.js"; import { runUpsert } from "./utils.js"; @@ -97,3 +102,64 @@ export function clearPaginationCursor( "DELETE FROM pagination_cursors WHERE command_key = ? AND context = ?" ).run(commandKey, context); } + +/** + * Escape a user-provided value for safe inclusion in a context key. + * + * Context keys use `|` as a segment delimiter. If user input (e.g., a search + * query or platform filter) contains `|`, it must be escaped to prevent + * delimiter injection that could cause cache collisions between different + * query combinations. + * + * @param value - Raw user-provided string + * @returns Escaped string with `|` replaced by `%7C` + */ +export function escapeContextKeyValue(value: string): string { + return value.replaceAll("|", "%7C"); +} + +/** + * Build a context key for org-scoped pagination cursor storage. + * + * Encodes the API base URL and org slug so cursors from different hosts or + * orgs are never mixed up in the cursor cache. + * + * @param org - Organization slug + * @returns Composite context key string + */ +export function buildOrgContextKey(org: string): string { + return `host:${getApiBaseUrl()}|type:org:${org}`; +} + +/** + * Resolve the cursor value from a `--cursor` flag. + * + * Handles the magic `"last"` value by looking up the cached cursor for the + * given context key. Throws a {@link ContextError} if `"last"` is requested + * but no cursor has been cached yet. + * + * @param cursorFlag - Raw value of the `--cursor` flag (undefined if not set) + * @param commandKey - Command identifier used for cursor storage + * @param contextKey - Serialized query context used for cursor storage + * @returns Resolved cursor string, or `undefined` if no cursor was specified + */ +export function resolveOrgCursor( + cursorFlag: string | undefined, + commandKey: string, + contextKey: string +): string | undefined { + if (!cursorFlag) { + return; + } + if (cursorFlag === "last") { + const cached = getPaginationCursor(commandKey, contextKey); + if (!cached) { + throw new ContextError( + "Pagination cursor", + "No saved cursor for this query. Run without --cursor first." + ); + } + return cached; + } + return cursorFlag; +} diff --git a/src/lib/formatters/index.ts b/src/lib/formatters/index.ts index 2a52393b22..9dadb5306d 100644 --- a/src/lib/formatters/index.ts +++ b/src/lib/formatters/index.ts @@ -11,4 +11,5 @@ export * from "./json.js"; export * from "./log.js"; export * from "./output.js"; export * from "./seer.js"; +export * from "./table.js"; export * from "./trace.js"; diff --git a/src/lib/formatters/table.ts b/src/lib/formatters/table.ts new file mode 100644 index 0000000000..4bb07a857e --- /dev/null +++ b/src/lib/formatters/table.ts @@ -0,0 +1,74 @@ +/** + * Generic column-based table renderer. + * + * Replaces the duplicated calculateColumnWidths / writeHeader / writeRows + * pattern used across team, repo, and project list commands. + */ + +import type { Writer } from "../../types/index.js"; + +/** + * Describes a single column in a table. + * + * @template T - Row data type + */ +export type Column = { + /** Column header label (e.g., "ORG", "SLUG") */ + header: string; + /** Extract the display value from a row */ + value: (item: T) => string; + /** Column alignment. Defaults to "left". */ + align?: "left" | "right"; + /** Minimum column width (header width is always respected) */ + minWidth?: number; +}; + +/** + * Render items as a formatted table with auto-sized columns. + * + * Column widths are computed as `max(header.length, minWidth, longestValue)`. + * Columns are separated by two spaces. No trailing separator after the last column. + * + * @param stdout - Output writer + * @param items - Row data + * @param columns - Column definitions (ordering determines display order) + */ +export function writeTable( + stdout: Writer, + items: T[], + columns: Column[] +): void { + // Pre-compute widths + const widths = columns.map((col) => { + const headerLen = col.header.length; + const minLen = col.minWidth ?? 0; + const maxValue = items.reduce( + (max, item) => Math.max(max, col.value(item).length), + 0 + ); + return Math.max(headerLen, minLen, maxValue); + }); + + // Header row + const headerCells = columns.map((col, i) => + pad(col.header, widths[i] as number, col.align) + ); + stdout.write(`${headerCells.join(" ")}\n`); + + // Data rows + for (const item of items) { + const cells = columns.map((col, i) => + pad(col.value(item), widths[i] as number, col.align) + ); + stdout.write(`${cells.join(" ")}\n`); + } +} + +/** Pad a string to width with the given alignment. */ +function pad( + value: string, + width: number, + align: "left" | "right" = "left" +): string { + return align === "right" ? value.padStart(width) : value.padEnd(width); +} diff --git a/src/lib/list-command.ts b/src/lib/list-command.ts new file mode 100644 index 0000000000..dbc06d12c6 --- /dev/null +++ b/src/lib/list-command.ts @@ -0,0 +1,155 @@ +/** + * Shared building blocks for org-scoped list commands. + * + * Provides reusable Stricli parameter definitions (target positional, common + * flags, aliases) and a `buildOrgListCommand` factory for commands whose + * entire `func` body is handled by `dispatchOrgScopedList`. + * + * Level A — shared constants (used by all four list commands): + * LIST_TARGET_POSITIONAL, LIST_JSON_FLAG, LIST_CURSOR_FLAG, + * buildListLimitFlag, LIST_BASE_ALIASES + * + * Level B — full command builder (team / repo only): + * buildOrgListCommand + */ + +import type { Aliases, Command } from "@stricli/core"; +import type { SentryContext } from "../context.js"; +import { parseOrgProjectArg } from "./arg-parsing.js"; +import { buildCommand, numberParser } from "./command.js"; +import { dispatchOrgScopedList, type OrgListConfig } from "./org-list.js"; + +// --------------------------------------------------------------------------- +// Level A: shared parameter / flag definitions +// --------------------------------------------------------------------------- + +/** + * Positional `target` parameter shared by all list commands. + * + * Accepts `/`, `/`, or bare `` / ``. + * Marked optional so the command falls back to auto-detection when omitted. + */ +export const LIST_TARGET_POSITIONAL = { + kind: "tuple" as const, + parameters: [ + { + placeholder: "target", + brief: "Target: /, /, or ", + parse: String, + optional: true as const, + }, + ], +}; + +/** + * The `--json` flag shared by all list commands. + * Outputs machine-readable JSON instead of a human-readable table. + */ +export const LIST_JSON_FLAG = { + kind: "boolean" as const, + brief: "Output JSON", + default: false, +} as const; + +/** + * The `--cursor` / `-c` flag shared by all list commands. + * + * Accepts an opaque cursor string or the special value `"last"` to continue + * from the previous page. Only meaningful in `/` (org-all) mode. + */ +export const LIST_CURSOR_FLAG = { + kind: "parsed" as const, + parse: String, + brief: 'Pagination cursor (use "last" to continue from previous page)', + optional: true as const, +}; + +/** + * Build the `--limit` / `-n` flag for a list command. + * + * @param entityPlural - Plural entity name used in the brief (e.g. "teams") + * @param defaultValue - Default limit as a string (default: "30") + */ +export function buildListLimitFlag( + entityPlural: string, + defaultValue = "30" +): { + kind: "parsed"; + parse: typeof numberParser; + brief: string; + default: string; +} { + return { + kind: "parsed", + parse: numberParser, + brief: `Maximum number of ${entityPlural} to list`, + default: defaultValue, + }; +} + +/** + * Alias map shared by all list commands. + * `-n` → `--limit`, `-c` → `--cursor`. + * + * Commands with additional flags should spread this and add their own aliases: + * ```ts + * aliases: { ...LIST_BASE_ALIASES, p: "platform" } + * ``` + */ +export const LIST_BASE_ALIASES: Aliases = { n: "limit", c: "cursor" }; + +// --------------------------------------------------------------------------- +// Level B: full command builder for dispatchOrgScopedList-based commands +// --------------------------------------------------------------------------- + +/** Documentation strings for a list command built with `buildOrgListCommand`. */ +export type OrgListCommandDocs = { + /** One-line description shown in `--help` summaries. */ + readonly brief: string; + /** Multi-line description shown in the command's own `--help` output. */ + readonly fullDescription?: string; +}; + +/** + * Build a complete Stricli command whose entire `func` body delegates to + * `dispatchOrgScopedList`. + * + * This covers the team and repo list commands, where all runtime behaviour is + * encapsulated in the shared org-list framework. The resulting command has: + * - An optional positional `target` argument + * - `--limit` / `-n`, `--json`, `--cursor` / `-c` flags + * - A `func` that calls `parseOrgProjectArg` then `dispatchOrgScopedList` + * + * @param config - The `OrgListConfig` that drives fetching and display + * @param docs - Brief and optional full description for `--help` + */ +export function buildOrgListCommand( + config: OrgListConfig, + docs: OrgListCommandDocs +): Command { + return buildCommand({ + docs, + parameters: { + positional: LIST_TARGET_POSITIONAL, + flags: { + limit: buildListLimitFlag(config.entityPlural), + json: LIST_JSON_FLAG, + cursor: LIST_CURSOR_FLAG, + }, + aliases: LIST_BASE_ALIASES, + }, + async func( + this: SentryContext, + flags: { + readonly limit: number; + readonly json: boolean; + readonly cursor?: string; + }, + target?: string + ): Promise { + const { stdout, cwd } = this; + const parsed = parseOrgProjectArg(target); + await dispatchOrgScopedList({ config, stdout, cwd, flags, parsed }); + }, + }); +} diff --git a/src/lib/org-list.ts b/src/lib/org-list.ts new file mode 100644 index 0000000000..316e8a9b35 --- /dev/null +++ b/src/lib/org-list.ts @@ -0,0 +1,760 @@ +/** + * Shared infrastructure for org-scoped list commands (team, repo, project, issue, …). + * + * ## Config types + * + * Commands that rely entirely on default handlers supply a full {@link OrgListConfig}. + * Commands that override every mode only need {@link ListCommandMeta} (metadata used + * for error messages and cursor keys). + * + * ## Dispatch + * + * {@link dispatchOrgScopedList} merges a map of default handlers with caller-supplied + * {@link ModeOverrides} using `{ ...defaults, ...overrides }`, then calls the handler + * for the current parsed target type. This lets any command replace exactly the modes + * it needs to customise while inheriting the rest. + * + * ## Default handler behaviour + * + * | Mode | Default behaviour | + * |----------------|--------------------------------------------------------------------------| + * | auto-detect | Resolve orgs from DSN/config; fetch from all, then display table | + * | explicit | If `listForProject` provided, use project-scoped fetch; else org-scoped | + * | project-search | Find project via `findProjectsBySlug`; use project or org-scoped fetch | + * | org-all | Cursor-paginated single-org listing | + */ + +import type { Writer } from "../types/index.js"; +import { + findProjectsBySlug, + listOrganizations, + type PaginatedResponse, +} from "./api-client.js"; +import type { ParsedOrgProject } from "./arg-parsing.js"; +import { + buildOrgContextKey, + clearPaginationCursor, + resolveOrgCursor, + setPaginationCursor, +} from "./db/pagination.js"; +import { AuthError, ContextError, ValidationError } from "./errors.js"; +import { writeFooter, writeJson } from "./formatters/index.js"; +import { resolveOrgsForListing } from "./resolve-target.js"; + +// --------------------------------------------------------------------------- +// Config types +// --------------------------------------------------------------------------- + +/** + * Metadata required by all list commands. + * + * Commands that override every dispatch mode can provide just this — the + * metadata is used for cursor storage keys, error messages, and usage hints. + */ +export type ListCommandMeta = { + /** Key stored in the pagination cursor table (e.g., "team-list") */ + paginationKey: string; + /** Singular entity name for messages (e.g., "team") */ + entityName: string; + /** Plural entity name for messages (e.g., "teams") */ + entityPlural: string; + /** CLI command prefix for hints (e.g., "sentry team list") */ + commandPrefix: string; +}; + +/** Minimal flags required by the shared infrastructure. */ +export type BaseListFlags = { + readonly limit: number; + readonly json: boolean; + readonly cursor?: string; +}; + +/** + * Full configuration for an org-scoped list command using default handlers. + * + * @template TEntity Raw entity type from the API (e.g., SentryTeam) + * @template TWithOrg Entity with orgSlug attached for display + */ +export type OrgListConfig = ListCommandMeta & { + /** + * Fetch all entities for one org (non-paginated). + * @returns Raw entities from the API + */ + listForOrg: (orgSlug: string) => Promise; + + /** + * Fetch one page of entities for an org (paginated). + * @returns Paginated response with cursor info + */ + listPaginated: ( + orgSlug: string, + opts: { cursor?: string; perPage: number } + ) => Promise>; + + /** + * Attach org context to a raw entity for display. + * Typically `{ ...entity, orgSlug }`. + */ + withOrg: (entity: TEntity, orgSlug: string) => TWithOrg; + + /** + * Render a list of entities as a formatted table. + * Called by all human-output paths. + */ + displayTable: (stdout: Writer, items: TWithOrg[]) => void; + + /** + * Fetch entities scoped to a specific project (optional). + * + * When provided: + * - `explicit` mode (`org/project`) fetches project-scoped entities instead + * of all entities in the org. + * - `project-search` mode fetches project-scoped entities after finding the + * project via cross-org search. + * + * When absent: + * - `explicit` mode falls back to org-scoped listing with a note that the + * entity type is org-scoped and the project part is ignored. + * - `project-search` mode falls back to org-scoped listing from the found + * project's parent org. + */ + listForProject?: (orgSlug: string, projectSlug: string) => Promise; +}; + +// --------------------------------------------------------------------------- +// Mode handler types +// --------------------------------------------------------------------------- + +/** Extract a specific variant from the {@link ParsedOrgProject} union by its `type` discriminant. */ +export type ParsedVariant = Extract< + ParsedOrgProject, + { type: T } +>; + +/** + * Context object passed to every mode handler by the dispatcher. + * + * Contains the correctly-narrowed parsed variant plus shared I/O and flags, + * so handlers don't need to close over these values from their parent scope. + * Commands that need additional fields (e.g. `setContext`, `stderr`) can + * spread the context and add their own: `(ctx) => handle({ ...ctx, extra })`. + */ +export type HandlerContext< + T extends ParsedOrgProject["type"] = ParsedOrgProject["type"], +> = { + /** Correctly-narrowed parsed target for this mode. */ + parsed: ParsedVariant; + /** Standard output writer. */ + stdout: Writer; + /** Current working directory (for DSN auto-detection). */ + cwd: string; + /** Shared list command flags (limit, json, cursor). */ + flags: BaseListFlags; +}; + +/** + * A dispatch handler that receives a {@link HandlerContext} with the + * correctly-narrowed parsed variant for its mode. + * + * The dispatcher guarantees `ctx.parsed.type` matches the handler key, so + * callers can safely access variant-specific fields (e.g. `.org`, `.projectSlug`) + * without runtime checks or manual casts. + */ +export type ModeHandler< + T extends ParsedOrgProject["type"] = ParsedOrgProject["type"], +> = (ctx: HandlerContext) => Promise; + +/** + * Complete handler map — one handler per parsed target type. + * Each handler receives a {@link HandlerContext} with the corresponding + * {@link ParsedVariant}. + */ +export type ModeHandlerMap = { + [K in ParsedOrgProject["type"]]: ModeHandler; +}; + +/** + * Partial handler map for overriding specific dispatch modes. + * + * Provide only the modes you need to customise; the rest will use + * the default handlers from {@link buildDefaultHandlers}. + */ +export type ModeOverrides = { + [K in ParsedOrgProject["type"]]?: ModeHandler; +}; + +// --------------------------------------------------------------------------- +// Type guard +// --------------------------------------------------------------------------- + +/** + * Narrows `ListCommandMeta | OrgListConfig` to a full `OrgListConfig`. + * Checks for the presence of `listForOrg` which only the full config has. + */ +export function isOrgListConfig( + config: ListCommandMeta | OrgListConfig +): config is OrgListConfig { + return "listForOrg" in config; +} + +// --------------------------------------------------------------------------- +// Fetch helpers (exported for direct use in tests and commands) +// --------------------------------------------------------------------------- + +/** + * Fetch entities for a single org, returning empty array on non-auth errors. + * Auth errors propagate so the user sees "please log in". + */ +export async function fetchOrgSafe( + config: OrgListConfig, + orgSlug: string +): Promise { + try { + const items = await config.listForOrg(orgSlug); + return items.map((item) => config.withOrg(item, orgSlug)); + } catch (error) { + if (error instanceof AuthError) { + throw error; + } + return []; + } +} + +/** + * Fetch entities from all accessible organisations. + * Skips orgs where the user lacks access (non-auth errors are swallowed). + */ +export async function fetchAllOrgs( + config: OrgListConfig +): Promise { + const orgs = await listOrganizations(); + const results = await Promise.all( + orgs.map((org) => fetchOrgSafe(config, org.slug)) + ); + return results.flat(); +} + +// --------------------------------------------------------------------------- +// Default handlers +// --------------------------------------------------------------------------- + +/** Formats the "next page" hint used in org-all output. */ +function nextPageHint(commandPrefix: string, org: string): string { + return `${commandPrefix} ${org}/ -c last`; +} + +/** Options for {@link handleOrgAll}. */ +type OrgAllOptions = { + config: OrgListConfig; + stdout: Writer; + org: string; + flags: BaseListFlags; + contextKey: string; + cursor: string | undefined; +}; + +/** + * Handle org-all mode: cursor-paginated listing for a single org. + */ +export async function handleOrgAll( + options: OrgAllOptions +): Promise { + const { config, stdout, org, flags, contextKey, cursor } = options; + + const response = await config.listPaginated(org, { + cursor, + perPage: flags.limit, + }); + + const { data: rawItems, nextCursor } = response; + // Attach org context to each entity so displayTable can show the ORG column + const items = rawItems.map((entity) => config.withOrg(entity, org)); + const hasMore = !!nextCursor; + + if (nextCursor) { + setPaginationCursor(config.paginationKey, contextKey, nextCursor); + } else { + clearPaginationCursor(config.paginationKey, contextKey); + } + + if (flags.json) { + const output = hasMore + ? { data: items, nextCursor, hasMore: true } + : { data: items, hasMore: false }; + writeJson(stdout, output); + return; + } + + if (items.length === 0) { + if (hasMore) { + stdout.write( + `No ${config.entityPlural} on this page. Try the next page: ${nextPageHint(config.commandPrefix, org)}\n` + ); + } else { + stdout.write( + `No ${config.entityPlural} found in organization '${org}'.\n` + ); + } + return; + } + + config.displayTable(stdout, items); + + if (hasMore) { + stdout.write( + `\nShowing ${items.length} ${config.entityPlural} (more available)\n` + ); + stdout.write(`Next page: ${nextPageHint(config.commandPrefix, org)}\n`); + } else { + stdout.write(`\nShowing ${items.length} ${config.entityPlural}\n`); + } +} + +/** + * Handle auto-detect mode: resolve orgs from config/DSN, fetch all entities. + */ +export async function handleAutoDetect( + config: OrgListConfig, + stdout: Writer, + cwd: string, + flags: BaseListFlags +): Promise { + const { + orgs: orgsToFetch, + footer, + skippedSelfHosted, + } = await resolveOrgsForListing(undefined, cwd); + + let allItems: TWithOrg[]; + if (orgsToFetch.length > 0) { + const results = await Promise.all( + orgsToFetch.map((org) => fetchOrgSafe(config, org)) + ); + allItems = results.flat(); + } else { + allItems = await fetchAllOrgs(config); + } + + const limitCount = + orgsToFetch.length > 1 ? flags.limit * orgsToFetch.length : flags.limit; + const limited = allItems.slice(0, limitCount); + + if (flags.json) { + writeJson(stdout, limited); + return; + } + + if (limited.length === 0) { + const msg = + orgsToFetch.length === 1 + ? `No ${config.entityPlural} found in organization '${orgsToFetch[0]}'.\n` + : `No ${config.entityPlural} found.\n`; + stdout.write(msg); + return; + } + + config.displayTable(stdout, limited); + + if (allItems.length > limited.length) { + stdout.write( + `\nShowing ${limited.length} of ${allItems.length} ${config.entityPlural}\n` + ); + } + + if (footer) { + stdout.write(`\n${footer}\n`); + } + + if (skippedSelfHosted) { + stdout.write( + `\nNote: ${skippedSelfHosted} DSN(s) could not be resolved. ` + + `Specify the organization explicitly: ${config.commandPrefix} /\n` + ); + } + + writeFooter( + stdout, + `Tip: Use '${config.commandPrefix} /' to filter by organization` + ); +} + +/** Options for {@link displayFetchedItems}. */ +type DisplayFetchedItemsOptions = { + config: OrgListConfig; + stdout: Writer; + items: TWithOrg[]; + flags: BaseListFlags; + /** Human-readable context for "No X found in