diff --git a/AGENTS.md b/AGENTS.md index 832cd2e4..4090cb9f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -317,7 +317,27 @@ else clearPaginationCursor(PAGINATION_KEY, contextKey); Show `-c last` in the hint footer when more pages are available. Include `nextCursor` in the JSON envelope. -Reference template: `trace/list.ts`, `span/list.ts` +**Auto-pagination for large limits:** + +When `--limit` exceeds `API_MAX_PER_PAGE` (100), list commands MUST transparently +fetch multiple pages to fill the requested limit. Cap `perPage` at +`Math.min(flags.limit, API_MAX_PER_PAGE)` and loop until `results.length >= limit` +or pages are exhausted. This matches the `listIssuesAllPages` pattern. + +```typescript +const perPage = Math.min(flags.limit, API_MAX_PER_PAGE); +for (let page = 0; page < MAX_PAGINATION_PAGES; page++) { + const { data, nextCursor } = await listPaginated(org, { perPage, cursor }); + results.push(...data); + if (results.length >= flags.limit || !nextCursor) break; + cursor = nextCursor; +} +``` + +Never pass a `per_page` value larger than `API_MAX_PER_PAGE` to the API — the +server silently caps it, causing the command to return fewer items than requested. + +Reference template: `trace/list.ts`, `span/list.ts`, `dashboard/list.ts` ### ID Validation @@ -433,6 +453,14 @@ throw new ResolutionError("Project 'cli'", "not found", "sentry issue list throw new ValidationError("Invalid trace ID format", "traceId"); ``` +**Fuzzy suggestions in resolution errors:** + +When a user-provided name/title doesn't match any entity, use `fuzzyMatch()` from +`src/lib/fuzzy.ts` to suggest similar candidates instead of listing all entities +(which can be overwhelming). Show at most 5 fuzzy matches. + +Reference: `resolveDashboardId()` in `src/commands/dashboard/resolve.ts`. + ### Auto-Recovery for Wrong Entity Types When a user provides the wrong type of identifier (e.g., an issue short ID @@ -812,8 +840,91 @@ mock.module("./some-module", () => ({ ## Long-term Knowledge +### Architecture + + +* **API client wraps all errors as CliError subclasses — no raw exceptions escape**: The API client (src/lib/api-client.ts) wraps ALL errors as CliError subclasses (ApiError or AuthError) — no raw exceptions escape. Commands don't need try-catch for error display; the central handler in app.ts formats CliError cleanly. Only add try-catch when a command needs to handle errors specially (e.g., login continuing despite user-info fetch failure). + + +* **Completion fast-path skips Sentry SDK via SENTRY\_CLI\_NO\_TELEMETRY and SQLite telemetry queue**: Shell completions (\`\_\_complete\`) set \`SENTRY\_CLI\_NO\_TELEMETRY=1\` in \`bin.ts\` before any imports, which causes \`db/index.ts\` to skip the \`createTracedDatabase\` wrapper (lazy \`require\` of telemetry.ts). This avoids loading \`@sentry/node-core/light\` (~85ms). Completion timing is recorded to \`completion\_telemetry\_queue\` SQLite table via \`queueCompletionTelemetry()\` (~1ms overhead). During normal CLI runs, \`withTelemetry()\` calls \`drainCompletionTelemetry()\` which uses \`DELETE FROM ... RETURNING\` for atomic read+delete, then emits each entry as \`Sentry.metrics.distribution("completion.duration\_ms", ...)\`. Schema version 11 added this table. The fast-path achieves ~60ms dev / ~140ms CI, with a 200ms e2e test budget. + + +* **Sentry API: events require org+project, issues have legacy global endpoint**: Sentry API scoping: Events require org+project in URL path (\`/projects/{org}/{project}/events/{id}/\`). Issues use legacy global endpoint (\`/api/0/issues/{id}/\`) without org context. Traces need only org (\`/organizations/{org}/trace/{traceId}/\`). Two-step lookup for events: fetch issue → extract org/project from response → fetch event. Cross-project event search possible via Discover endpoint \`/organizations/{org}/events/\` with \`query=id:{eventId}\`. + + +* **Sentry CLI authenticated fetch architecture with response caching**: \`createAuthenticatedFetch()\` wraps fetch with auth, 30s timeout, retry (max 2), 401 refresh, and span tracing. Response caching integrates BEFORE auth/retry via \`http-cache-semantics\` (RFC 7234) with filesystem storage at \`~/.sentry/cache/responses/\`. URL-based fallback TTL tiers: immutable (24hr), stable (5min), volatile (60s), no-cache (0). Only GET 2xx cached. \`--fresh\` and \`SENTRY\_NO\_CACHE=1\` bypass cache. Cache cleared on login/logout. \`hasServerCacheDirectives(policy)\` distinguishes \`max-age=0\` from missing headers. + + +* **Sentry CLI has two distribution channels with different runtimes**: Sentry CLI ships two ways: (1) Standalone binary via \`Bun.build()\` with \`compile: true\`. (2) npm package via esbuild producing CJS \`dist/bin.cjs\` for Node 22+, with Bun API polyfills from \`script/node-polyfills.ts\`. \`Bun.$\` has NO polyfill — use \`execSync\` instead. \`require()\` in ESM is safe (Bun native, esbuild resolves at bundle time). As of PR #474, SDK is \`@sentry/node-core/light\` (not \`@sentry/bun\`), reducing import cost from ~218ms to ~85ms. + + +* **Sentry CLI resolve-target cascade has 5 priority levels with env var support**: Resolve-target cascade (src/lib/resolve-target.ts) has 5 priority levels: (1) Explicit CLI flags, (2) SENTRY\_ORG/SENTRY\_PROJECT env vars, (3) SQLite config defaults, (4) DSN auto-detection, (5) Directory name inference. SENTRY\_PROJECT supports combo notation \`org/project\` — when used, SENTRY\_ORG is ignored. If combo parse fails (e.g. \`org/\`), the entire value is discarded. The \`resolveFromEnvVars()\` helper is injected into all four resolution functions. + +### Decision + + +* **Issue list global limit with fair per-project distribution and representation guarantees**: \`issue list --limit\` is a global total across all detected projects. \`fetchWithBudget\` Phase 1 divides evenly, Phase 2 redistributes surplus via cursor resume. \`trimWithProjectGuarantee\` ensures at least 1 issue per project before filling remaining slots. JSON output wraps in \`{ data, hasMore }\` with optional \`errors\` array. Compound cursor (pipe-separated) enables \`-c last\` for multi-target pagination, keyed by sorted target fingerprint. + + +* **Sentry CLI config dir should stay at ~/.sentry/, not move to XDG**: Config dir stays at \`~/.sentry/\` (not XDG). The readonly DB errors on macOS are from \`sudo brew install\` creating root-owned files. Fixes: (1) bestEffort() makes setup steps non-fatal, (2) tryRepairReadonly() detects root-owned files and prints \`sudo chown\` instructions, (3) \`sentry cli fix\` handles ownership repair. Ownership must be checked BEFORE permissions — root-owned files cause chmod to EPERM. + +### Gotcha + + +* **Biome noExcessiveCognitiveComplexity max 15 requires extracting helpers from command handlers**: Biome enforces \`noExcessiveCognitiveComplexity\` with max 15. Stricli \`func()\` handlers that contain fetch loops, pagination, conditional logic, and output formatting easily exceed this. Fix: extract inner loops and multi-branch logic into standalone helper functions (e.g., \`fetchDashboards()\` separate from the command's \`func()\`). This also improves testability per the existing pattern of extracting logic from \`func()\` handlers. When a function still exceeds 15 after one extraction, split further — e.g., extract page-processing logic from the fetch loop into its own function. + + +* **Biome prefers .at(-1) over arr\[arr.length - 1] indexing**: Biome's \`useAtIndex\` rule flags \`arr\[arr.length - 1]\` patterns. Use \`arr.at(-1)\` instead. This applies throughout the codebase — the lint check will fail CI otherwise. + + +* **Biome requires block statements — no single-line if/break/return**: Biome's \`useBlockStatements\` rule rejects braceless control flow like \`if (match) return match.id;\` or \`if (!nextCursor) break;\`. Always wrap in braces: \`if (match) { return match.id; }\`. Similarly, nested ternaries are banned (\`noNestedTernary\`) — use if/else chains or extract into a variable with sequential assignments. + + +* **brew is not in VALID\_METHODS but Homebrew formula passes --method brew**: Homebrew install: \`isHomebrewInstall()\` detects via Cellar realpath (checked before stored install info). Upgrade command tells users \`brew upgrade getsentry/tools/sentry\`. Formula runs \`sentry cli setup --method brew --no-modify-path\` as post\_install. Version pinning throws 'unsupported\_operation'. Uses .gz artifacts. Tap at getsentry/tools. + + +* **Bun mock.module() leaks globally across test files in same process**: Bun's mock.module() replaces modules globally and leaks across test files in the same process. Solution: tests using mock.module() must run in a separate \`bun test\` invocation. In package.json, use \`bun run test:unit && bun run test:isolated\` instead of \`bun test\`. The \`test/isolated/\` directory exists for these tests. This was the root cause of ~100 test failures (getsentry/cli#258). + + +* **Making clearAuth() async breaks model-based tests — use non-async Promise\ return instead**: Making \`clearAuth()\` \`async\` breaks fast-check model-based tests — real async yields (macrotasks) during \`asyncModelRun\` cause \`createIsolatedDbContext\` cleanup to interleave. Fix: keep non-async, return \`clearResponseCache().catch(...)\` directly. Model-based tests should NOT await it. Also: model-based tests need explicit timeouts (e.g., \`30\_000\`) — Bun's default 5s causes false failures during shrinking. + + +* **Multiregion mock must include all control silo API routes**: When changing which Sentry API endpoint a function uses, mock routes must be updated in BOTH \`test/mocks/routes.ts\` (single-region) AND \`test/mocks/multiregion.ts\` \`createControlSiloRoutes()\`. Missing the multiregion mock causes 404s in multi-region test scenarios. + + +* **Sentry /users/me/ endpoint returns 403 for OAuth tokens — use /auth/ instead**: The Sentry \`/users/me/\` endpoint returns 403 for OAuth tokens. Use \`/auth/\` instead — it works with ALL token types and lives on the control silo. In the CLI, \`getControlSiloUrl()\` handles routing correctly. \`SentryUserSchema\` (with \`.passthrough()\`) handles the \`/auth/\` response since it only requires \`id\`. + + +* **Sentry chunk upload API returns camelCase not snake\_case**: The Sentry chunk upload options endpoint (\`/api/0/organizations/{org}/chunk-upload/\`) returns camelCase keys (\`chunkSize\`, \`chunksPerRequest\`, \`maxRequestSize\`, \`hashAlgorithm\`), NOT snake\_case. Zod schemas for these responses should use camelCase field names. This is an exception to the typical Sentry API convention of snake\_case. Verified by direct API query. The \`AssembleResponse\` also uses camelCase (\`missingChunks\`). + + +* **Stricli command context uses this.stdout not this.process.stdout**: In Stricli command \`func()\` handlers, use \`this.stdout\` and \`this.stderr\` directly — NOT \`this.process.stdout\`. The \`SentryContext\` interface has both \`process\` and \`stdout\`/\`stderr\` as separate top-level properties. Test mock contexts typically provide \`stdout\` but not a full \`process\` object, so \`this.process.stdout\` causes \`TypeError: undefined is not an object\` at runtime in tests even though TypeScript doesn't flag it. + + +* **Stricli defaultCommand blends default command flags into route completions**: When a Stricli route map has \`defaultCommand\` set, requesting completions for that route (e.g. \`\["issues", ""]\`) returns both the subcommand names AND the default command's flags/positional completions. This means completion tests that compare against \`extractCommandTree()\` subcommand lists will fail for groups with defaultCommand, since the actual completions include extra entries like \`--limit\`, \`--query\`, etc. Solution: track \`hasDefaultCommand\` in the command tree and skip strict subcommand-matching assertions for those groups. + + +* **Upgrade command tests are flaky when run in full suite due to test ordering**: The \`upgradeCommand.func\` tests in \`test/commands/cli/upgrade.test.ts\` sometimes fail when run as part of the full \`bun run test:unit\` suite but pass consistently in isolation (\`bun test test/commands/cli/upgrade.test.ts\`). This is a test-ordering/state-leak issue, not a code bug. Don't chase these failures when your changes are unrelated to the upgrade command. + ### Pattern - -* **Target argument 4-mode parsing convention (project-search-first)**: \`parseOrgProjectArg()\` in \`src/lib/arg-parsing.ts\` returns a 4-mode discriminated union: \`auto-detect\` (empty), \`explicit\` (\`org/project\`), \`org-all\` (\`org/\` trailing slash), \`project-search\` (bare slug). Bare slugs are ALWAYS \`project-search\` first. The "is this an org?" check is secondary: list commands with \`orgSlugMatchBehavior\` pre-check cached orgs (\`redirect\` or \`error\` mode), and \`handleProjectSearch()\` has a safety net checking orgs after project search fails. Non-list commands (init, view) treat bare slugs purely as project search with no org pre-check. For \`init\`, unmatched bare slugs become new project names. Key files: \`src/lib/arg-parsing.ts\` (parsing), \`src/lib/org-list.ts\` (dispatch + org pre-check), \`src/lib/resolve-target.ts\` (resolution cascade). + +* **Extract logic from Stricli func() handlers into standalone functions for testability**: Stricli command \`func()\` handlers are hard to unit test because they require full command context setup. To boost coverage, extract flag validation and body-building logic into standalone exported functions (e.g., \`resolveBody()\` extracted from the \`api\` command's \`func()\`). This moved ~20 lines of mutual-exclusivity checks and flag routing from an untestable handler into a directly testable pure function. Property-based tests on the extracted function drove patch coverage from 78% to 97%. The general pattern: keep \`func()\` as a thin orchestrator that calls exported helpers. This also keeps biome complexity under the limit (max 15). + + +* **Non-essential DB cache writes should be guarded with try-catch**: Non-essential DB cache writes (e.g., \`setUserInfo()\` in whoami.ts and login.ts) must be wrapped in try-catch. If the DB is broken, the cache write shouldn't crash the command when its primary operation already succeeded. In login.ts specifically, \`getCurrentUser()\` failure after token save must not block authentication — wrap in try-catch, log warning to stderr, let login succeed. This differs from \`getUserRegions()\` failure which should \`clearAuth()\` and fail hard (indicates invalid token). + + +* **Stricli buildCommand output config injects json flag into func params**: When a Stricli command uses \`output: { json: true, human: formatFn }\`, the framework injects \`--json\` and \`--fields\` flags automatically. The \`func\` handler receives these as its first parameter. Type it explicitly (e.g., \`flags: { json?: boolean }\`) rather than \`\_flags: unknown\` to access the json flag for conditional behavior (e.g., skipping interactive output in JSON mode). The \`human\` formatter runs on the returned \`data\` for non-JSON output. Commands that produce interactive side effects (browser prompts, QR codes) should check \`flags.json\` and skip them when true. + + +* **ZipWriter and file handle cleanup: always use try/finally with close()**: ZipWriter in \`src/lib/sourcemap/zip.ts\` has a \`close()\` method for error cleanup and \`finalize()\` uses try/finally to ensure \`fh.close()\` runs even if central directory writes fail. Callers (like \`buildArtifactBundle\`) must wrap ZipWriter usage in try/catch and call \`zip.close()\` in the catch path. The \`finalize()\` method handles its own cleanup internally. Pattern: create zip → try { add entries + finalize } catch { zip.close(); throw }. This prevents file handle leaks when entry writes or finalization fail partway through. + +### Preference + + +* **Code style: Array.from() over spread for iterators, allowlist not whitelist**: User prefers \`Array.from(map.keys())\` over \`\[...map.keys()]\` for converting iterators to arrays (avoids intermediate spread). Use "allowlist" terminology instead of "whitelist" in comments and variable names. When a reviewer asks "Why not .filter() here?" — it may be a question, not a change request; the \`for..of\` loop may be intentionally more efficient. Confirm intent before changing. + + +* **PR workflow: address review comments, resolve threads, wait for CI**: User's PR workflow after creation: (1) Wait for CI checks to pass, (2) Check for unresolved review comments via \`gh api\` for PR review comments, (3) Fix issues in follow-up commits (not amends), (4) Reply to the comment thread explaining the fix, (5) Resolve the thread programmatically via \`gh api graphql\` with \`resolveReviewThread\` mutation, (6) Push and wait for CI again, (7) Final sweep for any remaining unresolved comments. Use \`git notes add\` to attach implementation plans to commits. Branch naming: \`fix/descriptive-slug\` or \`feat/descriptive-slug\`. diff --git a/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index 5139f79f..ab07e7ec 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -269,7 +269,7 @@ CLI-related commands Manage Sentry dashboards -- `sentry dashboard list ` — List dashboards +- `sentry dashboard list ` — List dashboards - `sentry dashboard view ` — View a dashboard - `sentry dashboard create ` — Create a dashboard - `sentry dashboard widget add ` — Add a widget to a dashboard diff --git a/plugins/sentry-cli/skills/sentry-cli/references/dashboards.md b/plugins/sentry-cli/skills/sentry-cli/references/dashboards.md index 5ba0a214..b8eb9feb 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/dashboards.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/dashboards.md @@ -11,7 +11,7 @@ requires: Manage Sentry dashboards -### `sentry dashboard list ` +### `sentry dashboard list ` List dashboards @@ -19,6 +19,7 @@ List dashboards - `-w, --web - Open in browser` - `-n, --limit - Maximum number of dashboards to list - (default: "30")` - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` +- `-c, --cursor - Pagination cursor (use "last" to continue from previous page)` **Examples:** diff --git a/src/commands/dashboard/list.ts b/src/commands/dashboard/list.ts index c30e96a1..509885a1 100644 --- a/src/commands/dashboard/list.ts +++ b/src/commands/dashboard/list.ts @@ -1,22 +1,32 @@ /** * sentry dashboard list * - * List dashboards in a Sentry organization. + * List dashboards in a Sentry organization with cursor-based pagination + * and optional client-side glob filtering by title. */ import type { SentryContext } from "../../context.js"; -import { listDashboards } from "../../lib/api-client.js"; +import { MAX_PAGINATION_PAGES } from "../../lib/api/infrastructure.js"; +import { + API_MAX_PER_PAGE, + listDashboardsPaginated, +} from "../../lib/api-client.js"; import { parseOrgProjectArg } from "../../lib/arg-parsing.js"; import { openInBrowser } from "../../lib/browser.js"; -import { buildCommand } from "../../lib/command.js"; +import { + buildPaginationContextKey, + clearPaginationCursor, + resolveOrgCursor, + setPaginationCursor, +} from "../../lib/db/pagination.js"; +import { filterFields } from "../../lib/formatters/json.js"; import { colorTag, escapeMarkdownCell } from "../../lib/formatters/markdown.js"; import { CommandOutput } from "../../lib/formatters/output.js"; import { type Column, writeTable } from "../../lib/formatters/table.js"; +import { fuzzyMatch } from "../../lib/fuzzy.js"; import { - applyFreshFlag, + buildListCommand, buildListLimitFlag, - FRESH_ALIASES, - FRESH_FLAG, } from "../../lib/list-command.js"; import { withProgress } from "../../lib/polling.js"; import { @@ -25,12 +35,16 @@ import { } from "../../lib/sentry-urls.js"; import type { DashboardListItem } from "../../types/dashboard.js"; import type { Writer } from "../../types/index.js"; -import { resolveOrgFromTarget } from "./resolve.js"; +import { parseDashboardListArgs, resolveOrgFromTarget } from "./resolve.js"; + +/** Command key for pagination cursor storage */ +export const PAGINATION_KEY = "dashboard-list"; type ListFlags = { readonly web: boolean; readonly fresh: boolean; readonly limit: number; + readonly cursor?: string; readonly json: boolean; readonly fields?: string[]; }; @@ -38,8 +52,64 @@ type ListFlags = { type DashboardListResult = { dashboards: DashboardListItem[]; orgSlug: string; + hasMore: boolean; + nextCursor?: string; + /** The title filter used (for empty-state messaging) */ + titleFilter?: string; + /** All titles seen during fetch (for fuzzy suggestions on empty filter results) */ + allTitles?: string[]; }; +// Extended cursor encoding + +/** + * Encode a cursor with an optional dashboard-ID bookmark for mid-page resume. + * + * When client-side filtering fills `--limit` partway through a server page, + * we store the ID of the last processed dashboard so we can resume from that + * exact position on the next `--cursor last` invocation. + * + * @param serverCursor - The server cursor used to fetch the current page (undefined for page 1) + * @param afterId - Dashboard ID of the last processed item (omit for page-boundary cursors) + * @returns Encoded cursor string, or undefined if no server cursor and no afterId + */ +export function encodeCursor( + serverCursor: string | undefined, + afterId?: string +): string | undefined { + if (afterId) { + return `${serverCursor ?? ""}|${afterId}`; + } + return serverCursor; +} + +/** + * Decode an extended cursor into a server cursor and an optional resume-after ID. + * + * Extended format: `serverCursor|afterId` where `afterId` is a dashboard ID. + * Plain server cursors (no `|`) pass through unchanged. + * + * @param cursor - Raw cursor string from storage + * @returns Server cursor for the API and optional dashboard ID to skip past + */ +export function decodeCursor(cursor: string): { + serverCursor: string | undefined; + afterId: string | undefined; +} { + const pipeIdx = cursor.lastIndexOf("|"); + if (pipeIdx === -1) { + return { serverCursor: cursor || undefined, afterId: undefined }; + } + const afterId = cursor.slice(pipeIdx + 1); + const serverPart = cursor.slice(0, pipeIdx); + return { + serverCursor: serverPart || undefined, + afterId: afterId || undefined, + }; +} + +// Human output + /** * Format dashboard list for human-readable terminal output. * @@ -48,6 +118,22 @@ type DashboardListResult = { */ function formatDashboardListHuman(result: DashboardListResult): string { if (result.dashboards.length === 0) { + if (result.titleFilter && result.allTitles && result.allTitles.length > 0) { + // Strip glob metacharacters before fuzzy matching so '*', '?', '[' + // don't inflate Levenshtein distances (e.g. "Error*" → "Error"). + const stripped = result.titleFilter.replace(/[*?[\]]/g, ""); + const similar = fuzzyMatch( + stripped || result.titleFilter, + result.allTitles, + { + maxResults: 5, + } + ); + if (similar.length > 0) { + return `No dashboards matching '${result.titleFilter}'. Did you mean:\n${similar.map((t) => ` • ${t}`).join("\n")}`; + } + return `No dashboards matching '${result.titleFilter}'.`; + } return "No dashboards found."; } @@ -79,33 +165,198 @@ function formatDashboardListHuman(result: DashboardListResult): string { return parts.join("").trimEnd(); } -export const listCommand = buildCommand({ +// JSON transform + +/** + * Transform dashboard list result for JSON output. + * + * Produces the standard `{ data, hasMore, nextCursor? }` envelope. + * Field filtering is applied per-element inside `data`. + */ +function jsonTransformDashboardList( + result: DashboardListResult, + fields?: string[] +): unknown { + const items = + fields && fields.length > 0 + ? result.dashboards.map((d) => filterFields(d, fields)) + : result.dashboards; + + const envelope: Record = { + data: items, + hasMore: result.hasMore, + }; + if (result.nextCursor) { + envelope.nextCursor = result.nextCursor; + } + return envelope; +} + +// Fetch with pagination + optional client-side glob filter + +/** Result of the paginated fetch loop */ +type FetchResult = { + dashboards: DashboardListItem[]; + cursorToStore: string | undefined; + /** All dashboard titles seen during fetch (for fuzzy suggestions when filter matches nothing) */ + allTitles: string[]; +}; + +/** Result of processing a single page of dashboards */ +type PageResult = { + /** Whether the limit was reached on this page */ + filled: boolean; + /** Cursor bookmark for mid-page resume, if applicable */ + bookmark: string | undefined; +}; + +/** + * Process a single page of dashboard data, applying skip logic and + * optional glob filtering. Pushes matching items into `results`. + * + * @param data - Raw page items from the API + * @param results - Accumulator for matching dashboards + * @param opts - Processing options + * @returns Whether the limit was reached and the bookmark cursor if so + */ +function processPage( + data: DashboardListItem[], + results: DashboardListItem[], + opts: { + limit: number; + serverCursor: string | undefined; + afterId: string | undefined; + glob: InstanceType | undefined; + } +): PageResult { + // When resuming mid-page, find the afterId and skip everything up to and + // including it. If the afterId was deleted between requests, fall through + // and process the entire page from the start (no results lost). + let startIdx = 0; + if (opts.afterId) { + const afterPos = data.findIndex((d) => d.id === opts.afterId); + if (afterPos !== -1) { + startIdx = afterPos + 1; + } + } + + for (let i = startIdx; i < data.length; i++) { + const item = data[i] as DashboardListItem; + if (!opts.glob || opts.glob.match(item.title.toLowerCase())) { + results.push(item); + if (results.length >= opts.limit) { + return { + filled: true, + bookmark: encodeCursor(opts.serverCursor, item.id), + }; + } + } + } + + return { filled: false, bookmark: undefined }; +} + +/** + * Fetch dashboards with cursor-based pagination, optionally filtering by + * a glob pattern on the title. Accumulates results up to `limit`, fetching + * multiple server pages as needed. + * + * When a glob filter fills `--limit` mid-page, the returned cursor encodes + * the dashboard ID of the last processed item so the next invocation can + * resume from that exact position. + * + * @param orgSlug - Organization slug + * @param opts - Fetch options + * @returns Fetched dashboards and the cursor to store for the next page + */ +async function fetchDashboards( + orgSlug: string, + opts: { + limit: number; + perPage: number; + serverCursor: string | undefined; + afterId: string | undefined; + glob: InstanceType | undefined; + } +): Promise { + let { serverCursor, afterId } = opts; + const results: DashboardListItem[] = []; + const allTitles: string[] = []; + let cursorToStore: string | undefined; + + for (let page = 0; page < MAX_PAGINATION_PAGES; page++) { + const { data, nextCursor } = await listDashboardsPaginated(orgSlug, { + perPage: opts.perPage, + cursor: serverCursor, + }); + + // Collect all titles for fuzzy suggestions when filtering matches nothing + if (opts.glob) { + for (const item of data) { + allTitles.push(item.title); + } + } + + const pageResult = processPage(data, results, { + limit: opts.limit, + serverCursor, + afterId, + glob: opts.glob, + }); + afterId = undefined; // only applies to first iteration + + if (pageResult.filled) { + cursorToStore = pageResult.bookmark; + // If the bookmark points to the last item on the page, the real + // resume point is the next server page (no mid-page bookmark needed) + if (cursorToStore === encodeCursor(serverCursor, data.at(-1)?.id)) { + cursorToStore = nextCursor; + } + break; + } + + if (!nextCursor) { + cursorToStore = undefined; + break; + } + serverCursor = nextCursor; + } + + return { dashboards: results, cursorToStore, allTitles }; +} + +// Command + +export const listCommand = buildListCommand("dashboard", { docs: { brief: "List dashboards", fullDescription: "List dashboards in a Sentry organization.\n\n" + + "The optional name argument supports glob patterns for filtering by title.\n" + + "Glob matching is case-insensitive. Quote patterns to prevent shell expansion.\n\n" + "Examples:\n" + - " sentry dashboard list\n" + - " sentry dashboard list my-org/\n" + - " sentry dashboard list --json\n" + + " sentry dashboard list # auto-detect org\n" + + " sentry dashboard list my-org/ # explicit org\n" + + " sentry dashboard list my-org/my-project # org from explicit project\n" + + " sentry dashboard list 'Error*' # filter by title glob\n" + + " sentry dashboard list my-org '*API*' # bare org + filter\n" + + " sentry dashboard list my-org/ '*API*' # org/ + filter\n" + + " sentry dashboard list -c last # next page\n" + + " sentry dashboard list --json # JSON with pagination envelope\n" + " sentry dashboard list --web", }, output: { human: formatDashboardListHuman, - jsonTransform: (result: DashboardListResult) => result.dashboards, + jsonTransform: jsonTransformDashboardList, }, parameters: { positional: { - kind: "tuple", - parameters: [ - { - placeholder: "org/project", - brief: - "/ (all projects), /, or (search)", - parse: String, - optional: true, - }, - ], + kind: "array", + parameter: { + placeholder: "org/title-filter", + brief: "[] []", + parse: String, + }, }, flags: { web: { @@ -114,15 +365,14 @@ export const listCommand = buildCommand({ default: false, }, limit: buildListLimitFlag("dashboards"), - fresh: FRESH_FLAG, }, - aliases: { ...FRESH_ALIASES, w: "web", n: "limit" }, + aliases: { w: "web", n: "limit" }, }, - async *func(this: SentryContext, flags: ListFlags, target?: string) { - applyFreshFlag(flags); + async *func(this: SentryContext, flags: ListFlags, ...args: string[]) { const { cwd } = this; - const parsed = parseOrgProjectArg(target); + const { targetArg, titleFilter } = parseDashboardListArgs(args); + const parsed = parseOrgProjectArg(targetArg); const orgSlug = await resolveOrgFromTarget( parsed, cwd, @@ -134,18 +384,77 @@ export const listCommand = buildCommand({ return; } - const dashboards = await withProgress( + // Resolve pagination cursor (handles "last" magic value) + // Lowercase the filter in the context key to match the case-insensitive + // glob matching — 'Error*' and 'error*' produce identical results. + const contextKey = buildPaginationContextKey("dashboard", orgSlug, { + ...(titleFilter && { q: titleFilter.toLowerCase() }), + }); + const rawCursor = resolveOrgCursor( + flags.cursor, + PAGINATION_KEY, + contextKey + ); + const { serverCursor, afterId } = decodeCursor(rawCursor ?? ""); + + const glob = titleFilter + ? new Bun.Glob(titleFilter.toLowerCase()) + : undefined; + + // When filtering, fetch max-size pages to minimize round trips. + // When not filtering, cap at the smaller of limit and API max. + const perPage = glob + ? API_MAX_PER_PAGE + : Math.min(flags.limit, API_MAX_PER_PAGE); + + const { + dashboards: results, + cursorToStore, + allTitles, + } = await withProgress( { - message: `Fetching dashboards (up to ${flags.limit})...`, + message: `Fetching dashboards${titleFilter ? ` matching '${titleFilter}'` : ""} (up to ${flags.limit})...`, json: flags.json, }, - () => listDashboards(orgSlug, { perPage: flags.limit }) + () => + fetchDashboards(orgSlug, { + limit: flags.limit, + perPage, + serverCursor, + afterId, + glob, + }) ); + + // Store or clear pagination cursor + if (cursorToStore) { + setPaginationCursor(PAGINATION_KEY, contextKey, cursorToStore); + } else { + clearPaginationCursor(PAGINATION_KEY, contextKey); + } + + const hasMore = !!cursorToStore; const url = buildDashboardsListUrl(orgSlug); - yield new CommandOutput({ dashboards, orgSlug } as DashboardListResult); - return { - hint: dashboards.length > 0 ? `Dashboards: ${url}` : undefined, - }; + yield new CommandOutput({ + dashboards: results, + orgSlug, + hasMore, + nextCursor: cursorToStore, + titleFilter, + allTitles, + } satisfies DashboardListResult); + + // Build footer hint + let hint: string | undefined; + if (results.length === 0) { + hint = undefined; + } else if (hasMore) { + const filterArg = titleFilter ? ` '${titleFilter}'` : ""; + hint = `Showing ${results.length} dashboard(s). Next page: sentry dashboard list ${orgSlug}/${filterArg} -c last\nDashboards: ${url}`; + } else { + hint = `Showing ${results.length} dashboard(s).\nDashboards: ${url}`; + } + return { hint }; }, }); diff --git a/src/commands/dashboard/resolve.ts b/src/commands/dashboard/resolve.ts index 58cfa3a1..851ae2aa 100644 --- a/src/commands/dashboard/resolve.ts +++ b/src/commands/dashboard/resolve.ts @@ -5,9 +5,14 @@ * ID resolution from numeric IDs or title strings. */ -import { listDashboards } from "../../lib/api-client.js"; +import { MAX_PAGINATION_PAGES } from "../../lib/api/infrastructure.js"; +import { + API_MAX_PER_PAGE, + listDashboardsPaginated, +} from "../../lib/api-client.js"; import type { parseOrgProjectArg } from "../../lib/arg-parsing.js"; import { ContextError, ValidationError } from "../../lib/errors.js"; +import { fuzzyMatch } from "../../lib/fuzzy.js"; import { resolveOrg } from "../../lib/resolve-target.js"; import { setOrgProjectContext } from "../../lib/telemetry.js"; import { isAllDigits } from "../../lib/utils.js"; @@ -80,8 +85,11 @@ export async function resolveOrgFromTarget( * - `` — single arg (auto-detect org) * - ` ` — explicit target + dashboard ref * + * When two args are provided and the first is a bare slug (no `/`), it is + * normalized to `slug/` so `parseOrgProjectArg` treats it as an org-all + * target. Dashboards are org-scoped so the project component is irrelevant. + * * @param args - Raw positional arguments - * @param usageHint - Error message label (e.g. "Dashboard ID or title") * @returns Dashboard reference string and optional target arg */ export function parseDashboardPositionalArgs(args: string[]): { @@ -100,17 +108,106 @@ export function parseDashboardPositionalArgs(args: string[]): { targetArg: undefined, }; } + // Normalize bare org slug → org/ (dashboards are org-scoped) + const raw = args[0] as string; + const target = raw.includes("/") ? raw : `${raw}/`; return { dashboardRef: args[1] as string, - targetArg: args[0] as string, + targetArg: target, }; } +/** + * Parse dashboard list positional args into a target and optional title filter. + * + * Handles: + * - (empty) — auto-detect org, no filter + * - `` or `` — target, no filter + * - `'CLI'` or `'Error*'` or `'*API*'` — auto-detect org, title filter + * - ` 'Error*'` or ` 'Error*'` — target + glob filter + * + * A single arg without `/` is always treated as a title filter, not a + * project-search target. Dashboards are org-scoped so project-search + * doesn't apply — `resolveOrgFromTarget` ignores the slug anyway. + * To specify an org, use `org/` or pass two args: `org 'filter'`. + * + * When two args are provided and the first is a bare slug (no `/`), it is + * normalized to `slug/` so `parseOrgProjectArg` treats it as an org-all target. + * + * @param args - Raw positional arguments + * @returns Target arg for org resolution and optional title filter glob + */ +export function parseDashboardListArgs(args: string[]): { + targetArg: string | undefined; + titleFilter: string | undefined; +} { + // buildListCommand's interceptSubcommand may replace args[0] with undefined + // when the first positional matches a subcommand name (e.g. "view", "create"). + // Filter those out so we don't crash on .includes("/"). + const filtered = args.filter( + (a): a is string => a !== null && a !== undefined && a !== "" + ); + if (filtered.length === 0) { + return { targetArg: undefined, titleFilter: undefined }; + } + if (filtered.length >= 2) { + // First arg is the target, remaining args are joined as the filter. + // This handles unquoted multi-word titles: `my-org/ CLI Health` arrives + // as ["my-org/", "CLI", "Health"] and becomes filter "CLI Health". + // Normalize bare org slug to org/ format so parseOrgProjectArg treats + // it as org-all (dashboards are org-scoped, project is irrelevant). + const raw = filtered[0] as string; + const target = raw.includes("/") ? raw : `${raw}/`; + const titleFilter = filtered.slice(1).join(" "); + return { targetArg: target, titleFilter }; + } + // 1 arg: if it contains "/" it may be a target, or an org/project/name combo. + // Without "/" it's always a title filter (dashboards are org-scoped). + const arg = filtered[0] as string; + if (arg.includes("/")) { + return splitOrgProjectName(arg); + } + return { targetArg: undefined, titleFilter: arg }; +} + +/** + * Split a slash-containing single arg into target and optional title filter. + * + * - `org/` or `org/project` (≤1 slash) → target only, no filter + * - `org/project/name` (2+ slashes) → target is `org/project`, filter is the rest + * + * This lets users type `sentry dashboard list my-org/my-project/CLI` as a + * single arg instead of requiring two separate args. + */ +function splitOrgProjectName(arg: string): { + targetArg: string | undefined; + titleFilter: string | undefined; +} { + const firstSlash = arg.indexOf("/"); + const secondSlash = arg.indexOf("/", firstSlash + 1); + + if (secondSlash === -1) { + // Only one slash: "org/" or "org/project" — target only + return { targetArg: arg, titleFilter: undefined }; + } + + // Two+ slashes: split into target + name filter + const target = arg.slice(0, secondSlash); + const name = arg.slice(secondSlash + 1); + if (!name) { + // Trailing slash after project: "org/project/" → target only + return { targetArg: arg, titleFilter: undefined }; + } + return { targetArg: target, titleFilter: name }; +} + /** * Resolve a dashboard reference (numeric ID or title) to a numeric ID string. * - * If the reference is all digits, returns it directly. Otherwise, lists - * dashboards in the org and finds a case-insensitive title match. + * If the reference is all digits, returns it directly. Otherwise, paginates + * through all dashboards searching for a case-insensitive title match. + * Stops early on first match. On failure, uses fuzzy matching to suggest + * similar dashboard titles. * * @param orgSlug - Organization slug * @param ref - Dashboard reference (numeric ID or title) @@ -124,27 +221,51 @@ export async function resolveDashboardId( return ref; } - const dashboards = await listDashboards(orgSlug); const lowerRef = ref.toLowerCase(); - // Match by ID/slug first (e.g. "default-overview"), then fall back to title - const match = - dashboards.find((d) => d.id.toLowerCase() === lowerRef) ?? - dashboards.find((d) => d.title.toLowerCase() === lowerRef); + const allTitles: string[] = []; + const titleToId = new Map(); + let cursor: string | undefined; - if (!match) { - const available = dashboards - .slice(0, 5) - .map((d) => ` ${d.id} ${d.title}`) - .join("\n"); - const suffix = - dashboards.length > 5 ? `\n ... and ${dashboards.length - 5} more` : ""; - throw new ValidationError( - `No dashboard matching '${ref}' found in '${orgSlug}'.\n\n` + - `Available dashboards (ID Title):\n${available}${suffix}` - ); + for (let page = 0; page < MAX_PAGINATION_PAGES; page++) { + const { data, nextCursor } = await listDashboardsPaginated(orgSlug, { + perPage: API_MAX_PER_PAGE, + cursor, + }); + // Match by ID/slug first (e.g. "default-overview"), then fall back to title + const match = + data.find((d) => d.id.toLowerCase() === lowerRef) ?? + data.find((d) => d.title.toLowerCase() === lowerRef); + if (match) { + return match.id; + } + + for (const d of data) { + allTitles.push(d.title); + titleToId.set(d.title, d.id); + } + if (!nextCursor) { + break; + } + cursor = nextCursor; + } + + // No match — use fuzzy search for suggestions + const similar = fuzzyMatch(ref, allTitles, { maxResults: 5 }); + const suggestions = similar + .map((t) => ` ${titleToId.get(t)} ${t}`) + .join("\n"); + let hint: string; + if (similar.length > 0) { + hint = `\n\nDid you mean:\n${suggestions}`; + } else if (allTitles.length > 0) { + hint = `\n\nThe org has ${allTitles.length} dashboard(s) but none matched.`; + } else { + hint = "\n\nNo dashboards found in this organization."; } - return match.id; + throw new ValidationError( + `No dashboard with title '${ref}' found in '${orgSlug}'.${hint}` + ); } /** diff --git a/src/commands/dashboard/view.ts b/src/commands/dashboard/view.ts index 4dca226a..2763ec16 100644 --- a/src/commands/dashboard/view.ts +++ b/src/commands/dashboard/view.ts @@ -133,7 +133,9 @@ export const viewCommand = buildCommand({ "Examples:\n" + " sentry dashboard view 12345\n" + " sentry dashboard view 'My Dashboard'\n" + - " sentry dashboard view my-org/ 12345\n" + + " sentry dashboard view my-org 12345\n" + + " sentry dashboard view my-org 'My Dashboard'\n" + + " sentry dashboard view my-org/my-project 12345\n" + " sentry dashboard view 12345 --json\n" + " sentry dashboard view 12345 --period 7d\n" + " sentry dashboard view 12345 -r\n" + diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index 038f50cc..9835819d 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -22,7 +22,7 @@ export { createDashboard, getDashboard, - listDashboards, + listDashboardsPaginated, queryAllWidgets, updateDashboard, } from "./api/dashboards.js"; diff --git a/src/lib/api/dashboards.ts b/src/lib/api/dashboards.ts index cd615d39..19353e38 100644 --- a/src/lib/api/dashboards.ts +++ b/src/lib/api/dashboards.ts @@ -31,26 +31,32 @@ import { invalidateCachedResponse } from "../response-cache.js"; import { apiRequestToRegion, ORG_FANOUT_CONCURRENCY, + type PaginatedResponse, + parseLinkHeader, } from "./infrastructure.js"; /** - * List dashboards in an organization. + * List dashboards in an organization with cursor-based pagination. + * + * Returns both the dashboard list items and pagination metadata so callers + * can iterate through pages. Use `cursor` to resume from a previous page. * * @param orgSlug - Organization slug - * @param options - Optional pagination parameters - * @returns Array of dashboard list items + * @param options - Pagination parameters (perPage, cursor) + * @returns Paginated response with dashboard list items and optional next cursor */ -export async function listDashboards( +export async function listDashboardsPaginated( orgSlug: string, - options: { perPage?: number } = {} -): Promise { + options: { perPage?: number; cursor?: string } = {} +): Promise> { const regionUrl = await resolveOrgRegion(orgSlug); - const { data } = await apiRequestToRegion( + const { data, headers } = await apiRequestToRegion( regionUrl, `/organizations/${orgSlug}/dashboards/`, - { params: { per_page: options.perPage } } + { params: { per_page: options.perPage, cursor: options.cursor } } ); - return data; + const { nextCursor } = parseLinkHeader(headers.get("link") ?? null); + return { data, nextCursor }; } /** diff --git a/test/commands/dashboard/list.test.ts b/test/commands/dashboard/list.test.ts index fe30e0a5..4f682fb1 100644 --- a/test/commands/dashboard/list.test.ts +++ b/test/commands/dashboard/list.test.ts @@ -2,7 +2,12 @@ * Dashboard List Command Tests * * Tests for the dashboard list command in src/commands/dashboard/list.ts. - * Uses spyOn pattern to mock API client, resolve-target, and browser. + * Uses spyOn pattern to mock API client, pagination DB, resolve-target, + * browser, and polling modules. + * + * Note: Core cursor encoding invariants (round-trips, edge cases) are tested + * via unit tests on the exported encodeCursor/decodeCursor functions. Command + * integration tests focus on end-to-end behavior through the Stricli func(). */ import { @@ -15,12 +20,18 @@ import { test, } from "bun:test"; -import { listCommand } from "../../../src/commands/dashboard/list.js"; +import { + decodeCursor, + encodeCursor, + listCommand, +} from "../../../src/commands/dashboard/list.js"; // biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking import * as apiClient from "../../../src/lib/api-client.js"; // biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking import * as browser from "../../../src/lib/browser.js"; // biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as paginationDb from "../../../src/lib/db/pagination.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking import * as polling from "../../../src/lib/polling.js"; // biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking import * as resolveTarget from "../../../src/lib/resolve-target.js"; @@ -44,6 +55,26 @@ function createMockContext(cwd = "/tmp") { }; } +/** Default flags for most tests (no cursor, no web, no fresh) */ +function defaultFlags(overrides: Partial = {}): ListFlags { + return { + json: false, + web: false, + fresh: false, + limit: 30, + ...overrides, + }; +} + +type ListFlags = { + readonly web: boolean; + readonly fresh: boolean; + readonly limit: number; + readonly cursor?: string; + readonly json?: boolean; + readonly fields?: string[]; +}; + // --------------------------------------------------------------------------- // Test data // --------------------------------------------------------------------------- @@ -62,18 +93,27 @@ const DASHBOARD_B: DashboardListItem = { dateCreated: "2026-02-20T12:00:00Z", }; +const DASHBOARD_C: DashboardListItem = { + id: "99", + title: "API Monitoring", + widgetDisplay: ["line", "bar"], + dateCreated: "2026-03-01T08:00:00Z", +}; + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- describe("dashboard list command", () => { - let listDashboardsSpy: ReturnType; + let listDashboardsPaginatedSpy: ReturnType; let resolveOrgSpy: ReturnType; let openInBrowserSpy: ReturnType; let withProgressSpy: ReturnType; + let setPaginationCursorSpy: ReturnType; + let clearPaginationCursorSpy: ReturnType; beforeEach(() => { - listDashboardsSpy = spyOn(apiClient, "listDashboards"); + listDashboardsPaginatedSpy = spyOn(apiClient, "listDashboardsPaginated"); resolveOrgSpy = spyOn(resolveTarget, "resolveOrg"); openInBrowserSpy = spyOn(browser, "openInBrowser").mockResolvedValue( undefined as never @@ -85,63 +125,77 @@ describe("dashboard list command", () => { /* no-op setMessage */ }) ); + setPaginationCursorSpy = spyOn(paginationDb, "setPaginationCursor"); + clearPaginationCursorSpy = spyOn(paginationDb, "clearPaginationCursor"); + + setPaginationCursorSpy.mockReturnValue(undefined); + clearPaginationCursorSpy.mockReturnValue(undefined); }); afterEach(() => { - listDashboardsSpy.mockRestore(); + listDashboardsPaginatedSpy.mockRestore(); resolveOrgSpy.mockRestore(); openInBrowserSpy.mockRestore(); withProgressSpy.mockRestore(); + setPaginationCursorSpy.mockRestore(); + clearPaginationCursorSpy.mockRestore(); }); - test("outputs JSON array of dashboards with --json", async () => { + // ------------------------------------------------------------------------- + // JSON output + // ------------------------------------------------------------------------- + + test("outputs JSON envelope with { data, hasMore } when --json", async () => { resolveOrgSpy.mockResolvedValue({ org: "test-org" }); - listDashboardsSpy.mockResolvedValue([DASHBOARD_A, DASHBOARD_B]); + listDashboardsPaginatedSpy.mockResolvedValue({ + data: [DASHBOARD_A, DASHBOARD_B], + nextCursor: undefined, + }); const { context, stdoutWrite } = createMockContext(); const func = await listCommand.loader(); - await func.call( - context, - { json: true, web: false, fresh: false, limit: 30 }, - undefined - ); + await func.call(context, defaultFlags({ json: true })); const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); const parsed = JSON.parse(output); - expect(Array.isArray(parsed)).toBe(true); - expect(parsed).toHaveLength(2); - expect(parsed[0].id).toBe("1"); - expect(parsed[0].title).toBe("Errors Overview"); - expect(parsed[1].id).toBe("42"); + expect(parsed).toHaveProperty("data"); + expect(parsed).toHaveProperty("hasMore", false); + expect(parsed.data).toHaveLength(2); + expect(parsed.data[0].id).toBe("1"); + expect(parsed.data[0].title).toBe("Errors Overview"); + expect(parsed.data[1].id).toBe("42"); }); - test("outputs empty JSON array when no dashboards exist", async () => { + test("outputs { data: [], hasMore: false } when no dashboards exist", async () => { resolveOrgSpy.mockResolvedValue({ org: "test-org" }); - listDashboardsSpy.mockResolvedValue([]); + listDashboardsPaginatedSpy.mockResolvedValue({ + data: [], + nextCursor: undefined, + }); const { context, stdoutWrite } = createMockContext(); const func = await listCommand.loader(); - await func.call( - context, - { json: true, web: false, fresh: false, limit: 30 }, - undefined - ); + await func.call(context, defaultFlags({ json: true })); const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); - expect(JSON.parse(output)).toEqual([]); + const parsed = JSON.parse(output); + expect(parsed).toEqual({ data: [], hasMore: false }); }); + // ------------------------------------------------------------------------- + // Human output + // ------------------------------------------------------------------------- + test("outputs human-readable table with column headers", async () => { resolveOrgSpy.mockResolvedValue({ org: "test-org" }); - listDashboardsSpy.mockResolvedValue([DASHBOARD_A, DASHBOARD_B]); + listDashboardsPaginatedSpy.mockResolvedValue({ + data: [DASHBOARD_A, DASHBOARD_B], + nextCursor: undefined, + }); const { context, stdoutWrite } = createMockContext(); const func = await listCommand.loader(); - await func.call( - context, - { json: false, web: false, fresh: false, limit: 30 }, - undefined - ); + await func.call(context, defaultFlags()); const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); expect(output).toContain("ID"); @@ -153,97 +207,309 @@ describe("dashboard list command", () => { test("shows empty state message when no dashboards exist", async () => { resolveOrgSpy.mockResolvedValue({ org: "test-org" }); - listDashboardsSpy.mockResolvedValue([]); + listDashboardsPaginatedSpy.mockResolvedValue({ + data: [], + nextCursor: undefined, + }); const { context, stdoutWrite } = createMockContext(); const func = await listCommand.loader(); - await func.call( - context, - { json: false, web: false, fresh: false, limit: 30 }, - undefined - ); + await func.call(context, defaultFlags()); const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); expect(output).toContain("No dashboards found."); }); - test("human output footer contains dashboards URL", async () => { + // ------------------------------------------------------------------------- + // --web flag + // ------------------------------------------------------------------------- + + test("--web flag opens browser instead of listing", async () => { + resolveOrgSpy.mockResolvedValue({ org: "test-org" }); + + const { context } = createMockContext(); + const func = await listCommand.loader(); + await func.call(context, defaultFlags({ web: true })); + + expect(openInBrowserSpy).toHaveBeenCalled(); + expect(listDashboardsPaginatedSpy).not.toHaveBeenCalled(); + }); + + // ------------------------------------------------------------------------- + // --limit flag + // ------------------------------------------------------------------------- + + test("passes limit as perPage to API", async () => { resolveOrgSpy.mockResolvedValue({ org: "test-org" }); - listDashboardsSpy.mockResolvedValue([DASHBOARD_A]); + listDashboardsPaginatedSpy.mockResolvedValue({ + data: [DASHBOARD_A], + nextCursor: undefined, + }); + + const { context } = createMockContext(); + const func = await listCommand.loader(); + await func.call(context, defaultFlags({ json: true, limit: 10 })); + + expect(withProgressSpy).toHaveBeenCalled(); + expect(listDashboardsPaginatedSpy).toHaveBeenCalledWith("test-org", { + perPage: 10, + cursor: undefined, + }); + }); + + // ------------------------------------------------------------------------- + // Pagination + // ------------------------------------------------------------------------- + + test("hasMore is true in JSON when API returns nextCursor", async () => { + resolveOrgSpy.mockResolvedValue({ org: "test-org" }); + listDashboardsPaginatedSpy.mockResolvedValue({ + data: [DASHBOARD_A, DASHBOARD_B], + nextCursor: "1735689600:0:0", + }); const { context, stdoutWrite } = createMockContext(); const func = await listCommand.loader(); - await func.call( - context, - { json: false, web: false, fresh: false, limit: 30 }, - undefined - ); + await func.call(context, defaultFlags({ json: true, limit: 2 })); const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); - expect(output).toContain("dashboards"); - expect(output).toContain("test-org"); + const parsed = JSON.parse(output); + expect(parsed.hasMore).toBe(true); + expect(parsed.nextCursor).toBeDefined(); }); - test("uses org from positional argument", async () => { - resolveOrgSpy.mockResolvedValue({ org: "my-org" }); - listDashboardsSpy.mockResolvedValue([DASHBOARD_A]); + test("hint includes -c last when more pages available", async () => { + resolveOrgSpy.mockResolvedValue({ org: "test-org" }); + listDashboardsPaginatedSpy.mockResolvedValue({ + data: [DASHBOARD_A, DASHBOARD_B], + nextCursor: "1735689600:0:0", + }); - const { context } = createMockContext(); + const { context, stdoutWrite } = createMockContext(); const func = await listCommand.loader(); - await func.call( - context, - { json: true, web: false, fresh: false, limit: 30 }, - "my-org/" - ); + await func.call(context, defaultFlags({ limit: 2 })); - expect(listDashboardsSpy).toHaveBeenCalledWith("my-org", { perPage: 30 }); + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + expect(output).toContain("-c last"); }); - test("throws ContextError when org cannot be resolved", async () => { - resolveOrgSpy.mockResolvedValue(null); + test("hasMore is false when API returns no nextCursor", async () => { + resolveOrgSpy.mockResolvedValue({ org: "test-org" }); + listDashboardsPaginatedSpy.mockResolvedValue({ + data: [DASHBOARD_A], + nextCursor: undefined, + }); - const { context } = createMockContext(); + const { context, stdoutWrite } = createMockContext(); const func = await listCommand.loader(); + await func.call(context, defaultFlags({ json: true })); - await expect( - func.call( - context, - { json: false, web: false, fresh: false, limit: 30 }, - undefined - ) - ).rejects.toThrow("Organization"); + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + const parsed = JSON.parse(output); + expect(parsed.hasMore).toBe(false); + expect(parsed.nextCursor).toBeUndefined(); }); - test("--web flag opens browser instead of listing", async () => { + test("auto-pagination: --limit larger than page size fetches multiple pages", async () => { resolveOrgSpy.mockResolvedValue({ org: "test-org" }); - const { context } = createMockContext(); + // First page returns 2 items + nextCursor, second page returns 1 item + listDashboardsPaginatedSpy + .mockResolvedValueOnce({ + data: [DASHBOARD_A, DASHBOARD_B], + nextCursor: "cursor-page-2", + }) + .mockResolvedValueOnce({ + data: [DASHBOARD_C], + nextCursor: undefined, + }); + + const { context, stdoutWrite } = createMockContext(); const func = await listCommand.loader(); - await func.call( - context, - { json: false, web: true, fresh: false, limit: 30 }, - undefined - ); + // Request 3 items, which exceeds a single page of 2 + await func.call(context, defaultFlags({ json: true, limit: 3 })); - expect(openInBrowserSpy).toHaveBeenCalled(); - expect(listDashboardsSpy).not.toHaveBeenCalled(); + // Should have called API twice + expect(listDashboardsPaginatedSpy).toHaveBeenCalledTimes(2); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + const parsed = JSON.parse(output); + expect(parsed.data).toHaveLength(3); + expect(parsed.hasMore).toBe(false); }); - test("passes limit to API via withProgress", async () => { + // ------------------------------------------------------------------------- + // Glob filter + // ------------------------------------------------------------------------- + + test("single glob arg filters dashboards by title", async () => { resolveOrgSpy.mockResolvedValue({ org: "test-org" }); - listDashboardsSpy.mockResolvedValue([DASHBOARD_A]); + listDashboardsPaginatedSpy.mockResolvedValue({ + data: [DASHBOARD_A, DASHBOARD_B, DASHBOARD_C], + nextCursor: undefined, + }); + + const { context, stdoutWrite } = createMockContext(); + const func = await listCommand.loader(); + // "Error*" should match "Errors Overview" only + await func.call(context, defaultFlags({ json: true }), "Error*"); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + const parsed = JSON.parse(output); + expect(parsed.data).toHaveLength(1); + expect(parsed.data[0].title).toBe("Errors Overview"); + }); + + test("two-arg form: explicit org + glob filter", async () => { + // With "my-org/" as first arg, resolveOrgFromTarget returns "my-org" + // directly (explicit/org-all mode), no resolveOrg call needed + listDashboardsPaginatedSpy.mockResolvedValue({ + data: [DASHBOARD_A, DASHBOARD_B, DASHBOARD_C], + nextCursor: undefined, + }); + + const { context, stdoutWrite } = createMockContext(); + const func = await listCommand.loader(); + await func.call(context, defaultFlags({ json: true }), "my-org/", "*API*"); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + const parsed = JSON.parse(output); + expect(parsed.data).toHaveLength(1); + expect(parsed.data[0].title).toBe("API Monitoring"); + }); + + test("glob filter is case-insensitive", async () => { + resolveOrgSpy.mockResolvedValue({ org: "test-org" }); + listDashboardsPaginatedSpy.mockResolvedValue({ + data: [DASHBOARD_A, DASHBOARD_B, DASHBOARD_C], + nextCursor: undefined, + }); + + const { context, stdoutWrite } = createMockContext(); + const func = await listCommand.loader(); + // Lowercase "error*" should still match "Errors Overview" + await func.call(context, defaultFlags({ json: true }), "error*"); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + const parsed = JSON.parse(output); + expect(parsed.data).toHaveLength(1); + expect(parsed.data[0].title).toBe("Errors Overview"); + }); + + test("glob filter with no matches shows filter-aware message", async () => { + resolveOrgSpy.mockResolvedValue({ org: "test-org" }); + listDashboardsPaginatedSpy.mockResolvedValue({ + data: [DASHBOARD_A, DASHBOARD_B, DASHBOARD_C], + nextCursor: undefined, + }); + + const { context, stdoutWrite } = createMockContext(); + const func = await listCommand.loader(); + await func.call(context, defaultFlags(), "NoMatch*"); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + expect(output).toContain("No dashboards matching 'NoMatch*'."); + }); + + test("glob filter with no matches shows fuzzy suggestions for close input", async () => { + resolveOrgSpy.mockResolvedValue({ org: "test-org" }); + listDashboardsPaginatedSpy.mockResolvedValue({ + data: [DASHBOARD_A, DASHBOARD_B, DASHBOARD_C], + nextCursor: undefined, + }); + + const { context, stdoutWrite } = createMockContext(); + const func = await listCommand.loader(); + // "Perf" is a prefix of "Performance" → fuzzy match finds it + await func.call(context, defaultFlags(), "Perf"); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + expect(output).toContain("Did you mean:"); + expect(output).toContain("Performance"); + }); + + // ------------------------------------------------------------------------- + // Org argument + // ------------------------------------------------------------------------- + + test("uses org from positional argument (org/ form)", async () => { + // "my-org/" is parsed as org-all, resolveOrgFromTarget returns "my-org" + // directly without calling resolveOrg + listDashboardsPaginatedSpy.mockResolvedValue({ + data: [DASHBOARD_A], + nextCursor: undefined, + }); + + const { context } = createMockContext(); + const func = await listCommand.loader(); + await func.call(context, defaultFlags({ json: true }), "my-org/"); + + expect(listDashboardsPaginatedSpy).toHaveBeenCalledWith("my-org", { + perPage: 30, + cursor: undefined, + }); + }); + + test("throws ContextError when org cannot be resolved", async () => { + resolveOrgSpy.mockResolvedValue(null); const { context } = createMockContext(); const func = await listCommand.loader(); - await func.call( - context, - { json: true, web: false, fresh: false, limit: 10 }, - undefined + + await expect(func.call(context, defaultFlags())).rejects.toThrow( + "Organization" ); + }); +}); - expect(withProgressSpy).toHaveBeenCalled(); - expect(listDashboardsSpy).toHaveBeenCalledWith("test-org", { - perPage: 10, +// --------------------------------------------------------------------------- +// Extended cursor encoding / decoding +// --------------------------------------------------------------------------- + +describe("encodeCursor", () => { + test("encodes server cursor with afterId", () => { + expect(encodeCursor("1:0:0", "42")).toBe("1:0:0|42"); + }); + + test("returns plain server cursor when no afterId", () => { + expect(encodeCursor("1:0:0")).toBe("1:0:0"); + }); + + test("encodes afterId without server cursor", () => { + expect(encodeCursor(undefined, "42")).toBe("|42"); + }); + + test("returns undefined when no cursor and no afterId", () => { + expect(encodeCursor(undefined)).toBeUndefined(); + }); +}); + +describe("decodeCursor", () => { + test("decodes cursor with pipe separator", () => { + expect(decodeCursor("1:0:0|42")).toEqual({ + serverCursor: "1:0:0", + afterId: "42", + }); + }); + + test("decodes plain server cursor (no pipe)", () => { + expect(decodeCursor("1:0:0")).toEqual({ + serverCursor: "1:0:0", + afterId: undefined, + }); + }); + + test("decodes afterId-only cursor (leading pipe)", () => { + expect(decodeCursor("|42")).toEqual({ + serverCursor: undefined, + afterId: "42", + }); + }); + + test("decodes empty string to all undefined", () => { + expect(decodeCursor("")).toEqual({ + serverCursor: undefined, + afterId: undefined, }); }); }); diff --git a/test/commands/dashboard/resolve.test.ts b/test/commands/dashboard/resolve.test.ts index edb50a04..98aa6c3b 100644 --- a/test/commands/dashboard/resolve.test.ts +++ b/test/commands/dashboard/resolve.test.ts @@ -7,6 +7,7 @@ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { + parseDashboardListArgs, parseDashboardPositionalArgs, resolveDashboardId, resolveOrgFromTarget, @@ -50,6 +51,126 @@ describe("parseDashboardPositionalArgs", () => { expect(result.dashboardRef).toBe("My Dashboard"); expect(result.targetArg).toBe("my-org/"); }); + + test("two args with bare org slug auto-appends /", () => { + const result = parseDashboardPositionalArgs(["my-org", "12345"]); + expect(result.dashboardRef).toBe("12345"); + expect(result.targetArg).toBe("my-org/"); + }); + + test("two args with org/project is unchanged", () => { + const result = parseDashboardPositionalArgs(["my-org/my-project", "12345"]); + expect(result.dashboardRef).toBe("12345"); + expect(result.targetArg).toBe("my-org/my-project"); + }); +}); + +// --------------------------------------------------------------------------- +// parseDashboardListArgs +// --------------------------------------------------------------------------- + +describe("parseDashboardListArgs", () => { + test("empty args returns undefined for both", () => { + const result = parseDashboardListArgs([]); + expect(result.targetArg).toBeUndefined(); + expect(result.titleFilter).toBeUndefined(); + }); + + test("single arg with trailing slash is target", () => { + const result = parseDashboardListArgs(["my-org/"]); + expect(result.targetArg).toBe("my-org/"); + expect(result.titleFilter).toBeUndefined(); + }); + + test("single arg with glob * is title filter", () => { + const result = parseDashboardListArgs(["Error*"]); + expect(result.targetArg).toBeUndefined(); + expect(result.titleFilter).toBe("Error*"); + }); + + test("single arg with space is title filter", () => { + const result = parseDashboardListArgs(["My Dashboard"]); + expect(result.targetArg).toBeUndefined(); + expect(result.titleFilter).toBe("My Dashboard"); + }); + + test("single arg with glob ? is title filter", () => { + const result = parseDashboardListArgs(["?something"]); + expect(result.targetArg).toBeUndefined(); + expect(result.titleFilter).toBe("?something"); + }); + + test("single arg with glob [ is title filter", () => { + const result = parseDashboardListArgs(["[abc]"]); + expect(result.targetArg).toBeUndefined(); + expect(result.titleFilter).toBe("[abc]"); + }); + + test("single bare word is title filter (dashboards are org-scoped)", () => { + const result = parseDashboardListArgs(["performance"]); + expect(result.targetArg).toBeUndefined(); + expect(result.titleFilter).toBe("performance"); + }); + + test("two args with trailing slash target and glob filter", () => { + const result = parseDashboardListArgs(["my-org/", "Error*"]); + expect(result.targetArg).toBe("my-org/"); + expect(result.titleFilter).toBe("Error*"); + }); + + test("two args with bare org slug auto-appends /", () => { + const result = parseDashboardListArgs(["my-org", "Error*"]); + expect(result.targetArg).toBe("my-org/"); + expect(result.titleFilter).toBe("Error*"); + }); + + test("two args with org/project and glob filter", () => { + const result = parseDashboardListArgs(["my-org/my-project", "*API*"]); + expect(result.targetArg).toBe("my-org/my-project"); + expect(result.titleFilter).toBe("*API*"); + }); + + test("multi-word unquoted filter: remaining args joined with spaces", () => { + const result = parseDashboardListArgs(["sentry/cli", "CLI", "Health"]); + expect(result.targetArg).toBe("sentry/cli"); + expect(result.titleFilter).toBe("CLI Health"); + }); + + test("multi-word unquoted filter with bare org", () => { + const result = parseDashboardListArgs(["my-org", "Error", "Overview"]); + expect(result.targetArg).toBe("my-org/"); + expect(result.titleFilter).toBe("Error Overview"); + }); + + test("single arg org/project/name splits into target + filter", () => { + const result = parseDashboardListArgs(["sentry/cli/CLI"]); + expect(result.targetArg).toBe("sentry/cli"); + expect(result.titleFilter).toBe("CLI"); + }); + + test("single arg org/project/name with spaces in name", () => { + const result = parseDashboardListArgs(["sentry/cli/My Dashboard"]); + expect(result.targetArg).toBe("sentry/cli"); + expect(result.titleFilter).toBe("My Dashboard"); + }); + + test("single arg org/ is target only (one slash, trailing)", () => { + const result = parseDashboardListArgs(["my-org/"]); + expect(result.targetArg).toBe("my-org/"); + expect(result.titleFilter).toBeUndefined(); + }); + + test("single arg org/project is target only (one slash)", () => { + const result = parseDashboardListArgs(["my-org/my-project"]); + expect(result.targetArg).toBe("my-org/my-project"); + expect(result.titleFilter).toBeUndefined(); + }); + + test("single arg org/project/ is target only (trailing slash after project)", () => { + const result = parseDashboardListArgs(["my-org/my-project/"]); + expect(result.targetArg).toBe("my-org/my-project/"); + expect(result.titleFilter).toBeUndefined(); + }); }); // --------------------------------------------------------------------------- @@ -57,84 +178,99 @@ describe("parseDashboardPositionalArgs", () => { // --------------------------------------------------------------------------- describe("resolveDashboardId", () => { - let listDashboardsSpy: ReturnType; + let listDashboardsPaginatedSpy: ReturnType; beforeEach(() => { - listDashboardsSpy = spyOn(apiClient, "listDashboards"); + listDashboardsPaginatedSpy = spyOn(apiClient, "listDashboardsPaginated"); }); afterEach(() => { - listDashboardsSpy.mockRestore(); + listDashboardsPaginatedSpy.mockRestore(); }); test("numeric string returns directly without API call", async () => { const id = await resolveDashboardId("test-org", "42"); expect(id).toBe("42"); - expect(listDashboardsSpy).not.toHaveBeenCalled(); + expect(listDashboardsPaginatedSpy).not.toHaveBeenCalled(); }); test("title match returns matching dashboard ID", async () => { - listDashboardsSpy.mockResolvedValue([ - { id: "10", title: "Errors Overview" }, - { id: "20", title: "Performance" }, - ]); + listDashboardsPaginatedSpy.mockResolvedValue({ + data: [ + { id: "10", title: "Errors Overview" }, + { id: "20", title: "Performance" }, + ], + nextCursor: undefined, + }); const id = await resolveDashboardId("test-org", "Performance"); expect(id).toBe("20"); }); test("title match is case-insensitive", async () => { - listDashboardsSpy.mockResolvedValue([ - { id: "10", title: "Errors Overview" }, - ]); + listDashboardsPaginatedSpy.mockResolvedValue({ + data: [{ id: "10", title: "Errors Overview" }], + nextCursor: undefined, + }); const id = await resolveDashboardId("test-org", "errors overview"); expect(id).toBe("10"); }); test("ID/slug match returns matching dashboard ID", async () => { - listDashboardsSpy.mockResolvedValue([ - { id: "default-overview", title: "General" }, - { id: "20", title: "Performance" }, - ]); + listDashboardsPaginatedSpy.mockResolvedValue({ + data: [ + { id: "default-overview", title: "General" }, + { id: "20", title: "Performance" }, + ], + nextCursor: undefined, + }); const id = await resolveDashboardId("test-org", "default-overview"); expect(id).toBe("default-overview"); }); test("ID match is case-insensitive", async () => { - listDashboardsSpy.mockResolvedValue([ - { id: "default-overview", title: "General" }, - ]); + listDashboardsPaginatedSpy.mockResolvedValue({ + data: [{ id: "default-overview", title: "General" }], + nextCursor: undefined, + }); const id = await resolveDashboardId("test-org", "Default-Overview"); expect(id).toBe("default-overview"); }); test("ID match takes priority over title match", async () => { - listDashboardsSpy.mockResolvedValue([ - { id: "perf", title: "Performance Dashboard" }, - { id: "30", title: "perf" }, - ]); + listDashboardsPaginatedSpy.mockResolvedValue({ + data: [ + { id: "perf", title: "Performance Dashboard" }, + { id: "30", title: "perf" }, + ], + nextCursor: undefined, + }); const id = await resolveDashboardId("test-org", "perf"); expect(id).toBe("perf"); }); test("title match still works when no ID matches", async () => { - listDashboardsSpy.mockResolvedValue([ - { id: "default-overview", title: "General" }, - ]); + listDashboardsPaginatedSpy.mockResolvedValue({ + data: [{ id: "default-overview", title: "General" }], + nextCursor: undefined, + }); const id = await resolveDashboardId("test-org", "General"); expect(id).toBe("default-overview"); }); - test("no match throws ValidationError with improved error message", async () => { - listDashboardsSpy.mockResolvedValue([ - { id: "10", title: "Errors Overview" }, - { id: "20", title: "Performance" }, - ]); + test("no match throws ValidationError with fuzzy suggestions", async () => { + listDashboardsPaginatedSpy.mockResolvedValue({ + data: [ + { id: "10", title: "Errors Overview" }, + { id: "20", title: "Performance" }, + ], + nextCursor: undefined, + }); try { await resolveDashboardId("test-org", "Missing Dashboard"); @@ -143,10 +279,44 @@ describe("resolveDashboardId", () => { expect(error).toBeInstanceOf(ValidationError); const message = (error as ValidationError).message; expect(message).toContain("Missing Dashboard"); - expect(message).toContain("matching"); - expect(message).toContain("(ID Title)"); + } + }); + + test("title not found shows fuzzy suggestions for close misspelling", async () => { + listDashboardsPaginatedSpy.mockResolvedValue({ + data: [ + { id: "10", title: "Errors Overview" }, + { id: "20", title: "Performance" }, + ], + nextCursor: undefined, + }); + + try { + await resolveDashboardId("test-org", "Eror Overview"); + expect.unreachable("Should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ValidationError); + const message = (error as ValidationError).message; + expect(message).toContain("Eror Overview"); + expect(message).toContain("Did you mean"); expect(message).toContain("Errors Overview"); - expect(message).toContain("Performance"); + } + }); + + test("no dashboards at all shows empty org message", async () => { + listDashboardsPaginatedSpy.mockResolvedValue({ + data: [], + nextCursor: undefined, + }); + + try { + await resolveDashboardId("test-org", "My Dashboard"); + expect.unreachable("Should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ValidationError); + const message = (error as ValidationError).message; + expect(message).toContain("My Dashboard"); + expect(message).toContain("No dashboards found in this organization."); } }); });