From 138b0739186a8addda89927fd167cfa9802d356c Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Wed, 18 Feb 2026 11:06:27 +0000 Subject: [PATCH 01/22] fix(test): pass through real formatMultipleProjectsFooter in mock Bun's mock.module() leaks across test files in the same run. The simplified stub for formatMultipleProjectsFooter in the dsn/index.js mock was poisoning errors.test.ts, causing 4 failures in test:isolated. Import the real function from its source file (dsn/errors.js) and pass it through the mock so the leaked version retains real behavior. Fixes #258 --- test/isolated/resolve-target.test.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/test/isolated/resolve-target.test.ts b/test/isolated/resolve-target.test.ts index 07d94cbfb2..5d3c9a07f8 100644 --- a/test/isolated/resolve-target.test.ts +++ b/test/isolated/resolve-target.test.ts @@ -10,6 +10,13 @@ import { beforeEach, describe, expect, mock, test } from "bun:test"; +// IMPORTANT: Import the real formatMultipleProjectsFooter from its source file +// (not the barrel dsn/index.js). We pass this through the mock below so that +// if Bun leaks the mock.module() into other test files (which it does — see +// https://github.com/getsentry/cli/issues/258), the leaked version still has +// the real behavior instead of a simplified stub. +import { formatMultipleProjectsFooter } from "../../src/lib/dsn/errors.js"; + // ============================================================================ // Mock Setup - All dependency modules mocked before importing resolve-target // ============================================================================ @@ -53,15 +60,17 @@ mock.module("../../src/lib/db/defaults.js", () => ({ getDefaultProject: mockGetDefaultProject, })); +// Bun's mock.module() replaces the ENTIRE barrel module. Since resolve-target.ts +// imports formatMultipleProjectsFooter from dsn/index.js, we must include it here. +// We pass through the real function (imported above from dsn/errors.js) rather than +// a stub, because Bun leaks mock.module() state across test files in the same run +// and a simplified stub would break tests in dsn/errors.test.ts. mock.module("../../src/lib/dsn/index.js", () => ({ detectDsn: mockDetectDsn, detectAllDsns: mockDetectAllDsns, findProjectRoot: mockFindProjectRoot, getDsnSourceDescription: mockGetDsnSourceDescription, - formatMultipleProjectsFooter: (projects: unknown[]) => - (projects as { orgDisplay: string; projectDisplay: string }[]).length > 1 - ? `Found ${(projects as unknown[]).length} projects` - : "", + formatMultipleProjectsFooter, })); mock.module("../../src/lib/db/project-cache.js", () => ({ From 9b42e75e93312982153c82e11eec81bb653005d7 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Wed, 18 Feb 2026 12:01:03 +0000 Subject: [PATCH 02/22] feat(list): add pagination and consistent target parsing to all list commands --- src/commands/issue/list.ts | 117 +++++++- src/commands/log/list.ts | 111 +------ src/commands/repo/list.ts | 485 ++++++++++++++++++++----------- src/commands/team/list.ts | 474 ++++++++++++++++++++---------- src/commands/trace/list.ts | 111 +------ src/lib/api-client.ts | 84 ++++++ src/lib/arg-parsing.ts | 25 ++ src/lib/resolve-target.ts | 162 +++++++++++ test/commands/trace/list.test.ts | 88 +++--- test/e2e/issue.test.ts | 8 +- 10 files changed, 1113 insertions(+), 552 deletions(-) diff --git a/src/commands/issue/list.ts b/src/commands/issue/list.ts index e1bba9b1cb..31ddd162f8 100644 --- a/src/commands/issue/list.ts +++ b/src/commands/issue/list.ts @@ -10,16 +10,27 @@ 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 { + clearPaginationCursor, + getPaginationCursor, + setPaginationCursor, +} from "../../lib/db/pagination.js"; import { clearProjectAliases, setProjectAliases, } from "../../lib/db/project-aliases.js"; import { createDsnFingerprint } from "../../lib/dsn/index.js"; -import { ApiError, AuthError, ContextError } from "../../lib/errors.js"; +import { + ApiError, + AuthError, + ContextError, + ValidationError, +} from "../../lib/errors.js"; import { divider, type FormatShortIdOptions, @@ -32,17 +43,22 @@ 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"; @@ -429,8 +445,15 @@ export const listCommand = buildCommand({ brief: "Output as JSON", default: false, }, + cursor: { + kind: "parsed", + parse: String, + brief: + 'Pagination cursor — only for / mode (use "last" to continue)', + optional: true, + }, }, - aliases: { q: "query", s: "sort", n: "limit" }, + aliases: { q: "query", s: "sort", n: "limit", c: "cursor" }, }, // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: command entry point with inherent complexity async func( @@ -443,6 +466,96 @@ export const listCommand = buildCommand({ // Parse positional argument to determine resolution strategy const parsed = parseOrgProjectArg(target); + // Cursor pagination is only supported in org-all mode + if (flags.cursor && parsed.type !== "org-all") { + throw new ValidationError( + "The --cursor flag is only supported when listing issues for a specific organization " + + "(e.g., sentry issue list /). " + + "Use 'sentry issue list /' for paginated results.", + "cursor" + ); + } + + // Handle org-all mode with cursor pagination (different code path) + if (parsed.type === "org-all") { + const org = parsed.org; + const contextKey = `host:${getApiBaseUrl()}|type:org:${org}|sort:${flags.sort}${flags.query ? `|q:${flags.query}` : ""}`; + let cursor: string | undefined; + if (flags.cursor) { + if (flags.cursor === "last") { + const cached = getPaginationCursor(PAGINATION_KEY, contextKey); + if (!cached) { + throw new ContextError( + "Pagination cursor", + "No saved cursor for this query. Run without --cursor first." + ); + } + cursor = cached; + } else { + cursor = flags.cursor; + } + } + + setContext([org], []); + + const response = await listIssuesPaginated(org, "", { + query: flags.query, + cursor, + perPage: flags.limit, + sort: flags.sort, + }); + + // Strip the project filter since we're listing org-wide (pass empty projectSlug) + // The API handles org-wide issue listing without a project filter + + 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) { + stdout.write(`No issues found in organization '${org}'.\n`); + return; + } + + writeListHeader(stdout, `Issues in ${org}`, false); + const termWidth = process.stdout.columns || 80; + const issuesWithOpts = response.data.map((issue) => ({ + issue, + formatOptions: { + projectSlug: issue.project?.slug ?? "", + isMultiProject: false, + }, + })); + writeIssueRows(stdout, issuesWithOpts, termWidth); + + if (hasMore) { + const hint = `sentry issue list ${org}/ -c last`; + stdout.write( + `\nShowing ${response.data.length} issues (more available)\n` + ); + stdout.write(`Next page: ${hint}\n`); + } else { + stdout.write(`\nShowing ${response.data.length} issues\n`); + } + return; + } + // Resolve targets based on parsed argument type const { targets, footer, skippedSelfHosted, detectedDsns } = await resolveTargetsFromParsedArg(parsed, cwd); diff --git a/src/commands/log/list.ts b/src/commands/log/list.ts index 17b6156289..f7c55f8477 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 } from "../../lib/errors.js"; +import { AuthError } 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/repo/list.ts b/src/commands/repo/list.ts index 58020d83c2..23a71ddd5e 100644 --- a/src/commands/repo/list.ts +++ b/src/commands/repo/list.ts @@ -1,81 +1,47 @@ /** * 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 { + listOrganizations, + listRepositories, + listRepositoriesPaginated, + type PaginatedResponse, +} from "../../lib/api-client.js"; +import { parseOrgProjectArg } from "../../lib/arg-parsing.js"; import { buildCommand, numberParser } from "../../lib/command.js"; -import { getDefaultOrganization } from "../../lib/db/defaults.js"; -import { AuthError } from "../../lib/errors.js"; +import { + clearPaginationCursor, + getPaginationCursor, + setPaginationCursor, +} from "../../lib/db/pagination.js"; +import { AuthError, ContextError, ValidationError } from "../../lib/errors.js"; import { writeFooter, writeJson } from "../../lib/formatters/index.js"; -import { resolveAllTargets } from "../../lib/resolve-target.js"; +import { resolveOrgsForListing } from "../../lib/resolve-target.js"; +import { getApiBaseUrl } from "../../lib/sentry-client.js"; import type { SentryRepository, Writer } from "../../types/index.js"; +/** Command key for pagination cursor storage */ +export const PAGINATION_KEY = "repo-list"; + type ListFlags = { readonly limit: number; readonly json: boolean; + readonly cursor?: string; }; /** 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; @@ -131,69 +97,273 @@ function writeRows(options: WriteRowsOptions): void { } } -/** Result of resolving organizations to fetch repositories from */ -type OrgResolution = { - orgs: string[]; - footer?: string; - skippedSelfHosted?: number; -}; +/** Display repositories in table format with header and rows */ +function displayRepoTable(stdout: Writer, repos: RepositoryWithOrg[]): void { + const widths = calculateColumnWidths(repos); + writeHeader(stdout, widths); + writeRows({ stdout, repos, ...widths }); +} /** - * Resolve which organizations to fetch repositories from. - * Uses CLI flag, config defaults, or DSN auto-detection. + * Fetch repositories for a single org, returning empty array on non-auth errors. */ -async function resolveOrgsToFetch( - orgFlag: string | undefined, - cwd: string -): Promise { - // 1. If positional org provided, use it directly - if (orgFlag) { - return { orgs: [orgFlag] }; +async function fetchOrgRepositoriesSafe( + orgSlug: string +): Promise { + try { + const repos = await listRepositories(orgSlug); + return repos.map((r) => ({ ...r, orgSlug })); + } catch (error) { + if (error instanceof AuthError) { + throw error; + } + return []; } +} - // 2. Check config defaults - const defaultOrg = await getDefaultOrganization(); - if (defaultOrg) { - return { orgs: [defaultOrg] }; +/** + * Fetch repositories from all accessible organizations. + */ +async function fetchAllOrgRepositories(): Promise { + const orgs = await listOrganizations(); + const results: RepositoryWithOrg[] = []; + + for (const org of orgs) { + try { + const repos = await listRepositories(org.slug); + results.push(...repos.map((r) => ({ ...r, orgSlug: org.slug }))); + } catch (error) { + if (error instanceof AuthError) { + throw error; + } + // User may lack access to some orgs + } } - // 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, - }; + return results; +} + +/** + * Build a context key for pagination cursor validation. + * Captures the org so cursors from different orgs are never mixed. + */ +function buildContextKey(org: string): string { + return `host:${getApiBaseUrl()}|type:org:${org}`; +} + +/** + * Resolve the cursor value from --cursor flag. + * Handles the magic "last" value by looking up the cached cursor. + */ +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; +} - // 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; +/** Build the CLI hint for fetching the next page. */ +function nextPageHint(org: string): string { + return `sentry repo list ${org}/ -c last`; +} + +type OrgAllOptions = { + stdout: Writer; + org: string; + flags: ListFlags; + contextKey: string; + cursor: string | undefined; +}; + +/** + * Handle org-all mode (e.g., sentry/). + * Uses cursor pagination for efficient page-by-page listing. + */ +async function handleOrgAll(options: OrgAllOptions): Promise { + const { stdout, org, flags, contextKey, cursor } = options; + const response: PaginatedResponse = + await listRepositoriesPaginated(org, { cursor, perPage: flags.limit }); + + const repos: RepositoryWithOrg[] = response.data.map((r) => ({ + ...r, + orgSlug: org, + })); + const hasMore = !!response.nextCursor; + + // Update cursor cache for `--cursor last` support + if (response.nextCursor) { + setPaginationCursor(PAGINATION_KEY, contextKey, response.nextCursor); + } else { + clearPaginationCursor(PAGINATION_KEY, contextKey); + } + + if (flags.json) { + const output = hasMore + ? { data: repos, nextCursor: response.nextCursor, hasMore: true } + : { data: repos, hasMore: false }; + writeJson(stdout, output); + return; + } + + if (repos.length === 0) { + if (hasMore) { + stdout.write( + `No repositories on this page. Try the next page: ${nextPageHint(org)}\n` + ); + } else { + stdout.write(`No repositories found in organization '${org}'.\n`); } - // Fall through to empty orgs for other errors (network, etc.) + return; + } + + displayRepoTable(stdout, repos); + + if (hasMore) { + stdout.write(`\nShowing ${repos.length} repositories (more available)\n`); + stdout.write(`Next page: ${nextPageHint(org)}\n`); + } else { + stdout.write(`\nShowing ${repos.length} repositories\n`); + } + + writeFooter( + stdout, + "Tip: Use 'sentry repo list /' for paginated results" + ); +} + +/** + * Handle auto-detect mode: resolve orgs from config/DSN, fetch all repos. + */ +async function handleAutoDetect( + stdout: Writer, + cwd: string, + flags: ListFlags +): Promise { + const { + orgs: orgsToFetch, + footer, + skippedSelfHosted, + } = await resolveOrgsForListing(undefined, cwd); + + let allRepos: RepositoryWithOrg[]; + if (orgsToFetch.length > 0) { + const results = await Promise.all( + orgsToFetch.map(fetchOrgRepositoriesSafe) + ); + allRepos = results.flat(); + } else { + allRepos = await fetchAllOrgRepositories(); + } + + 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; + } + + displayRepoTable(stdout, limited); + + if (allRepos.length > limited.length) { + stdout.write( + `\nShowing ${limited.length} of ${allRepos.length} repositories\n` + ); } - return { orgs: [] }; + 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" + ); +} + +/** + * Handle a single explicit org (non-paginated fetch). + */ +async function handleExplicitOrg( + stdout: Writer, + org: string, + flags: ListFlags +): Promise { + const repos = await fetchOrgRepositoriesSafe(org); + const limited = repos.slice(0, flags.limit); + + if (flags.json) { + writeJson(stdout, limited); + return; + } + + if (limited.length === 0) { + stdout.write(`No repositories found in organization '${org}'.\n`); + return; + } + + displayRepoTable(stdout, limited); + + if (repos.length > limited.length) { + stdout.write( + `\nShowing ${limited.length} of ${repos.length} repositories. ` + + `Use 'sentry repo list ${org}/' for paginated results.\n` + ); + } else { + stdout.write(`\nShowing ${limited.length} repositories\n`); + } + + writeFooter( + stdout, + `Tip: Use 'sentry repo list ${org}/' for paginated results` + ); } 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" + + "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\n" + + " sentry repo list my-org/ # list repositories in my-org (paginated)\n" + " sentry repo list --limit 10\n" + " sentry repo list --json", }, @@ -202,8 +372,8 @@ export const listCommand = buildCommand({ kind: "tuple", parameters: [ { - placeholder: "org", - brief: "Organization slug (optional)", + placeholder: "target", + brief: "Target: /, /, or ", parse: String, optional: true, }, @@ -221,81 +391,66 @@ export const listCommand = buildCommand({ brief: "Output JSON", default: false, }, + cursor: { + kind: "parsed", + parse: String, + brief: 'Pagination cursor (use "last" to continue from previous page)', + optional: true, + }, }, - aliases: { n: "limit" }, + aliases: { n: "limit", c: "cursor" }, }, async func( this: SentryContext, flags: ListFlags, - org?: string + target?: 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); + const parsed = parseOrgProjectArg(target); - if (flags.json) { - writeJson(stdout, limited); - return; + // Cursor pagination is only supported in org-all mode + if (flags.cursor && parsed.type !== "org-all") { + throw new ValidationError( + "The --cursor flag is only supported when listing repositories for a specific organization " + + "(e.g., sentry repo list /). " + + "Use 'sentry repo list /' for paginated results.", + "cursor" + ); } - 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; - } + switch (parsed.type) { + case "auto-detect": + await handleAutoDetect(stdout, cwd, flags); + break; - const widths = calculateColumnWidths(limited); - writeHeader(stdout, widths); - writeRows({ - stdout, - repos: limited, - ...widths, - }); + case "explicit": + // Use the org context; project part is ignored for repo listing + await handleExplicitOrg(stdout, parsed.org, flags); + break; - if (allRepos.length > limited.length) { - stdout.write( - `\nShowing ${limited.length} of ${allRepos.length} repositories\n` - ); - } + case "project-search": + // Bare slug treated as org slug (no slash → repo list for that org) + await handleExplicitOrg(stdout, parsed.projectSlug, flags); + break; - if (footer) { - stdout.write(`\n${footer}\n`); - } + case "org-all": { + const contextKey = buildContextKey(parsed.org); + const cursor = resolveCursor(flags.cursor, contextKey); + await handleOrgAll({ + stdout, + org: parsed.org, + flags, + contextKey, + cursor, + }); + break; + } - if (skippedSelfHosted) { - stdout.write( - `\nNote: ${skippedSelfHosted} DSN(s) could not be resolved. ` + - "Specify the organization explicitly: sentry repo list \n" - ); + default: { + const _exhaustiveCheck: never = parsed; + throw new Error(`Unexpected parsed type: ${_exhaustiveCheck}`); + } } - - writeFooter( - stdout, - "Tip: Use 'sentry repo list ' to filter by organization" - ); }, }); diff --git a/src/commands/team/list.ts b/src/commands/team/list.ts index e56588fd69..34959efca3 100644 --- a/src/commands/team/list.ts +++ b/src/commands/team/list.ts @@ -1,77 +1,47 @@ /** * 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 { + listOrganizations, + listTeams, + listTeamsPaginated, + type PaginatedResponse, +} from "../../lib/api-client.js"; +import { parseOrgProjectArg } from "../../lib/arg-parsing.js"; import { buildCommand, numberParser } from "../../lib/command.js"; -import { getDefaultOrganization } from "../../lib/db/defaults.js"; -import { AuthError } from "../../lib/errors.js"; +import { + clearPaginationCursor, + getPaginationCursor, + setPaginationCursor, +} from "../../lib/db/pagination.js"; +import { AuthError, ContextError, ValidationError } from "../../lib/errors.js"; import { writeFooter, writeJson } from "../../lib/formatters/index.js"; -import { resolveAllTargets } from "../../lib/resolve-target.js"; +import { resolveOrgsForListing } from "../../lib/resolve-target.js"; +import { getApiBaseUrl } from "../../lib/sentry-client.js"; import type { SentryTeam, Writer } from "../../types/index.js"; +/** Command key for pagination cursor storage */ +export const PAGINATION_KEY = "team-list"; + type ListFlags = { readonly limit: number; readonly json: boolean; + readonly cursor?: string; }; /** 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; @@ -126,69 +96,270 @@ function writeRows(options: WriteRowsOptions): void { } } -/** Result of resolving organizations to fetch teams from */ -type OrgResolution = { - orgs: string[]; - footer?: string; - skippedSelfHosted?: number; -}; +/** Display teams in table format with header and rows */ +function displayTeamTable(stdout: Writer, teams: TeamWithOrg[]): void { + const widths = calculateColumnWidths(teams); + writeHeader(stdout, widths); + writeRows({ stdout, teams, ...widths }); +} /** - * Resolve which organizations to fetch teams from. - * Uses CLI flag, config defaults, or DSN auto-detection. + * Fetch teams for a single org, returning empty array on non-auth errors. */ -async function resolveOrgsToFetch( - orgFlag: string | undefined, - cwd: string -): Promise { - // 1. If positional org provided, use it directly - if (orgFlag) { - return { orgs: [orgFlag] }; +async function fetchOrgTeamsSafe(orgSlug: string): Promise { + try { + const teams = await listTeams(orgSlug); + return teams.map((t) => ({ ...t, orgSlug })); + } catch (error) { + if (error instanceof AuthError) { + throw error; + } + return []; } +} + +/** + * Fetch teams from all accessible organizations. + */ +async function fetchAllOrgTeams(): Promise { + const orgs = await listOrganizations(); + const results: TeamWithOrg[] = []; - // 2. Check config defaults - const defaultOrg = await getDefaultOrganization(); - if (defaultOrg) { - return { orgs: [defaultOrg] }; + for (const org of orgs) { + try { + const teams = await listTeams(org.slug); + results.push(...teams.map((t) => ({ ...t, orgSlug: org.slug }))); + } catch (error) { + if (error instanceof AuthError) { + throw error; + } + // User may lack access to some orgs + } } - // 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, - }; + return results; +} + +/** + * Build a context key for pagination cursor validation. + * Captures the org so cursors from different orgs are never mixed. + */ +function buildContextKey(org: string): string { + return `host:${getApiBaseUrl()}|type:org:${org}`; +} + +/** + * Resolve the cursor value from --cursor flag. + * Handles the magic "last" value by looking up the cached cursor. + */ +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; +} - // 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; +/** Build the CLI hint for fetching the next page. */ +function nextPageHint(org: string): string { + return `sentry team list ${org}/ -c last`; +} + +type OrgAllOptions = { + stdout: Writer; + org: string; + flags: ListFlags; + contextKey: string; + cursor: string | undefined; +}; + +/** + * Handle org-all mode (e.g., sentry/). + * Uses cursor pagination for efficient page-by-page listing. + */ +async function handleOrgAll(options: OrgAllOptions): Promise { + const { stdout, org, flags, contextKey, cursor } = options; + const response: PaginatedResponse = await listTeamsPaginated( + org, + { cursor, perPage: flags.limit } + ); + + const teams: TeamWithOrg[] = response.data.map((t) => ({ + ...t, + orgSlug: org, + })); + const hasMore = !!response.nextCursor; + + // Update cursor cache for `--cursor last` support + if (response.nextCursor) { + setPaginationCursor(PAGINATION_KEY, contextKey, response.nextCursor); + } else { + clearPaginationCursor(PAGINATION_KEY, contextKey); + } + + if (flags.json) { + const output = hasMore + ? { data: teams, nextCursor: response.nextCursor, hasMore: true } + : { data: teams, hasMore: false }; + writeJson(stdout, output); + return; + } + + if (teams.length === 0) { + if (hasMore) { + stdout.write( + `No teams on this page. Try the next page: ${nextPageHint(org)}\n` + ); + } else { + stdout.write(`No teams found in organization '${org}'.\n`); } - // Fall through to empty orgs for other errors (network, etc.) + return; + } + + displayTeamTable(stdout, teams); + + if (hasMore) { + stdout.write(`\nShowing ${teams.length} teams (more available)\n`); + stdout.write(`Next page: ${nextPageHint(org)}\n`); + } else { + stdout.write(`\nShowing ${teams.length} teams\n`); } - return { orgs: [] }; + writeFooter( + stdout, + "Tip: Use 'sentry team list /' for paginated results" + ); +} + +/** + * Handle auto-detect and explicit org modes. + * Fetches all teams for the resolved orgs (no cursor pagination). + */ +async function handleAutoDetect( + stdout: Writer, + cwd: string, + flags: ListFlags +): Promise { + const { + orgs: orgsToFetch, + footer, + skippedSelfHosted, + } = await resolveOrgsForListing(undefined, cwd); + + let allTeams: TeamWithOrg[]; + if (orgsToFetch.length > 0) { + const results = await Promise.all(orgsToFetch.map(fetchOrgTeamsSafe)); + allTeams = results.flat(); + } else { + allTeams = await fetchAllOrgTeams(); + } + + 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; + } + + displayTeamTable(stdout, limited); + + 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" + ); +} + +/** + * Handle a single explicit org (non-paginated fetch). + */ +async function handleExplicitOrg( + stdout: Writer, + org: string, + flags: ListFlags +): Promise { + const teams = await fetchOrgTeamsSafe(org); + const limited = teams.slice(0, flags.limit); + + if (flags.json) { + writeJson(stdout, limited); + return; + } + + if (limited.length === 0) { + stdout.write(`No teams found in organization '${org}'.\n`); + return; + } + + displayTeamTable(stdout, limited); + + if (teams.length > limited.length) { + stdout.write( + `\nShowing ${limited.length} of ${teams.length} teams. ` + + `Use 'sentry team list ${org}/' for paginated results.\n` + ); + } else { + stdout.write(`\nShowing ${limited.length} teams\n`); + } + + writeFooter( + stdout, + `Tip: Use 'sentry team list ${org}/' for paginated results` + ); } 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" + + "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\n" + + " sentry team list my-org/ # list teams in my-org (paginated)\n" + " sentry team list --limit 10\n" + " sentry team list --json", }, @@ -197,8 +368,8 @@ export const listCommand = buildCommand({ kind: "tuple", parameters: [ { - placeholder: "org", - brief: "Organization slug (optional)", + placeholder: "target", + brief: "Target: /, /, or ", parse: String, optional: true, }, @@ -216,77 +387,66 @@ export const listCommand = buildCommand({ brief: "Output JSON", default: false, }, + cursor: { + kind: "parsed", + parse: String, + brief: 'Pagination cursor (use "last" to continue from previous page)', + optional: true, + }, }, - aliases: { n: "limit" }, + aliases: { n: "limit", c: "cursor" }, }, async func( this: SentryContext, flags: ListFlags, - org?: string + target?: 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(); - } + const parsed = parseOrgProjectArg(target); - // 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; + // Cursor pagination is only supported in org-all mode + if (flags.cursor && parsed.type !== "org-all") { + throw new ValidationError( + "The --cursor flag is only supported when listing teams for a specific organization " + + "(e.g., sentry team list /). " + + "Use 'sentry team list /' for paginated results.", + "cursor" + ); } - 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; - } + switch (parsed.type) { + case "auto-detect": + await handleAutoDetect(stdout, cwd, flags); + break; - const widths = calculateColumnWidths(limited); - writeHeader(stdout, widths); - writeRows({ - stdout, - teams: limited, - ...widths, - }); + case "explicit": + // Use the org context; project part is ignored for team listing + await handleExplicitOrg(stdout, parsed.org, flags); + break; - if (allTeams.length > limited.length) { - stdout.write(`\nShowing ${limited.length} of ${allTeams.length} teams\n`); - } + case "project-search": + // Bare slug treated as org slug (no slash → team list for that org) + await handleExplicitOrg(stdout, parsed.projectSlug, flags); + break; - if (footer) { - stdout.write(`\n${footer}\n`); - } + case "org-all": { + const contextKey = buildContextKey(parsed.org); + const cursor = resolveCursor(flags.cursor, contextKey); + await handleOrgAll({ + stdout, + org: parsed.org, + flags, + contextKey, + cursor, + }); + break; + } - if (skippedSelfHosted) { - stdout.write( - `\nNote: ${skippedSelfHosted} DSN(s) could not be resolved. ` + - "Specify the organization explicitly: sentry team list \n" - ); + default: { + const _exhaustiveCheck: never = parsed; + throw new Error(`Unexpected parsed type: ${_exhaustiveCheck}`); + } } - - writeFooter( - stdout, - "Tip: Use 'sentry team list ' to filter by organization" - ); }, }); 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 3e095fcafb..7d9ccd7e12 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -685,6 +685,52 @@ 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 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 +979,44 @@ 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> { + const projectFilter = `project:${projectSlug}`; + const fullQuery = [projectFilter, options.query].filter(Boolean).join(" "); + + return orgScopedRequestPaginated( + `/organizations/${orgSlug}/issues/`, + { + params: { + query: fullQuery, + cursor: options.cursor, + per_page: options.perPage ?? 25, + sort: options.sort, + statsPeriod: options.statsPeriod, + }, + } + ); +} + /** * Get a specific issue by numeric ID. */ diff --git a/src/lib/arg-parsing.ts b/src/lib/arg-parsing.ts index b2bbd3a362..764e122e3c 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/resolve-target.ts b/src/lib/resolve-target.ts index 310e98716a..ba91858cb2 100644 --- a/src/lib/resolve-target.ts +++ b/src/lib/resolve-target.ts @@ -18,6 +18,7 @@ import { findProjectsBySlug, getProject, } from "./api-client.js"; +import { type ParsedOrgProject, parseOrgProjectArg } from "./arg-parsing.js"; import { getDefaultOrganization, getDefaultProject } from "./db/defaults.js"; import { getCachedDsn, setCachedDsn } from "./db/dsn-cache.js"; import { @@ -723,3 +724,164 @@ export async function resolveProjectBySlug( project: foundProject.slug, }; } + +/** Result of resolving organizations to fetch from for listing commands */ +export type OrgListResolution = { + /** Organization slugs to list from */ + orgs: string[]; + /** Optional multi-org footer to display after listing */ + footer?: string; + /** Number of self-hosted DSNs that could not be resolved */ + skippedSelfHosted?: number; +}; + +/** + * Resolve which organizations to fetch data from for listing commands (team, repo). + * + * Resolution priority: + * 1. Explicit org flag → use that single org + * 2. Config default org → use that org + * 3. DSN auto-detection → extract unique orgs from detected targets + * 4. No context found → empty list (caller must decide to show all orgs or error) + * + * @param orgFlag - Explicit org slug from CLI positional arg, or undefined + * @param cwd - Current working directory for DSN detection + * @returns Orgs to fetch and optional display metadata + */ +export async function resolveOrgsForListing( + orgFlag: string | undefined, + cwd: string +): Promise { + if (orgFlag) { + return { orgs: [orgFlag] }; + } + + const defaultOrg = await getDefaultOrganization(); + if (defaultOrg) { + return { orgs: [defaultOrg] }; + } + + 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 }; + } + + return { orgs: [], skippedSelfHosted }; + } catch (error) { + if (error instanceof AuthError) { + throw error; + } + } + + return { orgs: [] }; +} + +/** Resolved org and project returned by `resolveOrgProjectTarget` */ +export type ResolvedOrgProject = { + /** Organization slug */ + org: string; + /** Project slug */ + project: string; +}; + +/** + * Resolve an org/project target for commands that require a single project + * (trace list, log list). Rejects `org-all` mode since these commands require + * a specific project. + * + * Handles: + * - explicit `/` → use directly + * - project-search `` → find project across all orgs + * - auto-detect → use DSN detection or config defaults + * - org-all `/` → throw ContextError asking for a specific project + * + * @param parsed - Parsed org/project argument + * @param cwd - Current working directory for DSN auto-detection + * @param commandName - Command name used in error messages (e.g., "trace list") + * @returns Resolved org and project slugs + * @throws {ContextError} When target cannot be resolved or org-all is used + */ +export async function resolveOrgProjectTarget( + parsed: ParsedOrgProject, + cwd: string, + commandName: string +): Promise { + const usageHint = `sentry ${commandName} /`; + + switch (parsed.type) { + case "explicit": + return { org: parsed.org, project: parsed.project }; + + case "org-all": + throw new ContextError( + "Project", + `Please specify a project: sentry ${commandName} ${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 ${commandName} /${parsed.projectSlug}` + ); + } + + if (matches.length > 1) { + const options = matches + .map((m) => ` sentry ${commandName} ${m.orgSlug}/${m.slug}`) + .join("\n"); + throw new ContextError( + "Project", + `Found '${parsed.projectSlug}' in ${matches.length} organizations. Please specify:\n${options}` + ); + } + + const match = matches[0] as (typeof matches)[number]; + return { org: match.orgSlug, project: match.slug }; + } + + case "auto-detect": { + const resolved = await resolveOrgAndProject({ + cwd, + usageHint, + }); + if (!resolved) { + throw new ContextError("Organization and project", usageHint); + } + return { org: resolved.org, project: resolved.project }; + } + + default: { + const _exhaustiveCheck: never = parsed; + throw new Error(`Unexpected parsed type: ${_exhaustiveCheck}`); + } + } +} + +/** + * Resolve an org/project target from a raw CLI argument string for commands + * that require a single project (trace list, log list). + * + * Convenience wrapper around `resolveOrgProjectTarget` that also calls + * `parseOrgProjectArg` on the raw string argument. + * + * @param target - Raw CLI argument string (or undefined for auto-detect) + * @param cwd - Current working directory for DSN auto-detection + * @param commandName - Command name used in error messages (e.g., "trace list") + * @returns Resolved org and project slugs + */ +export function resolveOrgProjectFromArg( + target: string | undefined, + cwd: string, + commandName: string +): Promise { + return resolveOrgProjectTarget(parseOrgProjectArg(target), cwd, commandName); +} diff --git a/test/commands/trace/list.test.ts b/test/commands/trace/list.test.ts index ca2278e92c..a3541b8023 100644 --- a/test/commands/trace/list.test.ts +++ b/test/commands/trace/list.test.ts @@ -17,49 +17,45 @@ import { spyOn, test, } from "bun:test"; -import { - listCommand, - parseSort, - resolveTraceTarget, - validateLimit, -} from "../../../src/commands/trace/list.js"; +import { listCommand, parseSort } from "../../../src/commands/trace/list.js"; import type { ProjectWithOrg } from "../../../src/lib/api-client.js"; // biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking import * as apiClient from "../../../src/lib/api-client.js"; +import { validateLimit } from "../../../src/lib/arg-parsing.js"; import { ContextError } from "../../../src/lib/errors.js"; // biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking import * as resolveTarget from "../../../src/lib/resolve-target.js"; import type { TransactionListItem } from "../../../src/types/sentry.js"; // ============================================================================ -// validateLimit +// validateLimit (shared utility from arg-parsing.ts) // ============================================================================ describe("validateLimit", () => { test("returns number for valid value", () => { - expect(validateLimit("1")).toBe(1); - expect(validateLimit("500")).toBe(500); - expect(validateLimit("1000")).toBe(1000); + expect(validateLimit("1", 1, 1000)).toBe(1); + expect(validateLimit("500", 1, 1000)).toBe(500); + expect(validateLimit("1000", 1, 1000)).toBe(1000); }); test("returns number for boundary values", () => { - expect(validateLimit("1")).toBe(1); - expect(validateLimit("1000")).toBe(1000); + expect(validateLimit("1", 1, 1000)).toBe(1); + expect(validateLimit("1000", 1, 1000)).toBe(1000); }); test("throws for value below minimum", () => { - expect(() => validateLimit("0")).toThrow("must be between"); - expect(() => validateLimit("-1")).toThrow("must be between"); + expect(() => validateLimit("0", 1, 1000)).toThrow("must be between"); + expect(() => validateLimit("-1", 1, 1000)).toThrow("must be between"); }); test("throws for value above maximum", () => { - expect(() => validateLimit("1001")).toThrow("must be between"); - expect(() => validateLimit("9999")).toThrow("must be between"); + expect(() => validateLimit("1001", 1, 1000)).toThrow("must be between"); + expect(() => validateLimit("9999", 1, 1000)).toThrow("must be between"); }); test("throws for non-numeric value", () => { - expect(() => validateLimit("abc")).toThrow("must be between"); - expect(() => validateLimit("")).toThrow("must be between"); + expect(() => validateLimit("abc", 1, 1000)).toThrow("must be between"); + expect(() => validateLimit("", 1, 1000)).toThrow("must be between"); }); }); @@ -84,10 +80,10 @@ describe("parseSort", () => { }); // ============================================================================ -// resolveTraceTarget +// resolveOrgProjectFromArg (via shared resolve-target.ts) // ============================================================================ -describe("resolveTraceTarget", () => { +describe("resolveOrgProjectFromArg", () => { let findProjectsBySlugSpy: ReturnType; let resolveOrgAndProjectSpy: ReturnType; @@ -102,21 +98,29 @@ describe("resolveTraceTarget", () => { }); test("returns explicit org/project directly", async () => { - const result = await resolveTraceTarget("my-org/my-project", "/tmp"); + const result = await resolveTarget.resolveOrgProjectFromArg( + "my-org/my-project", + "/tmp", + "trace list" + ); expect(result).toEqual({ org: "my-org", project: "my-project" }); expect(findProjectsBySlugSpy).not.toHaveBeenCalled(); expect(resolveOrgAndProjectSpy).not.toHaveBeenCalled(); }); test("throws for org-all target (org/ without project)", async () => { - await expect(resolveTraceTarget("my-org/", "/tmp")).rejects.toThrow( - ContextError - ); + await expect( + resolveTarget.resolveOrgProjectFromArg("my-org/", "/tmp", "trace list") + ).rejects.toThrow(ContextError); }); test("throws ContextError with project hint for org-all", async () => { try { - await resolveTraceTarget("my-org/", "/tmp"); + await resolveTarget.resolveOrgProjectFromArg( + "my-org/", + "/tmp", + "trace list" + ); expect.unreachable("Should have thrown"); } catch (error) { expect(error).toBeInstanceOf(ContextError); @@ -129,16 +133,24 @@ describe("resolveTraceTarget", () => { { slug: "frontend", orgSlug: "acme", id: "1", name: "Frontend" }, ] as ProjectWithOrg[]); - const result = await resolveTraceTarget("frontend", "/tmp"); + const result = await resolveTarget.resolveOrgProjectFromArg( + "frontend", + "/tmp", + "trace list" + ); expect(result).toEqual({ org: "acme", project: "frontend" }); }); test("throws when no project found", async () => { findProjectsBySlugSpy.mockResolvedValue([]); - await expect(resolveTraceTarget("nonexistent", "/tmp")).rejects.toThrow( - ContextError - ); + await expect( + resolveTarget.resolveOrgProjectFromArg( + "nonexistent", + "/tmp", + "trace list" + ) + ).rejects.toThrow(ContextError); }); test("throws when multiple projects found", async () => { @@ -148,7 +160,11 @@ describe("resolveTraceTarget", () => { ] as ProjectWithOrg[]); try { - await resolveTraceTarget("frontend", "/tmp"); + await resolveTarget.resolveOrgProjectFromArg( + "frontend", + "/tmp", + "trace list" + ); expect.unreachable("Should have thrown"); } catch (error) { expect(error).toBeInstanceOf(ContextError); @@ -162,7 +178,11 @@ describe("resolveTraceTarget", () => { project: "detected-project", }); - const result = await resolveTraceTarget(undefined, "/tmp"); + const result = await resolveTarget.resolveOrgProjectFromArg( + undefined, + "/tmp", + "trace list" + ); expect(result).toEqual({ org: "detected-org", project: "detected-project", @@ -176,9 +196,9 @@ describe("resolveTraceTarget", () => { test("throws when auto-detect returns null", async () => { resolveOrgAndProjectSpy.mockResolvedValue(null); - await expect(resolveTraceTarget(undefined, "/tmp")).rejects.toThrow( - ContextError - ); + await expect( + resolveTarget.resolveOrgProjectFromArg(undefined, "/tmp", "trace list") + ).rejects.toThrow(ContextError); }); }); diff --git a/test/e2e/issue.test.ts b/test/e2e/issue.test.ts index b533aa4a3e..d059263e21 100644 --- a/test/e2e/issue.test.ts +++ b/test/e2e/issue.test.ts @@ -92,9 +92,11 @@ describe("sentry issue list", () => { const result = await ctx.run(["issue", "list", `${TEST_ORG}/`, "--json"]); expect(result.exitCode).toBe(0); - // Should be valid JSON array (issues from all projects in org) - const data = JSON.parse(result.stdout); - expect(Array.isArray(data)).toBe(true); + // Org-all mode returns paginated JSON object with data array and hasMore flag + const parsed = JSON.parse(result.stdout); + expect(parsed).toHaveProperty("data"); + expect(Array.isArray(parsed.data)).toBe(true); + expect(parsed).toHaveProperty("hasMore"); }); test("searches for project across orgs with project-only arg", async () => { From 591481b6a07469c6a469423a38929b141261385c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 18 Feb 2026 12:15:32 +0000 Subject: [PATCH 03/22] chore: regenerate SKILL.md --- plugins/sentry-cli/skills/sentry-cli/SKILL.md | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index a2ba5469c0..20eed69da7 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -197,6 +197,7 @@ List issues in a project - `-n, --limit - Maximum number of issues to return - (default: "10")` - `-s, --sort - Sort by: date, new, freq, user - (default: "date")` - `--json - Output as 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:** @@ -584,6 +587,7 @@ List issues in a project - `-n, --limit - Maximum number of issues to return - (default: "10")` - `-s, --sort - Sort by: date, new, freq, user - (default: "date")` - `--json - Output as 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 From f22f51e420a860bfffdca66f2b2a9a8d34e8df7b Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Wed, 18 Feb 2026 12:31:33 +0000 Subject: [PATCH 04/22] fix(test): update issue list partial failure tests for org-all cursor path --- test/commands/issue/list.test.ts | 140 ++++++++++++++++++++----------- 1 file changed, 89 insertions(+), 51 deletions(-) diff --git a/test/commands/issue/list.test.ts b/test/commands/issue/list.test.ts index 30a71606b3..b0214bcf86 100644 --- a/test/commands/issue/list.test.ts +++ b/test/commands/issue/list.test.ts @@ -201,42 +201,67 @@ describe("issue list: error propagation", () => { }); describe("issue list: partial failure handling", () => { + // Partial failure handling applies to the per-project fetch path (auto-detect, + // explicit, and project-search modes). The org-all mode (e.g. "multi-org/") + // uses a single paginated API call and does not do per-project fetching. + // + // To trigger partial failures, we use project-search (bare slug) which fans + // out across orgs via findProjectsBySlug → getProject per org, creating + // multiple per-project fetch targets where some can fail independently. + // + // findProjectsBySlug flow: + // 1. listOrganizations() → GET /api/0/organizations/ + // 2. getProject(org, slug) → GET /api/0/projects/{org}/{slug}/ (per org) + // 3. listIssues(org, slug) → GET /api/0/organizations/{org}/issues/?query=project:{slug} + test("JSON output includes error info on partial failures", async () => { - await setOrgRegion("multi-org", DEFAULT_SENTRY_URL); + await setOrgRegion("org-one", DEFAULT_SENTRY_URL); + await setOrgRegion("org-two", DEFAULT_SENTRY_URL); globalThis.fetch = mockFetch(async (input, init) => { const req = new Request(input, init); const url = req.url; - // listProjects: /api/0/organizations/multi-org/projects/ - if (url.includes("/organizations/multi-org/projects/")) { + // listOrganizations → returns org-one and org-two + if ( + url.includes("/api/0/organizations/") && + !url.includes("/organizations/org-") + ) { return new Response( JSON.stringify([ - { id: "1", slug: "proj-a", name: "Project A" }, - { id: "2", slug: "proj-b", name: "Project B" }, + { slug: "org-one", name: "Org One" }, + { slug: "org-two", name: "Org Two" }, ]), - { - status: 200, - headers: { "Content-Type": "application/json", Link: "" }, - } + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + + // getProject for each org (findProjectsBySlug) + if (url.includes("/projects/org-one/myproj/")) { + return new Response( + JSON.stringify({ id: "1", slug: "myproj", name: "My Project" }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + if (url.includes("/projects/org-two/myproj/")) { + return new Response( + JSON.stringify({ id: "2", slug: "myproj", name: "My Project" }), + { status: 200, headers: { "Content-Type": "application/json" } } ); } - // listIssues: /api/0/organizations/multi-org/issues/?query=project:proj-a... - if (url.includes("/organizations/multi-org/issues/")) { - const queryParam = new URL(url).searchParams.get("query") ?? ""; - if (queryParam.includes("project:proj-a")) { - return new Response(JSON.stringify([mockIssue({ id: "1" })]), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); - } - if (queryParam.includes("project:proj-b")) { - return new Response( - JSON.stringify({ detail: "Invalid query syntax" }), - { status: 400 } - ); - } + // listIssues: org-one succeeds, org-two fails with 400 + if (url.includes("/organizations/org-one/issues/")) { + return new Response(JSON.stringify([mockIssue({ id: "1" })]), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + if (url.includes("/organizations/org-two/issues/")) { + return new Response( + JSON.stringify({ detail: "Invalid query syntax" }), + { status: 400 } + ); } return new Response(JSON.stringify([]), { @@ -247,11 +272,9 @@ describe("issue list: partial failure handling", () => { const { context, stdout } = createContext(); - await func.call( - context, - { limit: 10, sort: "date", json: true }, - "multi-org/" - ); + // project-search for "myproj" — finds it in org-one and org-two, creating + // two per-project targets; org-one succeeds, org-two fails → partial failure + await func.call(context, { limit: 10, sort: "date", json: true }, "myproj"); const output = JSON.parse(stdout.output); expect(output).toHaveProperty("issues"); @@ -262,38 +285,52 @@ describe("issue list: partial failure handling", () => { }); test("stderr warning on partial failures in human output", async () => { - await setOrgRegion("multi-org", DEFAULT_SENTRY_URL); + await setOrgRegion("org-one", DEFAULT_SENTRY_URL); + await setOrgRegion("org-two", DEFAULT_SENTRY_URL); globalThis.fetch = mockFetch(async (input, init) => { const req = new Request(input, init); const url = req.url; - if (url.includes("/organizations/multi-org/projects/")) { + // listOrganizations → returns org-one and org-two + if ( + url.includes("/api/0/organizations/") && + !url.includes("/organizations/org-") + ) { return new Response( JSON.stringify([ - { id: "1", slug: "proj-a", name: "Project A" }, - { id: "2", slug: "proj-b", name: "Project B" }, + { slug: "org-one", name: "Org One" }, + { slug: "org-two", name: "Org Two" }, ]), - { - status: 200, - headers: { "Content-Type": "application/json", Link: "" }, - } + { status: 200, headers: { "Content-Type": "application/json" } } ); } - if (url.includes("/organizations/multi-org/issues/")) { - const queryParam = new URL(url).searchParams.get("query") ?? ""; - if (queryParam.includes("project:proj-a")) { - return new Response(JSON.stringify([mockIssue({ id: "1" })]), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); - } - if (queryParam.includes("project:proj-b")) { - return new Response(JSON.stringify({ detail: "Permission denied" }), { - status: 403, - }); - } + // getProject for each org (findProjectsBySlug) + if (url.includes("/projects/org-one/myproj/")) { + return new Response( + JSON.stringify({ id: "1", slug: "myproj", name: "My Project" }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + if (url.includes("/projects/org-two/myproj/")) { + return new Response( + JSON.stringify({ id: "2", slug: "myproj", name: "My Project" }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + + // listIssues: org-one succeeds, org-two fails with 403 + if (url.includes("/organizations/org-one/issues/")) { + return new Response(JSON.stringify([mockIssue({ id: "1" })]), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + if (url.includes("/organizations/org-two/issues/")) { + return new Response(JSON.stringify({ detail: "Permission denied" }), { + status: 403, + }); } return new Response(JSON.stringify([]), { @@ -304,10 +341,11 @@ describe("issue list: partial failure handling", () => { const { context, stderr } = createContext(); + // project-search for "myproj" — org-one succeeds, org-two gets 403 → partial failure await func.call( context, { limit: 10, sort: "date", json: false }, - "multi-org/" + "myproj" ); expect(stderr.output).toContain("Failed to fetch issues from 1 project(s)"); From 93e559fd10aed3baa95c6cd509a2a7d8f6be652b Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Wed, 18 Feb 2026 12:47:00 +0000 Subject: [PATCH 05/22] test: add comprehensive coverage for pagination and target-parsing - Rewrite team/list and repo/list tests with 23 tests each covering explicit, auto-detect, org-all, cursor, ValidationError, and ContextError modes - Add org-all cursor pagination tests to issue/list - Add new resolve-target-listing.test.ts with 19 tests for resolveOrgsForListing, resolveOrgProjectTarget, and resolveOrgProjectFromArg - Fix api-client.test.ts: close missing listTeamsPaginated describe block, add cursor/perPage tests for listTeamsPaginated, fix Link header format to use named cursor attribute instead of URL query param --- test/commands/issue/list.test.ts | 261 +++++++++++++++++++- test/commands/repo/list.test.ts | 315 +++++++++++++++++++++--- test/commands/team/list.test.ts | 310 +++++++++++++++++++++-- test/lib/api-client.test.ts | 245 +++++++++++++++++- test/lib/resolve-target-listing.test.ts | 288 ++++++++++++++++++++++ 5 files changed, 1361 insertions(+), 58 deletions(-) create mode 100644 test/lib/resolve-target-listing.test.ts diff --git a/test/commands/issue/list.test.ts b/test/commands/issue/list.test.ts index b0214bcf86..f290bb15c5 100644 --- a/test/commands/issue/list.test.ts +++ b/test/commands/issue/list.test.ts @@ -5,13 +5,25 @@ * in src/commands/issue/list.ts */ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { + afterEach, + beforeEach, + describe, + expect, + mock, + spyOn, + test, +} from "bun:test"; import { listCommand } from "../../../src/commands/issue/list.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as apiClient from "../../../src/lib/api-client.js"; import { DEFAULT_SENTRY_URL } from "../../../src/lib/constants.js"; import { setAuthToken } from "../../../src/lib/db/auth.js"; import { setDefaults } from "../../../src/lib/db/defaults.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as paginationDb from "../../../src/lib/db/pagination.js"; import { setOrgRegion } from "../../../src/lib/db/regions.js"; -import { ApiError } from "../../../src/lib/errors.js"; +import { ApiError, ValidationError } from "../../../src/lib/errors.js"; import { mockFetch, useTestConfigDir } from "../../helpers.js"; type ListFlags = { @@ -376,3 +388,248 @@ describe("issue list: partial failure handling", () => { expect(Array.isArray(output)).toBe(true); }); }); + +describe("issue list: org-all mode (cursor pagination)", () => { + let listIssuesPaginatedSpy: ReturnType; + let getPaginationCursorSpy: ReturnType; + let setPaginationCursorSpy: ReturnType; + let clearPaginationCursorSpy: ReturnType; + + function createOrgAllContext() { + const stdoutWrite = mock(() => true); + const stderrWrite = mock(() => true); + return { + context: { + stdout: { write: stdoutWrite }, + stderr: { write: stderrWrite }, + cwd: "/tmp", + setContext: mock(() => {}), + }, + stdoutWrite, + stderrWrite, + }; + } + + const sampleIssue = { + id: "1", + shortId: "PROJ-1", + title: "Test Error", + status: "unresolved", + platform: "javascript", + type: "error", + count: "5", + userCount: 2, + lastSeen: "2025-01-01T00:00:00Z", + firstSeen: "2025-01-01T00:00:00Z", + level: "error", + project: { slug: "test-proj" }, + }; + + beforeEach(() => { + listIssuesPaginatedSpy = spyOn(apiClient, "listIssuesPaginated"); + getPaginationCursorSpy = spyOn(paginationDb, "getPaginationCursor"); + setPaginationCursorSpy = spyOn(paginationDb, "setPaginationCursor"); + clearPaginationCursorSpy = spyOn(paginationDb, "clearPaginationCursor"); + + setPaginationCursorSpy.mockReturnValue(undefined); + clearPaginationCursorSpy.mockReturnValue(undefined); + }); + + afterEach(() => { + listIssuesPaginatedSpy.mockRestore(); + getPaginationCursorSpy.mockRestore(); + setPaginationCursorSpy.mockRestore(); + clearPaginationCursorSpy.mockRestore(); + }); + + test("throws ValidationError when --cursor used outside org-all mode", async () => { + const orgAllFunc = (await listCommand.loader()) as unknown as ( + this: unknown, + flags: Record, + target?: string + ) => Promise; + + const { context } = createOrgAllContext(); + + await expect( + orgAllFunc.call( + context, + { limit: 10, sort: "date", json: false, cursor: "some-cursor" }, + "my-org/my-project" + ) + ).rejects.toThrow(ValidationError); + }); + + test("returns paginated JSON with hasMore=false when no nextCursor", async () => { + listIssuesPaginatedSpy.mockResolvedValue({ + data: [sampleIssue], + nextCursor: undefined, + }); + + const orgAllFunc = (await listCommand.loader()) as unknown as ( + this: unknown, + flags: Record, + target?: string + ) => Promise; + + const { context, stdoutWrite } = createOrgAllContext(); + await orgAllFunc.call( + context, + { limit: 10, sort: "date", json: true }, + "my-org/" + ); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + const parsed = JSON.parse(output); + expect(parsed).toHaveProperty("data"); + expect(parsed).toHaveProperty("hasMore", false); + expect(clearPaginationCursorSpy).toHaveBeenCalled(); + }); + + test("returns paginated JSON with hasMore=true when nextCursor present", async () => { + listIssuesPaginatedSpy.mockResolvedValue({ + data: [sampleIssue], + nextCursor: "cursor:xyz:1", + }); + + const orgAllFunc = (await listCommand.loader()) as unknown as ( + this: unknown, + flags: Record, + target?: string + ) => Promise; + + const { context, stdoutWrite } = createOrgAllContext(); + await orgAllFunc.call( + context, + { limit: 10, sort: "date", json: true }, + "my-org/" + ); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + const parsed = JSON.parse(output); + expect(parsed).toHaveProperty("hasMore", true); + expect(parsed).toHaveProperty("nextCursor", "cursor:xyz:1"); + expect(setPaginationCursorSpy).toHaveBeenCalled(); + }); + + test("human output shows next page hint when hasMore", async () => { + listIssuesPaginatedSpy.mockResolvedValue({ + data: [sampleIssue], + nextCursor: "cursor:xyz:1", + }); + + const orgAllFunc = (await listCommand.loader()) as unknown as ( + this: unknown, + flags: Record, + target?: string + ) => Promise; + + const { context, stdoutWrite } = createOrgAllContext(); + await orgAllFunc.call( + context, + { limit: 10, sort: "date", json: false }, + "my-org/" + ); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + expect(output).toContain("more available"); + expect(output).toContain("Next page:"); + expect(output).toContain("-c last"); + }); + + test("human output 'No issues found' when empty org-all", async () => { + listIssuesPaginatedSpy.mockResolvedValue({ + data: [], + nextCursor: undefined, + }); + + const orgAllFunc = (await listCommand.loader()) as unknown as ( + this: unknown, + flags: Record, + target?: string + ) => Promise; + + const { context, stdoutWrite } = createOrgAllContext(); + await orgAllFunc.call( + context, + { limit: 10, sort: "date", json: false }, + "my-org/" + ); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + expect(output).toContain("No issues found in organization 'my-org'."); + }); + + test("resolves 'last' cursor from cache in org-all mode", async () => { + getPaginationCursorSpy.mockReturnValue("cached:cursor:789"); + listIssuesPaginatedSpy.mockResolvedValue({ + data: [sampleIssue], + nextCursor: undefined, + }); + + const orgAllFunc = (await listCommand.loader()) as unknown as ( + this: unknown, + flags: Record, + target?: string + ) => Promise; + + const { context } = createOrgAllContext(); + await orgAllFunc.call( + context, + { limit: 10, sort: "date", json: false, cursor: "last" }, + "my-org/" + ); + + expect(listIssuesPaginatedSpy).toHaveBeenCalledWith( + "my-org", + "", + expect.objectContaining({ cursor: "cached:cursor:789" }) + ); + }); + + test("throws ContextError when 'last' cursor not in cache", async () => { + getPaginationCursorSpy.mockReturnValue(null); + + const orgAllFunc = (await listCommand.loader()) as unknown as ( + this: unknown, + flags: Record, + target?: string + ) => Promise; + + const { context } = createOrgAllContext(); + + await expect( + orgAllFunc.call( + context, + { limit: 10, sort: "date", json: false, cursor: "last" }, + "my-org/" + ) + ).rejects.toThrow("No saved cursor"); + }); + + test("uses explicit cursor string in org-all mode", async () => { + listIssuesPaginatedSpy.mockResolvedValue({ + data: [sampleIssue], + nextCursor: undefined, + }); + + const orgAllFunc = (await listCommand.loader()) as unknown as ( + this: unknown, + flags: Record, + target?: string + ) => Promise; + + const { context } = createOrgAllContext(); + await orgAllFunc.call( + context, + { limit: 10, sort: "date", json: false, cursor: "explicit:cursor:val" }, + "my-org/" + ); + + expect(listIssuesPaginatedSpy).toHaveBeenCalledWith( + "my-org", + "", + expect.objectContaining({ cursor: "explicit:cursor:val" }) + ); + }); +}); diff --git a/test/commands/repo/list.test.ts b/test/commands/repo/list.test.ts index ed70e00131..356bf7a342 100644 --- a/test/commands/repo/list.test.ts +++ b/test/commands/repo/list.test.ts @@ -2,8 +2,8 @@ * Repository List Command Tests * * Tests for the repo list command in src/commands/repo/list.ts. - * Uses spyOn to mock api-client and resolve-target to test - * the func() body without real HTTP calls or database access. + * Covers all four target modes (auto-detect, explicit, project-search, org-all) + * plus cursor pagination, --cursor last, and error paths. */ import { @@ -21,6 +21,9 @@ 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 paginationDb from "../../../src/lib/db/pagination.js"; +import { ValidationError } from "../../../src/lib/errors.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking import * as resolveTarget from "../../../src/lib/resolve-target.js"; import type { SentryRepository } from "../../../src/types/sentry.js"; @@ -50,43 +53,30 @@ const sampleRepos: SentryRepository[] = [ }, ]; -function createMockContext() { +function createMockContext(cwd = "/tmp") { const stdoutWrite = mock(() => true); + const stderrWrite = mock(() => true); return { context: { stdout: { write: stdoutWrite }, - stderr: { write: mock(() => true) }, - cwd: "/tmp", - setContext: mock(() => { - // no-op for test - }), + stderr: { write: stderrWrite }, + cwd, + setContext: mock(() => {}), }, stdoutWrite, + stderrWrite, }; } -describe("listCommand.func", () => { +describe("listCommand.func — explicit org (project-search / bare slug)", () => { let listRepositoriesSpy: ReturnType; - let listOrganizationsSpy: ReturnType; - let getDefaultOrganizationSpy: ReturnType; - let resolveAllTargetsSpy: ReturnType; beforeEach(() => { listRepositoriesSpy = spyOn(apiClient, "listRepositories"); - 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(() => { listRepositoriesSpy.mockRestore(); - listOrganizationsSpy.mockRestore(); - getDefaultOrganizationSpy.mockRestore(); - resolveAllTargetsSpy.mockRestore(); }); test("outputs JSON array when --json flag is set", async () => { @@ -101,6 +91,7 @@ describe("listCommand.func", () => { expect(Array.isArray(parsed)).toBe(true); expect(parsed).toHaveLength(2); expect(parsed[0].name).toBe("getsentry/sentry"); + expect(parsed[1].name).toBe("getsentry/sentry-javascript"); }); test("outputs empty JSON array when no repos found with --json", async () => { @@ -133,25 +124,21 @@ describe("listCommand.func", () => { 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("NAME"); expect(output).toContain("PROVIDER"); expect(output).toContain("STATUS"); expect(output).toContain("URL"); - // Check data expect(output).toContain("getsentry/sentry"); expect(output).toContain("getsentry/sentry-javascript"); expect(output).toContain("GitHub"); expect(output).toContain("active"); - // Check footer expect(output).toContain("sentry repo list"); }); test("shows count when results exceed limit", async () => { - // Create more repos than the limit const manyRepos = Array.from({ length: 10 }, (_, i) => ({ - ...sampleRepos[0], + ...sampleRepos[0]!, id: String(i), name: `repo-${i}`, })); @@ -165,6 +152,52 @@ describe("listCommand.func", () => { expect(output).toContain("Showing 5 of 10 repositories"); }); + test("shows all repos when count is under limit", async () => { + listRepositoriesSpy.mockResolvedValue(sampleRepos); + + 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("Showing 2 repositories"); + }); + + test("explicit org/project uses org part only", async () => { + listRepositoriesSpy.mockResolvedValue(sampleRepos); + + const { context } = createMockContext(); + const func = await listCommand.loader(); + // "my-org/my-project" — explicit mode, org = "my-org" + await func.call(context, { limit: 30, json: false }, "my-org/my-project"); + + expect(listRepositoriesSpy).toHaveBeenCalledWith("my-org"); + }); +}); + +describe("listCommand.func — auto-detect mode", () => { + let listRepositoriesSpy: ReturnType; + let listOrganizationsSpy: ReturnType; + let getDefaultOrganizationSpy: ReturnType; + let resolveAllTargetsSpy: ReturnType; + + beforeEach(() => { + listRepositoriesSpy = spyOn(apiClient, "listRepositories"); + listOrganizationsSpy = spyOn(apiClient, "listOrganizations"); + getDefaultOrganizationSpy = spyOn(defaults, "getDefaultOrganization"); + resolveAllTargetsSpy = spyOn(resolveTarget, "resolveAllTargets"); + + getDefaultOrganizationSpy.mockResolvedValue(null); + resolveAllTargetsSpy.mockResolvedValue({ targets: [] }); + }); + + afterEach(() => { + listRepositoriesSpy.mockRestore(); + listOrganizationsSpy.mockRestore(); + getDefaultOrganizationSpy.mockRestore(); + resolveAllTargetsSpy.mockRestore(); + }); + test("uses default organization when no org provided", async () => { getDefaultOrganizationSpy.mockResolvedValue("default-org"); listRepositoriesSpy.mockResolvedValue(sampleRepos); @@ -200,7 +233,233 @@ describe("listCommand.func", () => { const func = await listCommand.loader(); await func.call(context, { limit: 30, json: false }, undefined); - // Should have called listOrganizations and then listRepositories for each expect(listOrganizationsSpy).toHaveBeenCalled(); }); + + test("outputs JSON in auto-detect mode", async () => { + getDefaultOrganizationSpy.mockResolvedValue("auto-org"); + listRepositoriesSpy.mockResolvedValue(sampleRepos); + + const { context, stdoutWrite } = createMockContext(); + const func = await listCommand.loader(); + await func.call(context, { limit: 30, json: true }, undefined); + + 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); + }); + + test("shows 'No repositories found' in auto-detect when empty and single org", async () => { + getDefaultOrganizationSpy.mockResolvedValue("empty-org"); + listRepositoriesSpy.mockResolvedValue([]); + + const { context, stdoutWrite } = createMockContext(); + const func = await listCommand.loader(); + await func.call(context, { limit: 30, json: false }, undefined); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + expect(output).toContain("No repositories found"); + }); + + test("shows 'No repositories found.' fallback when no orgs at all", async () => { + listOrganizationsSpy.mockResolvedValue([]); + listRepositoriesSpy.mockResolvedValue([]); + + const { context, stdoutWrite } = createMockContext(); + const func = await listCommand.loader(); + await func.call(context, { limit: 30, json: false }, undefined); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + expect(output).toContain("No repositories found"); + }); +}); + +describe("listCommand.func — org-all mode (cursor pagination)", () => { + let listRepositoriesPaginatedSpy: ReturnType; + let getPaginationCursorSpy: ReturnType; + let setPaginationCursorSpy: ReturnType; + let clearPaginationCursorSpy: ReturnType; + + beforeEach(() => { + listRepositoriesPaginatedSpy = spyOn( + apiClient, + "listRepositoriesPaginated" + ); + getPaginationCursorSpy = spyOn(paginationDb, "getPaginationCursor"); + setPaginationCursorSpy = spyOn(paginationDb, "setPaginationCursor"); + clearPaginationCursorSpy = spyOn(paginationDb, "clearPaginationCursor"); + + setPaginationCursorSpy.mockReturnValue(undefined); + clearPaginationCursorSpy.mockReturnValue(undefined); + }); + + afterEach(() => { + listRepositoriesPaginatedSpy.mockRestore(); + getPaginationCursorSpy.mockRestore(); + setPaginationCursorSpy.mockRestore(); + clearPaginationCursorSpy.mockRestore(); + }); + + test("returns paginated JSON with hasMore=false when no nextCursor", async () => { + listRepositoriesPaginatedSpy.mockResolvedValue({ + data: sampleRepos, + nextCursor: undefined, + }); + + const { context, stdoutWrite } = createMockContext(); + const func = await listCommand.loader(); + await func.call(context, { limit: 25, json: true }, "my-org/"); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + const parsed = JSON.parse(output); + expect(parsed).toHaveProperty("data"); + expect(parsed).toHaveProperty("hasMore", false); + expect(parsed.data).toHaveLength(2); + expect(clearPaginationCursorSpy).toHaveBeenCalled(); + }); + + test("returns paginated JSON with hasMore=true and nextCursor when more pages", async () => { + listRepositoriesPaginatedSpy.mockResolvedValue({ + data: sampleRepos, + nextCursor: "cursor:abc:123", + }); + + const { context, stdoutWrite } = createMockContext(); + const func = await listCommand.loader(); + await func.call(context, { limit: 25, json: true }, "my-org/"); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + const parsed = JSON.parse(output); + expect(parsed).toHaveProperty("hasMore", true); + expect(parsed).toHaveProperty("nextCursor", "cursor:abc:123"); + expect(setPaginationCursorSpy).toHaveBeenCalled(); + }); + + test("human output shows table and next page hint when hasMore", async () => { + listRepositoriesPaginatedSpy.mockResolvedValue({ + data: sampleRepos, + nextCursor: "cursor:abc:123", + }); + + const { context, stdoutWrite } = createMockContext(); + const func = await listCommand.loader(); + await func.call(context, { limit: 25, json: false }, "my-org/"); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + expect(output).toContain("getsentry/sentry"); + expect(output).toContain("more available"); + expect(output).toContain("Next page:"); + expect(output).toContain("-c last"); + }); + + test("human output shows count without next-page hint when no more", async () => { + listRepositoriesPaginatedSpy.mockResolvedValue({ + data: sampleRepos, + nextCursor: undefined, + }); + + const { context, stdoutWrite } = createMockContext(); + const func = await listCommand.loader(); + await func.call(context, { limit: 25, json: false }, "my-org/"); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + expect(output).toContain("Showing 2 repositories"); + expect(output).not.toContain("Next page:"); + }); + + test("human output 'No repositories found' when empty and no cursor", async () => { + listRepositoriesPaginatedSpy.mockResolvedValue({ + data: [], + nextCursor: undefined, + }); + + const { context, stdoutWrite } = createMockContext(); + const func = await listCommand.loader(); + await func.call(context, { limit: 25, json: false }, "my-org/"); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + expect(output).toContain("No repositories found in organization 'my-org'."); + }); + + test("uses explicit cursor string when provided", async () => { + listRepositoriesPaginatedSpy.mockResolvedValue({ + data: sampleRepos, + nextCursor: undefined, + }); + + const { context } = createMockContext(); + const func = await listCommand.loader(); + await func.call( + context, + { limit: 25, json: false, cursor: "explicit:cursor:value" }, + "my-org/" + ); + + expect(listRepositoriesPaginatedSpy).toHaveBeenCalledWith( + "my-org", + expect.objectContaining({ cursor: "explicit:cursor:value" }) + ); + }); + + test("resolves 'last' cursor from cache", async () => { + getPaginationCursorSpy.mockReturnValue("cached:cursor:456"); + listRepositoriesPaginatedSpy.mockResolvedValue({ + data: sampleRepos, + nextCursor: undefined, + }); + + const { context } = createMockContext(); + const func = await listCommand.loader(); + await func.call( + context, + { limit: 25, json: false, cursor: "last" }, + "my-org/" + ); + + expect(listRepositoriesPaginatedSpy).toHaveBeenCalledWith( + "my-org", + expect.objectContaining({ cursor: "cached:cursor:456" }) + ); + }); + + test("throws ContextError when 'last' cursor not in cache", async () => { + getPaginationCursorSpy.mockReturnValue(null); + + const { context } = createMockContext(); + const func = await listCommand.loader(); + + await expect( + func.call(context, { limit: 25, json: false, cursor: "last" }, "my-org/") + ).rejects.toThrow("No saved cursor"); + }); + + test("throws ValidationError when --cursor used outside org-all mode", async () => { + const { context } = createMockContext(); + const func = await listCommand.loader(); + + await expect( + func.call( + context, + { limit: 25, json: false, cursor: "some-cursor" }, + "my-org/my-project" + ) + ).rejects.toThrow(ValidationError); + }); + + test("passes perPage from limit to paginated call", async () => { + listRepositoriesPaginatedSpy.mockResolvedValue({ + data: [], + nextCursor: undefined, + }); + + const { context } = createMockContext(); + const func = await listCommand.loader(); + await func.call(context, { limit: 10, json: false }, "my-org/"); + + expect(listRepositoriesPaginatedSpy).toHaveBeenCalledWith( + "my-org", + expect.objectContaining({ perPage: 10 }) + ); + }); }); diff --git a/test/commands/team/list.test.ts b/test/commands/team/list.test.ts index 2b54221180..d2ccb8e401 100644 --- a/test/commands/team/list.test.ts +++ b/test/commands/team/list.test.ts @@ -2,8 +2,8 @@ * 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. + * Covers all four target modes (auto-detect, explicit, project-search, org-all) + * plus cursor pagination, --cursor last, and error paths. */ import { @@ -21,6 +21,9 @@ 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 paginationDb from "../../../src/lib/db/pagination.js"; +import { ValidationError } from "../../../src/lib/errors.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"; @@ -46,43 +49,30 @@ const sampleTeams: SentryTeam[] = [ }, ]; -function createMockContext() { +function createMockContext(cwd = "/tmp") { const stdoutWrite = mock(() => true); + const stderrWrite = mock(() => true); return { context: { stdout: { write: stdoutWrite }, - stderr: { write: mock(() => true) }, - cwd: "/tmp", - setContext: mock(() => { - // no-op for test - }), + stderr: { write: stderrWrite }, + cwd, + setContext: mock(() => {}), }, stdoutWrite, + stderrWrite, }; } -describe("listCommand.func", () => { +describe("listCommand.func — explicit org (project-search / bare slug)", () => { 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 () => { @@ -130,25 +120,22 @@ describe("listCommand.func", () => { 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], + ...sampleTeams[0]!, id: String(i), slug: `team-${i}`, name: `Team ${i}`, @@ -163,6 +150,52 @@ describe("listCommand.func", () => { expect(output).toContain("Showing 5 of 10 teams"); }); + test("shows all teams when count is under limit", 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(""); + expect(output).toContain("Showing 2 teams"); + }); + + test("explicit org/project uses org part only", async () => { + listTeamsSpy.mockResolvedValue(sampleTeams); + + const { context } = createMockContext(); + const func = await listCommand.loader(); + // "my-org/my-project" — explicit mode, org = "my-org" + await func.call(context, { limit: 30, json: false }, "my-org/my-project"); + + expect(listTeamsSpy).toHaveBeenCalledWith("my-org"); + }); +}); + +describe("listCommand.func — auto-detect mode", () => { + 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"); + + getDefaultOrganizationSpy.mockResolvedValue(null); + resolveAllTargetsSpy.mockResolvedValue({ targets: [] }); + }); + + afterEach(() => { + listTeamsSpy.mockRestore(); + listOrganizationsSpy.mockRestore(); + getDefaultOrganizationSpy.mockRestore(); + resolveAllTargetsSpy.mockRestore(); + }); + test("uses default organization when no org provided", async () => { getDefaultOrganizationSpy.mockResolvedValue("default-org"); listTeamsSpy.mockResolvedValue(sampleTeams); @@ -198,7 +231,230 @@ describe("listCommand.func", () => { 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(); }); + + test("outputs JSON in auto-detect mode", async () => { + getDefaultOrganizationSpy.mockResolvedValue("auto-org"); + listTeamsSpy.mockResolvedValue(sampleTeams); + + const { context, stdoutWrite } = createMockContext(); + const func = await listCommand.loader(); + await func.call(context, { limit: 30, json: true }, undefined); + + 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); + }); + + test("shows 'No teams found' in auto-detect when empty and single org", async () => { + getDefaultOrganizationSpy.mockResolvedValue("empty-org"); + listTeamsSpy.mockResolvedValue([]); + + const { context, stdoutWrite } = createMockContext(); + const func = await listCommand.loader(); + await func.call(context, { limit: 30, json: false }, undefined); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + expect(output).toContain("No teams found"); + }); + + test("shows 'No teams found.' fallback when no orgs at all", async () => { + listOrganizationsSpy.mockResolvedValue([]); + listTeamsSpy.mockResolvedValue([]); + + const { context, stdoutWrite } = createMockContext(); + const func = await listCommand.loader(); + await func.call(context, { limit: 30, json: false }, undefined); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + expect(output).toContain("No teams found"); + }); +}); + +describe("listCommand.func — org-all mode (cursor pagination)", () => { + let listTeamsPaginatedSpy: ReturnType; + let getPaginationCursorSpy: ReturnType; + let setPaginationCursorSpy: ReturnType; + let clearPaginationCursorSpy: ReturnType; + + beforeEach(() => { + listTeamsPaginatedSpy = spyOn(apiClient, "listTeamsPaginated"); + getPaginationCursorSpy = spyOn(paginationDb, "getPaginationCursor"); + setPaginationCursorSpy = spyOn(paginationDb, "setPaginationCursor"); + clearPaginationCursorSpy = spyOn(paginationDb, "clearPaginationCursor"); + + setPaginationCursorSpy.mockReturnValue(undefined); + clearPaginationCursorSpy.mockReturnValue(undefined); + }); + + afterEach(() => { + listTeamsPaginatedSpy.mockRestore(); + getPaginationCursorSpy.mockRestore(); + setPaginationCursorSpy.mockRestore(); + clearPaginationCursorSpy.mockRestore(); + }); + + test("returns paginated JSON with hasMore=false when no nextCursor", async () => { + listTeamsPaginatedSpy.mockResolvedValue({ + data: sampleTeams, + nextCursor: undefined, + }); + + const { context, stdoutWrite } = createMockContext(); + const func = await listCommand.loader(); + await func.call(context, { limit: 25, json: true }, "my-org/"); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + const parsed = JSON.parse(output); + expect(parsed).toHaveProperty("data"); + expect(parsed).toHaveProperty("hasMore", false); + expect(parsed.data).toHaveLength(2); + expect(clearPaginationCursorSpy).toHaveBeenCalled(); + }); + + test("returns paginated JSON with hasMore=true and nextCursor when more pages", async () => { + listTeamsPaginatedSpy.mockResolvedValue({ + data: sampleTeams, + nextCursor: "cursor:abc:123", + }); + + const { context, stdoutWrite } = createMockContext(); + const func = await listCommand.loader(); + await func.call(context, { limit: 25, json: true }, "my-org/"); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + const parsed = JSON.parse(output); + expect(parsed).toHaveProperty("hasMore", true); + expect(parsed).toHaveProperty("nextCursor", "cursor:abc:123"); + expect(setPaginationCursorSpy).toHaveBeenCalled(); + }); + + test("human output shows table and next page hint when hasMore", async () => { + listTeamsPaginatedSpy.mockResolvedValue({ + data: sampleTeams, + nextCursor: "cursor:abc:123", + }); + + const { context, stdoutWrite } = createMockContext(); + const func = await listCommand.loader(); + await func.call(context, { limit: 25, json: false }, "my-org/"); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + expect(output).toContain("backend"); + expect(output).toContain("more available"); + expect(output).toContain("Next page:"); + expect(output).toContain("-c last"); + }); + + test("human output shows count without next-page hint when no more", async () => { + listTeamsPaginatedSpy.mockResolvedValue({ + data: sampleTeams, + nextCursor: undefined, + }); + + const { context, stdoutWrite } = createMockContext(); + const func = await listCommand.loader(); + await func.call(context, { limit: 25, json: false }, "my-org/"); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + expect(output).toContain("Showing 2 teams"); + expect(output).not.toContain("Next page:"); + }); + + test("human output 'No teams found' when empty and no cursor", async () => { + listTeamsPaginatedSpy.mockResolvedValue({ + data: [], + nextCursor: undefined, + }); + + const { context, stdoutWrite } = createMockContext(); + const func = await listCommand.loader(); + await func.call(context, { limit: 25, json: false }, "my-org/"); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + expect(output).toContain("No teams found in organization 'my-org'."); + }); + + test("uses explicit cursor string when provided", async () => { + listTeamsPaginatedSpy.mockResolvedValue({ + data: sampleTeams, + nextCursor: undefined, + }); + + const { context } = createMockContext(); + const func = await listCommand.loader(); + await func.call( + context, + { limit: 25, json: false, cursor: "explicit:cursor:value" }, + "my-org/" + ); + + expect(listTeamsPaginatedSpy).toHaveBeenCalledWith( + "my-org", + expect.objectContaining({ cursor: "explicit:cursor:value" }) + ); + }); + + test("resolves 'last' cursor from cache", async () => { + getPaginationCursorSpy.mockReturnValue("cached:cursor:456"); + listTeamsPaginatedSpy.mockResolvedValue({ + data: sampleTeams, + nextCursor: undefined, + }); + + const { context } = createMockContext(); + const func = await listCommand.loader(); + await func.call( + context, + { limit: 25, json: false, cursor: "last" }, + "my-org/" + ); + + expect(listTeamsPaginatedSpy).toHaveBeenCalledWith( + "my-org", + expect.objectContaining({ cursor: "cached:cursor:456" }) + ); + }); + + test("throws ContextError when 'last' cursor not in cache", async () => { + getPaginationCursorSpy.mockReturnValue(null); + + const { context } = createMockContext(); + const func = await listCommand.loader(); + + await expect( + func.call(context, { limit: 25, json: false, cursor: "last" }, "my-org/") + ).rejects.toThrow("No saved cursor"); + }); + + test("throws ValidationError when --cursor used outside org-all mode", async () => { + const { context } = createMockContext(); + const func = await listCommand.loader(); + + await expect( + func.call( + context, + { limit: 25, json: false, cursor: "some-cursor" }, + "my-org/my-project" + ) + ).rejects.toThrow(ValidationError); + }); + + test("passes perPage from limit to paginated call", async () => { + listTeamsPaginatedSpy.mockResolvedValue({ + data: [], + nextCursor: undefined, + }); + + const { context } = createMockContext(); + const func = await listCommand.loader(); + await func.call(context, { limit: 10, json: false }, "my-org/"); + + expect(listTeamsPaginatedSpy).toHaveBeenCalledWith( + "my-org", + expect.objectContaining({ perPage: 10 }) + ); + }); }); diff --git a/test/lib/api-client.test.ts b/test/lib/api-client.test.ts index e16920b20d..de0c64f814 100644 --- a/test/lib/api-client.test.ts +++ b/test/lib/api-client.test.ts @@ -6,8 +6,16 @@ */ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { buildSearchParams, rawApiRequest } from "../../src/lib/api-client.js"; +import { + buildSearchParams, + listIssuesPaginated, + listRepositoriesPaginated, + listTeamsPaginated, + rawApiRequest, +} from "../../src/lib/api-client.js"; +import { DEFAULT_SENTRY_URL } from "../../src/lib/constants.js"; import { setAuthToken } from "../../src/lib/db/auth.js"; +import { setOrgRegion } from "../../src/lib/db/regions.js"; import { useTestConfigDir } from "../helpers.js"; useTestConfigDir("test-api-"); @@ -722,3 +730,238 @@ describe("findProjectsBySlug", () => { expect(results[0].orgSlug).toBe("acme"); }); }); + +describe("listTeamsPaginated", () => { + beforeEach(async () => { + await setOrgRegion("my-org", DEFAULT_SENTRY_URL); + }); + + test("returns teams and nextCursor from Link header", async () => { + const teamData = [ + { id: "1", slug: "backend", name: "Backend", memberCount: 5 }, + { id: "2", slug: "frontend", name: "Frontend", memberCount: 3 }, + ]; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + if (req.url.includes("/teams/")) { + return new Response(JSON.stringify(teamData), { + status: 200, + headers: { + "Content-Type": "application/json", + link: '; rel="next"; results="true"; cursor="100:1:0"', + }, + }); + } + return new Response(JSON.stringify([]), { status: 200 }); + }; + + const result = await listTeamsPaginated("my-org"); + expect(result.data).toHaveLength(2); + expect(result.nextCursor).toBe("100:1:0"); + }); + + test("returns no nextCursor when Link header has results=false", async () => { + const teamData = [ + { id: "1", slug: "backend", name: "Backend", memberCount: 5 }, + ]; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + if (req.url.includes("/teams/")) { + return new Response(JSON.stringify(teamData), { + status: 200, + headers: { + "Content-Type": "application/json", + link: '; rel="previous"; results="false"; cursor="100:0:1", ; rel="next"; results="false"; cursor="100:1:0"', + }, + }); + } + return new Response(JSON.stringify([]), { status: 200 }); + }; + + const result = await listTeamsPaginated("my-org"); + expect(result.data).toHaveLength(1); + expect(result.nextCursor).toBeUndefined(); + }); + + test("passes cursor and perPage as query params", async () => { + let capturedUrl = ""; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + if (req.url.includes("/teams/")) { + capturedUrl = req.url; + return new Response(JSON.stringify([]), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + return new Response(JSON.stringify([]), { status: 200 }); + }; + + await listTeamsPaginated("my-org", { cursor: "100:2:0", perPage: 10 }); + + const url = new URL(capturedUrl); + expect(url.searchParams.get("cursor")).toBe("100:2:0"); + expect(url.searchParams.get("per_page")).toBe("10"); + }); +}); + +describe("listRepositoriesPaginated", () => { + beforeEach(async () => { + await setOrgRegion("my-org", DEFAULT_SENTRY_URL); + }); + + test("returns repos and nextCursor from Link header", async () => { + const repoData = [ + { + id: "1", + name: "getsentry/sentry", + provider: { name: "GitHub" }, + status: "active", + }, + ]; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + if (req.url.includes("/repos/")) { + return new Response(JSON.stringify(repoData), { + status: 200, + headers: { + "Content-Type": "application/json", + link: '; rel="next"; results="true"; cursor="0:1:0"', + }, + }); + } + return new Response(JSON.stringify([]), { status: 200 }); + }; + + const result = await listRepositoriesPaginated("my-org"); + expect(result.data).toHaveLength(1); + expect(result.nextCursor).toBe("0:1:0"); + }); + + test("passes cursor and perPage as query params", async () => { + let capturedUrl = ""; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + if (req.url.includes("/repos/")) { + capturedUrl = req.url; + return new Response(JSON.stringify([]), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + return new Response(JSON.stringify([]), { status: 200 }); + }; + + await listRepositoriesPaginated("my-org", { cursor: "0:2:0", perPage: 5 }); + + const url = new URL(capturedUrl); + expect(url.searchParams.get("cursor")).toBe("0:2:0"); + expect(url.searchParams.get("per_page")).toBe("5"); + }); +}); + +describe("listIssuesPaginated", () => { + beforeEach(async () => { + await setOrgRegion("my-org", DEFAULT_SENTRY_URL); + }); + + test("returns issues and nextCursor from Link header", async () => { + const issueData = [ + { id: "1", shortId: "PROJ-1", title: "Test Error", status: "unresolved" }, + ]; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + if (req.url.includes("/issues/")) { + return new Response(JSON.stringify(issueData), { + status: 200, + headers: { + "Content-Type": "application/json", + link: '; rel="next"; results="true"; cursor="0:1:0"', + }, + }); + } + return new Response(JSON.stringify([]), { status: 200 }); + }; + + const result = await listIssuesPaginated("my-org", "my-proj"); + expect(result.data).toHaveLength(1); + expect(result.nextCursor).toBe("0:1:0"); + }); + + test("includes project filter in query param", async () => { + let capturedUrl = ""; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + if (req.url.includes("/issues/")) { + capturedUrl = req.url; + return new Response(JSON.stringify([]), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + return new Response(JSON.stringify([]), { status: 200 }); + }; + + await listIssuesPaginated("my-org", "my-proj"); + + const url = new URL(capturedUrl); + expect(url.searchParams.get("query")).toContain("project:my-proj"); + }); + + test("combines project filter with custom query", async () => { + let capturedUrl = ""; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + if (req.url.includes("/issues/")) { + capturedUrl = req.url; + return new Response(JSON.stringify([]), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + return new Response(JSON.stringify([]), { status: 200 }); + }; + + await listIssuesPaginated("my-org", "my-proj", { query: "is:unresolved" }); + + const url = new URL(capturedUrl); + const query = url.searchParams.get("query") ?? ""; + expect(query).toContain("project:my-proj"); + expect(query).toContain("is:unresolved"); + }); + + test("passes cursor, perPage, and sort as query params", async () => { + let capturedUrl = ""; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + if (req.url.includes("/issues/")) { + capturedUrl = req.url; + return new Response(JSON.stringify([]), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + return new Response(JSON.stringify([]), { status: 200 }); + }; + + await listIssuesPaginated("my-org", "my-proj", { + cursor: "0:3:0", + perPage: 20, + sort: "freq", + }); + + const url = new URL(capturedUrl); + expect(url.searchParams.get("cursor")).toBe("0:3:0"); + expect(url.searchParams.get("per_page")).toBe("20"); + expect(url.searchParams.get("sort")).toBe("freq"); + }); +}); diff --git a/test/lib/resolve-target-listing.test.ts b/test/lib/resolve-target-listing.test.ts new file mode 100644 index 0000000000..ff70fed0c7 --- /dev/null +++ b/test/lib/resolve-target-listing.test.ts @@ -0,0 +1,288 @@ +/** + * Tests for new resolve-target listing functions + * + * Tests for resolveOrgsForListing, resolveOrgProjectTarget, and + * resolveOrgProjectFromArg added in the pagination PR. + * Uses spyOn to mock dependencies without real HTTP calls. + */ + +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +// 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"; +import { ContextError } from "../../src/lib/errors.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as resolveTargetModule from "../../src/lib/resolve-target.js"; +import { + resolveOrgProjectFromArg, + resolveOrgProjectTarget, + resolveOrgsForListing, +} from "../../src/lib/resolve-target.js"; + +const CWD = "/tmp/test-project"; + +// --------------------------------------------------------------------------- +// resolveOrgsForListing +// --------------------------------------------------------------------------- + +describe("resolveOrgsForListing", () => { + let getDefaultOrganizationSpy: ReturnType; + let resolveAllTargetsSpy: ReturnType; + + beforeEach(() => { + getDefaultOrganizationSpy = spyOn(defaults, "getDefaultOrganization"); + resolveAllTargetsSpy = spyOn(resolveTargetModule, "resolveAllTargets"); + + getDefaultOrganizationSpy.mockResolvedValue(null); + resolveAllTargetsSpy.mockResolvedValue({ targets: [] }); + }); + + afterEach(() => { + getDefaultOrganizationSpy.mockRestore(); + resolveAllTargetsSpy.mockRestore(); + }); + + test("returns explicit org when orgFlag is provided", async () => { + const result = await resolveOrgsForListing("my-org", CWD); + expect(result.orgs).toEqual(["my-org"]); + // Should not consult defaults or DSN when explicit org given + expect(getDefaultOrganizationSpy).not.toHaveBeenCalled(); + }); + + test("returns default org when no orgFlag and default exists", async () => { + getDefaultOrganizationSpy.mockResolvedValue("default-org"); + + const result = await resolveOrgsForListing(undefined, CWD); + expect(result.orgs).toEqual(["default-org"]); + }); + + test("returns unique orgs from DSN detection when no default", async () => { + resolveAllTargetsSpy.mockResolvedValue({ + targets: [ + { org: "org-a", project: "proj-1" }, + { org: "org-a", project: "proj-2" }, // same org, different project + { org: "org-b", project: "proj-3" }, + ], + }); + + const result = await resolveOrgsForListing(undefined, CWD); + // Should deduplicate orgs + expect(result.orgs).toEqual(["org-a", "org-b"]); + }); + + test("returns empty orgs when no detection results", async () => { + resolveAllTargetsSpy.mockResolvedValue({ targets: [] }); + + const result = await resolveOrgsForListing(undefined, CWD); + expect(result.orgs).toEqual([]); + }); + + test("propagates footer from DSN detection", async () => { + resolveAllTargetsSpy.mockResolvedValue({ + targets: [ + { org: "org-a", project: "proj-1" }, + { org: "org-b", project: "proj-2" }, + ], + footer: "Found 2 projects", + }); + + const result = await resolveOrgsForListing(undefined, CWD); + expect(result.footer).toBe("Found 2 projects"); + }); + + test("propagates skippedSelfHosted from DSN detection", async () => { + resolveAllTargetsSpy.mockResolvedValue({ + targets: [{ org: "org-a", project: "proj-1" }], + skippedSelfHosted: 2, + }); + + const result = await resolveOrgsForListing(undefined, CWD); + expect(result.skippedSelfHosted).toBe(2); + }); + + test("returns empty orgs and propagates skippedSelfHosted when targets empty but DSNs found", async () => { + resolveAllTargetsSpy.mockResolvedValue({ + targets: [], + skippedSelfHosted: 3, + }); + + const result = await resolveOrgsForListing(undefined, CWD); + expect(result.orgs).toEqual([]); + expect(result.skippedSelfHosted).toBe(3); + }); +}); + +// --------------------------------------------------------------------------- +// resolveOrgProjectTarget +// --------------------------------------------------------------------------- + +describe("resolveOrgProjectTarget", () => { + let findProjectsBySlugSpy: ReturnType; + let resolveOrgAndProjectSpy: ReturnType; + + beforeEach(() => { + findProjectsBySlugSpy = spyOn(apiClient, "findProjectsBySlug"); + resolveOrgAndProjectSpy = spyOn( + resolveTargetModule, + "resolveOrgAndProject" + ); + }); + + afterEach(() => { + findProjectsBySlugSpy.mockRestore(); + resolveOrgAndProjectSpy.mockRestore(); + }); + + test("returns org and project for explicit type", async () => { + const parsed = { + type: "explicit" as const, + org: "my-org", + project: "my-proj", + }; + + const result = await resolveOrgProjectTarget(parsed, CWD, "trace list"); + expect(result).toEqual({ org: "my-org", project: "my-proj" }); + expect(findProjectsBySlugSpy).not.toHaveBeenCalled(); + }); + + test("throws ContextError for org-all type", async () => { + const parsed = { type: "org-all" as const, org: "my-org" }; + + await expect( + resolveOrgProjectTarget(parsed, CWD, "trace list") + ).rejects.toThrow(ContextError); + }); + + test("resolves project-search when single match found", async () => { + findProjectsBySlugSpy.mockResolvedValue([ + { orgSlug: "found-org", slug: "my-proj", name: "My Project" }, + ]); + + const parsed = { type: "project-search" as const, projectSlug: "my-proj" }; + + const result = await resolveOrgProjectTarget(parsed, CWD, "trace list"); + expect(result).toEqual({ org: "found-org", project: "my-proj" }); + }); + + test("throws ContextError for project-search when no match", async () => { + findProjectsBySlugSpy.mockResolvedValue([]); + + const parsed = { + type: "project-search" as const, + projectSlug: "nonexistent", + }; + + await expect( + resolveOrgProjectTarget(parsed, CWD, "trace list") + ).rejects.toThrow(ContextError); + }); + + test("throws ContextError for project-search when multiple matches", async () => { + findProjectsBySlugSpy.mockResolvedValue([ + { orgSlug: "org-a", slug: "my-proj", name: "My Project" }, + { orgSlug: "org-b", slug: "my-proj", name: "My Project" }, + ]); + + const parsed = { type: "project-search" as const, projectSlug: "my-proj" }; + + await expect( + resolveOrgProjectTarget(parsed, CWD, "trace list") + ).rejects.toThrow(ContextError); + }); + + test("resolves auto-detect when DSN detection succeeds", async () => { + resolveOrgAndProjectSpy.mockResolvedValue({ + org: "detected-org", + project: "detected-proj", + orgDisplay: "Detected Org", + projectDisplay: "Detected Project", + }); + + const parsed = { type: "auto-detect" as const }; + + const result = await resolveOrgProjectTarget(parsed, CWD, "log list"); + expect(result).toEqual({ org: "detected-org", project: "detected-proj" }); + }); + + test("throws ContextError for auto-detect when no target found", async () => { + resolveOrgAndProjectSpy.mockResolvedValue(null); + + const parsed = { type: "auto-detect" as const }; + + await expect( + resolveOrgProjectTarget(parsed, CWD, "log list") + ).rejects.toThrow(ContextError); + }); + + test("error message for org-all includes command name and project hint", async () => { + const parsed = { type: "org-all" as const, org: "sentry" }; + + try { + await resolveOrgProjectTarget(parsed, CWD, "trace list"); + expect.unreachable("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(ContextError); + expect((err as Error).message).toContain("trace list"); + expect((err as Error).message).toContain("sentry"); + } + }); +}); + +// --------------------------------------------------------------------------- +// resolveOrgProjectFromArg +// --------------------------------------------------------------------------- + +describe("resolveOrgProjectFromArg", () => { + let findProjectsBySlugSpy: ReturnType; + let resolveOrgAndProjectSpy: ReturnType; + + beforeEach(() => { + findProjectsBySlugSpy = spyOn(apiClient, "findProjectsBySlug"); + resolveOrgAndProjectSpy = spyOn( + resolveTargetModule, + "resolveOrgAndProject" + ); + }); + + afterEach(() => { + findProjectsBySlugSpy.mockRestore(); + resolveOrgAndProjectSpy.mockRestore(); + }); + + test("resolves 'org/project' string to explicit target", async () => { + const result = await resolveOrgProjectFromArg( + "my-org/my-proj", + CWD, + "trace list" + ); + expect(result).toEqual({ org: "my-org", project: "my-proj" }); + }); + + test("resolves bare project slug string via project-search", async () => { + findProjectsBySlugSpy.mockResolvedValue([ + { orgSlug: "found-org", slug: "my-proj", name: "My Project" }, + ]); + + const result = await resolveOrgProjectFromArg("my-proj", CWD, "log list"); + expect(result).toEqual({ org: "found-org", project: "my-proj" }); + }); + + test("throws ContextError for 'org/' (org-all) string", async () => { + await expect( + resolveOrgProjectFromArg("sentry/", CWD, "trace list") + ).rejects.toThrow(ContextError); + }); + + test("resolves undefined to auto-detect", async () => { + resolveOrgAndProjectSpy.mockResolvedValue({ + org: "auto-org", + project: "auto-proj", + orgDisplay: "Auto Org", + projectDisplay: "Auto Project", + }); + + const result = await resolveOrgProjectFromArg(undefined, CWD, "trace list"); + expect(result).toEqual({ org: "auto-org", project: "auto-proj" }); + }); +}); From de709bf2d66c25d16f433e49724292c95c43d54d Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Wed, 18 Feb 2026 12:48:49 +0000 Subject: [PATCH 06/22] fix: add no-op comments to empty mock blocks to satisfy lint rule --- test/commands/issue/list.test.ts | 4 +++- test/commands/repo/list.test.ts | 4 +++- test/commands/team/list.test.ts | 4 +++- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/test/commands/issue/list.test.ts b/test/commands/issue/list.test.ts index f290bb15c5..efee1b085c 100644 --- a/test/commands/issue/list.test.ts +++ b/test/commands/issue/list.test.ts @@ -403,7 +403,9 @@ describe("issue list: org-all mode (cursor pagination)", () => { stdout: { write: stdoutWrite }, stderr: { write: stderrWrite }, cwd: "/tmp", - setContext: mock(() => {}), + setContext: mock(() => { + // no-op for test + }), }, stdoutWrite, stderrWrite, diff --git a/test/commands/repo/list.test.ts b/test/commands/repo/list.test.ts index 356bf7a342..ae00480b1e 100644 --- a/test/commands/repo/list.test.ts +++ b/test/commands/repo/list.test.ts @@ -61,7 +61,9 @@ function createMockContext(cwd = "/tmp") { stdout: { write: stdoutWrite }, stderr: { write: stderrWrite }, cwd, - setContext: mock(() => {}), + setContext: mock(() => { + // no-op for test + }), }, stdoutWrite, stderrWrite, diff --git a/test/commands/team/list.test.ts b/test/commands/team/list.test.ts index d2ccb8e401..a8a4b667a0 100644 --- a/test/commands/team/list.test.ts +++ b/test/commands/team/list.test.ts @@ -57,7 +57,9 @@ function createMockContext(cwd = "/tmp") { stdout: { write: stdoutWrite }, stderr: { write: stderrWrite }, cwd, - setContext: mock(() => {}), + setContext: mock(() => { + // no-op for test + }), }, stdoutWrite, stderrWrite, From 1e4ebe09b9042f5fb182e81d614ac632c8447dcf Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Wed, 18 Feb 2026 13:01:53 +0000 Subject: [PATCH 07/22] fix: address all bot review comments from PR #262 - Fix malformed 'project:' filter: empty projectSlug now produces empty string (falsy) so .filter(Boolean) correctly removes it for org-wide listing - Fix isMultiProject=false in org-all issue listing: set to true so the ALIAS column shows which project each issue belongs to - Fix missing hasMore check on empty page in issue list org-all path: show 'try the next page' hint instead of definitive 'no issues' when hasMore=true - Remove redundant footer tip from handleOrgAll in repo/list and team/list: the tip told users to do what they already did - Deduplicate buildContextKey/resolveCursor: extract buildOrgContextKey and resolveOrgCursor into src/lib/db/pagination.ts and update all callers --- src/commands/issue/list.ts | 30 +++++++++------------- src/commands/repo/list.ts | 51 +++++++------------------------------- src/commands/team/list.ts | 51 +++++++------------------------------- src/lib/api-client.ts | 5 +++- src/lib/db/pagination.ts | 51 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 85 insertions(+), 103 deletions(-) diff --git a/src/commands/issue/list.ts b/src/commands/issue/list.ts index 7e56ced615..46bb908153 100644 --- a/src/commands/issue/list.ts +++ b/src/commands/issue/list.ts @@ -17,7 +17,7 @@ import { parseOrgProjectArg } from "../../lib/arg-parsing.js"; import { buildCommand, numberParser } from "../../lib/command.js"; import { clearPaginationCursor, - getPaginationCursor, + resolveOrgCursor, setPaginationCursor, } from "../../lib/db/pagination.js"; import { @@ -468,22 +468,9 @@ export const listCommand = buildCommand({ // Handle org-all mode with cursor pagination (different code path) if (parsed.type === "org-all") { const org = parsed.org; + // Issue cursors encode sort+query so different searches don't share pages. const contextKey = `host:${getApiBaseUrl()}|type:org:${org}|sort:${flags.sort}${flags.query ? `|q:${flags.query}` : ""}`; - let cursor: string | undefined; - if (flags.cursor) { - if (flags.cursor === "last") { - const cached = getPaginationCursor(PAGINATION_KEY, contextKey); - if (!cached) { - throw new ContextError( - "Pagination cursor", - "No saved cursor for this query. Run without --cursor first." - ); - } - cursor = cached; - } else { - cursor = flags.cursor; - } - } + const cursor = resolveOrgCursor(flags.cursor, PAGINATION_KEY, contextKey); setContext([org], []); @@ -518,17 +505,24 @@ export const listCommand = buildCommand({ } if (response.data.length === 0) { - stdout.write(`No issues found in organization '${org}'.\n`); + if (hasMore) { + const hint = `sentry issue list ${org}/ -c last`; + stdout.write(`No issues on this page. Try the next page: ${hint}\n`); + } else { + stdout.write(`No issues found in organization '${org}'.\n`); + } return; } writeListHeader(stdout, `Issues in ${org}`, false); const termWidth = process.stdout.columns || 80; + // isMultiProject=true so the ALIAS column shows which project each issue + // belongs to — essential when viewing issues across an entire org. const issuesWithOpts = response.data.map((issue) => ({ issue, formatOptions: { projectSlug: issue.project?.slug ?? "", - isMultiProject: false, + isMultiProject: true, }, })); writeIssueRows(stdout, issuesWithOpts, termWidth); diff --git a/src/commands/repo/list.ts b/src/commands/repo/list.ts index 23a71ddd5e..a0f0432772 100644 --- a/src/commands/repo/list.ts +++ b/src/commands/repo/list.ts @@ -20,14 +20,14 @@ import { import { parseOrgProjectArg } from "../../lib/arg-parsing.js"; import { buildCommand, numberParser } from "../../lib/command.js"; import { + buildOrgContextKey, clearPaginationCursor, - getPaginationCursor, + resolveOrgCursor, setPaginationCursor, } from "../../lib/db/pagination.js"; -import { AuthError, ContextError, ValidationError } from "../../lib/errors.js"; +import { AuthError, ValidationError } from "../../lib/errors.js"; import { writeFooter, writeJson } from "../../lib/formatters/index.js"; import { resolveOrgsForListing } from "../../lib/resolve-target.js"; -import { getApiBaseUrl } from "../../lib/sentry-client.js"; import type { SentryRepository, Writer } from "../../types/index.js"; /** Command key for pagination cursor storage */ @@ -143,38 +143,6 @@ async function fetchAllOrgRepositories(): Promise { return results; } -/** - * Build a context key for pagination cursor validation. - * Captures the org so cursors from different orgs are never mixed. - */ -function buildContextKey(org: string): string { - return `host:${getApiBaseUrl()}|type:org:${org}`; -} - -/** - * Resolve the cursor value from --cursor flag. - * Handles the magic "last" value by looking up the cached cursor. - */ -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; -} - /** Build the CLI hint for fetching the next page. */ function nextPageHint(org: string): string { return `sentry repo list ${org}/ -c last`; @@ -237,11 +205,6 @@ async function handleOrgAll(options: OrgAllOptions): Promise { } else { stdout.write(`\nShowing ${repos.length} repositories\n`); } - - writeFooter( - stdout, - "Tip: Use 'sentry repo list /' for paginated results" - ); } /** @@ -435,8 +398,12 @@ export const listCommand = buildCommand({ break; case "org-all": { - const contextKey = buildContextKey(parsed.org); - const cursor = resolveCursor(flags.cursor, contextKey); + const contextKey = buildOrgContextKey(parsed.org); + const cursor = resolveOrgCursor( + flags.cursor, + PAGINATION_KEY, + contextKey + ); await handleOrgAll({ stdout, org: parsed.org, diff --git a/src/commands/team/list.ts b/src/commands/team/list.ts index 34959efca3..b761478b2d 100644 --- a/src/commands/team/list.ts +++ b/src/commands/team/list.ts @@ -20,14 +20,14 @@ import { import { parseOrgProjectArg } from "../../lib/arg-parsing.js"; import { buildCommand, numberParser } from "../../lib/command.js"; import { + buildOrgContextKey, clearPaginationCursor, - getPaginationCursor, + resolveOrgCursor, setPaginationCursor, } from "../../lib/db/pagination.js"; -import { AuthError, ContextError, ValidationError } from "../../lib/errors.js"; +import { AuthError, ValidationError } from "../../lib/errors.js"; import { writeFooter, writeJson } from "../../lib/formatters/index.js"; import { resolveOrgsForListing } from "../../lib/resolve-target.js"; -import { getApiBaseUrl } from "../../lib/sentry-client.js"; import type { SentryTeam, Writer } from "../../types/index.js"; /** Command key for pagination cursor storage */ @@ -140,38 +140,6 @@ async function fetchAllOrgTeams(): Promise { return results; } -/** - * Build a context key for pagination cursor validation. - * Captures the org so cursors from different orgs are never mixed. - */ -function buildContextKey(org: string): string { - return `host:${getApiBaseUrl()}|type:org:${org}`; -} - -/** - * Resolve the cursor value from --cursor flag. - * Handles the magic "last" value by looking up the cached cursor. - */ -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; -} - /** Build the CLI hint for fetching the next page. */ function nextPageHint(org: string): string { return `sentry team list ${org}/ -c last`; @@ -236,11 +204,6 @@ async function handleOrgAll(options: OrgAllOptions): Promise { } else { stdout.write(`\nShowing ${teams.length} teams\n`); } - - writeFooter( - stdout, - "Tip: Use 'sentry team list /' for paginated results" - ); } /** @@ -431,8 +394,12 @@ export const listCommand = buildCommand({ break; case "org-all": { - const contextKey = buildContextKey(parsed.org); - const cursor = resolveCursor(flags.cursor, contextKey); + const contextKey = buildOrgContextKey(parsed.org); + const cursor = resolveOrgCursor( + flags.cursor, + PAGINATION_KEY, + contextKey + ); await handleOrgAll({ stdout, org: parsed.org, diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index f511378277..d92757c048 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -1000,7 +1000,10 @@ export function listIssuesPaginated( statsPeriod?: string; } = {} ): Promise> { - const projectFilter = `project:${projectSlug}`; + // 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( diff --git a/src/lib/db/pagination.ts b/src/lib/db/pagination.ts index 06d2668018..ba71d76e5a 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,49 @@ export function clearPaginationCursor( "DELETE FROM pagination_cursors WHERE command_key = ? AND context = ?" ).run(commandKey, context); } + +/** + * 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; +} From 078dfa3b64c08f7e72ba6ae6a9b3f30a9d7797a5 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Wed, 18 Feb 2026 18:03:01 +0000 Subject: [PATCH 08/22] refactor: extract shared org-scoped list infrastructure for team and repo commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce OrgListConfig-driven dispatch and a generic table renderer to eliminate ~670 lines of duplicated code between team/list and repo/list. New modules: - src/lib/org-list.ts: fetchOrgSafe, fetchAllOrgs, handleOrgAll, handleAutoDetect, handleExplicitOrg, dispatchOrgScopedList - src/lib/formatters/table.ts: writeTable with Column definitions team/list.ts: 420 → 127 lines (-70%) repo/list.ts: 424 → 125 lines (-71%) --- src/commands/repo/list.ts | 370 +++--------------------- src/commands/team/list.ts | 375 +++--------------------- src/lib/formatters/index.ts | 1 + src/lib/formatters/table.ts | 74 +++++ src/lib/org-list.ts | 381 +++++++++++++++++++++++++ test/lib/formatters/table.test.ts | 104 +++++++ test/lib/org-list.test.ts | 458 ++++++++++++++++++++++++++++++ 7 files changed, 1095 insertions(+), 668 deletions(-) create mode 100644 src/lib/formatters/table.ts create mode 100644 src/lib/org-list.ts create mode 100644 test/lib/formatters/table.test.ts create mode 100644 test/lib/org-list.test.ts diff --git a/src/commands/repo/list.ts b/src/commands/repo/list.ts index a0f0432772..fe6a92d583 100644 --- a/src/commands/repo/list.ts +++ b/src/commands/repo/list.ts @@ -12,306 +12,46 @@ import type { SentryContext } from "../../context.js"; import { - listOrganizations, listRepositories, listRepositoriesPaginated, - type PaginatedResponse, } from "../../lib/api-client.js"; import { parseOrgProjectArg } from "../../lib/arg-parsing.js"; import { buildCommand, numberParser } from "../../lib/command.js"; +import { type Column, writeTable } from "../../lib/formatters/table.js"; import { - buildOrgContextKey, - clearPaginationCursor, - resolveOrgCursor, - setPaginationCursor, -} from "../../lib/db/pagination.js"; -import { AuthError, ValidationError } from "../../lib/errors.js"; -import { writeFooter, writeJson } from "../../lib/formatters/index.js"; -import { resolveOrgsForListing } from "../../lib/resolve-target.js"; + dispatchOrgScopedList, + type OrgListConfig, +} from "../../lib/org-list.js"; import type { SentryRepository, Writer } from "../../types/index.js"; /** Command key for pagination cursor storage */ export const PAGINATION_KEY = "repo-list"; -type ListFlags = { - readonly limit: number; - readonly json: boolean; - readonly cursor?: string; -}; - /** Repository with its organization context for display */ type RepositoryWithOrg = SentryRepository & { orgSlug?: string }; -/** 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[]; -}; - -/** - * 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`); - } -} - -/** Display repositories in table format with header and rows */ -function displayRepoTable(stdout: Writer, repos: RepositoryWithOrg[]): void { - const widths = calculateColumnWidths(repos); - writeHeader(stdout, widths); - writeRows({ stdout, repos, ...widths }); -} - -/** - * Fetch repositories for a single org, returning empty array on non-auth errors. - */ -async function fetchOrgRepositoriesSafe( - orgSlug: string -): Promise { - try { - const repos = await listRepositories(orgSlug); - return repos.map((r) => ({ ...r, orgSlug })); - } catch (error) { - if (error instanceof AuthError) { - throw error; - } - return []; - } -} - -/** - * Fetch repositories from all accessible organizations. - */ -async function fetchAllOrgRepositories(): Promise { - const orgs = await listOrganizations(); - const results: RepositoryWithOrg[] = []; - - for (const org of orgs) { - try { - const repos = await listRepositories(org.slug); - results.push(...repos.map((r) => ({ ...r, orgSlug: org.slug }))); - } catch (error) { - if (error instanceof AuthError) { - throw error; - } - // User may lack access to some orgs - } - } - - return results; -} - -/** Build the CLI hint for fetching the next page. */ -function nextPageHint(org: string): string { - return `sentry repo list ${org}/ -c last`; -} - -type OrgAllOptions = { - stdout: Writer; - org: string; - flags: ListFlags; - contextKey: string; - cursor: string | undefined; +/** 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), }; -/** - * Handle org-all mode (e.g., sentry/). - * Uses cursor pagination for efficient page-by-page listing. - */ -async function handleOrgAll(options: OrgAllOptions): Promise { - const { stdout, org, flags, contextKey, cursor } = options; - const response: PaginatedResponse = - await listRepositoriesPaginated(org, { cursor, perPage: flags.limit }); - - const repos: RepositoryWithOrg[] = response.data.map((r) => ({ - ...r, - orgSlug: org, - })); - const hasMore = !!response.nextCursor; - - // Update cursor cache for `--cursor last` support - if (response.nextCursor) { - setPaginationCursor(PAGINATION_KEY, contextKey, response.nextCursor); - } else { - clearPaginationCursor(PAGINATION_KEY, contextKey); - } - - if (flags.json) { - const output = hasMore - ? { data: repos, nextCursor: response.nextCursor, hasMore: true } - : { data: repos, hasMore: false }; - writeJson(stdout, output); - return; - } - - if (repos.length === 0) { - if (hasMore) { - stdout.write( - `No repositories on this page. Try the next page: ${nextPageHint(org)}\n` - ); - } else { - stdout.write(`No repositories found in organization '${org}'.\n`); - } - return; - } - - displayRepoTable(stdout, repos); - - if (hasMore) { - stdout.write(`\nShowing ${repos.length} repositories (more available)\n`); - stdout.write(`Next page: ${nextPageHint(org)}\n`); - } else { - stdout.write(`\nShowing ${repos.length} repositories\n`); - } -} - -/** - * Handle auto-detect mode: resolve orgs from config/DSN, fetch all repos. - */ -async function handleAutoDetect( - stdout: Writer, - cwd: string, - flags: ListFlags -): Promise { - const { - orgs: orgsToFetch, - footer, - skippedSelfHosted, - } = await resolveOrgsForListing(undefined, cwd); - - let allRepos: RepositoryWithOrg[]; - if (orgsToFetch.length > 0) { - const results = await Promise.all( - orgsToFetch.map(fetchOrgRepositoriesSafe) - ); - allRepos = results.flat(); - } else { - allRepos = await fetchAllOrgRepositories(); - } - - 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; - } - - displayRepoTable(stdout, limited); - - 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" - ); -} - -/** - * Handle a single explicit org (non-paginated fetch). - */ -async function handleExplicitOrg( - stdout: Writer, - org: string, - flags: ListFlags -): Promise { - const repos = await fetchOrgRepositoriesSafe(org); - const limited = repos.slice(0, flags.limit); - - if (flags.json) { - writeJson(stdout, limited); - return; - } - - if (limited.length === 0) { - stdout.write(`No repositories found in organization '${org}'.\n`); - return; - } - - displayRepoTable(stdout, limited); - - if (repos.length > limited.length) { - stdout.write( - `\nShowing ${limited.length} of ${repos.length} repositories. ` + - `Use 'sentry repo list ${org}/' for paginated results.\n` - ); - } else { - stdout.write(`\nShowing ${limited.length} repositories\n`); - } - - writeFooter( - stdout, - `Tip: Use 'sentry repo list ${org}/' for paginated results` - ); -} - export const listCommand = buildCommand({ docs: { brief: "List repositories", @@ -365,59 +105,21 @@ export const listCommand = buildCommand({ }, async func( this: SentryContext, - flags: ListFlags, + flags: { + readonly limit: number; + readonly json: boolean; + readonly cursor?: string; + }, target?: string ): Promise { const { stdout, cwd } = this; - const parsed = parseOrgProjectArg(target); - - // Cursor pagination is only supported in org-all mode - if (flags.cursor && parsed.type !== "org-all") { - throw new ValidationError( - "The --cursor flag is only supported when listing repositories for a specific organization " + - "(e.g., sentry repo list /). " + - "Use 'sentry repo list /' for paginated results.", - "cursor" - ); - } - - switch (parsed.type) { - case "auto-detect": - await handleAutoDetect(stdout, cwd, flags); - break; - - case "explicit": - // Use the org context; project part is ignored for repo listing - await handleExplicitOrg(stdout, parsed.org, flags); - break; - - case "project-search": - // Bare slug treated as org slug (no slash → repo list for that org) - await handleExplicitOrg(stdout, parsed.projectSlug, flags); - break; - - case "org-all": { - const contextKey = buildOrgContextKey(parsed.org); - const cursor = resolveOrgCursor( - flags.cursor, - PAGINATION_KEY, - contextKey - ); - await handleOrgAll({ - stdout, - org: parsed.org, - flags, - contextKey, - cursor, - }); - break; - } - - default: { - const _exhaustiveCheck: never = parsed; - throw new Error(`Unexpected parsed type: ${_exhaustiveCheck}`); - } - } + await dispatchOrgScopedList({ + config: repoListConfig, + stdout, + cwd, + flags, + parsed, + }); }, }); diff --git a/src/commands/team/list.ts b/src/commands/team/list.ts index b761478b2d..12c3ecddd1 100644 --- a/src/commands/team/list.ts +++ b/src/commands/team/list.ts @@ -11,303 +11,48 @@ */ import type { SentryContext } from "../../context.js"; -import { - listOrganizations, - listTeams, - listTeamsPaginated, - type PaginatedResponse, -} from "../../lib/api-client.js"; +import { listTeams, listTeamsPaginated } from "../../lib/api-client.js"; import { parseOrgProjectArg } from "../../lib/arg-parsing.js"; import { buildCommand, numberParser } from "../../lib/command.js"; +import { type Column, writeTable } from "../../lib/formatters/table.js"; import { - buildOrgContextKey, - clearPaginationCursor, - resolveOrgCursor, - setPaginationCursor, -} from "../../lib/db/pagination.js"; -import { AuthError, ValidationError } from "../../lib/errors.js"; -import { writeFooter, writeJson } from "../../lib/formatters/index.js"; -import { resolveOrgsForListing } from "../../lib/resolve-target.js"; + dispatchOrgScopedList, + type OrgListConfig, +} from "../../lib/org-list.js"; import type { SentryTeam, Writer } from "../../types/index.js"; /** Command key for pagination cursor storage */ export const PAGINATION_KEY = "team-list"; -type ListFlags = { - readonly limit: number; - readonly json: boolean; - readonly cursor?: string; -}; - /** Team with its organization context for display */ type TeamWithOrg = SentryTeam & { orgSlug?: string }; -/** 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`); - } -} - -/** Display teams in table format with header and rows */ -function displayTeamTable(stdout: Writer, teams: TeamWithOrg[]): void { - const widths = calculateColumnWidths(teams); - writeHeader(stdout, widths); - writeRows({ stdout, teams, ...widths }); -} - -/** - * Fetch teams for a single org, returning empty array on non-auth errors. - */ -async function fetchOrgTeamsSafe(orgSlug: string): Promise { - try { - const teams = await listTeams(orgSlug); - return teams.map((t) => ({ ...t, orgSlug })); - } catch (error) { - if (error instanceof AuthError) { - throw error; - } - return []; - } -} - -/** - * Fetch teams from all accessible organizations. - */ -async function fetchAllOrgTeams(): Promise { - const orgs = await listOrganizations(); - const results: TeamWithOrg[] = []; - - for (const org of orgs) { - try { - const teams = await listTeams(org.slug); - results.push(...teams.map((t) => ({ ...t, orgSlug: org.slug }))); - } catch (error) { - if (error instanceof AuthError) { - throw error; - } - // User may lack access to some orgs - } - } - - return results; -} - -/** Build the CLI hint for fetching the next page. */ -function nextPageHint(org: string): string { - return `sentry team list ${org}/ -c last`; -} - -type OrgAllOptions = { - stdout: Writer; - org: string; - flags: ListFlags; - contextKey: string; - cursor: string | undefined; +/** 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), }; -/** - * Handle org-all mode (e.g., sentry/). - * Uses cursor pagination for efficient page-by-page listing. - */ -async function handleOrgAll(options: OrgAllOptions): Promise { - const { stdout, org, flags, contextKey, cursor } = options; - const response: PaginatedResponse = await listTeamsPaginated( - org, - { cursor, perPage: flags.limit } - ); - - const teams: TeamWithOrg[] = response.data.map((t) => ({ - ...t, - orgSlug: org, - })); - const hasMore = !!response.nextCursor; - - // Update cursor cache for `--cursor last` support - if (response.nextCursor) { - setPaginationCursor(PAGINATION_KEY, contextKey, response.nextCursor); - } else { - clearPaginationCursor(PAGINATION_KEY, contextKey); - } - - if (flags.json) { - const output = hasMore - ? { data: teams, nextCursor: response.nextCursor, hasMore: true } - : { data: teams, hasMore: false }; - writeJson(stdout, output); - return; - } - - if (teams.length === 0) { - if (hasMore) { - stdout.write( - `No teams on this page. Try the next page: ${nextPageHint(org)}\n` - ); - } else { - stdout.write(`No teams found in organization '${org}'.\n`); - } - return; - } - - displayTeamTable(stdout, teams); - - if (hasMore) { - stdout.write(`\nShowing ${teams.length} teams (more available)\n`); - stdout.write(`Next page: ${nextPageHint(org)}\n`); - } else { - stdout.write(`\nShowing ${teams.length} teams\n`); - } -} - -/** - * Handle auto-detect and explicit org modes. - * Fetches all teams for the resolved orgs (no cursor pagination). - */ -async function handleAutoDetect( - stdout: Writer, - cwd: string, - flags: ListFlags -): Promise { - const { - orgs: orgsToFetch, - footer, - skippedSelfHosted, - } = await resolveOrgsForListing(undefined, cwd); - - let allTeams: TeamWithOrg[]; - if (orgsToFetch.length > 0) { - const results = await Promise.all(orgsToFetch.map(fetchOrgTeamsSafe)); - allTeams = results.flat(); - } else { - allTeams = await fetchAllOrgTeams(); - } - - 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; - } - - displayTeamTable(stdout, limited); - - 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" - ); -} - -/** - * Handle a single explicit org (non-paginated fetch). - */ -async function handleExplicitOrg( - stdout: Writer, - org: string, - flags: ListFlags -): Promise { - const teams = await fetchOrgTeamsSafe(org); - const limited = teams.slice(0, flags.limit); - - if (flags.json) { - writeJson(stdout, limited); - return; - } - - if (limited.length === 0) { - stdout.write(`No teams found in organization '${org}'.\n`); - return; - } - - displayTeamTable(stdout, limited); - - if (teams.length > limited.length) { - stdout.write( - `\nShowing ${limited.length} of ${teams.length} teams. ` + - `Use 'sentry team list ${org}/' for paginated results.\n` - ); - } else { - stdout.write(`\nShowing ${limited.length} teams\n`); - } - - writeFooter( - stdout, - `Tip: Use 'sentry team list ${org}/' for paginated results` - ); -} - export const listCommand = buildCommand({ docs: { brief: "List teams", @@ -361,59 +106,21 @@ export const listCommand = buildCommand({ }, async func( this: SentryContext, - flags: ListFlags, + flags: { + readonly limit: number; + readonly json: boolean; + readonly cursor?: string; + }, target?: string ): Promise { const { stdout, cwd } = this; - const parsed = parseOrgProjectArg(target); - - // Cursor pagination is only supported in org-all mode - if (flags.cursor && parsed.type !== "org-all") { - throw new ValidationError( - "The --cursor flag is only supported when listing teams for a specific organization " + - "(e.g., sentry team list /). " + - "Use 'sentry team list /' for paginated results.", - "cursor" - ); - } - - switch (parsed.type) { - case "auto-detect": - await handleAutoDetect(stdout, cwd, flags); - break; - - case "explicit": - // Use the org context; project part is ignored for team listing - await handleExplicitOrg(stdout, parsed.org, flags); - break; - - case "project-search": - // Bare slug treated as org slug (no slash → team list for that org) - await handleExplicitOrg(stdout, parsed.projectSlug, flags); - break; - - case "org-all": { - const contextKey = buildOrgContextKey(parsed.org); - const cursor = resolveOrgCursor( - flags.cursor, - PAGINATION_KEY, - contextKey - ); - await handleOrgAll({ - stdout, - org: parsed.org, - flags, - contextKey, - cursor, - }); - break; - } - - default: { - const _exhaustiveCheck: never = parsed; - throw new Error(`Unexpected parsed type: ${_exhaustiveCheck}`); - } - } + await dispatchOrgScopedList({ + config: teamListConfig, + stdout, + cwd, + flags, + parsed, + }); }, }); 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/org-list.ts b/src/lib/org-list.ts new file mode 100644 index 0000000000..41fa190c27 --- /dev/null +++ b/src/lib/org-list.ts @@ -0,0 +1,381 @@ +/** + * Shared infrastructure for org-scoped list commands (team, repo, etc.). + * + * Provides a config-driven framework that eliminates the duplicated patterns + * across `team/list`, `repo/list`, and partially `project/list`. + * + * Each command defines an {@link OrgListConfig} describing how to fetch, + * augment, and display its entities, then delegates to the shared dispatch + * and handler functions. + */ + +import type { Writer } from "../types/index.js"; +import { 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, ValidationError } from "./errors.js"; +import { writeFooter, writeJson } from "./formatters/index.js"; +import { resolveOrgsForListing } from "./resolve-target.js"; + +// --------------------------------------------------------------------------- +// Config types +// --------------------------------------------------------------------------- + +/** Minimal flags required by the shared infrastructure. */ +export type BaseListFlags = { + readonly limit: number; + readonly json: boolean; + readonly cursor?: string; +}; + +/** + * Configuration for an org-scoped list command. + * + * @template TEntity - Raw entity type from the API (e.g., SentryTeam) + * @template TWithOrg - Entity with orgSlug attached for display + */ +export type OrgListConfig = { + /** 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; + + /** + * 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 helpers +// --------------------------------------------------------------------------- + +/** + * 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 organizations. + * Skips orgs where the user lacks access. + */ +export async function fetchAllOrgs( + config: OrgListConfig +): Promise { + const orgs = await listOrganizations(); + const results: TWithOrg[] = []; + + for (const org of orgs) { + try { + const items = await config.listForOrg(org.slug); + results.push(...items.map((item) => config.withOrg(item, org.slug))); + } catch (error) { + if (error instanceof AuthError) { + throw error; + } + // User may lack access to some orgs + } + } + + return results; +} + +// --------------------------------------------------------------------------- +// Handlers +// --------------------------------------------------------------------------- + +/** Build the CLI hint for fetching the next page. */ +function nextPageHint(commandPrefix: string, org: string): string { + return `${commandPrefix} ${org}/ -c last`; +} + +type OrgAllOptions = { + config: OrgListConfig; + stdout: Writer; + org: string; + flags: BaseListFlags; + contextKey: string; + cursor: string | undefined; +}; + +/** + * Handle org-all mode (e.g., `sentry team list sentry/`). + * Uses cursor pagination for efficient page-by-page listing. + */ +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 items: TWithOrg[] = response.data.map((item) => + config.withOrg(item, org) + ); + const hasMore = !!response.nextCursor; + + // Update cursor cache for `--cursor last` support + if (response.nextCursor) { + setPaginationCursor(config.paginationKey, contextKey, response.nextCursor); + } else { + clearPaginationCursor(config.paginationKey, contextKey); + } + + if (flags.json) { + const output = hasMore + ? { data: items, nextCursor: response.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` + ); +} + +/** + * Handle a single explicit org (non-paginated fetch). + */ +export async function handleExplicitOrg( + config: OrgListConfig, + stdout: Writer, + org: string, + flags: BaseListFlags +): Promise { + const items = await fetchOrgSafe(config, org); + const limited = items.slice(0, flags.limit); + + if (flags.json) { + writeJson(stdout, limited); + return; + } + + if (limited.length === 0) { + stdout.write(`No ${config.entityPlural} found in organization '${org}'.\n`); + return; + } + + config.displayTable(stdout, limited); + + if (items.length > limited.length) { + stdout.write( + `\nShowing ${limited.length} of ${items.length} ${config.entityPlural}. ` + + `Use '${config.commandPrefix} ${org}/' for paginated results.\n` + ); + } else { + stdout.write(`\nShowing ${limited.length} ${config.entityPlural}\n`); + } + + writeFooter( + stdout, + `Tip: Use '${config.commandPrefix} ${org}/' for paginated results` + ); +} + +// --------------------------------------------------------------------------- +// Dispatch +// --------------------------------------------------------------------------- + +/** Options for {@link dispatchOrgScopedList}. */ +export type DispatchOptions = { + config: OrgListConfig; + stdout: Writer; + cwd: string; + flags: BaseListFlags; + parsed: ParsedOrgProject; +}; + +/** + * Validate cursor flag and dispatch to the correct handler based on the + * parsed target type. This is the single entry point for org-scoped list + * commands that follow the standard pattern. + */ +export async function dispatchOrgScopedList( + options: DispatchOptions +): Promise { + const { config, stdout, cwd, flags, parsed } = options; + // Cursor pagination is only supported in org-all mode + if (flags.cursor && parsed.type !== "org-all") { + throw new ValidationError( + `The --cursor flag is only supported when listing ${config.entityPlural} for a specific organization ` + + `(e.g., ${config.commandPrefix} /). ` + + `Use '${config.commandPrefix} /' for paginated results.`, + "cursor" + ); + } + + switch (parsed.type) { + case "auto-detect": + await handleAutoDetect(config, stdout, cwd, flags); + break; + + case "explicit": + // Use the org context; project part is ignored for this entity listing + await handleExplicitOrg(config, stdout, parsed.org, flags); + break; + + case "project-search": + // Bare slug treated as org slug + await handleExplicitOrg(config, stdout, parsed.projectSlug, flags); + break; + + case "org-all": { + const contextKey = buildOrgContextKey(parsed.org); + const cursor = resolveOrgCursor( + flags.cursor, + config.paginationKey, + contextKey + ); + await handleOrgAll({ + config, + stdout, + org: parsed.org, + flags, + contextKey, + cursor, + }); + break; + } + + default: { + const _exhaustiveCheck: never = parsed; + throw new Error(`Unexpected parsed type: ${_exhaustiveCheck}`); + } + } +} diff --git a/test/lib/formatters/table.test.ts b/test/lib/formatters/table.test.ts new file mode 100644 index 0000000000..5c3c968e8d --- /dev/null +++ b/test/lib/formatters/table.test.ts @@ -0,0 +1,104 @@ +/** + * Tests for the generic table renderer. + */ + +import { describe, expect, mock, test } from "bun:test"; +import { type Column, writeTable } from "../../../src/lib/formatters/table.js"; + +type Row = { name: string; count: number; status: string }; + +const columns: Column[] = [ + { header: "NAME", value: (r) => r.name }, + { header: "COUNT", value: (r) => String(r.count), align: "right" }, + { header: "STATUS", value: (r) => r.status }, +]; + +function capture(items: Row[], cols = columns): string { + const write = mock(() => true); + writeTable({ write }, items, cols); + return write.mock.calls.map((c) => c[0]).join(""); +} + +describe("writeTable", () => { + test("renders header and rows with auto-sized columns", () => { + const output = capture([ + { name: "alpha", count: 42, status: "active" }, + { name: "beta-longer", count: 7, status: "inactive" }, + ]); + + // Header present + expect(output).toContain("NAME"); + expect(output).toContain("COUNT"); + expect(output).toContain("STATUS"); + + // Data present + expect(output).toContain("alpha"); + expect(output).toContain("42"); + expect(output).toContain("active"); + expect(output).toContain("beta-longer"); + expect(output).toContain("7"); + expect(output).toContain("inactive"); + }); + + test("right-aligns columns when specified", () => { + const output = capture([ + { name: "a", count: 1, status: "ok" }, + { name: "b", count: 999, status: "ok" }, + ]); + + const lines = output.split("\n").filter(Boolean); + // The COUNT column should have right-aligned values + // Header: "COUNT" is 5 chars, max value "999" is 3 chars, so width = 5 + // "1" should be padded: " 1" (5 chars, right-aligned) + const headerLine = lines[0]!; + const countHeaderIdx = headerLine.indexOf("COUNT"); + expect(countHeaderIdx).toBeGreaterThan(-1); + + // Row with count=1 should have right-padding before count + const dataLine1 = lines[1]!; + const countSlice1 = dataLine1.slice( + countHeaderIdx, + countHeaderIdx + "COUNT".length + ); + expect(countSlice1.trim()).toBe("1"); + }); + + test("respects minWidth for columns", () => { + const cols: Column[] = [ + { header: "N", value: (r) => r.name, minWidth: 10 }, + { header: "C", value: (r) => String(r.count) }, + { header: "S", value: (r) => r.status }, + ]; + + const output = capture([{ name: "x", count: 1, status: "y" }], cols); + const lines = output.split("\n").filter(Boolean); + // Header "N" should be padded to at least 10 chars + const headerLine = lines[0]!; + const firstColEnd = headerLine.indexOf(" C"); + // First column should be at least 10 chars wide + expect(firstColEnd).toBeGreaterThanOrEqual(10); + }); + + test("handles empty items array (header only)", () => { + const output = capture([]); + const lines = output.split("\n").filter(Boolean); + expect(lines).toHaveLength(1); + expect(lines[0]).toContain("NAME"); + expect(lines[0]).toContain("COUNT"); + expect(lines[0]).toContain("STATUS"); + }); + + test("column width respects header length even with short values", () => { + const cols: Column<{ v: string }>[] = [ + { header: "VERY_LONG_HEADER", value: (r) => r.v }, + ]; + const write = mock(() => true); + writeTable({ write }, [{ v: "x" }], cols); + const output = write.mock.calls.map((c) => c[0]).join(""); + const lines = output.split("\n").filter(Boolean); + // Header line should have the full header + expect(lines[0]).toContain("VERY_LONG_HEADER"); + // Data line should be padded to header width + expect(lines[1]!.length).toBeGreaterThanOrEqual("VERY_LONG_HEADER".length); + }); +}); diff --git a/test/lib/org-list.test.ts b/test/lib/org-list.test.ts new file mode 100644 index 0000000000..fa20cdfae2 --- /dev/null +++ b/test/lib/org-list.test.ts @@ -0,0 +1,458 @@ +/** + * Tests for the shared org-scoped list infrastructure. + * + * Tests the core functions directly (fetchOrgSafe, fetchAllOrgs, handleOrgAll, + * handleAutoDetect, handleExplicitOrg, dispatchOrgScopedList). + */ + +import { + afterEach, + beforeEach, + describe, + expect, + mock, + spyOn, + test, +} from "bun:test"; +// 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 paginationDb from "../../src/lib/db/pagination.js"; +import { AuthError, ValidationError } from "../../src/lib/errors.js"; +import { + dispatchOrgScopedList, + fetchAllOrgs, + fetchOrgSafe, + handleOrgAll, + type OrgListConfig, +} from "../../src/lib/org-list.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as resolveTarget from "../../src/lib/resolve-target.js"; + +type FakeEntity = { id: string; name: string }; +type FakeWithOrg = FakeEntity & { orgSlug: string }; + +function makeConfig( + overrides?: Partial> +): OrgListConfig { + return { + paginationKey: "test-list", + entityName: "widget", + entityPlural: "widgets", + commandPrefix: "sentry widget list", + listForOrg: mock(() => Promise.resolve([])), + listPaginated: mock(() => + Promise.resolve({ data: [] as FakeEntity[], nextCursor: undefined }) + ), + withOrg: (entity, orgSlug) => ({ ...entity, orgSlug }), + displayTable: mock(() => { + // no-op for test + }), + ...overrides, + }; +} + +function createStdout() { + const write = mock((_chunk: string) => true); + return { writer: { write }, write }; +} + +// --------------------------------------------------------------------------- +// fetchOrgSafe +// --------------------------------------------------------------------------- + +describe("fetchOrgSafe", () => { + test("returns entities with org context on success", async () => { + const items: FakeEntity[] = [ + { id: "1", name: "A" }, + { id: "2", name: "B" }, + ]; + const config = makeConfig({ + listForOrg: mock(() => Promise.resolve(items)), + }); + const result = await fetchOrgSafe(config, "my-org"); + + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ id: "1", name: "A", orgSlug: "my-org" }); + expect(result[1]).toEqual({ id: "2", name: "B", orgSlug: "my-org" }); + }); + + test("returns empty array on non-auth error", async () => { + const config = makeConfig({ + listForOrg: mock(() => Promise.reject(new Error("network"))), + }); + const result = await fetchOrgSafe(config, "my-org"); + expect(result).toEqual([]); + }); + + test("rethrows AuthError", async () => { + const config = makeConfig({ + listForOrg: mock(() => + Promise.reject(new AuthError("not_authenticated")) + ), + }); + await expect(fetchOrgSafe(config, "my-org")).rejects.toThrow(AuthError); + }); +}); + +// --------------------------------------------------------------------------- +// fetchAllOrgs +// --------------------------------------------------------------------------- + +describe("fetchAllOrgs", () => { + let listOrganizationsSpy: ReturnType; + + beforeEach(() => { + listOrganizationsSpy = spyOn(apiClient, "listOrganizations"); + }); + + afterEach(() => { + listOrganizationsSpy.mockRestore(); + }); + + test("fetches entities from all accessible orgs", async () => { + listOrganizationsSpy.mockResolvedValue([ + { id: "1", slug: "org-a", name: "Org A" }, + { id: "2", slug: "org-b", name: "Org B" }, + ]); + + const items: FakeEntity[] = [{ id: "1", name: "Widget" }]; + const config = makeConfig({ + listForOrg: mock(() => Promise.resolve(items)), + }); + + const result = await fetchAllOrgs(config); + expect(result).toHaveLength(2); + expect(result[0]!.orgSlug).toBe("org-a"); + expect(result[1]!.orgSlug).toBe("org-b"); + }); + + test("skips orgs with non-auth errors", async () => { + listOrganizationsSpy.mockResolvedValue([ + { id: "1", slug: "org-a", name: "Org A" }, + { id: "2", slug: "org-b", name: "Org B" }, + ]); + + let callCount = 0; + const config = makeConfig({ + listForOrg: mock(() => { + callCount += 1; + if (callCount === 1) return Promise.reject(new Error("forbidden")); + return Promise.resolve([{ id: "1", name: "Widget" }]); + }), + }); + + const result = await fetchAllOrgs(config); + expect(result).toHaveLength(1); + expect(result[0]!.orgSlug).toBe("org-b"); + }); + + test("rethrows AuthError from any org", async () => { + listOrganizationsSpy.mockResolvedValue([ + { id: "1", slug: "org-a", name: "Org A" }, + ]); + + const config = makeConfig({ + listForOrg: mock(() => + Promise.reject(new AuthError("not_authenticated")) + ), + }); + + await expect(fetchAllOrgs(config)).rejects.toThrow(AuthError); + }); +}); + +// --------------------------------------------------------------------------- +// handleOrgAll +// --------------------------------------------------------------------------- + +describe("handleOrgAll", () => { + let setPaginationCursorSpy: ReturnType; + let clearPaginationCursorSpy: ReturnType; + + beforeEach(() => { + setPaginationCursorSpy = spyOn(paginationDb, "setPaginationCursor"); + clearPaginationCursorSpy = spyOn(paginationDb, "clearPaginationCursor"); + setPaginationCursorSpy.mockReturnValue(undefined); + clearPaginationCursorSpy.mockReturnValue(undefined); + }); + + afterEach(() => { + setPaginationCursorSpy.mockRestore(); + clearPaginationCursorSpy.mockRestore(); + }); + + test("JSON output with hasMore=true includes nextCursor", async () => { + const items: FakeEntity[] = [{ id: "1", name: "A" }]; + const config = makeConfig({ + listPaginated: mock(() => + Promise.resolve({ data: items, nextCursor: "next:123" }) + ), + }); + const { writer, write } = createStdout(); + + await handleOrgAll({ + config, + stdout: writer, + org: "my-org", + flags: { limit: 10, json: true }, + contextKey: "key", + cursor: undefined, + }); + + const output = write.mock.calls.map((c) => c[0]).join(""); + const parsed = JSON.parse(output); + expect(parsed.hasMore).toBe(true); + expect(parsed.nextCursor).toBe("next:123"); + expect(parsed.data).toHaveLength(1); + }); + + test("JSON output with hasMore=false when no nextCursor", async () => { + const config = makeConfig({ + listPaginated: mock(() => + Promise.resolve({ + data: [{ id: "1", name: "A" }], + nextCursor: undefined, + }) + ), + }); + const { writer, write } = createStdout(); + + await handleOrgAll({ + config, + stdout: writer, + org: "my-org", + flags: { limit: 10, json: true }, + contextKey: "key", + cursor: undefined, + }); + + const output = write.mock.calls.map((c) => c[0]).join(""); + const parsed = JSON.parse(output); + expect(parsed.hasMore).toBe(false); + expect(parsed.nextCursor).toBeUndefined(); + }); + + test("human output shows 'no entities found' when empty", async () => { + const config = makeConfig({ + listPaginated: mock(() => + Promise.resolve({ data: [] as FakeEntity[], nextCursor: undefined }) + ), + }); + const { writer, write } = createStdout(); + + await handleOrgAll({ + config, + stdout: writer, + org: "my-org", + flags: { limit: 10, json: false }, + contextKey: "key", + cursor: undefined, + }); + + const output = write.mock.calls.map((c) => c[0]).join(""); + expect(output).toContain("No widgets found in organization 'my-org'."); + }); + + test("human output shows next page hint when more available", async () => { + const config = makeConfig({ + listPaginated: mock(() => + Promise.resolve({ data: [{ id: "1", name: "A" }], nextCursor: "x" }) + ), + }); + const { writer, write } = createStdout(); + + await handleOrgAll({ + config, + stdout: writer, + org: "my-org", + flags: { limit: 10, json: false }, + contextKey: "key", + cursor: undefined, + }); + + const output = write.mock.calls.map((c) => c[0]).join(""); + expect(output).toContain("more available"); + expect(output).toContain("sentry widget list my-org/ -c last"); + }); + + test("sets pagination cursor when nextCursor present", async () => { + const config = makeConfig({ + listPaginated: mock(() => + Promise.resolve({ + data: [{ id: "1", name: "A" }], + nextCursor: "cursor:abc", + }) + ), + }); + const { writer } = createStdout(); + + await handleOrgAll({ + config, + stdout: writer, + org: "my-org", + flags: { limit: 10, json: false }, + contextKey: "ctx", + cursor: undefined, + }); + + expect(setPaginationCursorSpy).toHaveBeenCalledWith( + "test-list", + "ctx", + "cursor:abc" + ); + }); + + test("clears pagination cursor when no nextCursor", async () => { + const config = makeConfig({ + listPaginated: mock(() => + Promise.resolve({ + data: [{ id: "1", name: "A" }], + nextCursor: undefined, + }) + ), + }); + const { writer } = createStdout(); + + await handleOrgAll({ + config, + stdout: writer, + org: "my-org", + flags: { limit: 10, json: false }, + contextKey: "ctx", + cursor: undefined, + }); + + expect(clearPaginationCursorSpy).toHaveBeenCalledWith("test-list", "ctx"); + }); +}); + +// --------------------------------------------------------------------------- +// dispatchOrgScopedList +// --------------------------------------------------------------------------- + +describe("dispatchOrgScopedList", () => { + let getDefaultOrganizationSpy: ReturnType; + let resolveAllTargetsSpy: ReturnType; + let setPaginationCursorSpy: ReturnType; + let clearPaginationCursorSpy: ReturnType; + + beforeEach(() => { + getDefaultOrganizationSpy = spyOn(defaults, "getDefaultOrganization"); + resolveAllTargetsSpy = spyOn(resolveTarget, "resolveAllTargets"); + setPaginationCursorSpy = spyOn(paginationDb, "setPaginationCursor"); + clearPaginationCursorSpy = spyOn(paginationDb, "clearPaginationCursor"); + + getDefaultOrganizationSpy.mockResolvedValue(null); + resolveAllTargetsSpy.mockResolvedValue({ targets: [] }); + setPaginationCursorSpy.mockReturnValue(undefined); + clearPaginationCursorSpy.mockReturnValue(undefined); + }); + + afterEach(() => { + getDefaultOrganizationSpy.mockRestore(); + resolveAllTargetsSpy.mockRestore(); + setPaginationCursorSpy.mockRestore(); + clearPaginationCursorSpy.mockRestore(); + }); + + test("throws ValidationError when --cursor used outside org-all mode", async () => { + const config = makeConfig(); + const { writer } = createStdout(); + + await expect( + dispatchOrgScopedList({ + config, + stdout: writer, + cwd: "/tmp", + flags: { limit: 10, json: false, cursor: "some-cursor" }, + parsed: { type: "explicit", org: "my-org", project: "my-proj" }, + }) + ).rejects.toThrow(ValidationError); + }); + + test("delegates to handleOrgAll for org-all parsed type", async () => { + const items: FakeEntity[] = [{ id: "1", name: "A" }]; + const config = makeConfig({ + listPaginated: mock(() => + Promise.resolve({ data: items, nextCursor: undefined }) + ), + }); + const { writer, write } = createStdout(); + + await dispatchOrgScopedList({ + config, + stdout: writer, + cwd: "/tmp", + flags: { limit: 10, json: true }, + parsed: { type: "org-all", org: "my-org" }, + }); + + const output = write.mock.calls.map((c) => c[0]).join(""); + const parsed = JSON.parse(output); + expect(parsed.hasMore).toBe(false); + expect(parsed.data).toHaveLength(1); + }); + + test("delegates to handleExplicitOrg for explicit parsed type", async () => { + const items: FakeEntity[] = [{ id: "1", name: "A" }]; + const config = makeConfig({ + listForOrg: mock(() => Promise.resolve(items)), + }); + const { writer, write } = createStdout(); + + await dispatchOrgScopedList({ + config, + stdout: writer, + cwd: "/tmp", + flags: { limit: 10, json: true }, + parsed: { type: "explicit", org: "my-org", project: "proj" }, + }); + + const output = write.mock.calls.map((c) => c[0]).join(""); + const parsed = JSON.parse(output); + expect(Array.isArray(parsed)).toBe(true); + expect(parsed).toHaveLength(1); + }); + + test("delegates to handleExplicitOrg for project-search parsed type", async () => { + const items: FakeEntity[] = [{ id: "1", name: "A" }]; + const config = makeConfig({ + listForOrg: mock(() => Promise.resolve(items)), + }); + const { writer, write } = createStdout(); + + await dispatchOrgScopedList({ + config, + stdout: writer, + cwd: "/tmp", + flags: { limit: 10, json: true }, + parsed: { type: "project-search", projectSlug: "my-proj" }, + }); + + const output = write.mock.calls.map((c) => c[0]).join(""); + const parsed = JSON.parse(output); + expect(Array.isArray(parsed)).toBe(true); + }); + + test("error message includes entity name", async () => { + const config = makeConfig(); + const { writer } = createStdout(); + + try { + await dispatchOrgScopedList({ + config, + stdout: writer, + cwd: "/tmp", + flags: { limit: 10, json: false, cursor: "x" }, + parsed: { type: "auto-detect" }, + }); + expect.unreachable("should have thrown"); + } catch (e) { + expect(e).toBeInstanceOf(ValidationError); + expect((e as ValidationError).message).toContain("widgets"); + } + }); +}); From ece0a2969fc7037556e77524bce6c3d30e599ab5 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Wed, 18 Feb 2026 21:48:47 +0000 Subject: [PATCH 09/22] refactor: extract shared buildCommand boilerplate into list-command.ts Add LIST_TARGET_POSITIONAL, LIST_JSON_FLAG, LIST_CURSOR_FLAG, buildListLimitFlag, and LIST_BASE_ALIASES as shared constants so all four list commands stop redefining the same flag/positional shapes. Add buildOrgListCommand factory (Level B) for team and repo commands whose entire func body is dispatchOrgScopedList; reduces those files to config + column definitions + one call. team/list.ts and repo/list.ts each drop ~40 more lines. project/list.ts and issue/list.ts spread the shared constants, removing the duplicated positional/json/cursor/limit definitions. 12 new tests in test/lib/list-command.test.ts; 1850 unit tests pass. --- src/commands/issue/list.ts | 37 ++----- src/commands/project/list.ts | 44 +++----- src/commands/repo/list.ts | 100 ++++------------- src/commands/team/list.ts | 100 ++++------------- src/lib/list-command.ts | 155 ++++++++++++++++++++++++++ test/lib/list-command.test.ts | 199 ++++++++++++++++++++++++++++++++++ 6 files changed, 425 insertions(+), 210 deletions(-) create mode 100644 src/lib/list-command.ts create mode 100644 test/lib/list-command.test.ts diff --git a/src/commands/issue/list.ts b/src/commands/issue/list.ts index 46bb908153..72104feea9 100644 --- a/src/commands/issue/list.ts +++ b/src/commands/issue/list.ts @@ -14,7 +14,7 @@ import { 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, resolveOrgCursor, @@ -39,6 +39,12 @@ 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 { type ResolvedTarget, resolveAllTargets, @@ -398,17 +404,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", @@ -416,33 +412,24 @@ 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", c: "cursor" }, + aliases: { ...LIST_BASE_ALIASES, q: "query", s: "sort" }, }, // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: command entry point with inherent complexity async func( diff --git a/src/commands/project/list.ts b/src/commands/project/list.ts index 2f96b2db6b..2d9df82305 100644 --- a/src/commands/project/list.ts +++ b/src/commands/project/list.ts @@ -23,7 +23,7 @@ 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, @@ -37,6 +37,13 @@ import { 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 { resolveAllTargets } from "../../lib/resolve-target.js"; import { getApiBaseUrl } from "../../lib/sentry-client.js"; import type { SentryProject, Writer } from "../../types/index.js"; @@ -623,36 +630,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 +642,7 @@ export const listCommand = buildCommand({ optional: true, }, }, - aliases: { n: "limit", p: "platform", c: "cursor" }, + aliases: { ...LIST_BASE_ALIASES, p: "platform" }, }, async func( this: SentryContext, diff --git a/src/commands/repo/list.ts b/src/commands/repo/list.ts index fe6a92d583..f185c101f7 100644 --- a/src/commands/repo/list.ts +++ b/src/commands/repo/list.ts @@ -10,18 +10,16 @@ * - Bare org slug (e.g., sentry) - lists repos for that org */ -import type { SentryContext } from "../../context.js"; import { listRepositories, listRepositoriesPaginated, } from "../../lib/api-client.js"; -import { parseOrgProjectArg } from "../../lib/arg-parsing.js"; -import { buildCommand, numberParser } from "../../lib/command.js"; import { type Column, writeTable } from "../../lib/formatters/table.js"; import { - dispatchOrgScopedList, - type OrgListConfig, -} from "../../lib/org-list.js"; + buildOrgListCommand, + type OrgListCommandDocs, +} from "../../lib/list-command.js"; +import type { OrgListConfig } from "../../lib/org-list.js"; import type { SentryRepository, Writer } from "../../types/index.js"; /** Command key for pagination cursor storage */ @@ -52,74 +50,22 @@ const repoListConfig: OrgListConfig = { writeTable(stdout, repos, REPO_COLUMNS), }; -export const listCommand = buildCommand({ - docs: { - 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", - }, - parameters: { - positional: { - kind: "tuple", - parameters: [ - { - placeholder: "target", - brief: "Target: /, /, or ", - 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, - }, - cursor: { - kind: "parsed", - parse: String, - brief: 'Pagination cursor (use "last" to continue from previous page)', - optional: true, - }, - }, - aliases: { n: "limit", c: "cursor" }, - }, - 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: repoListConfig, - stdout, - cwd, - flags, - parsed, - }); - }, -}); +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", +}; + +export const listCommand = buildOrgListCommand(repoListConfig, docs); diff --git a/src/commands/team/list.ts b/src/commands/team/list.ts index 12c3ecddd1..f5624582d1 100644 --- a/src/commands/team/list.ts +++ b/src/commands/team/list.ts @@ -10,15 +10,13 @@ * - Cross-org project search (e.g., sentry) */ -import type { SentryContext } from "../../context.js"; import { listTeams, listTeamsPaginated } from "../../lib/api-client.js"; -import { parseOrgProjectArg } from "../../lib/arg-parsing.js"; -import { buildCommand, numberParser } from "../../lib/command.js"; import { type Column, writeTable } from "../../lib/formatters/table.js"; import { - dispatchOrgScopedList, - type OrgListConfig, -} from "../../lib/org-list.js"; + buildOrgListCommand, + type OrgListCommandDocs, +} from "../../lib/list-command.js"; +import type { OrgListConfig } from "../../lib/org-list.js"; import type { SentryTeam, Writer } from "../../types/index.js"; /** Command key for pagination cursor storage */ @@ -53,74 +51,22 @@ const teamListConfig: OrgListConfig = { writeTable(stdout, teams, TEAM_COLUMNS), }; -export const listCommand = buildCommand({ - docs: { - 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", - }, - parameters: { - positional: { - kind: "tuple", - parameters: [ - { - placeholder: "target", - brief: "Target: /, /, or ", - 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, - }, - cursor: { - kind: "parsed", - parse: String, - brief: 'Pagination cursor (use "last" to continue from previous page)', - optional: true, - }, - }, - aliases: { n: "limit", c: "cursor" }, - }, - 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: teamListConfig, - stdout, - cwd, - flags, - parsed, - }); - }, -}); +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", +}; + +export const listCommand = buildOrgListCommand(teamListConfig, docs); 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/test/lib/list-command.test.ts b/test/lib/list-command.test.ts new file mode 100644 index 0000000000..6bd4f44526 --- /dev/null +++ b/test/lib/list-command.test.ts @@ -0,0 +1,199 @@ +/** + * Tests for the shared list-command building blocks. + * + * Verifies that the shared flag/parameter constants have the correct shape + * and that `buildOrgListCommand` produces a working command that delegates + * to `dispatchOrgScopedList`. + */ + +import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"; +import { + buildListLimitFlag, + buildOrgListCommand, + LIST_BASE_ALIASES, + LIST_CURSOR_FLAG, + LIST_JSON_FLAG, + LIST_TARGET_POSITIONAL, + type OrgListCommandDocs, +} from "../../src/lib/list-command.js"; +import type { OrgListConfig } from "../../src/lib/org-list.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as orgListModule from "../../src/lib/org-list.js"; + +// --------------------------------------------------------------------------- +// Shared constants: shape / value assertions +// --------------------------------------------------------------------------- + +describe("LIST_TARGET_POSITIONAL", () => { + test("is a tuple with one optional string parameter", () => { + expect(LIST_TARGET_POSITIONAL.kind).toBe("tuple"); + expect(LIST_TARGET_POSITIONAL.parameters).toHaveLength(1); + const param = LIST_TARGET_POSITIONAL.parameters[0]; + expect(param.placeholder).toBe("target"); + expect(param.optional).toBe(true); + expect(param.parse).toBe(String); + }); +}); + +describe("LIST_JSON_FLAG", () => { + test("is a boolean flag defaulting to false", () => { + expect(LIST_JSON_FLAG.kind).toBe("boolean"); + expect(LIST_JSON_FLAG.default).toBe(false); + }); +}); + +describe("LIST_CURSOR_FLAG", () => { + test("is an optional parsed string flag", () => { + expect(LIST_CURSOR_FLAG.kind).toBe("parsed"); + expect(LIST_CURSOR_FLAG.optional).toBe(true); + expect(LIST_CURSOR_FLAG.parse).toBe(String); + expect(LIST_CURSOR_FLAG.brief).toContain('"last"'); + }); +}); + +describe("buildListLimitFlag", () => { + test("uses provided entity plural in brief", () => { + const flag = buildListLimitFlag("widgets"); + expect(flag.brief).toContain("widgets"); + expect(flag.kind).toBe("parsed"); + }); + + test("defaults to '30' when no default provided", () => { + const flag = buildListLimitFlag("teams"); + expect(flag.default).toBe("30"); + }); + + test("uses provided default value", () => { + const flag = buildListLimitFlag("issues", "10"); + expect(flag.default).toBe("10"); + }); +}); + +describe("LIST_BASE_ALIASES", () => { + test("maps n to limit and c to cursor", () => { + expect(LIST_BASE_ALIASES.n).toBe("limit"); + expect(LIST_BASE_ALIASES.c).toBe("cursor"); + }); + + test("can be spread with additional aliases", () => { + const extended = { ...LIST_BASE_ALIASES, p: "platform" }; + expect(extended.n).toBe("limit"); + expect(extended.c).toBe("cursor"); + expect(extended.p).toBe("platform"); + }); +}); + +// --------------------------------------------------------------------------- +// buildOrgListCommand: integration with dispatchOrgScopedList +// --------------------------------------------------------------------------- + +type FakeEntity = { id: string; name: string }; +type FakeWithOrg = FakeEntity & { orgSlug: string }; + +function makeFakeConfig( + overrides?: Partial> +): OrgListConfig { + return { + paginationKey: "fake-list", + entityName: "widget", + entityPlural: "widgets", + commandPrefix: "sentry widget list", + listForOrg: mock(() => Promise.resolve([])), + listPaginated: mock(() => + Promise.resolve({ data: [] as FakeEntity[], nextCursor: undefined }) + ), + withOrg: (entity, orgSlug) => ({ ...entity, orgSlug }), + displayTable: mock(() => { + // no-op for test + }), + ...overrides, + }; +} + +function createContext() { + const write = mock((_chunk: string) => true); + return { + context: { + stdout: { write }, + stderr: { write: mock((_chunk: string) => true) }, + cwd: "/tmp", + setContext: mock(() => { + // no-op for test + }), + }, + write, + }; +} + +describe("buildOrgListCommand", () => { + let dispatchSpy: ReturnType; + + afterEach(() => { + dispatchSpy?.mockRestore(); + }); + + test("returns a command object with a loader", () => { + const config = makeFakeConfig(); + const docs: OrgListCommandDocs = { brief: "List widgets" }; + const cmd = buildOrgListCommand(config, docs); + expect(typeof cmd.loader).toBe("function"); + }); + + test("calls dispatchOrgScopedList with correct config and flags", async () => { + dispatchSpy = spyOn( + orgListModule, + "dispatchOrgScopedList" + ).mockResolvedValue(undefined); + + const config = makeFakeConfig(); + const docs: OrgListCommandDocs = { brief: "List widgets" }; + const cmd = buildOrgListCommand(config, docs); + const func = await cmd.loader(); + const { context } = createContext(); + + await func.call(context, { limit: 5, json: true, cursor: undefined }); + + expect(dispatchSpy).toHaveBeenCalledTimes(1); + const callArgs = dispatchSpy.mock.calls[0]?.[0]; + expect(callArgs?.config).toBe(config); + expect(callArgs?.flags).toEqual({ + limit: 5, + json: true, + cursor: undefined, + }); + }); + + test("passes parsed target to dispatchOrgScopedList", async () => { + dispatchSpy = spyOn( + orgListModule, + "dispatchOrgScopedList" + ).mockResolvedValue(undefined); + + const config = makeFakeConfig(); + const cmd = buildOrgListCommand(config, { brief: "List widgets" }); + const func = await cmd.loader(); + const { context } = createContext(); + + await func.call(context, { limit: 30, json: false }, "my-org/"); + + const callArgs = dispatchSpy.mock.calls[0]?.[0]; + expect(callArgs?.parsed).toMatchObject({ type: "org-all", org: "my-org" }); + }); + + test("passes undefined parsed target when no positional arg given", async () => { + dispatchSpy = spyOn( + orgListModule, + "dispatchOrgScopedList" + ).mockResolvedValue(undefined); + + const config = makeFakeConfig(); + const cmd = buildOrgListCommand(config, { brief: "List widgets" }); + const func = await cmd.loader(); + const { context } = createContext(); + + await func.call(context, { limit: 30, json: false }); + + const callArgs = dispatchSpy.mock.calls[0]?.[0]; + expect(callArgs?.parsed).toMatchObject({ type: "auto-detect" }); + }); +}); From f7a4a1570bd6cdd91fdef6ec8dd014c227f19e77 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 18 Feb 2026 21:49:16 +0000 Subject: [PATCH 10/22] chore: regenerate SKILL.md --- plugins/sentry-cli/skills/sentry-cli/SKILL.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index 20eed69da7..e37edef3a5 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -194,9 +194,9 @@ 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:** @@ -584,9 +584,9 @@ 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 From 7c02d9f333995237038e182d9e46fddf9c7ea520 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Wed, 18 Feb 2026 23:04:03 +0000 Subject: [PATCH 11/22] refactor: migrate all list commands to dispatchOrgScopedList, fix bare-slug routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - team list: add listForProject (project-scoped teams via listAProject_sTeams) - repo list: bare slug now routes through project-search → org-scoped fallback - project list and issue list: replace switch-case dispatch with dispatchOrgScopedList + overrides - team/repo list tests: update to mock findProjectsBySlug for project-search mode - org-list.ts: full rewrite with ListCommandMeta, OrgListConfig, ModeHandlerMap, dispatchOrgScopedList - 1874 unit tests passing, lint and typecheck clean --- src/commands/issue/list.ts | 518 ++++++++++++++++--------------- src/commands/project/list.ts | 84 ++--- src/commands/team/list.ts | 7 +- src/lib/api-client.ts | 27 ++ src/lib/org-list.ts | 522 +++++++++++++++++++++++++------- test/commands/repo/list.test.ts | 88 +++++- test/commands/team/list.test.ts | 99 ++++-- test/lib/org-list.test.ts | 442 +++++++++++++++++++++++++-- 8 files changed, 1331 insertions(+), 456 deletions(-) diff --git a/src/commands/issue/list.ts b/src/commands/issue/list.ts index 72104feea9..799b38280c 100644 --- a/src/commands/issue/list.ts +++ b/src/commands/issue/list.ts @@ -25,12 +25,7 @@ import { setProjectAliases, } from "../../lib/db/project-aliases.js"; import { createDsnFingerprint } from "../../lib/dsn/index.js"; -import { - ApiError, - AuthError, - ContextError, - ValidationError, -} from "../../lib/errors.js"; +import { ApiError, AuthError, ContextError } from "../../lib/errors.js"; import { divider, type FormatShortIdOptions, @@ -45,6 +40,10 @@ import { LIST_JSON_FLAG, LIST_TARGET_POSITIONAL, } from "../../lib/list-command.js"; +import { + dispatchOrgScopedList, + type ListCommandMeta, +} from "../../lib/org-list.js"; import { type ResolvedTarget, resolveAllTargets, @@ -391,6 +390,249 @@ async function fetchIssuesForTarget( } } +/** 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 contextKey = `host:${getApiBaseUrl()}|type:org:${org}|sort:${flags.sort}${flags.query ? `|q:${flags.query}` : ""}`; + 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: sentry issue list ${org}/ -c last\n` + ); + } else { + stdout.write(`No issues found in organization '${org}'.\n`); + } + return; + } + + writeListHeader(stdout, `Issues in ${org}`, false); + const termWidth = process.stdout.columns || 80; + // isMultiProject=true so the ALIAS column shows which project each issue belongs to + 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: sentry issue list ${org}/ -c last\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", @@ -431,7 +673,6 @@ export const listCommand = buildCommand({ }, 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, @@ -439,248 +680,29 @@ export const listCommand = buildCommand({ ): Promise { const { stdout, stderr, cwd, setContext } = this; - // Parse positional argument to determine resolution strategy const parsed = parseOrgProjectArg(target); - // Cursor pagination is only supported in org-all mode - if (flags.cursor && parsed.type !== "org-all") { - throw new ValidationError( - "The --cursor flag is only supported when listing issues for a specific organization " + - "(e.g., sentry issue list /). " + - "Use 'sentry issue list /' for paginated results.", - "cursor" - ); - } - - // Handle org-all mode with cursor pagination (different code path) - if (parsed.type === "org-all") { - const org = parsed.org; - // Issue cursors encode sort+query so different searches don't share pages. - const contextKey = `host:${getApiBaseUrl()}|type:org:${org}|sort:${flags.sort}${flags.query ? `|q:${flags.query}` : ""}`; - 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, - }); - - // Strip the project filter since we're listing org-wide (pass empty projectSlug) - // The API handles org-wide issue listing without a project filter - - 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) { - const hint = `sentry issue list ${org}/ -c last`; - stdout.write(`No issues on this page. Try the next page: ${hint}\n`); - } else { - stdout.write(`No issues found in organization '${org}'.\n`); - } - return; - } - - writeListHeader(stdout, `Issues in ${org}`, false); - const termWidth = process.stdout.columns || 80; - // isMultiProject=true so the ALIAS column shows which project each issue - // belongs to — essential when viewing issues across an entire org. - const issuesWithOpts = response.data.map((issue) => ({ - issue, - formatOptions: { - projectSlug: issue.project?.slug ?? "", - isMultiProject: true, - }, - })); - writeIssueRows(stdout, issuesWithOpts, termWidth); - - if (hasMore) { - const hint = `sentry issue list ${org}/ -c last`; - stdout.write( - `\nShowing ${response.data.length} issues (more available)\n` - ); - stdout.write(`Next page: ${hint}\n`); - } else { - stdout.write(`\nShowing ${response.data.length} issues\n`); - } - return; - } - - // 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`); - } + const resolveAndHandle = () => + handleResolvedTargets({ stdout, stderr, parsed, flags, cwd, setContext }); + + await dispatchOrgScopedList({ + config: issueListMeta, + stdout, + cwd, + flags, + parsed, + overrides: { + "auto-detect": resolveAndHandle, + explicit: resolveAndHandle, + "project-search": resolveAndHandle, + "org-all": () => + handleOrgAllIssues({ + stdout, + org: parsed.type === "org-all" ? parsed.org : "", + flags, + setContext, + }), + }, + }); }, }); diff --git a/src/commands/project/list.ts b/src/commands/project/list.ts index 2d9df82305..f9b3e1ffcc 100644 --- a/src/commands/project/list.ts +++ b/src/commands/project/list.ts @@ -30,7 +30,7 @@ import { getPaginationCursor, setPaginationCursor, } from "../../lib/db/pagination.js"; -import { AuthError, ContextError, ValidationError } from "../../lib/errors.js"; +import { AuthError, ContextError } from "../../lib/errors.js"; import { calculateProjectColumnWidths, formatProjectRow, @@ -44,6 +44,10 @@ import { 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"; @@ -611,6 +615,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", @@ -652,47 +664,39 @@ export const listCommand = buildCommand({ const { stdout, cwd } = this; 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": () => handleAutoDetect(stdout, cwd, flags), + explicit: () => + handleExplicit( + stdout, + parsed.type === "explicit" ? parsed.org : "", + parsed.type === "explicit" ? parsed.project : "", + flags + ), + "org-all": () => + handleOrgAll({ + stdout, + org: parsed.type === "org-all" ? parsed.org : "", + flags, + contextKey, + cursor, + }), + "project-search": () => + handleProjectSearch( + stdout, + parsed.type === "project-search" ? parsed.projectSlug : "", + flags + ), + }, + }); }, }); diff --git a/src/commands/team/list.ts b/src/commands/team/list.ts index f5624582d1..a09d159984 100644 --- a/src/commands/team/list.ts +++ b/src/commands/team/list.ts @@ -10,7 +10,11 @@ * - Cross-org project search (e.g., sentry) */ -import { listTeams, listTeamsPaginated } from "../../lib/api-client.js"; +import { + listProjectTeams, + listTeams, + listTeamsPaginated, +} from "../../lib/api-client.js"; import { type Column, writeTable } from "../../lib/formatters/table.js"; import { buildOrgListCommand, @@ -49,6 +53,7 @@ const teamListConfig: OrgListConfig = { withOrg: (team, orgSlug) => ({ ...team, orgSlug }), displayTable: (stdout: Writer, teams: TeamWithOrg[]) => writeTable(stdout, teams, TEAM_COLUMNS), + listForProject: (org, project) => listProjectTeams(org, project), }; const docs: OrgListCommandDocs = { diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index d92757c048..72c8366abc 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, @@ -708,6 +709,32 @@ export function listTeamsPaginated( ); } +/** + * 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. diff --git a/src/lib/org-list.ts b/src/lib/org-list.ts index 41fa190c27..5193a7737a 100644 --- a/src/lib/org-list.ts +++ b/src/lib/org-list.ts @@ -1,16 +1,35 @@ /** - * Shared infrastructure for org-scoped list commands (team, repo, etc.). + * Shared infrastructure for org-scoped list commands (team, repo, project, issue, …). * - * Provides a config-driven framework that eliminates the duplicated patterns - * across `team/list`, `repo/list`, and partially `project/list`. + * ## Config types * - * Each command defines an {@link OrgListConfig} describing how to fetch, - * augment, and display its entities, then delegates to the shared dispatch - * and handler functions. + * 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 { listOrganizations, type PaginatedResponse } from "./api-client.js"; +import { + findProjectsBySlug, + listOrganizations, + type PaginatedResponse, +} from "./api-client.js"; import type { ParsedOrgProject } from "./arg-parsing.js"; import { buildOrgContextKey, @@ -18,7 +37,7 @@ import { resolveOrgCursor, setPaginationCursor, } from "./db/pagination.js"; -import { AuthError, ValidationError } from "./errors.js"; +import { AuthError, ContextError, ValidationError } from "./errors.js"; import { writeFooter, writeJson } from "./formatters/index.js"; import { resolveOrgsForListing } from "./resolve-target.js"; @@ -26,20 +45,13 @@ import { resolveOrgsForListing } from "./resolve-target.js"; // Config types // --------------------------------------------------------------------------- -/** Minimal flags required by the shared infrastructure. */ -export type BaseListFlags = { - readonly limit: number; - readonly json: boolean; - readonly cursor?: string; -}; - /** - * Configuration for an org-scoped list command. + * Metadata required by all list commands. * - * @template TEntity - Raw entity type from the API (e.g., SentryTeam) - * @template TWithOrg - Entity with orgSlug attached for display + * 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 OrgListConfig = { +export type ListCommandMeta = { /** Key stored in the pagination cursor table (e.g., "team-list") */ paginationKey: string; /** Singular entity name for messages (e.g., "team") */ @@ -48,7 +60,22 @@ export type OrgListConfig = { 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 @@ -75,10 +102,62 @@ export type OrgListConfig = { * 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; }; // --------------------------------------------------------------------------- -// Fetch helpers +// Mode handler types +// --------------------------------------------------------------------------- + +/** A single dispatch handler — a zero-argument async function. */ +export type ModeHandler = () => Promise; + +/** + * Complete handler map — one handler per parsed target type. + * Keys match `ParsedOrgProject["type"]`. + */ +export type ModeHandlerMap = Record; + +/** + * 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 = Partial; + +// --------------------------------------------------------------------------- +// 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) // --------------------------------------------------------------------------- /** @@ -101,39 +180,29 @@ export async function fetchOrgSafe( } /** - * Fetch entities from all accessible organizations. - * Skips orgs where the user lacks access. + * 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: TWithOrg[] = []; - - for (const org of orgs) { - try { - const items = await config.listForOrg(org.slug); - results.push(...items.map((item) => config.withOrg(item, org.slug))); - } catch (error) { - if (error instanceof AuthError) { - throw error; - } - // User may lack access to some orgs - } - } - - return results; + const results = await Promise.all( + orgs.map((org) => fetchOrgSafe(config, org.slug)) + ); + return results.flat(); } // --------------------------------------------------------------------------- -// Handlers +// Default handlers // --------------------------------------------------------------------------- -/** Build the CLI hint for fetching the next page. */ +/** 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; @@ -144,33 +213,30 @@ type OrgAllOptions = { }; /** - * Handle org-all mode (e.g., `sentry team list sentry/`). - * Uses cursor pagination for efficient page-by-page listing. + * 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 items: TWithOrg[] = response.data.map((item) => - config.withOrg(item, org) - ); - const hasMore = !!response.nextCursor; + const { data: items, nextCursor } = response; + const hasMore = !!nextCursor; - // Update cursor cache for `--cursor last` support - if (response.nextCursor) { - setPaginationCursor(config.paginationKey, contextKey, response.nextCursor); + if (nextCursor) { + setPaginationCursor(config.paginationKey, contextKey, nextCursor); } else { clearPaginationCursor(config.paginationKey, contextKey); } if (flags.json) { const output = hasMore - ? { data: items, nextCursor: response.nextCursor, hasMore: true } + ? { data: items, nextCursor, hasMore: true } : { data: items, hasMore: false }; writeJson(stdout, output); return; @@ -189,7 +255,7 @@ export async function handleOrgAll( return; } - config.displayTable(stdout, items); + config.displayTable(stdout, items as unknown as TWithOrg[]); if (hasMore) { stdout.write( @@ -269,17 +335,197 @@ export async function handleAutoDetect( ); } +/** Options for {@link displayFetchedItems}. */ +type DisplayFetchedItemsOptions = { + config: OrgListConfig; + stdout: Writer; + items: TWithOrg[]; + flags: BaseListFlags; + contextLabel: string; +}; + +/** + * Display a list of entities fetched for a single org or project scope. + * Shared by handleExplicitOrg and handleExplicitProject. + */ +function displayFetchedItems( + opts: DisplayFetchedItemsOptions +): void { + const { config, stdout, items, flags, contextLabel } = opts; + const limited = items.slice(0, flags.limit); + + if (flags.json) { + writeJson(stdout, limited); + return; + } + + if (limited.length === 0) { + stdout.write(`No ${config.entityPlural} found in ${contextLabel}.\n`); + return; + } + + config.displayTable(stdout, limited); + + if (items.length > limited.length) { + stdout.write( + `\nShowing ${limited.length} of ${items.length} ${config.entityPlural}. ` + + `Use '${config.commandPrefix} ${contextLabel}/' for paginated results.\n` + ); + } else { + stdout.write(`\nShowing ${limited.length} ${config.entityPlural}\n`); + } +} + +/** Options for {@link handleExplicitOrg}. */ +type ExplicitOrgOptions = { + config: OrgListConfig; + stdout: Writer; + org: string; + flags: BaseListFlags; + /** When true, write a note that the entity type is org-scoped. */ + noteOrgScoped?: boolean; +}; + /** * Handle a single explicit org (non-paginated fetch). + * When the config has no `listForProject`, this is also the fallback for + * explicit `org/project` mode — a subtle note is written to inform the user + * that the entity type is org-scoped. */ export async function handleExplicitOrg( + options: ExplicitOrgOptions +): Promise { + const { config, stdout, org, flags, noteOrgScoped = false } = options; + const items = await fetchOrgSafe(config, org); + + if (noteOrgScoped && !flags.json) { + stdout.write( + `Note: ${config.entityPlural} are org-scoped. Showing all ${config.entityPlural} in '${org}'.\n\n` + ); + } + + displayFetchedItems({ + config, + stdout, + items, + flags, + contextLabel: `organization '${org}'`, + }); + + if (!flags.json && items.length > 0) { + writeFooter( + stdout, + `Tip: Use '${config.commandPrefix} ${org}/' for paginated results` + ); + } +} + +/** Options for {@link handleExplicitProject}. */ +type ExplicitProjectOptions = { + config: OrgListConfig; + stdout: Writer; + org: string; + project: string; + flags: BaseListFlags; +}; + +/** + * Handle explicit `org/project` mode when `listForProject` is available. + * Fetches entities scoped to the specific project. + * + * `config.listForProject` must be defined — callers must guard before calling. + */ +export async function handleExplicitProject( + options: ExplicitProjectOptions +): Promise { + const { config, stdout, org, project, flags } = options; + // listForProject is guaranteed defined — callers must check before invoking + const listForProject = config.listForProject; + if (!listForProject) { + throw new Error( + "handleExplicitProject called but config.listForProject is not defined" + ); + } + const raw = await listForProject(org, project); + const items = raw.map((entity) => config.withOrg(entity, org)); + + displayFetchedItems({ + config, + stdout, + items, + flags, + contextLabel: `project '${org}/${project}'`, + }); + + if (!flags.json && items.length > 0) { + writeFooter( + stdout, + `Tip: Use '${config.commandPrefix} ${org}/' to see all ${config.entityPlural} in the org` + ); + } +} + +/** + * Handle project-search mode (bare slug, e.g., "cli"). + * + * Searches for a project matching the slug across all accessible orgs via + * `findProjectsBySlug`. This gives consistent UX with `project list` and + * `issue list` where a bare slug is always treated as a project slug, not + * an org slug. + * + * If `config.listForProject` is available, fetches entities scoped to each + * matched project. Otherwise fetches org-scoped entities from the matched + * project's parent org (since the entity type is org-scoped). + */ +export async function handleProjectSearch( config: OrgListConfig, stdout: Writer, - org: string, + projectSlug: string, flags: BaseListFlags ): Promise { - const items = await fetchOrgSafe(config, org); - const limited = items.slice(0, flags.limit); + const matches = await findProjectsBySlug(projectSlug); + + if (matches.length === 0) { + if (flags.json) { + writeJson(stdout, []); + return; + } + throw new ContextError( + config.entityName, + `No project '${projectSlug}' found in any accessible organization.\n\n` + + `Try: ${config.commandPrefix} /${projectSlug}` + ); + } + + let allItems: TWithOrg[]; + + if (config.listForProject) { + const listForProject = config.listForProject; + // Fetch entities scoped to each matched project in parallel + const results = await Promise.all( + matches.map(async (m) => { + try { + const raw = await listForProject(m.orgSlug, m.slug); + return raw.map((entity) => config.withOrg(entity, m.orgSlug)); + } catch (error) { + if (error instanceof AuthError) { + throw error; + } + return [] as TWithOrg[]; + } + }) + ); + allItems = results.flat(); + } else { + // Entity is org-scoped — fetch from each unique parent org + const uniqueOrgs = [...new Set(matches.map((m) => m.orgSlug))]; + const results = await Promise.all( + uniqueOrgs.map((org) => fetchOrgSafe(config, org)) + ); + allItems = results.flat(); + } + + const limited = allItems.slice(0, flags.limit); if (flags.json) { writeJson(stdout, limited); @@ -287,25 +533,119 @@ export async function handleExplicitOrg( } if (limited.length === 0) { - stdout.write(`No ${config.entityPlural} found in organization '${org}'.\n`); + stdout.write( + `No ${config.entityPlural} found for project '${projectSlug}'.\n` + ); return; } config.displayTable(stdout, limited); - if (items.length > limited.length) { + if (allItems.length > limited.length) { stdout.write( - `\nShowing ${limited.length} of ${items.length} ${config.entityPlural}. ` + - `Use '${config.commandPrefix} ${org}/' for paginated results.\n` + `\nShowing ${limited.length} of ${allItems.length} ${config.entityPlural}. Use --limit to show more.\n` ); } else { stdout.write(`\nShowing ${limited.length} ${config.entityPlural}\n`); } - writeFooter( - stdout, - `Tip: Use '${config.commandPrefix} ${org}/' for paginated results` + if (matches.length > 1) { + stdout.write( + `\nFound '${projectSlug}' in ${matches.length} organizations\n` + ); + } +} + +// --------------------------------------------------------------------------- +// Default handler map builder +// --------------------------------------------------------------------------- + +/** Options for {@link buildDefaultHandlers}. */ +type DefaultHandlerOptions = { + config: ListCommandMeta | OrgListConfig; + stdout: Writer; + cwd: string; + flags: BaseListFlags; + parsed: ParsedOrgProject; +}; + +/** + * Build the default `ModeHandlerMap` for the given config and request context. + * + * If `config` is only {@link ListCommandMeta} (not a full {@link OrgListConfig}), + * each default handler throws when invoked — this only happens if a mode is not + * covered by the caller's overrides, which would be a programming error. + */ +function buildDefaultHandlers( + options: DefaultHandlerOptions +): ModeHandlerMap { + const { config, stdout, cwd, flags, parsed } = options; + + const notSupported = + (mode: string): ModeHandler => + () => + Promise.reject( + new Error( + `No handler for '${mode}' mode in '${config.commandPrefix}'. ` + + "Provide a full OrgListConfig or an override for this mode." + ) + ); + + if (!isOrgListConfig(config)) { + // Metadata-only config — all modes must be overridden by the caller + return { + "auto-detect": notSupported("auto-detect"), + explicit: notSupported("explicit"), + "project-search": notSupported("project-search"), + "org-all": notSupported("org-all"), + }; + } + + const contextKey = buildOrgContextKey( + parsed.type === "org-all" ? parsed.org : "" ); + + return { + "auto-detect": () => handleAutoDetect(config, stdout, cwd, flags), + + explicit: () => { + if (config.listForProject) { + return handleExplicitProject({ + config, + stdout, + org: parsed.type === "explicit" ? parsed.org : "", + project: parsed.type === "explicit" ? parsed.project : "", + flags, + }); + } + // No project-scoped API — fall back to org listing with a note + return handleExplicitOrg({ + config, + stdout, + org: parsed.type === "explicit" ? parsed.org : "", + flags, + noteOrgScoped: true, + }); + }, + + "project-search": () => + handleProjectSearch( + config, + stdout, + parsed.type === "project-search" ? parsed.projectSlug : "", + flags + ), + + "org-all": () => { + const org = parsed.type === "org-all" ? parsed.org : ""; + const cursor = resolveOrgCursor( + flags.cursor, + config.paginationKey, + contextKey + ); + return handleOrgAll({ config, stdout, org, flags, contextKey, cursor }); + }, + }; } // --------------------------------------------------------------------------- @@ -313,23 +653,33 @@ export async function handleExplicitOrg( // --------------------------------------------------------------------------- /** Options for {@link dispatchOrgScopedList}. */ -export type DispatchOptions = { - config: OrgListConfig; +export type DispatchOptions = { + /** Full config (for default handlers) or just metadata (all modes overridden). */ + config: ListCommandMeta | OrgListConfig; stdout: Writer; cwd: string; flags: BaseListFlags; parsed: ParsedOrgProject; + /** + * Per-mode handler overrides. Each key matches a `ParsedOrgProject["type"]`. + * Provided handlers replace the corresponding default handler; unspecified + * modes fall back to the defaults from {@link buildDefaultHandlers}. + */ + overrides?: ModeOverrides; }; /** - * Validate cursor flag and dispatch to the correct handler based on the - * parsed target type. This is the single entry point for org-scoped list - * commands that follow the standard pattern. + * Validate the cursor flag and dispatch to the correct handler. + * + * Merges default handlers with caller-provided overrides using + * `{ ...defaults, ...overrides }`, then invokes `handlers[parsed.type]()`. + * This is the single entry point for all org-scoped list commands. */ export async function dispatchOrgScopedList( options: DispatchOptions ): Promise { - const { config, stdout, cwd, flags, parsed } = options; + const { config, stdout, cwd, flags, parsed, overrides } = options; + // Cursor pagination is only supported in org-all mode if (flags.cursor && parsed.type !== "org-all") { throw new ValidationError( @@ -340,42 +690,8 @@ export async function dispatchOrgScopedList( ); } - switch (parsed.type) { - case "auto-detect": - await handleAutoDetect(config, stdout, cwd, flags); - break; - - case "explicit": - // Use the org context; project part is ignored for this entity listing - await handleExplicitOrg(config, stdout, parsed.org, flags); - break; - - case "project-search": - // Bare slug treated as org slug - await handleExplicitOrg(config, stdout, parsed.projectSlug, flags); - break; + const defaults = buildDefaultHandlers({ config, stdout, cwd, flags, parsed }); + const handlers: ModeHandlerMap = { ...defaults, ...overrides }; - case "org-all": { - const contextKey = buildOrgContextKey(parsed.org); - const cursor = resolveOrgCursor( - flags.cursor, - config.paginationKey, - contextKey - ); - await handleOrgAll({ - config, - stdout, - org: parsed.org, - flags, - contextKey, - cursor, - }); - break; - } - - default: { - const _exhaustiveCheck: never = parsed; - throw new Error(`Unexpected parsed type: ${_exhaustiveCheck}`); - } - } + await handlers[parsed.type](); } diff --git a/test/commands/repo/list.test.ts b/test/commands/repo/list.test.ts index ae00480b1e..8833875b1f 100644 --- a/test/commands/repo/list.test.ts +++ b/test/commands/repo/list.test.ts @@ -70,23 +70,29 @@ function createMockContext(cwd = "/tmp") { }; } -describe("listCommand.func — explicit org (project-search / bare slug)", () => { +describe("listCommand.func — project-search (bare slug)", () => { let listRepositoriesSpy: ReturnType; + let findProjectsBySlugSpy: ReturnType; beforeEach(() => { listRepositoriesSpy = spyOn(apiClient, "listRepositories"); + findProjectsBySlugSpy = spyOn(apiClient, "findProjectsBySlug"); }); afterEach(() => { listRepositoriesSpy.mockRestore(); + findProjectsBySlugSpy.mockRestore(); }); test("outputs JSON array when --json flag is set", async () => { + findProjectsBySlugSpy.mockResolvedValue([ + { slug: "test-proj", orgSlug: "test-org" }, + ]); listRepositoriesSpy.mockResolvedValue(sampleRepos); const { context, stdoutWrite } = createMockContext(); const func = await listCommand.loader(); - await func.call(context, { limit: 30, json: true }, "test-org"); + await func.call(context, { limit: 30, json: true }, "test-proj"); const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); const parsed = JSON.parse(output); @@ -97,33 +103,42 @@ describe("listCommand.func — explicit org (project-search / bare slug)", () => }); test("outputs empty JSON array when no repos found with --json", async () => { + findProjectsBySlugSpy.mockResolvedValue([ + { slug: "test-proj", orgSlug: "test-org" }, + ]); listRepositoriesSpy.mockResolvedValue([]); const { context, stdoutWrite } = createMockContext(); const func = await listCommand.loader(); - await func.call(context, { limit: 30, json: true }, "test-org"); + await func.call(context, { limit: 30, json: true }, "test-proj"); const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); expect(JSON.parse(output)).toEqual([]); }); test("writes 'No repositories found' when empty without --json", async () => { + findProjectsBySlugSpy.mockResolvedValue([ + { slug: "test-proj", orgSlug: "test-org" }, + ]); listRepositoriesSpy.mockResolvedValue([]); const { context, stdoutWrite } = createMockContext(); const func = await listCommand.loader(); - await func.call(context, { limit: 30, json: false }, "test-org"); + await func.call(context, { limit: 30, json: false }, "test-proj"); const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); expect(output).toContain("No repositories found"); }); - test("writes header, rows, and footer for human output", async () => { + test("writes header and rows for human output", async () => { + findProjectsBySlugSpy.mockResolvedValue([ + { slug: "test-proj", orgSlug: "test-org" }, + ]); listRepositoriesSpy.mockResolvedValue(sampleRepos); const { context, stdoutWrite } = createMockContext(); const func = await listCommand.loader(); - await func.call(context, { limit: 30, json: false }, "test-org"); + await func.call(context, { limit: 30, json: false }, "test-proj"); const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); expect(output).toContain("ORG"); @@ -135,7 +150,6 @@ describe("listCommand.func — explicit org (project-search / bare slug)", () => expect(output).toContain("getsentry/sentry-javascript"); expect(output).toContain("GitHub"); expect(output).toContain("active"); - expect(output).toContain("sentry repo list"); }); test("shows count when results exceed limit", async () => { @@ -144,37 +158,89 @@ describe("listCommand.func — explicit org (project-search / bare slug)", () => id: String(i), name: `repo-${i}`, })); + findProjectsBySlugSpy.mockResolvedValue([ + { slug: "test-proj", orgSlug: "test-org" }, + ]); listRepositoriesSpy.mockResolvedValue(manyRepos); const { context, stdoutWrite } = createMockContext(); const func = await listCommand.loader(); - await func.call(context, { limit: 5, json: false }, "test-org"); + await func.call(context, { limit: 5, json: false }, "test-proj"); const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); expect(output).toContain("Showing 5 of 10 repositories"); }); test("shows all repos when count is under limit", async () => { + findProjectsBySlugSpy.mockResolvedValue([ + { slug: "test-proj", orgSlug: "test-org" }, + ]); listRepositoriesSpy.mockResolvedValue(sampleRepos); const { context, stdoutWrite } = createMockContext(); const func = await listCommand.loader(); - await func.call(context, { limit: 30, json: false }, "test-org"); + await func.call(context, { limit: 30, json: false }, "test-proj"); const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); expect(output).toContain("Showing 2 repositories"); }); - test("explicit org/project uses org part only", async () => { + test("outputs empty JSON array when project not found", async () => { + findProjectsBySlugSpy.mockResolvedValue([]); + + const { context, stdoutWrite } = createMockContext(); + const func = await listCommand.loader(); + await func.call(context, { limit: 30, json: true }, "unknown-proj"); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + expect(JSON.parse(output)).toEqual([]); + }); +}); + +describe("listCommand.func — explicit org/project (org-scoped with note)", () => { + let listRepositoriesSpy: ReturnType; + + beforeEach(() => { + listRepositoriesSpy = spyOn(apiClient, "listRepositories"); + }); + + afterEach(() => { + listRepositoriesSpy.mockRestore(); + }); + + test("explicit org/project uses org part (repos are org-scoped)", async () => { listRepositoriesSpy.mockResolvedValue(sampleRepos); const { context } = createMockContext(); const func = await listCommand.loader(); - // "my-org/my-project" — explicit mode, org = "my-org" await func.call(context, { limit: 30, json: false }, "my-org/my-project"); expect(listRepositoriesSpy).toHaveBeenCalledWith("my-org"); }); + + test("explicit org/project writes org-scoped note in human output", async () => { + listRepositoriesSpy.mockResolvedValue(sampleRepos); + + const { context, stdoutWrite } = createMockContext(); + const func = await listCommand.loader(); + await func.call(context, { limit: 30, json: false }, "my-org/my-project"); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + expect(output).toContain("org-scoped"); + }); + + test("explicit org/project suppresses note in JSON output", async () => { + listRepositoriesSpy.mockResolvedValue(sampleRepos); + + const { context, stdoutWrite } = createMockContext(); + const func = await listCommand.loader(); + await func.call(context, { limit: 30, json: true }, "my-org/my-project"); + + 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); + }); }); describe("listCommand.func — auto-detect mode", () => { diff --git a/test/commands/team/list.test.ts b/test/commands/team/list.test.ts index a8a4b667a0..6d84996d0e 100644 --- a/test/commands/team/list.test.ts +++ b/test/commands/team/list.test.ts @@ -66,23 +66,29 @@ function createMockContext(cwd = "/tmp") { }; } -describe("listCommand.func — explicit org (project-search / bare slug)", () => { - let listTeamsSpy: ReturnType; +describe("listCommand.func — project-search (bare slug)", () => { + let listProjectTeamsSpy: ReturnType; + let findProjectsBySlugSpy: ReturnType; beforeEach(() => { - listTeamsSpy = spyOn(apiClient, "listTeams"); + listProjectTeamsSpy = spyOn(apiClient, "listProjectTeams"); + findProjectsBySlugSpy = spyOn(apiClient, "findProjectsBySlug"); }); afterEach(() => { - listTeamsSpy.mockRestore(); + listProjectTeamsSpy.mockRestore(); + findProjectsBySlugSpy.mockRestore(); }); test("outputs JSON array when --json flag is set", async () => { - listTeamsSpy.mockResolvedValue(sampleTeams); + findProjectsBySlugSpy.mockResolvedValue([ + { slug: "test-org-proj", orgSlug: "test-org" }, + ]); + listProjectTeamsSpy.mockResolvedValue(sampleTeams); const { context, stdoutWrite } = createMockContext(); const func = await listCommand.loader(); - await func.call(context, { limit: 30, json: true }, "test-org"); + await func.call(context, { limit: 30, json: true }, "test-org-proj"); const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); const parsed = JSON.parse(output); @@ -93,33 +99,42 @@ describe("listCommand.func — explicit org (project-search / bare slug)", () => }); test("outputs empty JSON array when no teams found with --json", async () => { - listTeamsSpy.mockResolvedValue([]); + findProjectsBySlugSpy.mockResolvedValue([ + { slug: "test-org-proj", orgSlug: "test-org" }, + ]); + listProjectTeamsSpy.mockResolvedValue([]); const { context, stdoutWrite } = createMockContext(); const func = await listCommand.loader(); - await func.call(context, { limit: 30, json: true }, "test-org"); + await func.call(context, { limit: 30, json: true }, "test-org-proj"); 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([]); + findProjectsBySlugSpy.mockResolvedValue([ + { slug: "test-org-proj", orgSlug: "test-org" }, + ]); + listProjectTeamsSpy.mockResolvedValue([]); const { context, stdoutWrite } = createMockContext(); const func = await listCommand.loader(); - await func.call(context, { limit: 30, json: false }, "test-org"); + await func.call(context, { limit: 30, json: false }, "test-org-proj"); 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); + test("writes header and rows for human output", async () => { + findProjectsBySlugSpy.mockResolvedValue([ + { slug: "test-org-proj", orgSlug: "test-org" }, + ]); + listProjectTeamsSpy.mockResolvedValue(sampleTeams); const { context, stdoutWrite } = createMockContext(); const func = await listCommand.loader(); - await func.call(context, { limit: 30, json: false }, "test-org"); + await func.call(context, { limit: 30, json: false }, "test-org-proj"); const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); expect(output).toContain("ORG"); @@ -132,7 +147,6 @@ describe("listCommand.func — explicit org (project-search / bare slug)", () => expect(output).toContain("frontend"); expect(output).toContain("Frontend Team"); expect(output).toContain("5"); - expect(output).toContain("sentry team list"); }); test("shows count when results exceed limit", async () => { @@ -142,36 +156,77 @@ describe("listCommand.func — explicit org (project-search / bare slug)", () => slug: `team-${i}`, name: `Team ${i}`, })); - listTeamsSpy.mockResolvedValue(manyTeams); + findProjectsBySlugSpy.mockResolvedValue([ + { slug: "test-org-proj", orgSlug: "test-org" }, + ]); + listProjectTeamsSpy.mockResolvedValue(manyTeams); const { context, stdoutWrite } = createMockContext(); const func = await listCommand.loader(); - await func.call(context, { limit: 5, json: false }, "test-org"); + await func.call(context, { limit: 5, json: false }, "test-org-proj"); const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); expect(output).toContain("Showing 5 of 10 teams"); }); test("shows all teams when count is under limit", async () => { - listTeamsSpy.mockResolvedValue(sampleTeams); + findProjectsBySlugSpy.mockResolvedValue([ + { slug: "test-org-proj", orgSlug: "test-org" }, + ]); + listProjectTeamsSpy.mockResolvedValue(sampleTeams); const { context, stdoutWrite } = createMockContext(); const func = await listCommand.loader(); - await func.call(context, { limit: 30, json: false }, "test-org"); + await func.call(context, { limit: 30, json: false }, "test-org-proj"); const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); expect(output).toContain("Showing 2 teams"); }); - test("explicit org/project uses org part only", async () => { - listTeamsSpy.mockResolvedValue(sampleTeams); + test("outputs empty JSON array when project not found", async () => { + findProjectsBySlugSpy.mockResolvedValue([]); + + const { context, stdoutWrite } = createMockContext(); + const func = await listCommand.loader(); + await func.call(context, { limit: 30, json: true }, "unknown-proj"); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + expect(JSON.parse(output)).toEqual([]); + }); +}); + +describe("listCommand.func — explicit org/project", () => { + let listProjectTeamsSpy: ReturnType; + + beforeEach(() => { + listProjectTeamsSpy = spyOn(apiClient, "listProjectTeams"); + }); + + afterEach(() => { + listProjectTeamsSpy.mockRestore(); + }); + + test("explicit org/project calls listProjectTeams for that project", async () => { + listProjectTeamsSpy.mockResolvedValue(sampleTeams); const { context } = createMockContext(); const func = await listCommand.loader(); - // "my-org/my-project" — explicit mode, org = "my-org" await func.call(context, { limit: 30, json: false }, "my-org/my-project"); - expect(listTeamsSpy).toHaveBeenCalledWith("my-org"); + expect(listProjectTeamsSpy).toHaveBeenCalledWith("my-org", "my-project"); + }); + + test("explicit org/project outputs JSON from project-scoped fetch", async () => { + listProjectTeamsSpy.mockResolvedValue(sampleTeams); + + const { context, stdoutWrite } = createMockContext(); + const func = await listCommand.loader(); + await func.call(context, { limit: 30, json: true }, "my-org/my-project"); + + 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); }); }); diff --git a/test/lib/org-list.test.ts b/test/lib/org-list.test.ts index fa20cdfae2..e2cb67295a 100644 --- a/test/lib/org-list.test.ts +++ b/test/lib/org-list.test.ts @@ -1,8 +1,9 @@ /** * Tests for the shared org-scoped list infrastructure. * - * Tests the core functions directly (fetchOrgSafe, fetchAllOrgs, handleOrgAll, - * handleAutoDetect, handleExplicitOrg, dispatchOrgScopedList). + * Covers: fetchOrgSafe, fetchAllOrgs, handleOrgAll, handleAutoDetect, + * handleExplicitOrg, handleExplicitProject, handleProjectSearch, + * dispatchOrgScopedList (with and without overrides, metadata-only config). */ import { @@ -20,12 +21,21 @@ import * as apiClient from "../../src/lib/api-client.js"; import * as defaults from "../../src/lib/db/defaults.js"; // biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking import * as paginationDb from "../../src/lib/db/pagination.js"; -import { AuthError, ValidationError } from "../../src/lib/errors.js"; +import { + AuthError, + ContextError, + ValidationError, +} from "../../src/lib/errors.js"; import { dispatchOrgScopedList, fetchAllOrgs, fetchOrgSafe, + handleExplicitOrg, + handleExplicitProject, handleOrgAll, + handleProjectSearch, + isOrgListConfig, + type ListCommandMeta, type OrgListConfig, } from "../../src/lib/org-list.js"; // biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking @@ -54,11 +64,32 @@ function makeConfig( }; } +const META_ONLY: ListCommandMeta = { + paginationKey: "meta-list", + entityName: "thing", + entityPlural: "things", + commandPrefix: "sentry thing list", +}; + function createStdout() { const write = mock((_chunk: string) => true); return { writer: { write }, write }; } +// --------------------------------------------------------------------------- +// isOrgListConfig +// --------------------------------------------------------------------------- + +describe("isOrgListConfig", () => { + test("returns true for full OrgListConfig", () => { + expect(isOrgListConfig(makeConfig())).toBe(true); + }); + + test("returns false for ListCommandMeta only", () => { + expect(isOrgListConfig(META_ONLY)).toBe(false); + }); +}); + // --------------------------------------------------------------------------- // fetchOrgSafe // --------------------------------------------------------------------------- @@ -330,7 +361,274 @@ describe("handleOrgAll", () => { }); // --------------------------------------------------------------------------- -// dispatchOrgScopedList +// handleExplicitOrg +// --------------------------------------------------------------------------- + +describe("handleExplicitOrg", () => { + test("returns JSON array of entities", async () => { + const config = makeConfig({ + listForOrg: mock(() => Promise.resolve([{ id: "1", name: "A" }])), + }); + const { writer, write } = createStdout(); + + await handleExplicitOrg({ + config, + stdout: writer, + org: "my-org", + flags: { limit: 10, json: true }, + }); + + const output = write.mock.calls.map((c) => c[0]).join(""); + const parsed = JSON.parse(output); + expect(Array.isArray(parsed)).toBe(true); + expect(parsed[0].orgSlug).toBe("my-org"); + }); + + test("writes org-scoped note when noteOrgScoped=true", async () => { + const config = makeConfig({ + listForOrg: mock(() => Promise.resolve([{ id: "1", name: "A" }])), + }); + const { writer, write } = createStdout(); + + await handleExplicitOrg({ + config, + stdout: writer, + org: "my-org", + flags: { limit: 10, json: false }, + noteOrgScoped: true, + }); + + const output = write.mock.calls.map((c) => c[0]).join(""); + expect(output).toContain("widgets are org-scoped"); + expect(output).toContain("my-org"); + }); + + test("does not write org-scoped note when noteOrgScoped=false (default)", async () => { + const config = makeConfig({ + listForOrg: mock(() => Promise.resolve([{ id: "1", name: "A" }])), + }); + const { writer, write } = createStdout(); + + await handleExplicitOrg({ + config, + stdout: writer, + org: "my-org", + flags: { limit: 10, json: false }, + }); + + const output = write.mock.calls.map((c) => c[0]).join(""); + expect(output).not.toContain("org-scoped"); + }); + + test("does not write org-scoped note for JSON output even when noteOrgScoped=true", async () => { + const config = makeConfig({ + listForOrg: mock(() => Promise.resolve([{ id: "1", name: "A" }])), + }); + const { writer, write } = createStdout(); + + await handleExplicitOrg({ + config, + stdout: writer, + org: "my-org", + flags: { limit: 10, json: true }, + noteOrgScoped: true, + }); + + const output = write.mock.calls.map((c) => c[0]).join(""); + // Should be valid JSON, no prose note + expect(() => JSON.parse(output)).not.toThrow(); + expect(output).not.toContain("org-scoped"); + }); +}); + +// --------------------------------------------------------------------------- +// handleExplicitProject +// --------------------------------------------------------------------------- + +describe("handleExplicitProject", () => { + test("fetches and displays project-scoped entities", async () => { + const listForProject = mock(() => + Promise.resolve([{ id: "1", name: "Team A" }]) + ); + const config = makeConfig({ listForProject }); + const { writer, write } = createStdout(); + + await handleExplicitProject({ + config, + stdout: writer, + org: "my-org", + project: "my-proj", + flags: { limit: 10, json: true }, + }); + + expect(listForProject).toHaveBeenCalledWith("my-org", "my-proj"); + const output = write.mock.calls.map((c) => c[0]).join(""); + const parsed = JSON.parse(output); + expect(Array.isArray(parsed)).toBe(true); + expect(parsed[0].orgSlug).toBe("my-org"); + }); + + test("throws when listForProject is not defined on config", async () => { + const config = makeConfig(); // no listForProject + const { writer } = createStdout(); + + await expect( + handleExplicitProject({ + config, + stdout: writer, + org: "my-org", + project: "my-proj", + flags: { limit: 10, json: false }, + }) + ).rejects.toThrow("listForProject is not defined"); + }); + + test("shows 'no entities found' when project has none", async () => { + const config = makeConfig({ + listForProject: mock(() => Promise.resolve([])), + }); + const { writer, write } = createStdout(); + + await handleExplicitProject({ + config, + stdout: writer, + org: "my-org", + project: "my-proj", + flags: { limit: 10, json: false }, + }); + + const output = write.mock.calls.map((c) => c[0]).join(""); + expect(output).toContain("No widgets found"); + expect(output).toContain("my-org/my-proj"); + }); +}); + +// --------------------------------------------------------------------------- +// handleProjectSearch +// --------------------------------------------------------------------------- + +describe("handleProjectSearch", () => { + let findProjectsBySlugSpy: ReturnType; + + beforeEach(() => { + findProjectsBySlugSpy = spyOn(apiClient, "findProjectsBySlug"); + }); + + afterEach(() => { + findProjectsBySlugSpy.mockRestore(); + }); + + test("throws ContextError when no project found", async () => { + findProjectsBySlugSpy.mockResolvedValue([]); + const config = makeConfig(); + const { writer } = createStdout(); + + await expect( + handleProjectSearch(config, writer, "no-such-project", { + limit: 10, + json: false, + }) + ).rejects.toThrow(ContextError); + }); + + test("returns empty JSON array when no project found with --json", async () => { + findProjectsBySlugSpy.mockResolvedValue([]); + const config = makeConfig(); + const { writer, write } = createStdout(); + + await handleProjectSearch(config, writer, "no-such-project", { + limit: 10, + json: true, + }); + + const output = write.mock.calls.map((c) => c[0]).join(""); + expect(JSON.parse(output)).toEqual([]); + }); + + test("with listForProject: fetches project-scoped entities", async () => { + findProjectsBySlugSpy.mockResolvedValue([ + { orgSlug: "org-a", slug: "my-proj", id: "1", name: "My Project" }, + ]); + const listForProject = mock(() => + Promise.resolve([{ id: "1", name: "Team A" }]) + ); + const config = makeConfig({ listForProject }); + const { writer, write } = createStdout(); + + await handleProjectSearch(config, writer, "my-proj", { + limit: 10, + json: true, + }); + + expect(listForProject).toHaveBeenCalledWith("org-a", "my-proj"); + const output = write.mock.calls.map((c) => c[0]).join(""); + const parsed = JSON.parse(output); + expect(parsed[0].orgSlug).toBe("org-a"); + }); + + test("without listForProject: fetches from parent org (entity is org-scoped)", async () => { + findProjectsBySlugSpy.mockResolvedValue([ + { orgSlug: "org-a", slug: "my-proj", id: "1", name: "My Project" }, + ]); + const listForOrg = mock(() => + Promise.resolve([{ id: "1", name: "Repo A" }]) + ); + const config = makeConfig({ listForOrg }); + const { writer, write } = createStdout(); + + await handleProjectSearch(config, writer, "my-proj", { + limit: 10, + json: true, + }); + + expect(listForOrg).toHaveBeenCalledWith("org-a"); + const output = write.mock.calls.map((c) => c[0]).join(""); + const parsed = JSON.parse(output); + expect(parsed[0].orgSlug).toBe("org-a"); + }); + + test("deduplicates orgs when multiple projects share one org", async () => { + findProjectsBySlugSpy.mockResolvedValue([ + { orgSlug: "org-a", slug: "proj-1", id: "1", name: "Proj 1" }, + { orgSlug: "org-a", slug: "proj-2", id: "2", name: "Proj 2" }, + ]); + const listForOrg = mock(() => + Promise.resolve([{ id: "1", name: "Repo A" }]) + ); + const config = makeConfig({ listForOrg }); + const { writer } = createStdout(); + + await handleProjectSearch(config, writer, "proj", { + limit: 10, + json: true, + }); + + // org-a should only be fetched once + expect(listForOrg).toHaveBeenCalledTimes(1); + }); + + test("shows multi-org note when project found in multiple orgs", async () => { + findProjectsBySlugSpy.mockResolvedValue([ + { orgSlug: "org-a", slug: "my-proj", id: "1", name: "My Project" }, + { orgSlug: "org-b", slug: "my-proj", id: "2", name: "My Project" }, + ]); + const config = makeConfig({ + listForOrg: mock(() => Promise.resolve([{ id: "1", name: "Widget" }])), + }); + const { writer, write } = createStdout(); + + await handleProjectSearch(config, writer, "my-proj", { + limit: 10, + json: false, + }); + + const output = write.mock.calls.map((c) => c[0]).join(""); + expect(output).toContain("2 organizations"); + }); +}); + +// --------------------------------------------------------------------------- +// dispatchOrgScopedList — cursor validation and handler map pattern // --------------------------------------------------------------------------- describe("dispatchOrgScopedList", () => { @@ -373,7 +671,26 @@ describe("dispatchOrgScopedList", () => { ).rejects.toThrow(ValidationError); }); - test("delegates to handleOrgAll for org-all parsed type", async () => { + test("error message includes entity plural name", async () => { + const config = makeConfig(); + const { writer } = createStdout(); + + try { + await dispatchOrgScopedList({ + config, + stdout: writer, + cwd: "/tmp", + flags: { limit: 10, json: false, cursor: "x" }, + parsed: { type: "auto-detect" }, + }); + expect.unreachable("should have thrown"); + } catch (e) { + expect(e).toBeInstanceOf(ValidationError); + expect((e as ValidationError).message).toContain("widgets"); + } + }); + + test("delegates to handleOrgAll for org-all mode", async () => { const items: FakeEntity[] = [{ id: "1", name: "A" }]; const config = makeConfig({ listPaginated: mock(() => @@ -396,11 +713,11 @@ describe("dispatchOrgScopedList", () => { expect(parsed.data).toHaveLength(1); }); - test("delegates to handleExplicitOrg for explicit parsed type", async () => { - const items: FakeEntity[] = [{ id: "1", name: "A" }]; - const config = makeConfig({ - listForOrg: mock(() => Promise.resolve(items)), - }); + test("explicit mode uses listForProject when available", async () => { + const listForProject = mock(() => + Promise.resolve([{ id: "1", name: "T" }]) + ); + const config = makeConfig({ listForProject }); const { writer, write } = createStdout(); await dispatchOrgScopedList({ @@ -408,51 +725,114 @@ describe("dispatchOrgScopedList", () => { stdout: writer, cwd: "/tmp", flags: { limit: 10, json: true }, - parsed: { type: "explicit", org: "my-org", project: "proj" }, + parsed: { type: "explicit", org: "my-org", project: "my-proj" }, }); + expect(listForProject).toHaveBeenCalledWith("my-org", "my-proj"); const output = write.mock.calls.map((c) => c[0]).join(""); - const parsed = JSON.parse(output); - expect(Array.isArray(parsed)).toBe(true); - expect(parsed).toHaveLength(1); + expect(Array.isArray(JSON.parse(output))).toBe(true); + }); + + test("explicit mode falls back to org-scoped with note when no listForProject", async () => { + const listForOrg = mock(() => Promise.resolve([{ id: "1", name: "R" }])); + const config = makeConfig({ listForOrg }); // no listForProject + const { writer, write } = createStdout(); + + await dispatchOrgScopedList({ + config, + stdout: writer, + cwd: "/tmp", + flags: { limit: 10, json: false }, + parsed: { type: "explicit", org: "my-org", project: "my-proj" }, + }); + + expect(listForOrg).toHaveBeenCalledWith("my-org"); + const output = write.mock.calls.map((c) => c[0]).join(""); + expect(output).toContain("org-scoped"); + }); + + test("override replaces default handler for that mode", async () => { + const config = makeConfig(); + const { writer } = createStdout(); + const overrideCalled = mock(() => Promise.resolve()); + + await dispatchOrgScopedList({ + config, + stdout: writer, + cwd: "/tmp", + flags: { limit: 10, json: false }, + parsed: { type: "auto-detect" }, + overrides: { + "auto-detect": overrideCalled, + }, + }); + + expect(overrideCalled).toHaveBeenCalledTimes(1); }); - test("delegates to handleExplicitOrg for project-search parsed type", async () => { + test("override does not affect other modes", async () => { const items: FakeEntity[] = [{ id: "1", name: "A" }]; const config = makeConfig({ - listForOrg: mock(() => Promise.resolve(items)), + listPaginated: mock(() => + Promise.resolve({ data: items, nextCursor: undefined }) + ), }); const { writer, write } = createStdout(); + const autoDetectOverride = mock(() => Promise.resolve()); await dispatchOrgScopedList({ config, stdout: writer, cwd: "/tmp", flags: { limit: 10, json: true }, - parsed: { type: "project-search", projectSlug: "my-proj" }, + parsed: { type: "org-all", org: "my-org" }, + overrides: { + "auto-detect": autoDetectOverride, // overrides auto-detect, not org-all + }, }); + // org-all default handler ran, not the auto-detect override + expect(autoDetectOverride).not.toHaveBeenCalled(); const output = write.mock.calls.map((c) => c[0]).join(""); - const parsed = JSON.parse(output); - expect(Array.isArray(parsed)).toBe(true); + expect(JSON.parse(output).hasMore).toBe(false); }); - test("error message includes entity name", async () => { - const config = makeConfig(); + test("metadata-only config with full overrides dispatches correctly", async () => { const { writer } = createStdout(); + const handler = mock(() => Promise.resolve()); - try { - await dispatchOrgScopedList({ - config, + await dispatchOrgScopedList({ + config: META_ONLY, + stdout: writer, + cwd: "/tmp", + flags: { limit: 10, json: false }, + parsed: { type: "explicit", org: "my-org", project: "my-proj" }, + overrides: { + "auto-detect": handler, + explicit: handler, + "project-search": handler, + "org-all": handler, + }, + }); + + expect(handler).toHaveBeenCalledTimes(1); + }); + + test("metadata-only config without override for invoked mode throws", async () => { + const { writer } = createStdout(); + + await expect( + dispatchOrgScopedList({ + config: META_ONLY, stdout: writer, cwd: "/tmp", - flags: { limit: 10, json: false, cursor: "x" }, + flags: { limit: 10, json: false }, parsed: { type: "auto-detect" }, - }); - expect.unreachable("should have thrown"); - } catch (e) { - expect(e).toBeInstanceOf(ValidationError); - expect((e as ValidationError).message).toContain("widgets"); - } + overrides: { + // missing auto-detect override — should throw + explicit: mock(() => Promise.resolve()), + }, + }) + ).rejects.toThrow("No handler for 'auto-detect' mode"); }); }); From 4ac5c55766132fb79733cfd370f51c0184ccdd01 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Wed, 18 Feb 2026 23:29:22 +0000 Subject: [PATCH 12/22] fix: address bot review comments - handleOrgAll: apply withOrg mapping so ORG column is populated in paginated output - handleOrgAllIssues: use isMultiProject=true in header (already true in row formatOptions) - displayFetchedItems: add orgSlugForHint field so truncation hint emits valid CLI command - project/list: move resolveCursor inside org-all override closure so ValidationError fires before ContextError - list-command.ts: clean up leftover syntax fragment from reverted bare-slug change - Update PR description: bare slug = project search (consistent across all list commands) --- src/commands/issue/list.ts | 5 +++-- src/commands/project/list.ts | 18 ++++++++---------- src/lib/org-list.ts | 24 +++++++++++++++++++----- 3 files changed, 30 insertions(+), 17 deletions(-) diff --git a/src/commands/issue/list.ts b/src/commands/issue/list.ts index 799b38280c..70a9dbbc62 100644 --- a/src/commands/issue/list.ts +++ b/src/commands/issue/list.ts @@ -446,9 +446,10 @@ async function handleOrgAllIssues(options: OrgAllIssuesOptions): Promise { return; } - writeListHeader(stdout, `Issues in ${org}`, false); + // 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; - // isMultiProject=true so the ALIAS column shows which project each issue belongs to const issuesWithOpts = response.data.map((issue) => ({ issue, formatOptions: { diff --git a/src/commands/project/list.ts b/src/commands/project/list.ts index f9b3e1ffcc..78e34a2498 100644 --- a/src/commands/project/list.ts +++ b/src/commands/project/list.ts @@ -664,8 +664,6 @@ export const listCommand = buildCommand({ const { stdout, cwd } = this; const parsed = parseOrgProjectArg(target); - const contextKey = buildContextKey(parsed, flags, getApiBaseUrl()); - const cursor = resolveCursor(flags.cursor, contextKey); await dispatchOrgScopedList({ config: projectListMeta, @@ -682,14 +680,14 @@ export const listCommand = buildCommand({ parsed.type === "explicit" ? parsed.project : "", flags ), - "org-all": () => - handleOrgAll({ - stdout, - org: parsed.type === "org-all" ? parsed.org : "", - flags, - contextKey, - cursor, - }), + "org-all": () => { + // Build context key and resolve cursor only in org-all mode, after + // dispatchOrgScopedList has already validated --cursor is allowed here. + const org = parsed.type === "org-all" ? parsed.org : ""; + const contextKey = buildContextKey(parsed, flags, getApiBaseUrl()); + const cursor = resolveCursor(flags.cursor, contextKey); + return handleOrgAll({ stdout, org, flags, contextKey, cursor }); + }, "project-search": () => handleProjectSearch( stdout, diff --git a/src/lib/org-list.ts b/src/lib/org-list.ts index 5193a7737a..23afe87093 100644 --- a/src/lib/org-list.ts +++ b/src/lib/org-list.ts @@ -225,7 +225,9 @@ export async function handleOrgAll( perPage: flags.limit, }); - const { data: items, nextCursor } = response; + 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) { @@ -255,7 +257,7 @@ export async function handleOrgAll( return; } - config.displayTable(stdout, items as unknown as TWithOrg[]); + config.displayTable(stdout, items); if (hasMore) { stdout.write( @@ -341,7 +343,15 @@ type DisplayFetchedItemsOptions = { stdout: Writer; items: TWithOrg[]; flags: BaseListFlags; + /** Human-readable context for "No X found in