From 3f36c0d5d5502bffde61a228b1296da44afad73b Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 10 Feb 2026 10:45:58 +0000 Subject: [PATCH 01/27] fix(project): add pagination and org/ syntax to project list - Follow API cursor pagination so --limit >100 actually works - Add org/ prefix syntax to list projects for a specific org - Add org/project syntax to show a single project directly - Add --cursor/--prev flags for manual page navigation - Add pagination_cursors table (schema v5) with composite PK - Cache cursors per (command_key, context) for independent paging - Clear pagination cursors on auth logout - Regenerate SKILL.md for updated command help --- plugins/sentry-cli/skills/sentry-cli/SKILL.md | 6 +- src/commands/project/list.ts | 479 ++++++++++++++---- src/lib/api-client.ts | 200 +++++++- src/lib/db/auth.ts | 3 +- src/lib/db/pagination.ts | 99 ++++ src/lib/db/schema.ts | 67 ++- test/e2e/multiregion.test.ts | 10 +- test/e2e/project.test.ts | 12 +- 8 files changed, 755 insertions(+), 121 deletions(-) create mode 100644 src/lib/db/pagination.ts diff --git a/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index 0a00b3c8..b28a91b0 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -136,13 +136,14 @@ sentry org view my-org -w Work with Sentry projects -#### `sentry project list ` +#### `sentry project list ` List projects **Flags:** - `-n, --limit - Maximum number of projects to list - (default: "30")` - `--json - Output JSON` +- `-c, --cursor - Pagination cursor (use "last" to continue from previous page)` - `-p, --platform - Filter by platform (e.g., javascript, python)` **Examples:** @@ -573,13 +574,14 @@ List organizations List projects -#### `sentry projects ` +#### `sentry projects ` List projects **Flags:** - `-n, --limit - Maximum number of projects to list - (default: "30")` - `--json - Output JSON` +- `-c, --cursor - Pagination cursor (use "last" to continue from previous page)` - `-p, --platform - Filter by platform (e.g., javascript, python)` ### Repos diff --git a/src/commands/project/list.ts b/src/commands/project/list.ts index 01aa11f3..1b0d2413 100644 --- a/src/commands/project/list.ts +++ b/src/commands/project/list.ts @@ -1,14 +1,36 @@ /** * sentry project list * - * List projects in an organization. + * List projects in an organization with pagination and flexible targeting. + * + * Supports: + * - Auto-detection from DSN/config + * - Explicit org/project targeting (e.g., sentry/sentry) + * - Org-scoped listing with cursor pagination (e.g., sentry/) + * - Cross-org project search (e.g., sentry) */ import type { SentryContext } from "../../context.js"; -import { listOrganizations, listProjects } from "../../lib/api-client.js"; +import { + findProjectsBySlug, + getProject, + listOrganizations, + listProjects, + listProjectsPaginated, + type PaginatedResponse, +} from "../../lib/api-client.js"; +import { + type ParsedOrgProject, + 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 { calculateProjectColumnWidths, formatProjectRow, @@ -18,9 +40,13 @@ import { import { resolveAllTargets } from "../../lib/resolve-target.js"; import type { SentryProject, Writer } from "../../types/index.js"; +/** Command key for pagination cursor storage */ +const PAGINATION_KEY = "project-list"; + type ListFlags = { readonly limit: number; readonly json: boolean; + readonly cursor?: string; readonly platform?: string; }; @@ -28,7 +54,7 @@ type ListFlags = { type ProjectWithOrg = SentryProject & { orgSlug?: string }; /** - * Fetch projects for a single organization. + * Fetch projects for a single organization (all pages). * * @param orgSlug - Organization slug to fetch projects from * @returns Projects with org context attached @@ -135,6 +161,56 @@ function writeRows(options: WriteRowsOptions): void { } } +/** + * Build a context key for pagination cursor validation. + * Captures the query parameters that affect result ordering, + * so cursors from different queries are not accidentally reused. + */ +function buildContextKey( + parsed: ParsedOrgProject, + flags: { platform?: string } +): string { + const parts: string[] = []; + switch (parsed.type) { + case "org-all": + parts.push(`org:${parsed.org}`); + break; + case "auto-detect": + parts.push("auto"); + break; + default: + parts.push(`type:${parsed.type}`); + } + if (flags.platform) { + parts.push(`platform:${flags.platform}`); + } + return parts.join("|"); +} + +/** + * 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; +} + /** Result of resolving organizations to fetch projects from */ type OrgResolution = { orgs: string[]; @@ -143,25 +219,17 @@ type OrgResolution = { }; /** - * Resolve which organizations to fetch projects from. - * Uses CLI flag, config defaults, or DSN auto-detection. + * Resolve which organizations to fetch projects from (auto-detect mode). + * Uses config defaults or DSN auto-detection. */ -async function resolveOrgsToFetch( - orgFlag: string | undefined, - cwd: string -): Promise { - // 1. If org positional provided, use it directly - if (orgFlag) { - return { orgs: [orgFlag] }; - } - - // 2. Check config defaults +async function resolveOrgsForAutoDetect(cwd: string): Promise { + // 1. Check config defaults const defaultOrg = await getDefaultOrganization(); if (defaultOrg) { return { orgs: [defaultOrg] }; } - // 3. Auto-detect from DSNs (may find multiple in monorepos) + // 2. Auto-detect from DSNs (may find multiple in monorepos) try { const { targets, footer, skippedSelfHosted } = await resolveAllTargets({ cwd, @@ -169,46 +237,282 @@ async function resolveOrgsToFetch( if (targets.length > 0) { const uniqueOrgs = [...new Set(targets.map((t) => t.org))]; - return { - orgs: uniqueOrgs, - footer, - skippedSelfHosted, - }; + return { orgs: uniqueOrgs, footer, skippedSelfHosted }; } - // No resolvable targets, but may have self-hosted DSNs return { orgs: [], skippedSelfHosted }; } catch (error) { - // Auth errors should propagate - user needs to log in if (error instanceof AuthError) { throw error; } - // Fall through to empty orgs for other errors (network, etc.) } return { orgs: [] }; } +/** Display projects in table format with header and rows */ +function displayProjectTable(stdout: Writer, projects: ProjectWithOrg[]): void { + const { orgWidth, slugWidth, nameWidth } = + calculateProjectColumnWidths(projects); + writeHeader(stdout, orgWidth, slugWidth, nameWidth); + writeRows({ stdout, projects, orgWidth, slugWidth, nameWidth }); +} + +/** + * Handle auto-detect mode: resolve orgs from config/DSN, fetch all projects, + * apply client-side filtering and limiting. + */ +async function handleAutoDetect( + stdout: Writer, + cwd: string, + flags: ListFlags +): Promise { + const { + orgs: orgsToFetch, + footer, + skippedSelfHosted, + } = await resolveOrgsForAutoDetect(cwd); + + let allProjects: ProjectWithOrg[]; + if (orgsToFetch.length > 0) { + const results = await Promise.all(orgsToFetch.map(fetchOrgProjectsSafe)); + allProjects = results.flat(); + } else { + allProjects = await fetchAllOrgProjects(); + } + + const filtered = filterByPlatform(allProjects, flags.platform); + const limitCount = + orgsToFetch.length > 1 ? flags.limit * orgsToFetch.length : flags.limit; + const limited = filtered.slice(0, limitCount); + + if (flags.json) { + writeJson(stdout, limited); + return; + } + + if (limited.length === 0) { + stdout.write("No projects found.\n"); + writeSelfHostedWarning(stdout, skippedSelfHosted); + return; + } + + displayProjectTable(stdout, limited); + + if (filtered.length > limited.length) { + stdout.write( + `\nShowing ${limited.length} of ${filtered.length} projects. Use --limit to show more.\n` + ); + } + + if (footer) { + stdout.write(`\n${footer}\n`); + } + writeSelfHostedWarning(stdout, skippedSelfHosted); + writeFooter( + stdout, + "Tip: Use 'sentry project view /' for details" + ); +} + +/** + * Handle explicit org/project targeting (e.g., sentry/sentry). + * Fetches the specific project directly via the API. + */ +async function handleExplicit( + stdout: Writer, + org: string, + projectSlug: string, + flags: ListFlags +): Promise { + let project: ProjectWithOrg; + try { + const result = await getProject(org, projectSlug); + project = { ...result, orgSlug: org }; + } catch (error) { + if (error instanceof AuthError) { + throw error; + } + if (flags.json) { + writeJson(stdout, []); + return; + } + stdout.write( + `No project '${projectSlug}' found in organization '${org}'.\n` + ); + writeFooter( + stdout, + `Tip: Use 'sentry project list ${org}/' to see all projects` + ); + return; + } + + const filtered = filterByPlatform([project], flags.platform); + + if (flags.json) { + writeJson(stdout, filtered); + return; + } + + if (filtered.length === 0) { + stdout.write( + `No project '${projectSlug}' found matching platform '${flags.platform}'.\n` + ); + return; + } + + displayProjectTable(stdout, filtered); + writeFooter( + stdout, + `Tip: Use 'sentry project view ${org}/${projectSlug}' for details` + ); +} + +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 listProjectsPaginated(org, { + cursor, + perPage: flags.limit, + }); + + const projects: ProjectWithOrg[] = response.data.map((p) => ({ + ...p, + orgSlug: org, + })); + + const filtered = filterByPlatform(projects, flags.platform); + + // Update cursor cache for `--cursor last` support + if (response.hasMore && response.nextCursor) { + setPaginationCursor(PAGINATION_KEY, contextKey, response.nextCursor); + } else { + clearPaginationCursor(PAGINATION_KEY, contextKey); + } + + if (flags.json) { + const output = response.hasMore + ? { data: filtered, nextCursor: response.nextCursor, hasMore: true } + : { data: filtered, hasMore: false }; + writeJson(stdout, output); + return; + } + + if (filtered.length === 0) { + if (response.hasMore) { + stdout.write( + `No matching projects on this page. Try the next page: sentry project list ${org}/ -c last\n` + ); + } else { + stdout.write(`No projects found in organization '${org}'.\n`); + } + return; + } + + displayProjectTable(stdout, filtered); + + if (response.hasMore) { + stdout.write(`\nShowing ${filtered.length} projects (more available)\n`); + stdout.write(`Next page: sentry project list ${org}/ -c last\n`); + } else { + stdout.write(`\nShowing ${filtered.length} projects\n`); + } + + writeFooter( + stdout, + "Tip: Use 'sentry project view /' for details" + ); +} + +/** + * Handle project-search mode (bare slug, e.g., "sentry"). + * Searches for the project across all accessible organizations. + */ +async function handleProjectSearch( + stdout: Writer, + projectSlug: string, + flags: ListFlags +): Promise { + const matches = await findProjectsBySlug(projectSlug); + const projects: ProjectWithOrg[] = matches.map((m) => ({ + ...m, + orgSlug: m.orgSlug, + })); + const filtered = filterByPlatform(projects, flags.platform); + + if (flags.json) { + writeJson(stdout, filtered); + return; + } + + if (filtered.length === 0) { + throw new ContextError( + "Project", + `No project '${projectSlug}' found in any accessible organization.\n\n` + + `Try: sentry project list /${projectSlug}` + ); + } + + displayProjectTable(stdout, filtered); + + if (filtered.length > 1) { + stdout.write( + `\nFound '${projectSlug}' in ${filtered.length} organizations\n` + ); + } + + writeFooter( + stdout, + "Tip: Use 'sentry project view /' for details" + ); +} + +/** Write self-hosted DSN warning if applicable */ +function writeSelfHostedWarning( + stdout: Writer, + skippedSelfHosted: number | undefined +): void { + if (skippedSelfHosted) { + stdout.write( + `\nNote: ${skippedSelfHosted} DSN(s) could not be resolved. ` + + "Specify the organization explicitly: sentry project list /\n" + ); + } +} + export const listCommand = buildCommand({ docs: { brief: "List projects", fullDescription: - "List projects in an organization. If no organization is specified, " + - "uses the default organization or lists projects from all accessible organizations.\n\n" + - "Examples:\n" + - " sentry project list # auto-detect or list all\n" + - " sentry project list my-org # list projects in my-org\n" + - " sentry project list --limit 10\n" + - " sentry project list --json\n" + - " sentry project list --platform javascript", + "List projects in an organization.\n\n" + + "Target specification:\n" + + " sentry project list # auto-detect from DSN or config\n" + + " sentry project list / # list all projects in org (paginated)\n" + + " sentry project list / # show specific project\n" + + " sentry project list # find project across all orgs\n\n" + + "Pagination:\n" + + " sentry project list / -c last # continue from last page\n" + + " sentry project list / -c # resume at specific cursor", }, parameters: { positional: { kind: "tuple", parameters: [ { - placeholder: "org", - brief: "Organization slug (optional)", + placeholder: "target", + brief: "Target: /, /, or ", parse: String, optional: true, }, @@ -227,6 +531,12 @@ 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, + }, platform: { kind: "parsed", parse: String, @@ -234,82 +544,57 @@ export const listCommand = buildCommand({ optional: true, }, }, - aliases: { n: "limit", p: "platform" }, + aliases: { n: "limit", p: "platform", 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 projects from all orgs (or all accessible if none detected) - let allProjects: ProjectWithOrg[]; - if (orgsToFetch.length > 0) { - const results = await Promise.all(orgsToFetch.map(fetchOrgProjectsSafe)); - allProjects = results.flat(); - } else { - allProjects = await fetchAllOrgProjects(); - } + const parsed = parseOrgProjectArg(target); - // Filter and limit (limit is per-org when multiple orgs) - const filtered = filterByPlatform(allProjects, flags.platform); - const limitCount = - orgsToFetch.length > 1 ? flags.limit * orgsToFetch.length : flags.limit; - const limited = filtered.slice(0, limitCount); - - if (flags.json) { - writeJson(stdout, limited); - return; + // 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" + ); } - if (limited.length === 0) { - const msg = - orgsToFetch.length === 1 - ? `No projects found in organization '${orgsToFetch[0]}'.\n` - : "No projects found.\n"; - stdout.write(msg); - return; - } + const contextKey = buildContextKey(parsed, flags); + const cursor = resolveCursor(flags.cursor, contextKey); - const { orgWidth, slugWidth, nameWidth } = - calculateProjectColumnWidths(limited); - writeHeader(stdout, orgWidth, slugWidth, nameWidth); - writeRows({ - stdout, - projects: limited, - orgWidth, - slugWidth, - nameWidth, - }); + switch (parsed.type) { + case "auto-detect": + await handleAutoDetect(stdout, cwd, flags); + break; - if (filtered.length > limited.length) { - stdout.write( - `\nShowing ${limited.length} of ${filtered.length} projects\n` - ); - } + case "explicit": + await handleExplicit(stdout, parsed.org, parsed.project, flags); + break; - if (footer) { - stdout.write(`\n${footer}\n`); - } + case "org-all": + 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 project list \n" - ); - } + case "project-search": + await handleProjectSearch(stdout, parsed.projectSlug, flags); + break; - writeFooter( - stdout, - "Tip: Use 'sentry project view /' for details" - ); + default: { + const _exhaustiveCheck: never = parsed; + throw new Error(`Unexpected parsed type: ${_exhaustiveCheck}`); + } + } }, }); diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index e2146845..3c2d2047 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -374,18 +374,79 @@ async function createRegionApiClient(regionUrl: string): Promise { }); } +/** Regex to extract rel attribute from Link header */ +const LINK_REL_REGEX = /rel="([^"]+)"/; + +/** Regex to extract results attribute from Link header */ +const LINK_RESULTS_REGEX = /results="([^"]+)"/; + +/** Regex to extract cursor attribute from Link header */ +const LINK_CURSOR_REGEX = /cursor="([^"]+)"/; + +/** Maximum number of pages to follow when auto-paginating (safety limit) */ +const MAX_PAGINATION_PAGES = 50; + +/** Paginated API response with cursor metadata */ +export type PaginatedResponse = { + /** The response data */ + data: T; + /** Cursor for fetching the next page (undefined if no more pages) */ + nextCursor?: string; + /** Whether more results exist beyond this page */ + hasMore: boolean; +}; + /** - * Make an authenticated request to a specific Sentry region. + * Parse Sentry's RFC 5988 Link response header to extract pagination cursors. + * + * Sentry Link header format: + * `; rel="next"; results="true"; cursor="1735689600000:0:0"` + * + * @param header - Raw Link header string + * @returns Parsed pagination info with next cursor if available + */ +function parseLinkHeader(header: string | null): { + nextCursor?: string; + hasMore: boolean; +} { + if (!header) { + return { hasMore: false }; + } + + // Split on comma to get individual link entries + for (const part of header.split(",")) { + const relMatch = part.match(LINK_REL_REGEX); + const resultsMatch = part.match(LINK_RESULTS_REGEX); + const cursorMatch = part.match(LINK_CURSOR_REGEX); + + if ( + relMatch?.[1] === "next" && + resultsMatch?.[1] === "true" && + cursorMatch?.[1] + ) { + return { nextCursor: cursorMatch[1], hasMore: true }; + } + } + + return { hasMore: false }; +} + +/** + * Make an authenticated request to a specific Sentry region, + * returning both parsed data and raw response headers. + * + * Used internally by pagination helpers that need access to the Link header. * * @param regionUrl - The region's base URL (e.g., https://us.sentry.io) * @param endpoint - API endpoint path (e.g., "/organizations/") * @param options - Request options + * @returns Parsed data and response headers */ -export async function apiRequestToRegion( +async function apiRequestToRegionWithHeaders( regionUrl: string, endpoint: string, options: ApiRequestOptions = {} -): Promise { +): Promise<{ data: T; headers: Headers }> { const { method = "GET", body, params, schema } = options; const client = await createRegionApiClient(regionUrl); @@ -417,12 +478,29 @@ export async function apiRequestToRegion( } const data = await response.json(); + const validated = schema ? schema.parse(data) : (data as T); - if (schema) { - return schema.parse(data); - } + return { data: validated, headers: response.headers }; +} - return data as T; +/** + * Make an authenticated request to a specific Sentry region. + * + * @param regionUrl - The region's base URL (e.g., https://us.sentry.io) + * @param endpoint - API endpoint path (e.g., "/organizations/") + * @param options - Request options + */ +export async function apiRequestToRegion( + regionUrl: string, + endpoint: string, + options: ApiRequestOptions = {} +): Promise { + const { data } = await apiRequestToRegionWithHeaders( + regionUrl, + endpoint, + options + ); + return data; } /** @@ -507,6 +585,83 @@ async function orgScopedRequest( return apiRequestToRegion(regionUrl, endpoint, options); } +/** + * Make an org-scoped API request that returns pagination metadata. + * Used for single-page fetches where the caller needs cursor info. + * + * @param endpoint - API endpoint path containing the org slug + * @param options - Request options + * @returns Response data with pagination cursor metadata + */ +async function orgScopedRequestPaginated( + endpoint: string, + options: ApiRequestOptions = {} +): Promise> { + const orgSlug = extractOrgSlugFromEndpoint(endpoint); + if (!orgSlug) { + throw new Error( + `Cannot extract org slug from endpoint: ${endpoint}. ` + + "Endpoint must match /organizations/{slug}/..." + ); + } + const { resolveOrgRegion } = await import("./region.js"); + const regionUrl = await resolveOrgRegion(orgSlug); + const { data, headers } = await apiRequestToRegionWithHeaders( + regionUrl, + endpoint, + options + ); + const { nextCursor, hasMore } = parseLinkHeader(headers.get("link")); + return { data, nextCursor, hasMore }; +} + +/** + * Auto-paginate through all pages of an org-scoped API endpoint. + * Follows cursor links until no more results or the safety limit is reached. + * + * @param endpoint - API endpoint path containing the org slug + * @param options - Request options (schema must validate an array type) + * @param perPage - Number of items per API page (default: 100) + * @returns Combined array of all results across all pages + */ +async function orgScopedPaginateAll( + endpoint: string, + options: ApiRequestOptions, + perPage = 100 +): Promise { + const allResults: T[] = []; + let cursor: string | undefined; + let truncated = false; + + for (let page = 0; page < MAX_PAGINATION_PAGES; page++) { + const params = { ...options.params, per_page: perPage, cursor }; + const response = await orgScopedRequestPaginated(endpoint, { + ...options, + params, + }); + allResults.push(...response.data); + + if (!(response.hasMore && response.nextCursor)) { + break; + } + cursor = response.nextCursor; + + // Detect if we're about to exit due to the safety limit + if (page === MAX_PAGINATION_PAGES - 1) { + truncated = true; + } + } + + if (truncated) { + console.error( + `Warning: Pagination limit reached (${MAX_PAGINATION_PAGES} pages, ${allResults.length} items). ` + + "Results may be incomplete for this organization." + ); + } + + return allResults; +} + /** * List all organizations the user has access to across all regions. * Performs a fan-out to each region and combines results. @@ -571,13 +726,40 @@ export function getOrganization(orgSlug: string): Promise { } /** - * List projects in an organization. + * List all projects in an organization. + * Automatically paginates through all API pages to return the complete list. * Uses region-aware routing for multi-region support. + * + * @param orgSlug - Organization slug + * @returns All projects in the organization */ export function listProjects(orgSlug: string): Promise { - return orgScopedRequest( + return orgScopedPaginateAll( + `/organizations/${orgSlug}/projects/`, + { schema: z.array(SentryProjectSchema) } + ); +} + +/** + * List projects in an organization with pagination control. + * Returns a single page of results with cursor metadata for manual pagination. + * Uses region-aware routing for multi-region support. + * + * @param orgSlug - Organization slug + * @param options - Pagination options + * @returns Single page of projects with cursor metadata + */ +export function listProjectsPaginated( + orgSlug: string, + options: { cursor?: string; perPage?: number } = {} +): Promise> { + return orgScopedRequestPaginated( `/organizations/${orgSlug}/projects/`, { + params: { + per_page: options.perPage ?? 100, + cursor: options.cursor, + }, schema: z.array(SentryProjectSchema), } ); diff --git a/src/lib/db/auth.ts b/src/lib/db/auth.ts index 8a1657aa..f426de95 100644 --- a/src/lib/db/auth.ts +++ b/src/lib/db/auth.ts @@ -98,9 +98,10 @@ export function clearAuth(): void { withDbSpan("clearAuth", () => { const db = getDatabase(); db.query("DELETE FROM auth WHERE id = 1").run(); - // Also clear user info and org region cache when logging out + // Also clear user info, org region cache, and pagination cursors when logging out db.query("DELETE FROM user_info WHERE id = 1").run(); db.query("DELETE FROM org_regions").run(); + db.query("DELETE FROM pagination_cursors").run(); }); } diff --git a/src/lib/db/pagination.ts b/src/lib/db/pagination.ts new file mode 100644 index 00000000..06d26680 --- /dev/null +++ b/src/lib/db/pagination.ts @@ -0,0 +1,99 @@ +/** + * Pagination cursor storage for `--cursor last` support. + * + * Stores the most recent "next page" cursor for each (command, context) pair, + * 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. + */ + +import { getDatabase } from "./index.js"; +import { runUpsert } from "./utils.js"; + +/** Default TTL for stored cursors: 5 minutes */ +const CURSOR_TTL_MS = 5 * 60 * 1000; + +type PaginationCursorRow = { + command_key: string; + cursor: string; + context: string; + expires_at: number; +}; + +/** + * Get a stored pagination cursor if it exists and hasn't expired. + * + * @param commandKey - Command identifier (e.g., "project-list") + * @param context - Serialized query context for lookup + * @returns The stored cursor string, or undefined if not found/expired + */ +export function getPaginationCursor( + commandKey: string, + context: string +): string | undefined { + const db = getDatabase(); + const row = db + .query( + "SELECT cursor, expires_at FROM pagination_cursors WHERE command_key = ? AND context = ?" + ) + .get(commandKey, context) as PaginationCursorRow | undefined; + + if (!row) { + return; + } + + // Check expiry + if (row.expires_at <= Date.now()) { + db.query( + "DELETE FROM pagination_cursors WHERE command_key = ? AND context = ?" + ).run(commandKey, context); + return; + } + + return row.cursor; +} + +/** + * Store a pagination cursor for later retrieval via `--cursor last`. + * + * @param commandKey - Command identifier (e.g., "project-list") + * @param context - Serialized query context for lookup + * @param cursor - The cursor string to store + * @param ttlMs - Time-to-live in milliseconds (default: 5 minutes) + */ +export function setPaginationCursor( + commandKey: string, + context: string, + cursor: string, + ttlMs = CURSOR_TTL_MS +): void { + const db = getDatabase(); + runUpsert( + db, + "pagination_cursors", + { + command_key: commandKey, + context, + cursor, + expires_at: Date.now() + ttlMs, + }, + ["command_key", "context"] + ); +} + +/** + * Remove the stored pagination cursor for a command and context. + * Called when a non-paginated result is displayed (no more pages). + * + * @param commandKey - Command identifier (e.g., "project-list") + * @param context - Serialized query context to clear + */ +export function clearPaginationCursor( + commandKey: string, + context: string +): void { + const db = getDatabase(); + db.query( + "DELETE FROM pagination_cursors WHERE command_key = ? AND context = ?" + ).run(commandKey, context); +} diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index 8f72a0ab..0ccd2937 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -13,7 +13,7 @@ import type { Database } from "bun:sqlite"; -export const CURRENT_SCHEMA_VERSION = 4; +export const CURRENT_SCHEMA_VERSION = 5; /** Environment variable to disable auto-repair */ const NO_AUTO_REPAIR_ENV = "SENTRY_CLI_NO_AUTO_REPAIR"; @@ -137,6 +137,14 @@ export const TABLE_SCHEMAS: Record = { }, }, }, + pagination_cursors: { + columns: { + command_key: { type: "TEXT", notNull: true }, + cursor: { type: "TEXT", notNull: true }, + context: { type: "TEXT", notNull: true }, + expires_at: { type: "INTEGER", notNull: true }, + }, + }, metadata: { columns: { key: { type: "TEXT", primaryKey: true }, @@ -357,8 +365,31 @@ export type RepairResult = { failed: string[]; }; +/** Tables that require custom DDL (not auto-generated from TABLE_SCHEMAS) */ +const CUSTOM_DDL_TABLES = new Set(["pagination_cursors"]); + +function repairPaginationCursorsTable( + db: Database, + result: RepairResult +): void { + if (tableExists(db, "pagination_cursors")) { + return; + } + try { + db.exec(PAGINATION_CURSORS_DDL); + result.fixed.push("Created table pagination_cursors"); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + result.failed.push(`Failed to create table pagination_cursors: ${msg}`); + } +} + function repairMissingTables(db: Database, result: RepairResult): void { for (const [tableName, ddl] of Object.entries(EXPECTED_TABLES)) { + // Skip tables that need custom DDL + if (CUSTOM_DDL_TABLES.has(tableName)) { + continue; + } if (tableExists(db, tableName)) { continue; } @@ -370,6 +401,9 @@ function repairMissingTables(db: Database, result: RepairResult): void { result.failed.push(`Failed to create table ${tableName}: ${msg}`); } } + + // Handle tables with custom DDL + repairPaginationCursorsTable(db, result); } function repairMissingColumns(db: Database, result: RepairResult): void { @@ -508,11 +542,32 @@ export function tryRepairAndRetry( return { attempted: false }; } +/** + * Custom DDL for pagination_cursors table with composite primary key. + * Uses (command_key, context) so different contexts (e.g., different orgs) + * can each store their own cursor independently. + */ +const PAGINATION_CURSORS_DDL = ` + CREATE TABLE IF NOT EXISTS pagination_cursors ( + command_key TEXT NOT NULL, + context TEXT NOT NULL, + cursor TEXT NOT NULL, + expires_at INTEGER NOT NULL, + PRIMARY KEY (command_key, context) + ) +`; + export function initSchema(db: Database): void { - // Generate combined DDL from all table schemas - const ddlStatements = Object.values(EXPECTED_TABLES).join(";\n\n"); + // Generate combined DDL from all table schemas (except those with custom DDL) + const ddlStatements = Object.entries(EXPECTED_TABLES) + .filter(([name]) => !CUSTOM_DDL_TABLES.has(name)) + .map(([, ddl]) => ddl) + .join(";\n\n"); db.exec(ddlStatements); + // Add tables with composite primary keys + db.exec(PAGINATION_CURSORS_DDL); + const versionRow = db .query("SELECT version FROM schema_version LIMIT 1") .get() as { version: number } | null; @@ -569,6 +624,12 @@ export function runMigrations(db: Database): void { db.exec(EXPECTED_TABLES.project_root_cache as string); } + // Migration 4 -> 5: Add pagination_cursors table for --cursor last support + // Uses custom DDL for composite primary key (command_key, context) + if (currentVersion < 5) { + db.exec(PAGINATION_CURSORS_DDL); + } + if (currentVersion < CURRENT_SCHEMA_VERSION) { db.query("UPDATE schema_version SET version = ?").run( CURRENT_SCHEMA_VERSION diff --git a/test/e2e/multiregion.test.ts b/test/e2e/multiregion.test.ts index 7ee701a6..0e270ba7 100644 --- a/test/e2e/multiregion.test.ts +++ b/test/e2e/multiregion.test.ts @@ -163,7 +163,7 @@ describe("multi-region", () => { // First list orgs to populate region cache await ctx.run(["org", "list"]); - const result = await ctx.run(["project", "list", "acme-corp"]); + const result = await ctx.run(["project", "list", "acme-corp/"]); expect(result.exitCode).toBe(0); // Should contain US projects for acme-corp @@ -182,7 +182,7 @@ describe("multi-region", () => { // First list orgs to populate region cache await ctx.run(["org", "list"]); - const result = await ctx.run(["project", "list", "euro-gmbh"]); + const result = await ctx.run(["project", "list", "euro-gmbh/"]); expect(result.exitCode).toBe(0); // Should contain EU projects for euro-gmbh @@ -204,12 +204,14 @@ describe("multi-region", () => { const result = await ctx.run([ "project", "list", - "berlin-startup", + "berlin-startup/", "--json", ]); expect(result.exitCode).toBe(0); - const data = JSON.parse(result.stdout); + // JSON output in paginated mode wraps data in { data, hasMore } + const parsed = JSON.parse(result.stdout); + const data = Array.isArray(parsed) ? parsed : parsed.data; expect(Array.isArray(data)).toBe(true); const slugs = data.map((p: { slug: string }) => p.slug); diff --git a/test/e2e/project.test.ts b/test/e2e/project.test.ts index 15506698..8d653dff 100644 --- a/test/e2e/project.test.ts +++ b/test/e2e/project.test.ts @@ -97,11 +97,11 @@ describe("sentry project list", () => { async () => { await ctx.setAuthToken(TEST_TOKEN); - // Use positional argument for organization + // Use org/ syntax for org-scoped listing const result = await ctx.run([ "project", "list", - TEST_ORG, + `${TEST_ORG}/`, "--limit", "5", ]); @@ -116,18 +116,20 @@ describe("sentry project list", () => { async () => { await ctx.setAuthToken(TEST_TOKEN); - // Use positional argument for organization + // Use org/ syntax for org-scoped listing const result = await ctx.run([ "project", "list", - TEST_ORG, + `${TEST_ORG}/`, "--json", "--limit", "5", ]); expect(result.exitCode).toBe(0); - const data = JSON.parse(result.stdout); + // JSON output in paginated mode wraps data in { data, hasMore } + const parsed = JSON.parse(result.stdout); + const data = Array.isArray(parsed) ? parsed : parsed.data; expect(Array.isArray(data)).toBe(true); }, { timeout: 15_000 } From c033189d0300810cbb4144b342ce30be28c3b45f Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 10 Feb 2026 10:59:08 +0000 Subject: [PATCH 02/27] ci: trigger CI workflow From 9100231ed22d25a182916eb9002037ee8d6c6673 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 10 Feb 2026 11:22:41 +0000 Subject: [PATCH 03/27] test: add property and model-based tests for pagination and project list - Property-based tests for parseLinkHeader (RFC 5988 Link header parsing) - Model-based tests for pagination cursor storage (composite PK, TTL, upsert) - Unit + property tests for project list helpers (buildContextKey, filterByPlatform, resolveCursor) - Handler tests for handleExplicit, handleOrgAll, handleProjectSearch with fetch mocking - Verify clearAuth cross-cutting invariant clears pagination cursors - Export parseLinkHeader and project list helpers for testability --- src/commands/project/list.ts | 24 +- src/lib/api-client.ts | 2 +- test/commands/project/list.test.ts | 835 +++++++++++++++++++++ test/lib/api-client.property.test.ts | 193 +++++ test/lib/db/model-based.test.ts | 36 + test/lib/db/pagination.model-based.test.ts | 348 +++++++++ 6 files changed, 1425 insertions(+), 13 deletions(-) create mode 100644 test/commands/project/list.test.ts create mode 100644 test/lib/api-client.property.test.ts create mode 100644 test/lib/db/pagination.model-based.test.ts diff --git a/src/commands/project/list.ts b/src/commands/project/list.ts index 1b0d2413..1a406196 100644 --- a/src/commands/project/list.ts +++ b/src/commands/project/list.ts @@ -41,7 +41,7 @@ import { resolveAllTargets } from "../../lib/resolve-target.js"; import type { SentryProject, Writer } from "../../types/index.js"; /** Command key for pagination cursor storage */ -const PAGINATION_KEY = "project-list"; +export const PAGINATION_KEY = "project-list"; type ListFlags = { readonly limit: number; @@ -113,7 +113,7 @@ async function fetchAllOrgProjects(): Promise { * @param platform - Platform substring to match (e.g., "javascript", "python") * @returns Filtered projects, or all projects if no platform specified */ -function filterByPlatform( +export function filterByPlatform( projects: ProjectWithOrg[], platform?: string ): ProjectWithOrg[] { @@ -129,7 +129,7 @@ function filterByPlatform( /** * Write the column header row for project list output. */ -function writeHeader( +export function writeHeader( stdout: Writer, orgWidth: number, slugWidth: number, @@ -141,7 +141,7 @@ function writeHeader( stdout.write(`${org} ${project} ${name} PLATFORM\n`); } -type WriteRowsOptions = { +export type WriteRowsOptions = { stdout: Writer; projects: ProjectWithOrg[]; orgWidth: number; @@ -152,7 +152,7 @@ type WriteRowsOptions = { /** * Write formatted project rows to stdout. */ -function writeRows(options: WriteRowsOptions): void { +export function writeRows(options: WriteRowsOptions): void { const { stdout, projects, orgWidth, slugWidth, nameWidth } = options; for (const project of projects) { stdout.write( @@ -166,7 +166,7 @@ function writeRows(options: WriteRowsOptions): void { * Captures the query parameters that affect result ordering, * so cursors from different queries are not accidentally reused. */ -function buildContextKey( +export function buildContextKey( parsed: ParsedOrgProject, flags: { platform?: string } ): string { @@ -191,7 +191,7 @@ function buildContextKey( * Resolve the cursor value from --cursor flag. * Handles the magic "last" value by looking up the cached cursor. */ -function resolveCursor( +export function resolveCursor( cursorFlag: string | undefined, contextKey: string ): string | undefined { @@ -319,7 +319,7 @@ async function handleAutoDetect( * Handle explicit org/project targeting (e.g., sentry/sentry). * Fetches the specific project directly via the API. */ -async function handleExplicit( +export async function handleExplicit( stdout: Writer, org: string, projectSlug: string, @@ -368,7 +368,7 @@ async function handleExplicit( ); } -type OrgAllOptions = { +export type OrgAllOptions = { stdout: Writer; org: string; flags: ListFlags; @@ -380,7 +380,7 @@ type OrgAllOptions = { * Handle org-all mode (e.g., sentry/). * Uses cursor pagination for efficient page-by-page listing. */ -async function handleOrgAll(options: OrgAllOptions): Promise { +export async function handleOrgAll(options: OrgAllOptions): Promise { const { stdout, org, flags, contextKey, cursor } = options; const response: PaginatedResponse = await listProjectsPaginated(org, { @@ -440,7 +440,7 @@ async function handleOrgAll(options: OrgAllOptions): Promise { * Handle project-search mode (bare slug, e.g., "sentry"). * Searches for the project across all accessible organizations. */ -async function handleProjectSearch( +export async function handleProjectSearch( stdout: Writer, projectSlug: string, flags: ListFlags @@ -480,7 +480,7 @@ async function handleProjectSearch( } /** Write self-hosted DSN warning if applicable */ -function writeSelfHostedWarning( +export function writeSelfHostedWarning( stdout: Writer, skippedSelfHosted: number | undefined ): void { diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index 3c2d2047..487ff134 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -405,7 +405,7 @@ export type PaginatedResponse = { * @param header - Raw Link header string * @returns Parsed pagination info with next cursor if available */ -function parseLinkHeader(header: string | null): { +export function parseLinkHeader(header: string | null): { nextCursor?: string; hasMore: boolean; } { diff --git a/test/commands/project/list.test.ts b/test/commands/project/list.test.ts new file mode 100644 index 00000000..6993700f --- /dev/null +++ b/test/commands/project/list.test.ts @@ -0,0 +1,835 @@ +/** + * Unit Tests for Project List Command + * + * Tests the exported helper functions and handler functions. + * Handlers are tested with fetch mocking for API isolation. + */ + +// biome-ignore-all lint/suspicious/noMisplacedAssertion: Property tests use expect() inside fast-check callbacks. + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { + array, + constantFrom, + assert as fcAssert, + property, + tuple, +} from "fast-check"; +import { + buildContextKey, + filterByPlatform, + handleExplicit, + handleOrgAll, + handleProjectSearch, + PAGINATION_KEY, + resolveCursor, + writeHeader, + writeRows, + writeSelfHostedWarning, +} from "../../../src/commands/project/list.js"; +import type { ParsedOrgProject } from "../../../src/lib/arg-parsing.js"; +import { DEFAULT_SENTRY_URL } from "../../../src/lib/constants.js"; +import { setAuthToken } from "../../../src/lib/db/auth.js"; +import { CONFIG_DIR_ENV_VAR } from "../../../src/lib/db/index.js"; +import { + getPaginationCursor, + setPaginationCursor, +} from "../../../src/lib/db/pagination.js"; +import { setOrgRegion } from "../../../src/lib/db/regions.js"; +import { ContextError } from "../../../src/lib/errors.js"; +import type { SentryProject, Writer } from "../../../src/types/index.js"; +import { cleanupTestDir, createTestConfigDir } from "../../helpers.js"; +import { DEFAULT_NUM_RUNS } from "../../model-based/helpers.js"; + +// Test config directory for DB-dependent tests +let testConfigDir: string; + +beforeEach(async () => { + testConfigDir = await createTestConfigDir("test-project-list-", { + isolateProjectRoot: true, + }); + process.env[CONFIG_DIR_ENV_VAR] = testConfigDir; +}); + +afterEach(async () => { + await cleanupTestDir(testConfigDir); +}); + +/** Capture stdout writes */ +function createCapture(): { writer: Writer; output: () => string } { + const chunks: string[] = []; + return { + writer: { + write: (s: string) => { + chunks.push(s); + return true; + }, + } as Writer, + output: () => chunks.join(""), + }; +} + +/** Create a minimal project for testing */ +function makeProject( + overrides: Partial & { orgSlug?: string } = {} +): SentryProject & { orgSlug?: string } { + return { + id: "1", + slug: "test-project", + name: "Test Project", + platform: "javascript", + dateCreated: "2024-01-01T00:00:00Z", + status: "active", + ...overrides, + }; +} + +// Arbitraries + +const slugArb = array( + constantFrom(..."abcdefghijklmnopqrstuvwxyz0123456789".split("")), + { + minLength: 1, + maxLength: 12, + } +).map((chars) => chars.join("")); + +const platformArb = constantFrom( + "javascript", + "python", + "go", + "java", + "ruby", + "php", + "javascript-react", + "python-django" +); + +// Tests + +describe("buildContextKey", () => { + test("org-all mode produces org: prefix", () => { + fcAssert( + property(slugArb, (org) => { + const parsed: ParsedOrgProject = { type: "org-all", org }; + const key = buildContextKey(parsed, {}); + expect(key).toBe(`org:${org}`); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("auto-detect mode produces 'auto'", () => { + const parsed: ParsedOrgProject = { type: "auto-detect" }; + expect(buildContextKey(parsed, {})).toBe("auto"); + }); + + test("explicit mode produces type:explicit", () => { + fcAssert( + property(tuple(slugArb, slugArb), ([org, project]) => { + const parsed: ParsedOrgProject = { type: "explicit", org, project }; + const key = buildContextKey(parsed, {}); + expect(key).toBe("type:explicit"); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("project-search mode produces type:project-search", () => { + fcAssert( + property(slugArb, (projectSlug) => { + const parsed: ParsedOrgProject = { + type: "project-search", + projectSlug, + }; + const key = buildContextKey(parsed, {}); + expect(key).toBe("type:project-search"); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("platform flag is appended with pipe separator", () => { + fcAssert( + property(tuple(slugArb, platformArb), ([org, platform]) => { + const parsed: ParsedOrgProject = { type: "org-all", org }; + const key = buildContextKey(parsed, { platform }); + expect(key).toBe(`org:${org}|platform:${platform}`); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("no platform flag means no pipe in key", () => { + fcAssert( + property(slugArb, (org) => { + const parsed: ParsedOrgProject = { type: "org-all", org }; + const key = buildContextKey(parsed, {}); + expect(key).not.toContain("|"); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +describe("filterByPlatform", () => { + test("no platform returns all projects", () => { + const projects = [ + makeProject({ platform: "javascript" }), + makeProject({ platform: "python" }), + ]; + expect(filterByPlatform(projects)).toHaveLength(2); + expect(filterByPlatform(projects, undefined)).toHaveLength(2); + }); + + test("case-insensitive partial match", () => { + const projects = [ + makeProject({ slug: "web", platform: "javascript-react" }), + makeProject({ slug: "api", platform: "python-django" }), + makeProject({ slug: "cli", platform: "javascript" }), + ]; + + // Partial match + expect(filterByPlatform(projects, "javascript")).toHaveLength(2); + expect(filterByPlatform(projects, "python")).toHaveLength(1); + + // Case-insensitive + expect(filterByPlatform(projects, "JAVASCRIPT")).toHaveLength(2); + expect(filterByPlatform(projects, "Python")).toHaveLength(1); + }); + + test("no match returns empty array", () => { + const projects = [makeProject({ platform: "javascript" })]; + expect(filterByPlatform(projects, "rust")).toHaveLength(0); + }); + + test("null platform in project is not matched", () => { + const projects = [makeProject({ platform: null as unknown as string })]; + expect(filterByPlatform(projects, "javascript")).toHaveLength(0); + }); + + test("property: filtering is idempotent", () => { + fcAssert( + property(platformArb, (platform) => { + const projects = [ + makeProject({ slug: "a", platform: "javascript-react" }), + makeProject({ slug: "b", platform: "python-django" }), + makeProject({ slug: "c", platform: "go" }), + ]; + const once = filterByPlatform(projects, platform); + const twice = filterByPlatform(once, platform); + expect(twice).toEqual(once); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("property: filtered result is subset of input", () => { + fcAssert( + property(platformArb, (platform) => { + const projects = [ + makeProject({ slug: "a", platform: "javascript" }), + makeProject({ slug: "b", platform: "python" }), + makeProject({ slug: "c", platform: "go" }), + ]; + const filtered = filterByPlatform(projects, platform); + expect(filtered.length).toBeLessThanOrEqual(projects.length); + for (const p of filtered) { + expect(projects).toContain(p); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +describe("resolveCursor", () => { + test("undefined cursor returns undefined", () => { + expect(resolveCursor(undefined, "org:sentry")).toBeUndefined(); + }); + + test("explicit cursor value is passed through", () => { + expect(resolveCursor("1735689600000:100:0", "org:sentry")).toBe( + "1735689600000:100:0" + ); + }); + + test("'last' with no cached cursor throws ContextError", () => { + expect(() => resolveCursor("last", "org:sentry")).toThrow(ContextError); + expect(() => resolveCursor("last", "org:sentry")).toThrow( + /No saved cursor/ + ); + }); + + test("'last' with cached cursor returns the cached value", () => { + const cursor = "1735689600000:100:0"; + const contextKey = "org:test-resolve"; + setPaginationCursor(PAGINATION_KEY, contextKey, cursor, 300_000); + + const result = resolveCursor("last", contextKey); + expect(result).toBe(cursor); + }); + + test("'last' with expired cursor throws ContextError", () => { + const contextKey = "org:test-expired"; + setPaginationCursor(PAGINATION_KEY, contextKey, "old-cursor", -1000); + + expect(() => resolveCursor("last", contextKey)).toThrow(ContextError); + }); +}); + +describe("writeHeader", () => { + test("writes formatted header line", () => { + const { writer, output } = createCapture(); + writeHeader(writer, 10, 15, 20); + const line = output(); + expect(line).toContain("ORG"); + expect(line).toContain("PROJECT"); + expect(line).toContain("NAME"); + expect(line).toContain("PLATFORM"); + expect(line).toEndWith("\n"); + }); + + test("respects column widths", () => { + const { writer, output } = createCapture(); + writeHeader(writer, 5, 10, 8); + const line = output(); + // "ORG" padded to 5, "PROJECT" padded to 10, "NAME" padded to 8 + expect(line).toMatch(/^ORG\s{2}\s+PROJECT\s+NAME\s+PLATFORM\n$/); + }); +}); + +describe("writeRows", () => { + test("writes one line per project", () => { + const { writer, output } = createCapture(); + const projects = [ + makeProject({ + slug: "proj-a", + name: "Project A", + platform: "javascript", + orgSlug: "org1", + }), + makeProject({ + slug: "proj-b", + name: "Project B", + platform: "python", + orgSlug: "org2", + }), + ]; + writeRows({ + stdout: writer, + projects, + orgWidth: 10, + slugWidth: 15, + nameWidth: 20, + }); + const lines = output().split("\n").filter(Boolean); + expect(lines).toHaveLength(2); + }); +}); + +describe("writeSelfHostedWarning", () => { + test("writes nothing when skippedSelfHosted is undefined", () => { + const { writer, output } = createCapture(); + writeSelfHostedWarning(writer, undefined); + expect(output()).toBe(""); + }); + + test("writes nothing when skippedSelfHosted is 0", () => { + const { writer, output } = createCapture(); + writeSelfHostedWarning(writer, 0); + expect(output()).toBe(""); + }); + + test("writes warning when skippedSelfHosted > 0", () => { + const { writer, output } = createCapture(); + writeSelfHostedWarning(writer, 3); + const text = output(); + expect(text).toContain("3 DSN(s)"); + expect(text).toContain("could not be resolved"); + }); +}); + +// Handler tests with fetch mocking + +let originalFetch: typeof globalThis.fetch; + +/** Create a mock fetch for project API calls */ +function mockProjectFetch( + projects: SentryProject[], + options: { hasMore?: boolean; nextCursor?: string } = {} +): typeof globalThis.fetch { + const { hasMore = false, nextCursor } = options; + // @ts-expect-error - partial mock + return async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + const url = req.url; + + // getProject (single project fetch via /projects/{org}/{slug}/) + if (url.match(/\/projects\/[^/]+\/[^/]+\//)) { + if (projects.length > 0) { + return new Response(JSON.stringify(projects[0]), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + return new Response(JSON.stringify({ detail: "Not found" }), { + status: 404, + }); + } + + // listProjects / listProjectsPaginated (via /organizations/{org}/projects/) + if (url.includes("/projects/")) { + const linkParts: string[] = [ + `<${url}>; rel="previous"; results="false"; cursor="0:0:1"`, + ]; + if (hasMore && nextCursor) { + linkParts.push( + `<${url}>; rel="next"; results="true"; cursor="${nextCursor}"` + ); + } else { + linkParts.push(`<${url}>; rel="next"; results="false"; cursor="0:0:0"`); + } + return new Response(JSON.stringify(projects), { + status: 200, + headers: { + "Content-Type": "application/json", + Link: linkParts.join(", "), + }, + }); + } + + // listOrganizations + if ( + url.includes("/organizations/") && + !url.includes("/projects/") && + !url.includes("/issues/") + ) { + return new Response( + JSON.stringify([{ id: "1", slug: "test-org", name: "Test Org" }]), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); + } + + return new Response(JSON.stringify({ detail: "Not found" }), { + status: 404, + }); + }; +} + +const sampleProjects: SentryProject[] = [ + { + id: "1", + slug: "frontend", + name: "Frontend", + platform: "javascript", + dateCreated: "2024-01-01T00:00:00Z", + status: "active", + }, + { + id: "2", + slug: "backend", + name: "Backend", + platform: "python", + dateCreated: "2024-01-01T00:00:00Z", + status: "active", + }, +]; + +describe("handleExplicit", () => { + beforeEach(async () => { + originalFetch = globalThis.fetch; + await setAuthToken("test-token"); + await setOrgRegion("test-org", DEFAULT_SENTRY_URL); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + test("displays single project", async () => { + globalThis.fetch = mockProjectFetch(sampleProjects); + const { writer, output } = createCapture(); + + await handleExplicit(writer, "test-org", "frontend", { + limit: 30, + json: false, + }); + + const text = output(); + expect(text).toContain("ORG"); + expect(text).toContain("frontend"); + }); + + test("--json outputs JSON array", async () => { + globalThis.fetch = mockProjectFetch(sampleProjects); + const { writer, output } = createCapture(); + + await handleExplicit(writer, "test-org", "frontend", { + limit: 30, + json: true, + }); + + const parsed = JSON.parse(output()); + expect(Array.isArray(parsed)).toBe(true); + expect(parsed).toHaveLength(1); + }); + + test("not found shows message", async () => { + globalThis.fetch = mockProjectFetch([]); + const { writer, output } = createCapture(); + + await handleExplicit(writer, "test-org", "nonexistent", { + limit: 30, + json: false, + }); + + const text = output(); + expect(text).toContain("No project"); + expect(text).toContain("nonexistent"); + expect(text).toContain("Tip:"); + }); + + test("not found with --json outputs empty array", async () => { + globalThis.fetch = mockProjectFetch([]); + const { writer, output } = createCapture(); + + await handleExplicit(writer, "test-org", "nonexistent", { + limit: 30, + json: true, + }); + + const parsed = JSON.parse(output()); + expect(parsed).toHaveLength(0); + }); + + test("platform filter with no match shows message", async () => { + globalThis.fetch = mockProjectFetch(sampleProjects); + const { writer, output } = createCapture(); + + await handleExplicit(writer, "test-org", "frontend", { + limit: 30, + json: false, + platform: "ruby", + }); + + const text = output(); + expect(text).toContain("No project"); + expect(text).toContain("platform"); + }); + + test("platform filter match shows project", async () => { + globalThis.fetch = mockProjectFetch(sampleProjects); + const { writer, output } = createCapture(); + + await handleExplicit(writer, "test-org", "frontend", { + limit: 30, + json: false, + platform: "javascript", + }); + + const text = output(); + expect(text).toContain("frontend"); + expect(text).toContain("ORG"); + }); +}); + +describe("handleOrgAll", () => { + beforeEach(async () => { + originalFetch = globalThis.fetch; + await setAuthToken("test-token"); + await setOrgRegion("test-org", DEFAULT_SENTRY_URL); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + test("displays paginated project list", async () => { + globalThis.fetch = mockProjectFetch(sampleProjects); + const { writer, output } = createCapture(); + + await handleOrgAll({ + stdout: writer, + org: "test-org", + flags: { limit: 30, json: false }, + contextKey: "org:test-org", + cursor: undefined, + }); + + const text = output(); + expect(text).toContain("ORG"); + expect(text).toContain("frontend"); + expect(text).toContain("backend"); + expect(text).toContain("Showing 2 projects"); + }); + + test("--json with hasMore includes nextCursor", async () => { + globalThis.fetch = mockProjectFetch(sampleProjects, { + hasMore: true, + nextCursor: "1735689600000:100:0", + }); + const { writer, output } = createCapture(); + + await handleOrgAll({ + stdout: writer, + org: "test-org", + flags: { limit: 30, json: true }, + contextKey: "org:test-org", + cursor: undefined, + }); + + const parsed = JSON.parse(output()); + expect(parsed.hasMore).toBe(true); + expect(parsed.nextCursor).toBe("1735689600000:100:0"); + expect(parsed.data).toHaveLength(2); + }); + + test("--json without hasMore shows hasMore: false", async () => { + globalThis.fetch = mockProjectFetch(sampleProjects); + const { writer, output } = createCapture(); + + await handleOrgAll({ + stdout: writer, + org: "test-org", + flags: { limit: 30, json: true }, + contextKey: "org:test-org", + cursor: undefined, + }); + + const parsed = JSON.parse(output()); + expect(parsed.hasMore).toBe(false); + expect(parsed.data).toHaveLength(2); + }); + + test("hasMore saves cursor for --cursor last", async () => { + globalThis.fetch = mockProjectFetch(sampleProjects, { + hasMore: true, + nextCursor: "1735689600000:100:0", + }); + const { writer } = createCapture(); + + await handleOrgAll({ + stdout: writer, + org: "test-org", + flags: { limit: 30, json: false }, + contextKey: "org:test-org", + cursor: undefined, + }); + + const cached = getPaginationCursor(PAGINATION_KEY, "org:test-org"); + expect(cached).toBe("1735689600000:100:0"); + }); + + test("no hasMore clears cached cursor", async () => { + setPaginationCursor(PAGINATION_KEY, "org:test-org", "old-cursor", 300_000); + + globalThis.fetch = mockProjectFetch(sampleProjects); + const { writer } = createCapture(); + + await handleOrgAll({ + stdout: writer, + org: "test-org", + flags: { limit: 30, json: false }, + contextKey: "org:test-org", + cursor: undefined, + }); + + const cached = getPaginationCursor(PAGINATION_KEY, "org:test-org"); + expect(cached).toBeUndefined(); + }); + + test("empty page with hasMore suggests next page", async () => { + globalThis.fetch = mockProjectFetch([], { + hasMore: true, + nextCursor: "1735689600000:100:0", + }); + const { writer, output } = createCapture(); + + await handleOrgAll({ + stdout: writer, + org: "test-org", + flags: { limit: 30, json: false, platform: "rust" }, + contextKey: "org:test-org", + cursor: undefined, + }); + + const text = output(); + expect(text).toContain("No matching projects on this page"); + expect(text).toContain("-c last"); + }); + + test("empty page without hasMore shows no projects", async () => { + globalThis.fetch = mockProjectFetch([]); + const { writer, output } = createCapture(); + + await handleOrgAll({ + stdout: writer, + org: "test-org", + flags: { limit: 30, json: false }, + contextKey: "org:test-org", + cursor: undefined, + }); + + const text = output(); + expect(text).toContain("No projects found"); + }); + + test("hasMore shows next page hint", async () => { + globalThis.fetch = mockProjectFetch(sampleProjects, { + hasMore: true, + nextCursor: "1735689600000:100:0", + }); + const { writer, output } = createCapture(); + + await handleOrgAll({ + stdout: writer, + org: "test-org", + flags: { limit: 30, json: false }, + contextKey: "org:test-org", + cursor: undefined, + }); + + const text = output(); + expect(text).toContain("more available"); + expect(text).toContain("-c last"); + }); +}); + +describe("handleProjectSearch", () => { + beforeEach(async () => { + originalFetch = globalThis.fetch; + await setAuthToken("test-token"); + await setOrgRegion("test-org", DEFAULT_SENTRY_URL); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + test("finds project across orgs", async () => { + globalThis.fetch = mockProjectFetch(sampleProjects); + const { writer, output } = createCapture(); + + await handleProjectSearch(writer, "frontend", { + limit: 30, + json: false, + }); + + const text = output(); + expect(text).toContain("frontend"); + }); + + test("--json outputs JSON array", async () => { + globalThis.fetch = mockProjectFetch(sampleProjects); + const { writer, output } = createCapture(); + + await handleProjectSearch(writer, "frontend", { + limit: 30, + json: true, + }); + + const parsed = JSON.parse(output()); + expect(Array.isArray(parsed)).toBe(true); + }); + + test("not found throws ContextError", async () => { + // Mock returning empty projects + // @ts-expect-error - partial mock + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + const url = req.url; + + if (url.includes("/organizations/") && !url.includes("/projects/")) { + return new Response( + JSON.stringify([{ id: "1", slug: "test-org", name: "Test Org" }]), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); + } + + if (url.includes("/projects/")) { + return new Response(JSON.stringify([]), { + status: 200, + headers: { + "Content-Type": "application/json", + Link: '; rel="next"; results="false"; cursor="0:0:0"', + }, + }); + } + + return new Response(JSON.stringify({ detail: "Not found" }), { + status: 404, + }); + }; + + const { writer } = createCapture(); + + await expect( + handleProjectSearch(writer, "nonexistent", { + limit: 30, + json: false, + }) + ).rejects.toThrow(ContextError); + }); + + test("not found with --json outputs empty array", async () => { + // @ts-expect-error - partial mock + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + const url = req.url; + + if (url.includes("/organizations/") && !url.includes("/projects/")) { + return new Response( + JSON.stringify([{ id: "1", slug: "test-org", name: "Test Org" }]), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); + } + + if (url.includes("/projects/")) { + return new Response(JSON.stringify([]), { + status: 200, + headers: { + "Content-Type": "application/json", + Link: '; rel="next"; results="false"; cursor="0:0:0"', + }, + }); + } + + return new Response(JSON.stringify({ detail: "Not found" }), { + status: 404, + }); + }; + + const { writer, output } = createCapture(); + + await handleProjectSearch(writer, "nonexistent", { + limit: 30, + json: true, + }); + + const parsed = JSON.parse(output()); + expect(parsed).toHaveLength(0); + }); + + test("multiple results shows count", async () => { + globalThis.fetch = mockProjectFetch([...sampleProjects, ...sampleProjects]); + const { writer, output } = createCapture(); + + await handleProjectSearch(writer, "frontend", { + limit: 30, + json: false, + }); + + const text = output(); + expect(text).toContain("frontend"); + }); +}); diff --git a/test/lib/api-client.property.test.ts b/test/lib/api-client.property.test.ts new file mode 100644 index 00000000..efeb5851 --- /dev/null +++ b/test/lib/api-client.property.test.ts @@ -0,0 +1,193 @@ +/** + * Property-Based Tests for API Client Pagination Helpers + * + * Tests parseLinkHeader — the pure function that extracts pagination cursors + * from Sentry's RFC 5988 Link response headers. + */ + +import { describe, expect, test } from "bun:test"; +import { + constantFrom, + assert as fcAssert, + nat, + property, + string, + tuple, +} from "fast-check"; +import { parseLinkHeader } from "../../src/lib/api-client.js"; +import { DEFAULT_NUM_RUNS } from "../model-based/helpers.js"; + +// Arbitraries + +/** Generate a Sentry-style cursor: `timestamp:offset:is_prev` */ +const cursorArb = tuple( + nat(2_000_000_000_000), + nat(100), + constantFrom(0, 1) +).map(([ts, offset, isPrev]) => `${ts}:${offset}:${isPrev}`); + +/** Generate a valid "next" link part with results="true" and a cursor */ +const nextLinkWithResultsArb = cursorArb.map( + (cursor) => + `; rel="next"; results="true"; cursor="${cursor}"` +); + +/** Generate a "next" link part with results="false" */ +const nextLinkNoResultsArb = cursorArb.map( + (cursor) => + `; rel="next"; results="false"; cursor="${cursor}"` +); + +/** Generate a "previous" link part (should be ignored by parseLinkHeader) */ +const prevLinkArb = cursorArb.map( + (cursor) => + `; rel="previous"; results="true"; cursor="${cursor}"` +); + +/** Generate a rel value that is not "next" */ +const nonNextRelArb = constantFrom("previous", "first", "last", "self"); + +describe("property: parseLinkHeader", () => { + test("null or empty header returns hasMore=false, no cursor", () => { + const result1 = parseLinkHeader(null); + expect(result1).toEqual({ hasMore: false }); + + const result2 = parseLinkHeader(""); + expect(result2).toEqual({ hasMore: false }); + }); + + test("valid next link with results=true returns cursor and hasMore=true", () => { + fcAssert( + property(nextLinkWithResultsArb, (header) => { + const result = parseLinkHeader(header); + expect(result.hasMore).toBe(true); + expect(result.nextCursor).toBeDefined(); + expect(result.nextCursor).toMatch(/^\d+:\d+:\d+$/); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("next link with results=false returns hasMore=false, no cursor", () => { + fcAssert( + property(nextLinkNoResultsArb, (header) => { + const result = parseLinkHeader(header); + expect(result.hasMore).toBe(false); + expect(result.nextCursor).toBeUndefined(); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("previous-only link returns hasMore=false (ignores non-next)", () => { + fcAssert( + property(prevLinkArb, (header) => { + const result = parseLinkHeader(header); + expect(result.hasMore).toBe(false); + expect(result.nextCursor).toBeUndefined(); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("combined prev + next link extracts cursor from next part", () => { + fcAssert( + property(tuple(prevLinkArb, nextLinkWithResultsArb), ([prev, next]) => { + // Sentry sends both prev and next separated by comma + const header = `${prev}, ${next}`; + const result = parseLinkHeader(header); + expect(result.hasMore).toBe(true); + expect(result.nextCursor).toBeDefined(); + expect(result.nextCursor).toMatch(/^\d+:\d+:\d+$/); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("cursor value is preserved exactly", () => { + fcAssert( + property(cursorArb, (cursor) => { + const header = `; rel="next"; results="true"; cursor="${cursor}"`; + const result = parseLinkHeader(header); + expect(result.nextCursor).toBe(cursor); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("missing cursor attribute returns hasMore=false", () => { + const header = + '; rel="next"; results="true"'; + const result = parseLinkHeader(header); + expect(result.hasMore).toBe(false); + }); + + test("missing results attribute returns hasMore=false", () => { + fcAssert( + property(cursorArb, (cursor) => { + const header = `; rel="next"; cursor="${cursor}"`; + const result = parseLinkHeader(header); + expect(result.hasMore).toBe(false); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("non-next rel with results=true returns hasMore=false", () => { + fcAssert( + property(tuple(nonNextRelArb, cursorArb), ([rel, cursor]) => { + const header = `; rel="${rel}"; results="true"; cursor="${cursor}"`; + const result = parseLinkHeader(header); + expect(result.hasMore).toBe(false); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("multiple parts: only next with results=true is extracted", () => { + fcAssert( + property(tuple(cursorArb, cursorArb), ([prevCursor, nextCursor]) => { + const header = [ + `; rel="previous"; results="false"; cursor="${prevCursor}"`, + `; rel="next"; results="true"; cursor="${nextCursor}"`, + ].join(", "); + const result = parseLinkHeader(header); + expect(result.hasMore).toBe(true); + expect(result.nextCursor).toBe(nextCursor); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("random strings without expected attributes return hasMore=false", () => { + fcAssert( + property(string({ minLength: 0, maxLength: 200 }), (header) => { + // Any random string should not crash and should return a valid result + const result = parseLinkHeader(header); + expect(typeof result.hasMore).toBe("boolean"); + if (result.nextCursor !== undefined) { + expect(typeof result.nextCursor).toBe("string"); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("real Sentry response header with both links", () => { + const header = + '; rel="previous"; results="false"; cursor="1735689600000:0:1", ' + + '; rel="next"; results="true"; cursor="1735689600000:100:0"'; + const result = parseLinkHeader(header); + expect(result.hasMore).toBe(true); + expect(result.nextCursor).toBe("1735689600000:100:0"); + }); + + test("real Sentry last-page response header", () => { + const header = + '; rel="previous"; results="true"; cursor="1735689600000:0:1", ' + + '; rel="next"; results="false"; cursor="1735689600000:200:0"'; + const result = parseLinkHeader(header); + expect(result.hasMore).toBe(false); + expect(result.nextCursor).toBeUndefined(); + }); +}); diff --git a/test/lib/db/model-based.test.ts b/test/lib/db/model-based.test.ts index 9a490958..6b62756b 100644 --- a/test/lib/db/model-based.test.ts +++ b/test/lib/db/model-based.test.ts @@ -37,6 +37,10 @@ import { isAuthenticated, setAuthToken, } from "../../../src/lib/db/auth.js"; +import { + getPaginationCursor, + setPaginationCursor, +} from "../../../src/lib/db/pagination.js"; import { clearProjectAliases, getProjectAliases, @@ -676,6 +680,38 @@ describe("model-based: database layer", () => { ); }); + test("clearAuth also clears pagination cursors (key invariant)", () => { + fcAssert( + asyncProperty(tuple(slugArb, slugArb), async ([commandKey, context]) => { + const cleanup = createIsolatedDbContext(); + try { + // Set up auth and a pagination cursor + setAuthToken("test-token"); + setPaginationCursor( + commandKey, + context, + "1735689600000:100:0", + 300_000 + ); + + // Verify cursor was stored + const before = getPaginationCursor(commandKey, context); + expect(before).toBe("1735689600000:100:0"); + + // Clear auth + clearAuth(); + + // Verify pagination cursor was also cleared (this is the invariant!) + const after = getPaginationCursor(commandKey, context); + expect(after).toBeUndefined(); + } finally { + cleanup(); + } + }), + { numRuns: 50 } + ); + }); + test("alias lookup is case-insensitive", () => { fcAssert( asyncProperty( diff --git a/test/lib/db/pagination.model-based.test.ts b/test/lib/db/pagination.model-based.test.ts new file mode 100644 index 00000000..e43519b1 --- /dev/null +++ b/test/lib/db/pagination.model-based.test.ts @@ -0,0 +1,348 @@ +/** + * Model-Based Tests for Pagination Cursor Storage + * + * Uses fast-check to generate random sequences of get/set/clear operations + * and verifies behavior against a simplified model, including TTL expiry + * and composite primary key semantics. + */ + +// biome-ignore-all lint/suspicious/noMisplacedAssertion: Model-based testing uses expect() inside command classes, not directly in test() functions. This is the standard fast-check pattern for stateful testing. + +import { describe, expect, test } from "bun:test"; +import { + type AsyncCommand, + asyncModelRun, + asyncProperty, + commands, + constantFrom, + assert as fcAssert, + integer, + property, + tuple, +} from "fast-check"; +import { + clearPaginationCursor, + getPaginationCursor, + setPaginationCursor, +} from "../../../src/lib/db/pagination.js"; +import { + createIsolatedDbContext, + DEFAULT_NUM_RUNS, +} from "../../model-based/helpers.js"; + +/** + * Model representing expected pagination cursor state. + * Maps composite key `${commandKey}::${context}` to cursor info. + */ +type PaginationModel = { + cursors: Map; +}; + +/** Real system (we use module functions directly) */ +type RealDb = Record; + +/** Composite key for the model */ +function compositeKey(commandKey: string, context: string): string { + return `${commandKey}::${context}`; +} + +/** Create initial empty model */ +function createEmptyModel(): PaginationModel { + return { cursors: new Map() }; +} + +// Command classes + +class SetPaginationCursorCommand + implements AsyncCommand +{ + readonly commandKey: string; + readonly context: string; + readonly cursor: string; + readonly ttlMs: number; + + constructor( + commandKey: string, + context: string, + cursor: string, + ttlMs: number + ) { + this.commandKey = commandKey; + this.context = context; + this.cursor = cursor; + this.ttlMs = ttlMs; + } + + check = () => true; + + async run(model: PaginationModel, _real: RealDb): Promise { + const now = Date.now(); + setPaginationCursor(this.commandKey, this.context, this.cursor, this.ttlMs); + + const key = compositeKey(this.commandKey, this.context); + model.cursors.set(key, { + cursor: this.cursor, + expiresAt: now + this.ttlMs, + }); + } + + toString(): string { + return `setPaginationCursor("${this.commandKey}", "${this.context}", "${this.cursor}", ${this.ttlMs})`; + } +} + +class GetPaginationCursorCommand + implements AsyncCommand +{ + readonly commandKey: string; + readonly context: string; + + constructor(commandKey: string, context: string) { + this.commandKey = commandKey; + this.context = context; + } + + check = () => true; + + async run(model: PaginationModel, _real: RealDb): Promise { + const realCursor = getPaginationCursor(this.commandKey, this.context); + const key = compositeKey(this.commandKey, this.context); + const entry = model.cursors.get(key); + + if (!entry) { + expect(realCursor).toBeUndefined(); + return; + } + + const now = Date.now(); + if (entry.expiresAt <= now) { + // Expired — real system should return undefined and delete the row + expect(realCursor).toBeUndefined(); + model.cursors.delete(key); + } else { + expect(realCursor).toBe(entry.cursor); + } + } + + toString(): string { + return `getPaginationCursor("${this.commandKey}", "${this.context}")`; + } +} + +class ClearPaginationCursorCommand + implements AsyncCommand +{ + readonly commandKey: string; + readonly context: string; + + constructor(commandKey: string, context: string) { + this.commandKey = commandKey; + this.context = context; + } + + check = () => true; + + async run(model: PaginationModel, _real: RealDb): Promise { + clearPaginationCursor(this.commandKey, this.context); + const key = compositeKey(this.commandKey, this.context); + model.cursors.delete(key); + } + + toString(): string { + return `clearPaginationCursor("${this.commandKey}", "${this.context}")`; + } +} + +// Arbitraries + +const commandKeyArb = constantFrom("project-list", "issue-list", "log-list"); +const contextArb = constantFrom( + "org:sentry", + "org:acme", + "org:getsentry", + "auto", + "org:sentry|platform:python" +); +const cursorArb = constantFrom( + "1735689600000:0:0", + "1735689600000:100:0", + "1735689600000:200:0", + "9999999999999:50:1" +); + +/** TTL that won't expire during test (5 minutes) */ +const longTtlArb = integer({ min: 60_000, max: 300_000 }); + +// Command arbitraries + +const setCmdArb = tuple(commandKeyArb, contextArb, cursorArb, longTtlArb).map( + ([ck, ctx, cur, ttl]) => new SetPaginationCursorCommand(ck, ctx, cur, ttl) +); + +const getCmdArb = tuple(commandKeyArb, contextArb).map( + ([ck, ctx]) => new GetPaginationCursorCommand(ck, ctx) +); + +const clearCmdArb = tuple(commandKeyArb, contextArb).map( + ([ck, ctx]) => new ClearPaginationCursorCommand(ck, ctx) +); + +const allCommands = [setCmdArb, getCmdArb, clearCmdArb]; + +// Tests + +describe("model-based: pagination cursor storage", () => { + test("random sequences of pagination operations maintain consistency", () => { + fcAssert( + asyncProperty(commands(allCommands, { size: "+1" }), async (cmds) => { + const cleanup = createIsolatedDbContext(); + try { + const setup = () => ({ + model: createEmptyModel(), + real: {} as RealDb, + }); + await asyncModelRun(setup, cmds); + } finally { + cleanup(); + } + }), + { numRuns: DEFAULT_NUM_RUNS, verbose: false } + ); + }); + + test("composite key: different contexts are independent", () => { + fcAssert( + property( + tuple(commandKeyArb, cursorArb, cursorArb), + ([commandKey, cursor1, cursor2]) => { + const cleanup = createIsolatedDbContext(); + try { + const ctx1 = "org:sentry"; + const ctx2 = "org:acme"; + + // Set cursors for two different contexts + setPaginationCursor(commandKey, ctx1, cursor1, 300_000); + setPaginationCursor(commandKey, ctx2, cursor2, 300_000); + + // Each returns its own cursor + expect(getPaginationCursor(commandKey, ctx1)).toBe(cursor1); + expect(getPaginationCursor(commandKey, ctx2)).toBe(cursor2); + + // Clear one, the other remains + clearPaginationCursor(commandKey, ctx1); + expect(getPaginationCursor(commandKey, ctx1)).toBeUndefined(); + expect(getPaginationCursor(commandKey, ctx2)).toBe(cursor2); + } finally { + cleanup(); + } + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("composite key: different command keys are independent", () => { + fcAssert( + property( + tuple(contextArb, cursorArb, cursorArb), + ([context, cursor1, cursor2]) => { + const cleanup = createIsolatedDbContext(); + try { + const cmd1 = "project-list"; + const cmd2 = "issue-list"; + + setPaginationCursor(cmd1, context, cursor1, 300_000); + setPaginationCursor(cmd2, context, cursor2, 300_000); + + expect(getPaginationCursor(cmd1, context)).toBe(cursor1); + expect(getPaginationCursor(cmd2, context)).toBe(cursor2); + + clearPaginationCursor(cmd1, context); + expect(getPaginationCursor(cmd1, context)).toBeUndefined(); + expect(getPaginationCursor(cmd2, context)).toBe(cursor2); + } finally { + cleanup(); + } + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("expired cursors return undefined and are deleted", () => { + fcAssert( + property( + tuple(commandKeyArb, contextArb, cursorArb), + ([commandKey, context, cursor]) => { + const cleanup = createIsolatedDbContext(); + try { + // Set with immediately-expired TTL + setPaginationCursor(commandKey, context, cursor, -1000); + + // Should return undefined + const result = getPaginationCursor(commandKey, context); + expect(result).toBeUndefined(); + + // Second get should also return undefined (row was deleted on first get) + const result2 = getPaginationCursor(commandKey, context); + expect(result2).toBeUndefined(); + } finally { + cleanup(); + } + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("upsert: setting same key twice updates the cursor", () => { + fcAssert( + property( + tuple(commandKeyArb, contextArb, cursorArb, cursorArb), + ([commandKey, context, cursor1, cursor2]) => { + const cleanup = createIsolatedDbContext(); + try { + setPaginationCursor(commandKey, context, cursor1, 300_000); + expect(getPaginationCursor(commandKey, context)).toBe(cursor1); + + setPaginationCursor(commandKey, context, cursor2, 300_000); + expect(getPaginationCursor(commandKey, context)).toBe(cursor2); + } finally { + cleanup(); + } + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("get on empty table returns undefined", () => { + fcAssert( + property(tuple(commandKeyArb, contextArb), ([commandKey, context]) => { + const cleanup = createIsolatedDbContext(); + try { + expect(getPaginationCursor(commandKey, context)).toBeUndefined(); + } finally { + cleanup(); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("clear on non-existent key is a no-op", () => { + fcAssert( + property(tuple(commandKeyArb, contextArb), ([commandKey, context]) => { + const cleanup = createIsolatedDbContext(); + try { + // Should not throw + clearPaginationCursor(commandKey, context); + expect(getPaginationCursor(commandKey, context)).toBeUndefined(); + } finally { + cleanup(); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); From 8f39f3b5e8b45040e1ff2118d271d18ff022650c Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 10 Feb 2026 11:26:40 +0000 Subject: [PATCH 04/27] fix(test): make createIsolatedDbContext resilient to missing SENTRY_CONFIG_DIR Falls back to creating a temp directory when the env var is not set, which can happen due to test file ordering and worker isolation in CI. --- test/model-based/helpers.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/test/model-based/helpers.ts b/test/model-based/helpers.ts index 4b0f66af..9dcbee9a 100644 --- a/test/model-based/helpers.ts +++ b/test/model-based/helpers.ts @@ -5,6 +5,7 @@ */ import { mkdirSync } from "node:fs"; +import { homedir } from "node:os"; import { join } from "node:path"; import { CONFIG_DIR_ENV_VAR, closeDatabase } from "../../src/lib/db/index.js"; @@ -12,12 +13,18 @@ import { CONFIG_DIR_ENV_VAR, closeDatabase } from "../../src/lib/db/index.js"; * Create an isolated database context for model-based tests. * Each test run gets its own SQLite database to avoid interference. * + * Falls back to creating a temp directory if CONFIG_DIR_ENV_VAR is not set, + * which can happen due to test ordering/worker isolation in CI. + * * @returns Cleanup function to call after test completes */ export function createIsolatedDbContext(): () => void { - const testBaseDir = process.env[CONFIG_DIR_ENV_VAR]; + let testBaseDir = process.env[CONFIG_DIR_ENV_VAR]; if (!testBaseDir) { - throw new Error(`${CONFIG_DIR_ENV_VAR} not set - run tests via bun test`); + // Fallback: create a temp base dir (matches preload.ts pattern) + testBaseDir = join(homedir(), `.sentry-cli-test-model-${process.pid}`); + mkdirSync(testBaseDir, { recursive: true }); + process.env[CONFIG_DIR_ENV_VAR] = testBaseDir; } // Close any existing database connection From 64e03d81d250189c69f732ee7107532e925be6ac Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 10 Feb 2026 11:37:29 +0000 Subject: [PATCH 05/27] test: add coverage for displayProjectTable, fetch helpers, and handleAutoDetect - Test displayProjectTable (table formatting orchestrator) - Test fetchOrgProjects (attaches orgSlug context) - Test fetchOrgProjectsSafe (swallows non-auth errors, propagates AuthError) - Test fetchAllOrgProjects (fans out across orgs, skips access errors) - Test handleAutoDetect (auto-detect mode with platform filter, limit, JSON output) - Export additional functions from list.ts for testability --- src/commands/project/list.ts | 23 ++- test/commands/project/list.test.ts | 299 ++++++++++++++++++++++++++++- 2 files changed, 312 insertions(+), 10 deletions(-) diff --git a/src/commands/project/list.ts b/src/commands/project/list.ts index 1a406196..caad94d6 100644 --- a/src/commands/project/list.ts +++ b/src/commands/project/list.ts @@ -51,7 +51,7 @@ type ListFlags = { }; /** Project with its organization context for display */ -type ProjectWithOrg = SentryProject & { orgSlug?: string }; +export type ProjectWithOrg = SentryProject & { orgSlug?: string }; /** * Fetch projects for a single organization (all pages). @@ -59,7 +59,9 @@ type ProjectWithOrg = SentryProject & { orgSlug?: string }; * @param orgSlug - Organization slug to fetch projects from * @returns Projects with org context attached */ -async function fetchOrgProjects(orgSlug: string): Promise { +export async function fetchOrgProjects( + orgSlug: string +): Promise { const projects = await listProjects(orgSlug); return projects.map((p) => ({ ...p, orgSlug })); } @@ -68,7 +70,7 @@ async function fetchOrgProjects(orgSlug: string): Promise { * Fetch projects for a single org, returning empty array on non-auth errors. * Auth errors propagate so user sees "please log in" message. */ -async function fetchOrgProjectsSafe( +export async function fetchOrgProjectsSafe( orgSlug: string ): Promise { try { @@ -87,7 +89,7 @@ async function fetchOrgProjectsSafe( * * @returns Combined list of projects from all accessible orgs */ -async function fetchAllOrgProjects(): Promise { +export async function fetchAllOrgProjects(): Promise { const orgs = await listOrganizations(); const results: ProjectWithOrg[] = []; @@ -212,7 +214,7 @@ export function resolveCursor( } /** Result of resolving organizations to fetch projects from */ -type OrgResolution = { +export type OrgResolution = { orgs: string[]; footer?: string; skippedSelfHosted?: number; @@ -222,7 +224,9 @@ type OrgResolution = { * Resolve which organizations to fetch projects from (auto-detect mode). * Uses config defaults or DSN auto-detection. */ -async function resolveOrgsForAutoDetect(cwd: string): Promise { +export async function resolveOrgsForAutoDetect( + cwd: string +): Promise { // 1. Check config defaults const defaultOrg = await getDefaultOrganization(); if (defaultOrg) { @@ -251,7 +255,10 @@ async function resolveOrgsForAutoDetect(cwd: string): Promise { } /** Display projects in table format with header and rows */ -function displayProjectTable(stdout: Writer, projects: ProjectWithOrg[]): void { +export function displayProjectTable( + stdout: Writer, + projects: ProjectWithOrg[] +): void { const { orgWidth, slugWidth, nameWidth } = calculateProjectColumnWidths(projects); writeHeader(stdout, orgWidth, slugWidth, nameWidth); @@ -262,7 +269,7 @@ function displayProjectTable(stdout: Writer, projects: ProjectWithOrg[]): void { * Handle auto-detect mode: resolve orgs from config/DSN, fetch all projects, * apply client-side filtering and limiting. */ -async function handleAutoDetect( +export async function handleAutoDetect( stdout: Writer, cwd: string, flags: ListFlags diff --git a/test/commands/project/list.test.ts b/test/commands/project/list.test.ts index 6993700f..d761bc9d 100644 --- a/test/commands/project/list.test.ts +++ b/test/commands/project/list.test.ts @@ -17,7 +17,12 @@ import { } from "fast-check"; import { buildContextKey, + displayProjectTable, + fetchAllOrgProjects, + fetchOrgProjects, + fetchOrgProjectsSafe, filterByPlatform, + handleAutoDetect, handleExplicit, handleOrgAll, handleProjectSearch, @@ -29,14 +34,14 @@ import { } from "../../../src/commands/project/list.js"; import type { ParsedOrgProject } from "../../../src/lib/arg-parsing.js"; import { DEFAULT_SENTRY_URL } from "../../../src/lib/constants.js"; -import { setAuthToken } from "../../../src/lib/db/auth.js"; +import { clearAuth, setAuthToken } from "../../../src/lib/db/auth.js"; import { CONFIG_DIR_ENV_VAR } from "../../../src/lib/db/index.js"; import { getPaginationCursor, setPaginationCursor, } from "../../../src/lib/db/pagination.js"; import { setOrgRegion } from "../../../src/lib/db/regions.js"; -import { ContextError } from "../../../src/lib/errors.js"; +import { AuthError, ContextError } from "../../../src/lib/errors.js"; import type { SentryProject, Writer } from "../../../src/types/index.js"; import { cleanupTestDir, createTestConfigDir } from "../../helpers.js"; import { DEFAULT_NUM_RUNS } from "../../model-based/helpers.js"; @@ -833,3 +838,293 @@ describe("handleProjectSearch", () => { expect(text).toContain("frontend"); }); }); + +// ─── displayProjectTable ──────────────────────────────────────── + +describe("displayProjectTable", () => { + test("outputs header and rows", () => { + const { writer, output } = createCapture(); + const projects = [ + makeProject({ + slug: "web", + name: "Web App", + platform: "javascript", + orgSlug: "acme", + }), + makeProject({ + slug: "api", + name: "API", + platform: "python", + orgSlug: "acme", + }), + ]; + + displayProjectTable(writer, projects); + const text = output(); + + // Header row + expect(text).toContain("ORG"); + expect(text).toContain("PROJECT"); + expect(text).toContain("NAME"); + expect(text).toContain("PLATFORM"); + + // Data rows + expect(text).toContain("web"); + expect(text).toContain("api"); + expect(text).toContain("Web App"); + expect(text).toContain("API"); + }); + + test("handles single project", () => { + const { writer, output } = createCapture(); + displayProjectTable(writer, [ + makeProject({ slug: "solo", orgSlug: "org" }), + ]); + expect(output()).toContain("solo"); + }); +}); + +// ─── fetchOrgProjects ─────────────────────────────────────────── + +describe("fetchOrgProjects", () => { + beforeEach(async () => { + originalFetch = globalThis.fetch; + await setAuthToken("test-token"); + await setOrgRegion("myorg", DEFAULT_SENTRY_URL); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + test("returns projects with orgSlug attached", async () => { + globalThis.fetch = mockProjectFetch(sampleProjects); + const result = await fetchOrgProjects("myorg"); + + expect(result).toHaveLength(2); + for (const p of result) { + expect(p.orgSlug).toBe("myorg"); + } + expect(result[0].slug).toBe("frontend"); + expect(result[1].slug).toBe("backend"); + }); + + test("returns empty array when org has no projects", async () => { + globalThis.fetch = mockProjectFetch([]); + const result = await fetchOrgProjects("myorg"); + expect(result).toHaveLength(0); + }); +}); + +describe("fetchOrgProjectsSafe", () => { + beforeEach(async () => { + originalFetch = globalThis.fetch; + await setAuthToken("test-token"); + await setOrgRegion("myorg", DEFAULT_SENTRY_URL); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + test("returns projects on success", async () => { + globalThis.fetch = mockProjectFetch(sampleProjects); + const result = await fetchOrgProjectsSafe("myorg"); + expect(result).toHaveLength(2); + }); + + test("returns empty array on non-auth error", async () => { + // @ts-expect-error - partial mock + globalThis.fetch = async () => + new Response(JSON.stringify({ detail: "Forbidden" }), { + status: 403, + }); + const result = await fetchOrgProjectsSafe("myorg"); + expect(result).toHaveLength(0); + }); + + test("propagates AuthError when not authenticated", async () => { + // Clear auth token so the API client throws AuthError before making any request + clearAuth(); + + await expect(fetchOrgProjectsSafe("myorg")).rejects.toThrow(AuthError); + }); +}); + +// ─── fetchAllOrgProjects ──────────────────────────────────────── + +describe("fetchAllOrgProjects", () => { + beforeEach(async () => { + originalFetch = globalThis.fetch; + await setAuthToken("test-token"); + await setOrgRegion("test-org", DEFAULT_SENTRY_URL); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + test("fetches projects from all orgs", async () => { + globalThis.fetch = mockProjectFetch(sampleProjects); + const result = await fetchAllOrgProjects(); + + // mockProjectFetch returns 1 org (test-org) with sampleProjects + expect(result).toHaveLength(2); + for (const p of result) { + expect(p.orgSlug).toBe("test-org"); + } + }); + + test("skips orgs with access errors", async () => { + let callCount = 0; + // @ts-expect-error - partial mock + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + const url = req.url; + + // listOrganizations + if (url.includes("/organizations/") && !url.includes("/projects/")) { + return new Response( + JSON.stringify([ + { id: "1", slug: "org1", name: "Org 1" }, + { id: "2", slug: "org2", name: "Org 2" }, + ]), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); + } + + // projects - first org succeeds, second fails with 403 + if (url.includes("/projects/")) { + callCount += 1; + if (callCount === 1) { + return new Response(JSON.stringify(sampleProjects), { + status: 200, + headers: { + "Content-Type": "application/json", + Link: '; rel="next"; results="false"; cursor="0:0:0"', + }, + }); + } + return new Response(JSON.stringify({ detail: "Forbidden" }), { + status: 403, + }); + } + + return new Response("Not found", { status: 404 }); + }; + + await setOrgRegion("org1", DEFAULT_SENTRY_URL); + await setOrgRegion("org2", DEFAULT_SENTRY_URL); + + const result = await fetchAllOrgProjects(); + // Only org1's projects should be returned + expect(result).toHaveLength(2); + }); +}); + +// ─── handleAutoDetect ─────────────────────────────────────────── + +describe("handleAutoDetect", () => { + beforeEach(async () => { + originalFetch = globalThis.fetch; + await setAuthToken("test-token"); + await setOrgRegion("test-org", DEFAULT_SENTRY_URL); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + test("shows projects from all orgs when no default org", async () => { + globalThis.fetch = mockProjectFetch(sampleProjects); + const { writer, output } = createCapture(); + + await handleAutoDetect(writer, "/tmp/test-project", { + limit: 30, + json: false, + }); + + const text = output(); + // Should display table with projects + expect(text).toContain("ORG"); + expect(text).toContain("frontend"); + expect(text).toContain("backend"); + }); + + test("--json outputs array", async () => { + globalThis.fetch = mockProjectFetch(sampleProjects); + const { writer, output } = createCapture(); + + await handleAutoDetect(writer, "/tmp/test-project", { + limit: 30, + json: true, + }); + + const parsed = JSON.parse(output()); + expect(Array.isArray(parsed)).toBe(true); + expect(parsed).toHaveLength(2); + }); + + test("empty results shows no projects message", async () => { + globalThis.fetch = mockProjectFetch([]); + const { writer, output } = createCapture(); + + await handleAutoDetect(writer, "/tmp/test-project", { + limit: 30, + json: false, + }); + + expect(output()).toContain("No projects found"); + }); + + test("respects --limit flag", async () => { + const manyProjects = Array.from({ length: 5 }, (_, i) => + makeProject({ id: String(i), slug: `proj-${i}`, name: `Project ${i}` }) + ); + globalThis.fetch = mockProjectFetch(manyProjects); + const { writer, output } = createCapture(); + + await handleAutoDetect(writer, "/tmp/test-project", { + limit: 2, + json: true, + }); + + const parsed = JSON.parse(output()); + expect(parsed).toHaveLength(2); + }); + + test("respects --platform flag", async () => { + globalThis.fetch = mockProjectFetch(sampleProjects); + const { writer, output } = createCapture(); + + await handleAutoDetect(writer, "/tmp/test-project", { + limit: 30, + json: true, + platform: "python", + }); + + const parsed = JSON.parse(output()); + expect(parsed).toHaveLength(1); + expect(parsed[0].platform).toBe("python"); + }); + + test("shows limit message when more projects exist", async () => { + const manyProjects = Array.from({ length: 5 }, (_, i) => + makeProject({ id: String(i), slug: `proj-${i}`, name: `Project ${i}` }) + ); + globalThis.fetch = mockProjectFetch(manyProjects); + const { writer, output } = createCapture(); + + await handleAutoDetect(writer, "/tmp/test-project", { + limit: 2, + json: false, + }); + + const text = output(); + expect(text).toContain("Showing 2 of 5 projects"); + expect(text).toContain("--limit"); + }); +}); From 6d14fb2cb667c98398a441ba1434bec29a18d694 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 10 Feb 2026 12:17:01 +0000 Subject: [PATCH 06/27] fix(project): optimize auto-detect fetch and remove redundant mapping Address review comments: - Use single-page listProjectsPaginated for single-org auto-detect without platform filter, avoiding unnecessary full pagination (up to 5000 items) - Remove no-op identity mapping in handleProjectSearch --- src/commands/project/list.ts | 22 +++++++++++++----- test/commands/project/list.test.ts | 36 ++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 6 deletions(-) diff --git a/src/commands/project/list.ts b/src/commands/project/list.ts index caad94d6..d5e10340 100644 --- a/src/commands/project/list.ts +++ b/src/commands/project/list.ts @@ -268,6 +268,10 @@ export function displayProjectTable( /** * Handle auto-detect mode: resolve orgs from config/DSN, fetch all projects, * apply client-side filtering and limiting. + * + * Optimization: when targeting a single org without platform filter, uses + * single-page pagination (`perPage=limit`) to avoid fetching all projects. + * Multi-org or filtered queries still require full fetch + client-side slicing. */ export async function handleAutoDetect( stdout: Writer, @@ -281,7 +285,17 @@ export async function handleAutoDetect( } = await resolveOrgsForAutoDetect(cwd); let allProjects: ProjectWithOrg[]; - if (orgsToFetch.length > 0) { + + // Fast path: single org, no platform filter — fetch only one page + const canUsePaginated = orgsToFetch.length === 1 && !flags.platform; + + if (canUsePaginated) { + const org = orgsToFetch[0] as string; + const response = await listProjectsPaginated(org, { + perPage: flags.limit, + }); + allProjects = response.data.map((p) => ({ ...p, orgSlug: org })); + } else if (orgsToFetch.length > 0) { const results = await Promise.all(orgsToFetch.map(fetchOrgProjectsSafe)); allProjects = results.flat(); } else { @@ -452,11 +466,7 @@ export async function handleProjectSearch( projectSlug: string, flags: ListFlags ): Promise { - const matches = await findProjectsBySlug(projectSlug); - const projects: ProjectWithOrg[] = matches.map((m) => ({ - ...m, - orgSlug: m.orgSlug, - })); + const projects: ProjectWithOrg[] = await findProjectsBySlug(projectSlug); const filtered = filterByPlatform(projects, flags.platform); if (flags.json) { diff --git a/test/commands/project/list.test.ts b/test/commands/project/list.test.ts index d761bc9d..94d27d36 100644 --- a/test/commands/project/list.test.ts +++ b/test/commands/project/list.test.ts @@ -35,6 +35,7 @@ import { import type { ParsedOrgProject } from "../../../src/lib/arg-parsing.js"; import { DEFAULT_SENTRY_URL } from "../../../src/lib/constants.js"; import { clearAuth, setAuthToken } from "../../../src/lib/db/auth.js"; +import { setDefaults } from "../../../src/lib/db/defaults.js"; import { CONFIG_DIR_ENV_VAR } from "../../../src/lib/db/index.js"; import { getPaginationCursor, @@ -1127,4 +1128,39 @@ describe("handleAutoDetect", () => { expect(text).toContain("Showing 2 of 5 projects"); expect(text).toContain("--limit"); }); + + test("fast path: uses single-page fetch for single org without platform filter", async () => { + // Set default org to trigger single-org resolution + await setDefaults("test-org"); + globalThis.fetch = mockProjectFetch(sampleProjects); + const { writer, output } = createCapture(); + + await handleAutoDetect(writer, "/tmp/test-project", { + limit: 30, + json: true, + }); + + const parsed = JSON.parse(output()); + expect(Array.isArray(parsed)).toBe(true); + expect(parsed).toHaveLength(2); + // Verify orgSlug is attached + expect(parsed[0].orgSlug).toBe("test-org"); + }); + + test("slow path: uses full fetch when platform filter is active", async () => { + // Set default org — but platform filter forces slow path + await setDefaults("test-org"); + globalThis.fetch = mockProjectFetch(sampleProjects); + const { writer, output } = createCapture(); + + await handleAutoDetect(writer, "/tmp/test-project", { + limit: 30, + json: true, + platform: "python", + }); + + const parsed = JSON.parse(output()); + expect(parsed).toHaveLength(1); + expect(parsed[0].platform).toBe("python"); + }); }); From 327932ecd90313be95ea41f4c5c008f1b92f444a Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 10 Feb 2026 14:07:55 +0000 Subject: [PATCH 07/27] fix(project): show truncation message in auto-detect fast path and de-duplicate ProjectWithOrg Address review comments: - Track hasMore from listProjectsPaginated response in auto-detect fast path so truncation message appears when server has more results - Make ProjectWithOrg in list.ts private to eliminate duplicate exported type with conflicting orgSlug optionality (canonical export in api-client.ts) --- src/commands/project/list.ts | 15 +++++++++++---- test/commands/project/list.test.ts | 20 +++++++++++++++++++- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/src/commands/project/list.ts b/src/commands/project/list.ts index d5e10340..b54dac2a 100644 --- a/src/commands/project/list.ts +++ b/src/commands/project/list.ts @@ -50,8 +50,13 @@ type ListFlags = { readonly platform?: string; }; -/** Project with its organization context for display */ -export type ProjectWithOrg = SentryProject & { orgSlug?: string }; +/** + * Project with optional organization context for display. + * Uses optional orgSlug since some internal functions (e.g., filterByPlatform) + * operate on projects before org context is attached. + * The canonical exported type with required orgSlug lives in api-client.ts. + */ +type ProjectWithOrg = SentryProject & { orgSlug?: string }; /** * Fetch projects for a single organization (all pages). @@ -285,6 +290,7 @@ export async function handleAutoDetect( } = await resolveOrgsForAutoDetect(cwd); let allProjects: ProjectWithOrg[]; + let hasMore = false; // Fast path: single org, no platform filter — fetch only one page const canUsePaginated = orgsToFetch.length === 1 && !flags.platform; @@ -295,6 +301,7 @@ export async function handleAutoDetect( perPage: flags.limit, }); allProjects = response.data.map((p) => ({ ...p, orgSlug: org })); + hasMore = response.hasMore; } else if (orgsToFetch.length > 0) { const results = await Promise.all(orgsToFetch.map(fetchOrgProjectsSafe)); allProjects = results.flat(); @@ -320,9 +327,9 @@ export async function handleAutoDetect( displayProjectTable(stdout, limited); - if (filtered.length > limited.length) { + if (filtered.length > limited.length || hasMore) { stdout.write( - `\nShowing ${limited.length} of ${filtered.length} projects. Use --limit to show more.\n` + `\nShowing ${limited.length} projects (more available). Use --limit to show more.\n` ); } diff --git a/test/commands/project/list.test.ts b/test/commands/project/list.test.ts index 94d27d36..e9480088 100644 --- a/test/commands/project/list.test.ts +++ b/test/commands/project/list.test.ts @@ -1125,7 +1125,7 @@ describe("handleAutoDetect", () => { }); const text = output(); - expect(text).toContain("Showing 2 of 5 projects"); + expect(text).toContain("Showing 2 projects (more available)"); expect(text).toContain("--limit"); }); @@ -1147,6 +1147,24 @@ describe("handleAutoDetect", () => { expect(parsed[0].orgSlug).toBe("test-org"); }); + test("fast path: shows truncation message when server has more results", async () => { + await setDefaults("test-org"); + globalThis.fetch = mockProjectFetch(sampleProjects, { + hasMore: true, + nextCursor: "1735689600000:0:0", + }); + const { writer, output } = createCapture(); + + await handleAutoDetect(writer, "/tmp/test-project", { + limit: 30, + json: false, + }); + + const text = output(); + expect(text).toContain("Showing 2 projects (more available)"); + expect(text).toContain("--limit"); + }); + test("slow path: uses full fetch when platform filter is active", async () => { // Set default org — but platform filter forces slow path await setDefaults("test-org"); From 5ed6cac1020a235e70a3f7d5e21d6a1bf41e1547 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 10 Feb 2026 14:31:12 +0000 Subject: [PATCH 08/27] fix(project): distinguish platform filter from not-found in project search handleProjectSearch now shows 'No project X found matching platform Y' when projects exist but are filtered out, matching handleExplicit behavior. Previously it misleadingly said 'No project X found in any accessible org.' --- src/commands/project/list.ts | 6 ++++++ test/commands/project/list.test.ts | 15 +++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/src/commands/project/list.ts b/src/commands/project/list.ts index b54dac2a..15ae39d0 100644 --- a/src/commands/project/list.ts +++ b/src/commands/project/list.ts @@ -482,6 +482,12 @@ export async function handleProjectSearch( } if (filtered.length === 0) { + if (projects.length > 0 && flags.platform) { + stdout.write( + `No project '${projectSlug}' found matching platform '${flags.platform}'.\n` + ); + return; + } throw new ContextError( "Project", `No project '${projectSlug}' found in any accessible organization.\n\n` + diff --git a/test/commands/project/list.test.ts b/test/commands/project/list.test.ts index e9480088..5a980336 100644 --- a/test/commands/project/list.test.ts +++ b/test/commands/project/list.test.ts @@ -838,6 +838,21 @@ describe("handleProjectSearch", () => { const text = output(); expect(text).toContain("frontend"); }); + + test("found but filtered by platform shows platform message, not 'not found'", async () => { + globalThis.fetch = mockProjectFetch(sampleProjects); + const { writer, output } = createCapture(); + + await handleProjectSearch(writer, "frontend", { + limit: 30, + json: false, + platform: "rust", + }); + + const text = output(); + expect(text).toContain("matching platform 'rust'"); + expect(text).not.toContain("not found"); + }); }); // ─── displayProjectTable ──────────────────────────────────────── From c16425f291e25730ea128046fc97eeb3aa97f490 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 10 Feb 2026 16:24:34 +0000 Subject: [PATCH 09/27] perf(project): use direct getProject lookup instead of listing all projects findProjectsBySlug now calls getProject(org, slug) per org (1 API call each) instead of listProjects which auto-paginates up to 50 pages per org. For a user with 5 orgs, this reduces worst-case from ~250 API calls to 5. --- src/lib/api-client.ts | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index 487ff134..d3925e8d 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -798,22 +798,18 @@ export async function findProjectsBySlug( ): Promise { const orgs = await listOrganizations(); - // Search in parallel for performance + // Direct lookup in parallel — one API call per org instead of paginating all projects const searchResults = await Promise.all( orgs.map(async (org) => { try { - const projects = await listProjects(org.slug); - const match = projects.find((p) => p.slug === projectSlug); - if (match) { - return { ...match, orgSlug: org.slug }; - } - return null; + const project = await getProject(org.slug, projectSlug); + return { ...project, orgSlug: org.slug }; } catch (error) { // Re-throw auth errors - user needs to login if (error instanceof AuthError) { throw error; } - // Skip orgs where user lacks access (permission errors, etc.) + // 404 or permission errors — project doesn't exist in this org return null; } }) From bef06d41a504000657c23ddc3549ee296c6c184d Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 10 Feb 2026 16:36:56 +0000 Subject: [PATCH 10/27] test: update mocks for findProjectsBySlug using getProject instead of listProjects Tests now mock /projects/{org}/{slug}/ (direct lookup) instead of /organizations/{org}/projects/ (list all) to match the implementation change. --- test/commands/issue/utils.test.ts | 50 ++++++++++++++-------------- test/lib/api-client.test.ts | 55 +++++++++++++------------------ 2 files changed, 47 insertions(+), 58 deletions(-) diff --git a/test/commands/issue/utils.test.ts b/test/commands/issue/utils.test.ts index ad3ac173..93baac46 100644 --- a/test/commands/issue/utils.test.ts +++ b/test/commands/issue/utils.test.ts @@ -330,18 +330,15 @@ describe("resolveOrgAndIssueId", () => { ); } - // listProjects for my-org - if (url.includes("organizations/my-org/projects/")) { + // getProject for my-org/craft - found + if (url.includes("/projects/my-org/craft/")) { return new Response( - JSON.stringify([ - { id: "123", slug: "craft", name: "Craft", platform: "javascript" }, - { - id: "456", - slug: "other-project", - name: "Other", - platform: "python", - }, - ]), + JSON.stringify({ + id: "123", + slug: "craft", + name: "Craft", + platform: "javascript", + }), { status: 200, headers: { "Content-Type": "application/json" }, @@ -472,17 +469,15 @@ describe("resolveOrgAndIssueId", () => { ); } - // listProjects for org1 - has "common" project - if (url.includes("organizations/org1/projects/")) { + // getProject for org1/common - found + if (url.includes("/projects/org1/common/")) { return new Response( - JSON.stringify([ - { - id: "123", - slug: "common", - name: "Common", - platform: "javascript", - }, - ]), + JSON.stringify({ + id: "123", + slug: "common", + name: "Common", + platform: "javascript", + }), { status: 200, headers: { "Content-Type": "application/json" }, @@ -490,12 +485,15 @@ describe("resolveOrgAndIssueId", () => { ); } - // listProjects for org2 - also has "common" project - if (url.includes("organizations/org2/projects/")) { + // getProject for org2/common - also found + if (url.includes("/projects/org2/common/")) { return new Response( - JSON.stringify([ - { id: "456", slug: "common", name: "Common", platform: "python" }, - ]), + JSON.stringify({ + id: "456", + slug: "common", + name: "Common", + platform: "python", + }), { status: 200, headers: { "Content-Type": "application/json" }, diff --git a/test/lib/api-client.test.ts b/test/lib/api-client.test.ts index 18838a09..e48496d8 100644 --- a/test/lib/api-client.test.ts +++ b/test/lib/api-client.test.ts @@ -598,31 +598,29 @@ describe("findProjectsBySlug", () => { ); } - // Projects for acme org - has matching project - if (url.includes("/organizations/acme/projects/")) { + // getProject for acme/frontend - found + if (url.includes("/projects/acme/frontend/")) { return new Response( - JSON.stringify([ - { id: "101", slug: "frontend", name: "Frontend" }, - { id: "102", slug: "backend", name: "Backend" }, - ]), + JSON.stringify({ id: "101", slug: "frontend", name: "Frontend" }), { status: 200, headers: { "Content-Type": "application/json" } } ); } - // Projects for beta org - also has matching project - if (url.includes("/organizations/beta/projects/")) { + // getProject for beta/frontend - found + if (url.includes("/projects/beta/frontend/")) { return new Response( - JSON.stringify([ - { id: "201", slug: "frontend", name: "Beta Frontend" }, - { id: "202", slug: "api", name: "API" }, - ]), + JSON.stringify({ + id: "201", + slug: "frontend", + name: "Beta Frontend", + }), { status: 200, headers: { "Content-Type": "application/json" } } ); } - // Default response - return new Response(JSON.stringify([]), { - status: 200, + // Default - not found + return new Response(JSON.stringify({ detail: "Not found" }), { + status: 404, headers: { "Content-Type": "application/json" }, }); }; @@ -661,16 +659,9 @@ describe("findProjectsBySlug", () => { ); } - // Projects - no match - if (url.includes("/organizations/acme/projects/")) { - return new Response( - JSON.stringify([{ id: "101", slug: "backend", name: "Backend" }]), - { status: 200, headers: { "Content-Type": "application/json" } } - ); - } - - return new Response(JSON.stringify([]), { - status: 200, + // getProject - not found (404) + return new Response(JSON.stringify({ detail: "Not found" }), { + status: 404, headers: { "Content-Type": "application/json" }, }); }; @@ -708,24 +699,24 @@ describe("findProjectsBySlug", () => { ); } - // Projects for acme - success - if (url.includes("/organizations/acme/projects/")) { + // getProject for acme/frontend - success + if (url.includes("/projects/acme/frontend/")) { return new Response( - JSON.stringify([{ id: "101", slug: "frontend", name: "Frontend" }]), + JSON.stringify({ id: "101", slug: "frontend", name: "Frontend" }), { status: 200, headers: { "Content-Type": "application/json" } } ); } - // Projects for restricted org - 403 forbidden - if (url.includes("/organizations/restricted/projects/")) { + // getProject for restricted/frontend - 403 forbidden + if (url.includes("/projects/restricted/frontend/")) { return new Response(JSON.stringify({ detail: "Forbidden" }), { status: 403, headers: { "Content-Type": "application/json" }, }); } - return new Response(JSON.stringify([]), { - status: 200, + return new Response(JSON.stringify({ detail: "Not found" }), { + status: 404, headers: { "Content-Type": "application/json" }, }); }; From 0de91bc9708ebcebf883f5d294f2112ce979f8f8 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 10 Feb 2026 17:34:46 +0000 Subject: [PATCH 11/27] refactor(project): unexport resolveOrgsForAutoDetect and OrgResolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both are only used internally within list.ts — no external imports exist. --- src/commands/project/list.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/commands/project/list.ts b/src/commands/project/list.ts index 15ae39d0..a5ba1be7 100644 --- a/src/commands/project/list.ts +++ b/src/commands/project/list.ts @@ -219,7 +219,7 @@ export function resolveCursor( } /** Result of resolving organizations to fetch projects from */ -export type OrgResolution = { +type OrgResolution = { orgs: string[]; footer?: string; skippedSelfHosted?: number; @@ -229,9 +229,7 @@ export type OrgResolution = { * Resolve which organizations to fetch projects from (auto-detect mode). * Uses config defaults or DSN auto-detection. */ -export async function resolveOrgsForAutoDetect( - cwd: string -): Promise { +async function resolveOrgsForAutoDetect(cwd: string): Promise { // 1. Check config defaults const defaultOrg = await getDefaultOrganization(); if (defaultOrg) { From 351c6cee94a73d43f99ce455ba75034037e60b19 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 10 Feb 2026 18:55:56 +0000 Subject: [PATCH 12/27] fix: correct test helpers for pagination_cursors composite PK and improve fast-path truncation message Address two BugBot review comments: 1. Test helpers in schema.test.ts and fix.test.ts now use hand-written DDL for pagination_cursors with the correct composite PRIMARY KEY (command_key, context), matching production behavior. Previously the auto-generated DDL from EXPECTED_TABLES lacked the composite PK. 2. Fast-path truncation message now suggests 'sentry project list /' for paginated results instead of '--limit', since the API caps per_page at 100 and increasing --limit beyond that won't fetch more data. --- src/commands/project/list.ts | 14 +++++++++++--- test/commands/cli/fix.test.ts | 19 ++++++++++++++++--- test/commands/project/list.test.ts | 3 ++- test/lib/db/schema.test.ts | 19 ++++++++++++++++--- 4 files changed, 45 insertions(+), 10 deletions(-) diff --git a/src/commands/project/list.ts b/src/commands/project/list.ts index a5ba1be7..d2b29104 100644 --- a/src/commands/project/list.ts +++ b/src/commands/project/list.ts @@ -326,9 +326,17 @@ export async function handleAutoDetect( displayProjectTable(stdout, limited); if (filtered.length > limited.length || hasMore) { - stdout.write( - `\nShowing ${limited.length} projects (more available). Use --limit to show more.\n` - ); + if (hasMore && orgsToFetch.length === 1) { + const org = orgsToFetch[0] as string; + stdout.write( + `\nShowing ${limited.length} projects (more available). ` + + `Use 'sentry project list ${org}/' for paginated results.\n` + ); + } else { + stdout.write( + `\nShowing ${limited.length} projects (more available). Use --limit to show more.\n` + ); + } } if (footer) { diff --git a/test/commands/cli/fix.test.ts b/test/commands/cli/fix.test.ts index 82364b73..8d5a79be 100644 --- a/test/commands/cli/fix.test.ts +++ b/test/commands/cli/fix.test.ts @@ -17,6 +17,12 @@ import { initSchema, } from "../../../src/lib/db/schema.js"; +/** Hand-written DDL for pagination_cursors with composite PK (matches production) */ +const PAGINATION_CURSORS_DDL = `CREATE TABLE IF NOT EXISTS pagination_cursors ( + command_key TEXT NOT NULL, context TEXT NOT NULL, cursor TEXT NOT NULL, + expires_at INTEGER NOT NULL, PRIMARY KEY (command_key, context) +)`; + /** * Generate DDL for creating a database with pre-migration tables. * This simulates a database that was created before certain migrations ran. @@ -27,12 +33,15 @@ function createPreMigrationDatabase(db: Database): void { const statements: string[] = []; for (const tableName of Object.keys(EXPECTED_TABLES)) { + // pagination_cursors needs custom DDL with composite PK + if (tableName === "pagination_cursors") continue; if (preMigrationTables.includes(tableName)) { statements.push(generatePreMigrationTableDDL(tableName)); } else { statements.push(EXPECTED_TABLES[tableName] as string); } } + statements.push(PAGINATION_CURSORS_DDL); db.exec(statements.join(";\n")); db.query("INSERT INTO schema_version (version) VALUES (4)").run(); @@ -52,9 +61,13 @@ function createDatabaseWithMissingTables( const statements: string[] = []; for (const tableName of Object.keys(EXPECTED_TABLES)) { - if (!missingTables.includes(tableName)) { - statements.push(EXPECTED_TABLES[tableName] as string); - } + if (missingTables.includes(tableName)) continue; + // pagination_cursors needs custom DDL with composite PK + if (tableName === "pagination_cursors") continue; + statements.push(EXPECTED_TABLES[tableName] as string); + } + if (!missingTables.includes("pagination_cursors")) { + statements.push(PAGINATION_CURSORS_DDL); } db.exec(statements.join(";\n")); diff --git a/test/commands/project/list.test.ts b/test/commands/project/list.test.ts index 5a980336..332ff6d3 100644 --- a/test/commands/project/list.test.ts +++ b/test/commands/project/list.test.ts @@ -1177,7 +1177,8 @@ describe("handleAutoDetect", () => { const text = output(); expect(text).toContain("Showing 2 projects (more available)"); - expect(text).toContain("--limit"); + expect(text).toContain("sentry project list test-org/"); + expect(text).not.toContain("--limit"); }); test("slow path: uses full fetch when platform filter is active", async () => { diff --git a/test/lib/db/schema.test.ts b/test/lib/db/schema.test.ts index c83aeb3b..ba16ebe3 100644 --- a/test/lib/db/schema.test.ts +++ b/test/lib/db/schema.test.ts @@ -22,6 +22,12 @@ import { tableExists, } from "../../../src/lib/db/schema.js"; +/** Hand-written DDL for pagination_cursors with composite PK (matches production) */ +const PAGINATION_CURSORS_DDL = `CREATE TABLE IF NOT EXISTS pagination_cursors ( + command_key TEXT NOT NULL, context TEXT NOT NULL, cursor TEXT NOT NULL, + expires_at INTEGER NOT NULL, PRIMARY KEY (command_key, context) +)`; + /** * Create a database with all tables but some missing (for testing repair). */ @@ -31,9 +37,13 @@ function createDatabaseWithMissingTables( ): void { const statements: string[] = []; for (const tableName of Object.keys(EXPECTED_TABLES)) { - if (!missingTables.includes(tableName)) { - statements.push(EXPECTED_TABLES[tableName] as string); - } + if (missingTables.includes(tableName)) continue; + // pagination_cursors needs custom DDL with composite PK + if (tableName === "pagination_cursors") continue; + statements.push(EXPECTED_TABLES[tableName] as string); + } + if (!missingTables.includes("pagination_cursors")) { + statements.push(PAGINATION_CURSORS_DDL); } db.exec(statements.join(";\n")); db.query("INSERT INTO schema_version (version) VALUES (?)").run( @@ -51,12 +61,15 @@ function createPreMigrationDatabase( ): void { const statements: string[] = []; for (const tableName of Object.keys(EXPECTED_TABLES)) { + // pagination_cursors needs custom DDL with composite PK + if (tableName === "pagination_cursors") continue; if (preMigrationTables.includes(tableName)) { statements.push(generatePreMigrationTableDDL(tableName)); } else { statements.push(EXPECTED_TABLES[tableName] as string); } } + statements.push(PAGINATION_CURSORS_DDL); db.exec(statements.join(";\n")); db.query("INSERT INTO schema_version (version) VALUES (?)").run( CURRENT_SCHEMA_VERSION From 4b056d5058245594ef6f3d1c53833bb5cb7bb240 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 10 Feb 2026 20:28:31 +0000 Subject: [PATCH 13/27] fix(project): apply --limit flag in handleProjectSearch handleProjectSearch was accepting flags.limit but never applying it, displaying all matches regardless. Now slices results to flags.limit and shows a truncation message when matches exceed the limit. Also applies limit to JSON output for consistency with handleAutoDetect and handleOrgAll. --- src/commands/project/list.ts | 26 ++++--- test/commands/project/list.test.ts | 111 +++++++++++++++++++++++++++++ 2 files changed, 129 insertions(+), 8 deletions(-) diff --git a/src/commands/project/list.ts b/src/commands/project/list.ts index d2b29104..f461a088 100644 --- a/src/commands/project/list.ts +++ b/src/commands/project/list.ts @@ -482,12 +482,11 @@ export async function handleProjectSearch( const projects: ProjectWithOrg[] = await findProjectsBySlug(projectSlug); const filtered = filterByPlatform(projects, flags.platform); - if (flags.json) { - writeJson(stdout, filtered); - return; - } - if (filtered.length === 0) { + if (flags.json) { + writeJson(stdout, []); + return; + } if (projects.length > 0 && flags.platform) { stdout.write( `No project '${projectSlug}' found matching platform '${flags.platform}'.\n` @@ -501,11 +500,22 @@ export async function handleProjectSearch( ); } - displayProjectTable(stdout, filtered); + const limited = filtered.slice(0, flags.limit); - if (filtered.length > 1) { + if (flags.json) { + writeJson(stdout, limited); + return; + } + + displayProjectTable(stdout, limited); + + if (filtered.length > limited.length) { + stdout.write( + `\nShowing ${limited.length} of ${filtered.length} matches. Use --limit to show more.\n` + ); + } else if (limited.length > 1) { stdout.write( - `\nFound '${projectSlug}' in ${filtered.length} organizations\n` + `\nFound '${projectSlug}' in ${limited.length} organizations\n` ); } diff --git a/test/commands/project/list.test.ts b/test/commands/project/list.test.ts index 332ff6d3..d1624d05 100644 --- a/test/commands/project/list.test.ts +++ b/test/commands/project/list.test.ts @@ -853,6 +853,117 @@ describe("handleProjectSearch", () => { expect(text).toContain("matching platform 'rust'"); expect(text).not.toContain("not found"); }); + + test("respects --limit flag", async () => { + await setOrgRegion("org-a", DEFAULT_SENTRY_URL); + await setOrgRegion("org-b", DEFAULT_SENTRY_URL); + + const project: SentryProject = { + id: "1", + slug: "frontend", + name: "Frontend", + platform: "javascript", + dateCreated: "2024-01-01T00:00:00Z", + status: "active", + }; + + // Mock that returns 2 orgs, each with the same project slug + // @ts-expect-error - partial mock + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + const url = req.url; + + if (url.match(/\/projects\/[^/]+\/[^/]+\//)) { + return new Response(JSON.stringify(project), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + + if (url.includes("/organizations/") && !url.includes("/projects/")) { + return new Response( + JSON.stringify([ + { id: "1", slug: "org-a", name: "Org A" }, + { id: "2", slug: "org-b", name: "Org B" }, + ]), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); + } + + return new Response(JSON.stringify({ detail: "Not found" }), { + status: 404, + }); + }; + + const { writer, output } = createCapture(); + + await handleProjectSearch(writer, "frontend", { + limit: 1, + json: false, + }); + + const text = output(); + // Should show truncation message since 2 matches but limit is 1 + expect(text).toContain("Showing 1 of 2 matches"); + expect(text).toContain("--limit"); + }); + + test("--limit also applies to JSON output", async () => { + await setOrgRegion("org-a", DEFAULT_SENTRY_URL); + await setOrgRegion("org-b", DEFAULT_SENTRY_URL); + + const project: SentryProject = { + id: "1", + slug: "frontend", + name: "Frontend", + platform: "javascript", + dateCreated: "2024-01-01T00:00:00Z", + status: "active", + }; + + // @ts-expect-error - partial mock + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + const url = req.url; + + if (url.match(/\/projects\/[^/]+\/[^/]+\//)) { + return new Response(JSON.stringify(project), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + + if (url.includes("/organizations/") && !url.includes("/projects/")) { + return new Response( + JSON.stringify([ + { id: "1", slug: "org-a", name: "Org A" }, + { id: "2", slug: "org-b", name: "Org B" }, + ]), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); + } + + return new Response(JSON.stringify({ detail: "Not found" }), { + status: 404, + }); + }; + + const { writer, output } = createCapture(); + + await handleProjectSearch(writer, "frontend", { + limit: 1, + json: true, + }); + + const parsed = JSON.parse(output()); + expect(parsed).toHaveLength(1); + }); }); // ─── displayProjectTable ──────────────────────────────────────── From a25c23f5f96fdae5c2fcabe1d09eb452ddc45e23 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 10 Feb 2026 21:18:53 +0000 Subject: [PATCH 14/27] refactor(api): deduplicate orgScopedRequest by delegating to orgScopedRequestPaginated orgScopedRequest now delegates to orgScopedRequestPaginated and discards the pagination metadata, eliminating duplicated org-slug extraction, dynamic import, and region resolution boilerplate. --- src/lib/api-client.ts | 44 +++++++++++++++++++------------------------ 1 file changed, 19 insertions(+), 25 deletions(-) diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index d3925e8d..4845a38c 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -560,37 +560,14 @@ function extractOrgSlugFromEndpoint(endpoint: string): string | null { } /** - * Make an org-scoped API request, automatically resolving the correct region. - * This is the preferred way to make org-scoped requests. + * Make an org-scoped API request that returns pagination metadata. + * Used for single-page fetches where the caller needs cursor info. * * The endpoint must contain the org slug in the path (e.g., `/organizations/{slug}/...`). * The org slug is extracted to look up the correct region URL. * * @param endpoint - API endpoint path containing the org slug * @param options - Request options - */ -async function orgScopedRequest( - endpoint: string, - options: ApiRequestOptions = {} -): Promise { - const orgSlug = extractOrgSlugFromEndpoint(endpoint); - if (!orgSlug) { - throw new Error( - `Cannot extract org slug from endpoint: ${endpoint}. ` + - "Endpoint must match /organizations/{slug}/..." - ); - } - const { resolveOrgRegion } = await import("./region.js"); - const regionUrl = await resolveOrgRegion(orgSlug); - return apiRequestToRegion(regionUrl, endpoint, options); -} - -/** - * Make an org-scoped API request that returns pagination metadata. - * Used for single-page fetches where the caller needs cursor info. - * - * @param endpoint - API endpoint path containing the org slug - * @param options - Request options * @returns Response data with pagination cursor metadata */ async function orgScopedRequestPaginated( @@ -615,6 +592,23 @@ async function orgScopedRequestPaginated( return { data, nextCursor, hasMore }; } +/** + * Make an org-scoped API request, automatically resolving the correct region. + * This is the preferred way to make org-scoped requests. + * + * Delegates to {@link orgScopedRequestPaginated} and discards pagination metadata. + * + * @param endpoint - API endpoint path containing the org slug + * @param options - Request options + */ +async function orgScopedRequest( + endpoint: string, + options: ApiRequestOptions = {} +): Promise { + const { data } = await orgScopedRequestPaginated(endpoint, options); + return data; +} + /** * Auto-paginate through all pages of an org-scoped API endpoint. * Follows cursor links until no more results or the safety limit is reached. From 770ecad831e0d802d3823b2fcd5f5cee30ab61e3 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 10 Feb 2026 22:04:08 +0000 Subject: [PATCH 15/27] fix(schema): align TABLE_SCHEMAS column order with DDL and add pagination_cursors to test coverage - Reorder TABLE_SCHEMAS.pagination_cursors columns to match PAGINATION_CURSORS_DDL: command_key, context, cursor, expires_at - Add 'pagination_cursors' to expectedTableNames in schema.test.ts so the test catches accidental removal of the table from the schema --- src/lib/db/schema.ts | 2 +- test/lib/db/schema.test.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index 0ccd2937..94adaec5 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -140,8 +140,8 @@ export const TABLE_SCHEMAS: Record = { pagination_cursors: { columns: { command_key: { type: "TEXT", notNull: true }, - cursor: { type: "TEXT", notNull: true }, context: { type: "TEXT", notNull: true }, + cursor: { type: "TEXT", notNull: true }, expires_at: { type: "INTEGER", notNull: true }, }, }, diff --git a/test/lib/db/schema.test.ts b/test/lib/db/schema.test.ts index ba16ebe3..17636953 100644 --- a/test/lib/db/schema.test.ts +++ b/test/lib/db/schema.test.ts @@ -282,6 +282,7 @@ describe("EXPECTED_TABLES", () => { "user_info", "instance_info", "project_root_cache", + "pagination_cursors", ]; for (const table of expectedTableNames) { From 700d764899dd75789306256a484e4b0cbd58a8e8 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 13 Feb 2026 11:07:58 +0000 Subject: [PATCH 16/27] =?UTF-8?q?refactor:=20address=20PR=20review=20?= =?UTF-8?q?=E2=80=94=20standardize=20context=20keys,=20simplify=20paginati?= =?UTF-8?q?on=20types,=20replace=20regexes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - buildContextKey: use type:[:] notation (type:org:X, type:auto, etc.) - apiRequestToRegion: always return { data, headers }, remove wrapper - PaginatedResponse: remove redundant hasMore field, derive from nextCursor - parseLinkHeader: replace regexes with extractLinkAttr string parsing - MAX_PAGINATION_PAGES: add JSDoc rationale + SENTRY_MAX_PAGINATION_PAGES env override - CUSTOM_DDL_TABLES: add JSDoc explaining composite PK limitation - listCommand docs: add --platform, --limit, --json examples --- src/commands/project/list.ts | 42 +++++++--- src/lib/api-client.ts | 118 +++++++++++++-------------- src/lib/db/schema.ts | 9 +- src/lib/region.ts | 2 +- test/commands/project/list.test.ts | 45 +++++----- test/lib/api-client.property.test.ts | 34 +++----- 6 files changed, 134 insertions(+), 116 deletions(-) diff --git a/src/commands/project/list.ts b/src/commands/project/list.ts index f461a088..17627d32 100644 --- a/src/commands/project/list.ts +++ b/src/commands/project/list.ts @@ -172,6 +172,8 @@ export function writeRows(options: WriteRowsOptions): void { * Build a context key for pagination cursor validation. * Captures the query parameters that affect result ordering, * so cursors from different queries are not accidentally reused. + * + * Format: `type:[:][|platform:]` */ export function buildContextKey( parsed: ParsedOrgProject, @@ -180,13 +182,21 @@ export function buildContextKey( const parts: string[] = []; switch (parsed.type) { case "org-all": - parts.push(`org:${parsed.org}`); + parts.push(`type:org:${parsed.org}`); break; case "auto-detect": - parts.push("auto"); + parts.push("type:auto"); + break; + case "explicit": + parts.push(`type:explicit:${parsed.org}/${parsed.project}`); + break; + case "project-search": + parts.push(`type:search:${parsed.projectSlug}`); break; - default: - parts.push(`type:${parsed.type}`); + default: { + const _exhaustive: never = parsed; + parts.push(`type:unknown:${String(_exhaustive)}`); + } } if (flags.platform) { parts.push(`platform:${flags.platform}`); @@ -288,7 +298,7 @@ export async function handleAutoDetect( } = await resolveOrgsForAutoDetect(cwd); let allProjects: ProjectWithOrg[]; - let hasMore = false; + let nextCursor: string | undefined; // Fast path: single org, no platform filter — fetch only one page const canUsePaginated = orgsToFetch.length === 1 && !flags.platform; @@ -299,7 +309,7 @@ export async function handleAutoDetect( perPage: flags.limit, }); allProjects = response.data.map((p) => ({ ...p, orgSlug: org })); - hasMore = response.hasMore; + nextCursor = response.nextCursor; } else if (orgsToFetch.length > 0) { const results = await Promise.all(orgsToFetch.map(fetchOrgProjectsSafe)); allProjects = results.flat(); @@ -325,8 +335,8 @@ export async function handleAutoDetect( displayProjectTable(stdout, limited); - if (filtered.length > limited.length || hasMore) { - if (hasMore && orgsToFetch.length === 1) { + if (filtered.length > limited.length || nextCursor) { + if (nextCursor && orgsToFetch.length === 1) { const org = orgsToFetch[0] as string; stdout.write( `\nShowing ${limited.length} projects (more available). ` + @@ -429,15 +439,17 @@ export async function handleOrgAll(options: OrgAllOptions): Promise { const filtered = filterByPlatform(projects, flags.platform); + const hasMore = !!response.nextCursor; + // Update cursor cache for `--cursor last` support - if (response.hasMore && response.nextCursor) { + if (response.nextCursor) { setPaginationCursor(PAGINATION_KEY, contextKey, response.nextCursor); } else { clearPaginationCursor(PAGINATION_KEY, contextKey); } if (flags.json) { - const output = response.hasMore + const output = hasMore ? { data: filtered, nextCursor: response.nextCursor, hasMore: true } : { data: filtered, hasMore: false }; writeJson(stdout, output); @@ -445,7 +457,7 @@ export async function handleOrgAll(options: OrgAllOptions): Promise { } if (filtered.length === 0) { - if (response.hasMore) { + if (hasMore) { stdout.write( `No matching projects on this page. Try the next page: sentry project list ${org}/ -c last\n` ); @@ -457,7 +469,7 @@ export async function handleOrgAll(options: OrgAllOptions): Promise { displayProjectTable(stdout, filtered); - if (response.hasMore) { + if (hasMore) { stdout.write(`\nShowing ${filtered.length} projects (more available)\n`); stdout.write(`Next page: sentry project list ${org}/ -c last\n`); } else { @@ -550,7 +562,11 @@ export const listCommand = buildCommand({ " sentry project list # find project across all orgs\n\n" + "Pagination:\n" + " sentry project list / -c last # continue from last page\n" + - " sentry project list / -c # resume at specific cursor", + " sentry project list / -c # resume at specific cursor\n\n" + + "Filtering and output:\n" + + " sentry project list --platform javascript # filter by platform\n" + + " sentry project list --limit 50 # show more results\n" + + " sentry project list --json # output as JSON", }, parameters: { positional: { diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index 4845a38c..6b922a3d 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -374,26 +374,49 @@ async function createRegionApiClient(regionUrl: string): Promise { }); } -/** Regex to extract rel attribute from Link header */ -const LINK_REL_REGEX = /rel="([^"]+)"/; - -/** Regex to extract results attribute from Link header */ -const LINK_RESULTS_REGEX = /results="([^"]+)"/; - -/** Regex to extract cursor attribute from Link header */ -const LINK_CURSOR_REGEX = /cursor="([^"]+)"/; +/** + * Extract the value of a named attribute from a Link header segment. + * Parses `key="value"` pairs using string operations instead of regex + * for robustness and performance. + * + * @param segment - A single Link header segment (e.g., `; rel="next"; cursor="abc"`) + * @param attr - The attribute name to extract (e.g., "rel", "cursor") + * @returns The attribute value, or undefined if not found + */ +function extractLinkAttr(segment: string, attr: string): string | undefined { + const prefix = `${attr}="`; + const start = segment.indexOf(prefix); + if (start === -1) { + return; + } + const valueStart = start + prefix.length; + const end = segment.indexOf('"', valueStart); + if (end === -1) { + return; + } + return segment.slice(valueStart, end); +} -/** Maximum number of pages to follow when auto-paginating (safety limit) */ -const MAX_PAGINATION_PAGES = 50; +/** + * Maximum number of pages to follow when auto-paginating. + * + * Safety limit to prevent runaway pagination when the API returns an unexpectedly + * large number of pages. At 100 items/page this allows up to 5,000 items, which + * covers even the largest organizations. Override with SENTRY_MAX_PAGINATION_PAGES + * env var for edge cases. + */ +const MAX_PAGINATION_PAGES = + Number(process.env.SENTRY_MAX_PAGINATION_PAGES) || 50; -/** Paginated API response with cursor metadata */ +/** + * Paginated API response with cursor metadata. + * More pages exist when `nextCursor` is defined. + */ export type PaginatedResponse = { /** The response data */ data: T; /** Cursor for fetching the next page (undefined if no more pages) */ nextCursor?: string; - /** Whether more results exist beyond this page */ - hasMore: boolean; }; /** @@ -407,42 +430,35 @@ export type PaginatedResponse = { */ export function parseLinkHeader(header: string | null): { nextCursor?: string; - hasMore: boolean; } { if (!header) { - return { hasMore: false }; + return {}; } // Split on comma to get individual link entries for (const part of header.split(",")) { - const relMatch = part.match(LINK_REL_REGEX); - const resultsMatch = part.match(LINK_RESULTS_REGEX); - const cursorMatch = part.match(LINK_CURSOR_REGEX); - - if ( - relMatch?.[1] === "next" && - resultsMatch?.[1] === "true" && - cursorMatch?.[1] - ) { - return { nextCursor: cursorMatch[1], hasMore: true }; + const rel = extractLinkAttr(part, "rel"); + const results = extractLinkAttr(part, "results"); + const cursor = extractLinkAttr(part, "cursor"); + + if (rel === "next" && results === "true" && cursor) { + return { nextCursor: cursor }; } } - return { hasMore: false }; + return {}; } /** - * Make an authenticated request to a specific Sentry region, - * returning both parsed data and raw response headers. - * - * Used internally by pagination helpers that need access to the Link header. + * Make an authenticated request to a specific Sentry region. + * Returns both parsed response data and raw headers for pagination support. * * @param regionUrl - The region's base URL (e.g., https://us.sentry.io) * @param endpoint - API endpoint path (e.g., "/organizations/") * @param options - Request options * @returns Parsed data and response headers */ -async function apiRequestToRegionWithHeaders( +export async function apiRequestToRegion( regionUrl: string, endpoint: string, options: ApiRequestOptions = {} @@ -483,26 +499,6 @@ async function apiRequestToRegionWithHeaders( return { data: validated, headers: response.headers }; } -/** - * Make an authenticated request to a specific Sentry region. - * - * @param regionUrl - The region's base URL (e.g., https://us.sentry.io) - * @param endpoint - API endpoint path (e.g., "/organizations/") - * @param options - Request options - */ -export async function apiRequestToRegion( - regionUrl: string, - endpoint: string, - options: ApiRequestOptions = {} -): Promise { - const { data } = await apiRequestToRegionWithHeaders( - regionUrl, - endpoint, - options - ); - return data; -} - /** * Get the list of regions the user has organization membership in. * This endpoint is on the control silo (sentry.io) and returns all regions. @@ -511,12 +507,12 @@ export async function apiRequestToRegion( */ export async function getUserRegions(): Promise { // Always use control silo for this endpoint - const response = await apiRequestToRegion( + const { data } = await apiRequestToRegion( CONTROL_SILO_URL, "/users/me/regions/", { schema: UserRegionsResponseSchema } ); - return response.regions; + return data.regions; } /** @@ -525,16 +521,17 @@ export async function getUserRegions(): Promise { * @param regionUrl - The region's base URL * @returns Organizations in that region */ -export function listOrganizationsInRegion( +export async function listOrganizationsInRegion( regionUrl: string ): Promise { - return apiRequestToRegion( + const { data } = await apiRequestToRegion( regionUrl, "/organizations/", { schema: z.array(SentryOrganizationSchema), } ); + return data; } /** @@ -583,13 +580,13 @@ async function orgScopedRequestPaginated( } const { resolveOrgRegion } = await import("./region.js"); const regionUrl = await resolveOrgRegion(orgSlug); - const { data, headers } = await apiRequestToRegionWithHeaders( + const { data, headers } = await apiRequestToRegion( regionUrl, endpoint, options ); - const { nextCursor, hasMore } = parseLinkHeader(headers.get("link")); - return { data, nextCursor, hasMore }; + const { nextCursor } = parseLinkHeader(headers.get("link")); + return { data, nextCursor }; } /** @@ -635,7 +632,7 @@ async function orgScopedPaginateAll( }); allResults.push(...response.data); - if (!(response.hasMore && response.nextCursor)) { + if (!response.nextCursor) { break; } cursor = response.nextCursor; @@ -918,7 +915,7 @@ export async function findProjectByDsnKey( const results = await Promise.all( regions.map(async (region) => { try { - return await apiRequestToRegion( + const { data } = await apiRequestToRegion( region.url, "/projects/", { @@ -926,6 +923,7 @@ export async function findProjectByDsnKey( schema: z.array(SentryProjectSchema), } ); + return data; } catch { return []; } diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index 94adaec5..2187f657 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -365,7 +365,14 @@ export type RepairResult = { failed: string[]; }; -/** Tables that require custom DDL (not auto-generated from TABLE_SCHEMAS) */ +/** + * Tables that require hand-written DDL instead of auto-generation from TABLE_SCHEMAS. + * + * The auto-generation via `columnDefsToDDL` only supports single-column primary keys + * (via `primaryKey: true` on a column). Tables with composite primary keys (like + * `pagination_cursors` with `PRIMARY KEY (command_key, context)`) need custom DDL + * because SQLite requires composite PKs as a table-level constraint, not a column attribute. + */ const CUSTOM_DDL_TABLES = new Set(["pagination_cursors"]); function repairPaginationCursorsTable( diff --git a/src/lib/region.ts b/src/lib/region.ts index 55353cad..1b1475c9 100644 --- a/src/lib/region.ts +++ b/src/lib/region.ts @@ -34,7 +34,7 @@ export async function resolveOrgRegion(orgSlug: string): Promise { try { // First try the default URL - it may route correctly const baseUrl = getSentryBaseUrl(); - const org = await apiRequestToRegion( + const { data: org } = await apiRequestToRegion( baseUrl, `/organizations/${orgSlug}/`, { schema: SentryOrganizationSchema } diff --git a/test/commands/project/list.test.ts b/test/commands/project/list.test.ts index d1624d05..3ff38900 100644 --- a/test/commands/project/list.test.ts +++ b/test/commands/project/list.test.ts @@ -114,34 +114,34 @@ const platformArb = constantFrom( // Tests describe("buildContextKey", () => { - test("org-all mode produces org: prefix", () => { + test("org-all mode produces type:org:", () => { fcAssert( property(slugArb, (org) => { const parsed: ParsedOrgProject = { type: "org-all", org }; const key = buildContextKey(parsed, {}); - expect(key).toBe(`org:${org}`); + expect(key).toBe(`type:org:${org}`); }), { numRuns: DEFAULT_NUM_RUNS } ); }); - test("auto-detect mode produces 'auto'", () => { + test("auto-detect mode produces 'type:auto'", () => { const parsed: ParsedOrgProject = { type: "auto-detect" }; - expect(buildContextKey(parsed, {})).toBe("auto"); + expect(buildContextKey(parsed, {})).toBe("type:auto"); }); - test("explicit mode produces type:explicit", () => { + test("explicit mode produces type:explicit:/", () => { fcAssert( property(tuple(slugArb, slugArb), ([org, project]) => { const parsed: ParsedOrgProject = { type: "explicit", org, project }; const key = buildContextKey(parsed, {}); - expect(key).toBe("type:explicit"); + expect(key).toBe(`type:explicit:${org}/${project}`); }), { numRuns: DEFAULT_NUM_RUNS } ); }); - test("project-search mode produces type:project-search", () => { + test("project-search mode produces type:search:", () => { fcAssert( property(slugArb, (projectSlug) => { const parsed: ParsedOrgProject = { @@ -149,7 +149,7 @@ describe("buildContextKey", () => { projectSlug, }; const key = buildContextKey(parsed, {}); - expect(key).toBe("type:project-search"); + expect(key).toBe(`type:search:${projectSlug}`); }), { numRuns: DEFAULT_NUM_RUNS } ); @@ -160,7 +160,7 @@ describe("buildContextKey", () => { property(tuple(slugArb, platformArb), ([org, platform]) => { const parsed: ParsedOrgProject = { type: "org-all", org }; const key = buildContextKey(parsed, { platform }); - expect(key).toBe(`org:${org}|platform:${platform}`); + expect(key).toBe(`type:org:${org}|platform:${platform}`); }), { numRuns: DEFAULT_NUM_RUNS } ); @@ -562,7 +562,7 @@ describe("handleOrgAll", () => { stdout: writer, org: "test-org", flags: { limit: 30, json: false }, - contextKey: "org:test-org", + contextKey: "type:org:test-org", cursor: undefined, }); @@ -584,7 +584,7 @@ describe("handleOrgAll", () => { stdout: writer, org: "test-org", flags: { limit: 30, json: true }, - contextKey: "org:test-org", + contextKey: "type:org:test-org", cursor: undefined, }); @@ -602,7 +602,7 @@ describe("handleOrgAll", () => { stdout: writer, org: "test-org", flags: { limit: 30, json: true }, - contextKey: "org:test-org", + contextKey: "type:org:test-org", cursor: undefined, }); @@ -622,16 +622,21 @@ describe("handleOrgAll", () => { stdout: writer, org: "test-org", flags: { limit: 30, json: false }, - contextKey: "org:test-org", + contextKey: "type:org:test-org", cursor: undefined, }); - const cached = getPaginationCursor(PAGINATION_KEY, "org:test-org"); + const cached = getPaginationCursor(PAGINATION_KEY, "type:org:test-org"); expect(cached).toBe("1735689600000:100:0"); }); test("no hasMore clears cached cursor", async () => { - setPaginationCursor(PAGINATION_KEY, "org:test-org", "old-cursor", 300_000); + setPaginationCursor( + PAGINATION_KEY, + "type:org:test-org", + "old-cursor", + 300_000 + ); globalThis.fetch = mockProjectFetch(sampleProjects); const { writer } = createCapture(); @@ -640,11 +645,11 @@ describe("handleOrgAll", () => { stdout: writer, org: "test-org", flags: { limit: 30, json: false }, - contextKey: "org:test-org", + contextKey: "type:org:test-org", cursor: undefined, }); - const cached = getPaginationCursor(PAGINATION_KEY, "org:test-org"); + const cached = getPaginationCursor(PAGINATION_KEY, "type:org:test-org"); expect(cached).toBeUndefined(); }); @@ -659,7 +664,7 @@ describe("handleOrgAll", () => { stdout: writer, org: "test-org", flags: { limit: 30, json: false, platform: "rust" }, - contextKey: "org:test-org", + contextKey: "type:org:test-org", cursor: undefined, }); @@ -676,7 +681,7 @@ describe("handleOrgAll", () => { stdout: writer, org: "test-org", flags: { limit: 30, json: false }, - contextKey: "org:test-org", + contextKey: "type:org:test-org", cursor: undefined, }); @@ -695,7 +700,7 @@ describe("handleOrgAll", () => { stdout: writer, org: "test-org", flags: { limit: 30, json: false }, - contextKey: "org:test-org", + contextKey: "type:org:test-org", cursor: undefined, }); diff --git a/test/lib/api-client.property.test.ts b/test/lib/api-client.property.test.ts index efeb5851..47e56f0e 100644 --- a/test/lib/api-client.property.test.ts +++ b/test/lib/api-client.property.test.ts @@ -48,19 +48,18 @@ const prevLinkArb = cursorArb.map( const nonNextRelArb = constantFrom("previous", "first", "last", "self"); describe("property: parseLinkHeader", () => { - test("null or empty header returns hasMore=false, no cursor", () => { + test("null or empty header returns no cursor", () => { const result1 = parseLinkHeader(null); - expect(result1).toEqual({ hasMore: false }); + expect(result1).toEqual({}); const result2 = parseLinkHeader(""); - expect(result2).toEqual({ hasMore: false }); + expect(result2).toEqual({}); }); - test("valid next link with results=true returns cursor and hasMore=true", () => { + test("valid next link with results=true returns cursor", () => { fcAssert( property(nextLinkWithResultsArb, (header) => { const result = parseLinkHeader(header); - expect(result.hasMore).toBe(true); expect(result.nextCursor).toBeDefined(); expect(result.nextCursor).toMatch(/^\d+:\d+:\d+$/); }), @@ -68,22 +67,20 @@ describe("property: parseLinkHeader", () => { ); }); - test("next link with results=false returns hasMore=false, no cursor", () => { + test("next link with results=false returns no cursor", () => { fcAssert( property(nextLinkNoResultsArb, (header) => { const result = parseLinkHeader(header); - expect(result.hasMore).toBe(false); expect(result.nextCursor).toBeUndefined(); }), { numRuns: DEFAULT_NUM_RUNS } ); }); - test("previous-only link returns hasMore=false (ignores non-next)", () => { + test("previous-only link returns no cursor (ignores non-next)", () => { fcAssert( property(prevLinkArb, (header) => { const result = parseLinkHeader(header); - expect(result.hasMore).toBe(false); expect(result.nextCursor).toBeUndefined(); }), { numRuns: DEFAULT_NUM_RUNS } @@ -96,7 +93,6 @@ describe("property: parseLinkHeader", () => { // Sentry sends both prev and next separated by comma const header = `${prev}, ${next}`; const result = parseLinkHeader(header); - expect(result.hasMore).toBe(true); expect(result.nextCursor).toBeDefined(); expect(result.nextCursor).toMatch(/^\d+:\d+:\d+$/); }), @@ -115,30 +111,30 @@ describe("property: parseLinkHeader", () => { ); }); - test("missing cursor attribute returns hasMore=false", () => { + test("missing cursor attribute returns no cursor", () => { const header = '; rel="next"; results="true"'; const result = parseLinkHeader(header); - expect(result.hasMore).toBe(false); + expect(result.nextCursor).toBeUndefined(); }); - test("missing results attribute returns hasMore=false", () => { + test("missing results attribute returns no cursor", () => { fcAssert( property(cursorArb, (cursor) => { const header = `; rel="next"; cursor="${cursor}"`; const result = parseLinkHeader(header); - expect(result.hasMore).toBe(false); + expect(result.nextCursor).toBeUndefined(); }), { numRuns: DEFAULT_NUM_RUNS } ); }); - test("non-next rel with results=true returns hasMore=false", () => { + test("non-next rel with results=true returns no cursor", () => { fcAssert( property(tuple(nonNextRelArb, cursorArb), ([rel, cursor]) => { const header = `; rel="${rel}"; results="true"; cursor="${cursor}"`; const result = parseLinkHeader(header); - expect(result.hasMore).toBe(false); + expect(result.nextCursor).toBeUndefined(); }), { numRuns: DEFAULT_NUM_RUNS } ); @@ -152,19 +148,17 @@ describe("property: parseLinkHeader", () => { `; rel="next"; results="true"; cursor="${nextCursor}"`, ].join(", "); const result = parseLinkHeader(header); - expect(result.hasMore).toBe(true); expect(result.nextCursor).toBe(nextCursor); }), { numRuns: DEFAULT_NUM_RUNS } ); }); - test("random strings without expected attributes return hasMore=false", () => { + test("random strings without expected attributes return no cursor", () => { fcAssert( property(string({ minLength: 0, maxLength: 200 }), (header) => { // Any random string should not crash and should return a valid result const result = parseLinkHeader(header); - expect(typeof result.hasMore).toBe("boolean"); if (result.nextCursor !== undefined) { expect(typeof result.nextCursor).toBe("string"); } @@ -178,7 +172,6 @@ describe("property: parseLinkHeader", () => { '; rel="previous"; results="false"; cursor="1735689600000:0:1", ' + '; rel="next"; results="true"; cursor="1735689600000:100:0"'; const result = parseLinkHeader(header); - expect(result.hasMore).toBe(true); expect(result.nextCursor).toBe("1735689600000:100:0"); }); @@ -187,7 +180,6 @@ describe("property: parseLinkHeader", () => { '; rel="previous"; results="true"; cursor="1735689600000:0:1", ' + '; rel="next"; results="false"; cursor="1735689600000:200:0"'; const result = parseLinkHeader(header); - expect(result.hasMore).toBe(false); expect(result.nextCursor).toBeUndefined(); }); }); From 8d06c8ad434974618272583cc750bb771ae0c0ae Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 13 Feb 2026 11:17:40 +0000 Subject: [PATCH 17/27] refactor: support composite PKs in auto-DDL generator Add compositePrimaryKey field to TableSchema so columnDefsToDDL can emit table-level PRIMARY KEY constraints. This eliminates all special-casing for pagination_cursors: CUSTOM_DDL_TABLES, PAGINATION_CURSORS_DDL, repairPaginationCursorsTable, and the hand-written DDL in test files. Net -67 lines of code removed. --- src/lib/db/schema.ts | 83 ++++++++++------------------------- test/commands/cli/fix.test.ts | 14 ------ test/lib/db/schema.test.ts | 14 ------ 3 files changed, 22 insertions(+), 89 deletions(-) diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index 2187f657..fef39bfa 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -32,6 +32,13 @@ export type ColumnDef = { export type TableSchema = { columns: Record; + /** + * Composite primary key columns. When set, the DDL generator emits a + * table-level `PRIMARY KEY (col1, col2, ...)` constraint instead of + * per-column `PRIMARY KEY` attributes. Individual columns listed here + * should NOT also set `primaryKey: true`. + */ + compositePrimaryKey?: string[]; }; /** @@ -144,6 +151,7 @@ export const TABLE_SCHEMAS: Record = { cursor: { type: "TEXT", notNull: true }, expires_at: { type: "INTEGER", notNull: true }, }, + compositePrimaryKey: ["command_key", "context"], }, metadata: { columns: { @@ -206,7 +214,8 @@ export const TABLE_SCHEMAS: Record = { /** Generate CREATE TABLE DDL from column definitions */ function columnDefsToDDL( tableName: string, - columns: [string, ColumnDef][] + columns: [string, ColumnDef][], + compositePrimaryKey?: string[] ): string { const columnDefs = columns.map(([name, col]) => { const parts = [name, col.type]; @@ -225,6 +234,10 @@ function columnDefsToDDL( return parts.join(" "); }); + if (compositePrimaryKey && compositePrimaryKey.length > 0) { + columnDefs.push(`PRIMARY KEY (${compositePrimaryKey.join(", ")})`); + } + return `CREATE TABLE IF NOT EXISTS ${tableName} (\n ${columnDefs.join(",\n ")}\n )`; } @@ -233,7 +246,11 @@ export function generateTableDDL( tableName: string, schema: TableSchema ): string { - return columnDefsToDDL(tableName, Object.entries(schema.columns)); + return columnDefsToDDL( + tableName, + Object.entries(schema.columns), + schema.compositePrimaryKey + ); } /** @@ -258,7 +275,7 @@ export function generatePreMigrationTableDDL(tableName: string): string { ); } - return columnDefsToDDL(tableName, baseColumns); + return columnDefsToDDL(tableName, baseColumns, schema.compositePrimaryKey); } /** Generated DDL statements for all tables (used for repair and init) */ @@ -365,38 +382,8 @@ export type RepairResult = { failed: string[]; }; -/** - * Tables that require hand-written DDL instead of auto-generation from TABLE_SCHEMAS. - * - * The auto-generation via `columnDefsToDDL` only supports single-column primary keys - * (via `primaryKey: true` on a column). Tables with composite primary keys (like - * `pagination_cursors` with `PRIMARY KEY (command_key, context)`) need custom DDL - * because SQLite requires composite PKs as a table-level constraint, not a column attribute. - */ -const CUSTOM_DDL_TABLES = new Set(["pagination_cursors"]); - -function repairPaginationCursorsTable( - db: Database, - result: RepairResult -): void { - if (tableExists(db, "pagination_cursors")) { - return; - } - try { - db.exec(PAGINATION_CURSORS_DDL); - result.fixed.push("Created table pagination_cursors"); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - result.failed.push(`Failed to create table pagination_cursors: ${msg}`); - } -} - function repairMissingTables(db: Database, result: RepairResult): void { for (const [tableName, ddl] of Object.entries(EXPECTED_TABLES)) { - // Skip tables that need custom DDL - if (CUSTOM_DDL_TABLES.has(tableName)) { - continue; - } if (tableExists(db, tableName)) { continue; } @@ -408,9 +395,6 @@ function repairMissingTables(db: Database, result: RepairResult): void { result.failed.push(`Failed to create table ${tableName}: ${msg}`); } } - - // Handle tables with custom DDL - repairPaginationCursorsTable(db, result); } function repairMissingColumns(db: Database, result: RepairResult): void { @@ -549,32 +533,10 @@ export function tryRepairAndRetry( return { attempted: false }; } -/** - * Custom DDL for pagination_cursors table with composite primary key. - * Uses (command_key, context) so different contexts (e.g., different orgs) - * can each store their own cursor independently. - */ -const PAGINATION_CURSORS_DDL = ` - CREATE TABLE IF NOT EXISTS pagination_cursors ( - command_key TEXT NOT NULL, - context TEXT NOT NULL, - cursor TEXT NOT NULL, - expires_at INTEGER NOT NULL, - PRIMARY KEY (command_key, context) - ) -`; - export function initSchema(db: Database): void { - // Generate combined DDL from all table schemas (except those with custom DDL) - const ddlStatements = Object.entries(EXPECTED_TABLES) - .filter(([name]) => !CUSTOM_DDL_TABLES.has(name)) - .map(([, ddl]) => ddl) - .join(";\n\n"); + const ddlStatements = Object.values(EXPECTED_TABLES).join(";\n\n"); db.exec(ddlStatements); - // Add tables with composite primary keys - db.exec(PAGINATION_CURSORS_DDL); - const versionRow = db .query("SELECT version FROM schema_version LIMIT 1") .get() as { version: number } | null; @@ -632,9 +594,8 @@ export function runMigrations(db: Database): void { } // Migration 4 -> 5: Add pagination_cursors table for --cursor last support - // Uses custom DDL for composite primary key (command_key, context) if (currentVersion < 5) { - db.exec(PAGINATION_CURSORS_DDL); + db.exec(EXPECTED_TABLES.pagination_cursors as string); } if (currentVersion < CURRENT_SCHEMA_VERSION) { diff --git a/test/commands/cli/fix.test.ts b/test/commands/cli/fix.test.ts index 8d5a79be..e9206d27 100644 --- a/test/commands/cli/fix.test.ts +++ b/test/commands/cli/fix.test.ts @@ -17,12 +17,6 @@ import { initSchema, } from "../../../src/lib/db/schema.js"; -/** Hand-written DDL for pagination_cursors with composite PK (matches production) */ -const PAGINATION_CURSORS_DDL = `CREATE TABLE IF NOT EXISTS pagination_cursors ( - command_key TEXT NOT NULL, context TEXT NOT NULL, cursor TEXT NOT NULL, - expires_at INTEGER NOT NULL, PRIMARY KEY (command_key, context) -)`; - /** * Generate DDL for creating a database with pre-migration tables. * This simulates a database that was created before certain migrations ran. @@ -33,15 +27,12 @@ function createPreMigrationDatabase(db: Database): void { const statements: string[] = []; for (const tableName of Object.keys(EXPECTED_TABLES)) { - // pagination_cursors needs custom DDL with composite PK - if (tableName === "pagination_cursors") continue; if (preMigrationTables.includes(tableName)) { statements.push(generatePreMigrationTableDDL(tableName)); } else { statements.push(EXPECTED_TABLES[tableName] as string); } } - statements.push(PAGINATION_CURSORS_DDL); db.exec(statements.join(";\n")); db.query("INSERT INTO schema_version (version) VALUES (4)").run(); @@ -62,13 +53,8 @@ function createDatabaseWithMissingTables( for (const tableName of Object.keys(EXPECTED_TABLES)) { if (missingTables.includes(tableName)) continue; - // pagination_cursors needs custom DDL with composite PK - if (tableName === "pagination_cursors") continue; statements.push(EXPECTED_TABLES[tableName] as string); } - if (!missingTables.includes("pagination_cursors")) { - statements.push(PAGINATION_CURSORS_DDL); - } db.exec(statements.join(";\n")); db.query("INSERT INTO schema_version (version) VALUES (4)").run(); diff --git a/test/lib/db/schema.test.ts b/test/lib/db/schema.test.ts index 17636953..be1241da 100644 --- a/test/lib/db/schema.test.ts +++ b/test/lib/db/schema.test.ts @@ -22,12 +22,6 @@ import { tableExists, } from "../../../src/lib/db/schema.js"; -/** Hand-written DDL for pagination_cursors with composite PK (matches production) */ -const PAGINATION_CURSORS_DDL = `CREATE TABLE IF NOT EXISTS pagination_cursors ( - command_key TEXT NOT NULL, context TEXT NOT NULL, cursor TEXT NOT NULL, - expires_at INTEGER NOT NULL, PRIMARY KEY (command_key, context) -)`; - /** * Create a database with all tables but some missing (for testing repair). */ @@ -38,13 +32,8 @@ function createDatabaseWithMissingTables( const statements: string[] = []; for (const tableName of Object.keys(EXPECTED_TABLES)) { if (missingTables.includes(tableName)) continue; - // pagination_cursors needs custom DDL with composite PK - if (tableName === "pagination_cursors") continue; statements.push(EXPECTED_TABLES[tableName] as string); } - if (!missingTables.includes("pagination_cursors")) { - statements.push(PAGINATION_CURSORS_DDL); - } db.exec(statements.join(";\n")); db.query("INSERT INTO schema_version (version) VALUES (?)").run( CURRENT_SCHEMA_VERSION @@ -61,15 +50,12 @@ function createPreMigrationDatabase( ): void { const statements: string[] = []; for (const tableName of Object.keys(EXPECTED_TABLES)) { - // pagination_cursors needs custom DDL with composite PK - if (tableName === "pagination_cursors") continue; if (preMigrationTables.includes(tableName)) { statements.push(generatePreMigrationTableDDL(tableName)); } else { statements.push(EXPECTED_TABLES[tableName] as string); } } - statements.push(PAGINATION_CURSORS_DDL); db.exec(statements.join(";\n")); db.query("INSERT INTO schema_version (version) VALUES (?)").run( CURRENT_SCHEMA_VERSION From 76b8c09a560ef3f1bfd8bc6ef2d76cede07f0d58 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 13 Feb 2026 11:19:41 +0000 Subject: [PATCH 18/27] fix: normalize platform in cursor cache key and improve empty-result message - Lowercase platform in buildContextKey so --platform Python and --platform python resolve to the same cursor cache entry - When platform filter produces no results and there are no more pages, show "No projects matching platform 'X'" instead of the misleading "No projects found in organization" --- src/commands/project/list.ts | 7 ++++++- test/commands/project/list.test.ts | 17 +++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/commands/project/list.ts b/src/commands/project/list.ts index 17627d32..b53b96ac 100644 --- a/src/commands/project/list.ts +++ b/src/commands/project/list.ts @@ -199,7 +199,8 @@ export function buildContextKey( } } if (flags.platform) { - parts.push(`platform:${flags.platform}`); + // Normalize to lowercase since platform filtering is case-insensitive + parts.push(`platform:${flags.platform.toLowerCase()}`); } return parts.join("|"); } @@ -461,6 +462,10 @@ export async function handleOrgAll(options: OrgAllOptions): Promise { stdout.write( `No matching projects on this page. Try the next page: sentry project list ${org}/ -c last\n` ); + } else if (flags.platform) { + stdout.write( + `No projects matching platform '${flags.platform}' in organization '${org}'.\n` + ); } else { stdout.write(`No projects found in organization '${org}'.\n`); } diff --git a/test/commands/project/list.test.ts b/test/commands/project/list.test.ts index 3ff38900..32e27cd2 100644 --- a/test/commands/project/list.test.ts +++ b/test/commands/project/list.test.ts @@ -689,6 +689,23 @@ describe("handleOrgAll", () => { expect(text).toContain("No projects found"); }); + test("empty page without hasMore and platform filter shows platform message", async () => { + globalThis.fetch = mockProjectFetch([]); + const { writer, output } = createCapture(); + + await handleOrgAll({ + stdout: writer, + org: "test-org", + flags: { limit: 30, json: false, platform: "rust" }, + contextKey: "type:org:test-org", + cursor: undefined, + }); + + const text = output(); + expect(text).toContain("matching platform 'rust'"); + expect(text).not.toContain("No projects found in organization"); + }); + test("hasMore shows next page hint", async () => { globalThis.fetch = mockProjectFetch(sampleProjects, { hasMore: true, From bd213102b3ef3ea7b048ae766ee9fe3bb9f9ec03 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 13 Feb 2026 11:47:11 +0000 Subject: [PATCH 19/27] fix(test): save/restore SENTRY_CONFIG_DIR to prevent cross-file test pollution Three test files (version-check, install-info, project-cache) were deleting process.env.SENTRY_CONFIG_DIR in afterEach without saving the original value set by preload.ts. When Bun's test runner shares a process across files, this poisons the env for subsequent tests that capture the var at module load time. --- test/lib/db/install-info.test.ts | 8 +++++++- test/lib/db/project-cache.test.ts | 8 +++++++- test/lib/version-check.test.ts | 16 ++++++++++++++-- 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/test/lib/db/install-info.test.ts b/test/lib/db/install-info.test.ts index 7278e608..8b5146bb 100644 --- a/test/lib/db/install-info.test.ts +++ b/test/lib/db/install-info.test.ts @@ -12,15 +12,21 @@ import { import { cleanupTestDir, createTestConfigDir } from "../../helpers.js"; let testConfigDir: string; +let savedConfigDir: string | undefined; beforeEach(async () => { + savedConfigDir = process.env.SENTRY_CONFIG_DIR; testConfigDir = await createTestConfigDir("test-install-info-"); process.env.SENTRY_CONFIG_DIR = testConfigDir; }); afterEach(async () => { closeDatabase(); - delete process.env.SENTRY_CONFIG_DIR; + if (savedConfigDir !== undefined) { + process.env.SENTRY_CONFIG_DIR = savedConfigDir; + } else { + delete process.env.SENTRY_CONFIG_DIR; + } await cleanupTestDir(testConfigDir); }); diff --git a/test/lib/db/project-cache.test.ts b/test/lib/db/project-cache.test.ts index eb06d8bb..afc4f2a3 100644 --- a/test/lib/db/project-cache.test.ts +++ b/test/lib/db/project-cache.test.ts @@ -16,8 +16,10 @@ import { import { cleanupTestDir, createTestConfigDir } from "../../helpers.js"; let testConfigDir: string; +let savedConfigDir: string | undefined; beforeEach(async () => { + savedConfigDir = process.env.SENTRY_CONFIG_DIR; testConfigDir = await createTestConfigDir("test-project-cache-"); process.env.SENTRY_CONFIG_DIR = testConfigDir; }); @@ -25,7 +27,11 @@ beforeEach(async () => { afterEach(async () => { // Close database to release file handles before cleanup closeDatabase(); - delete process.env.SENTRY_CONFIG_DIR; + if (savedConfigDir !== undefined) { + process.env.SENTRY_CONFIG_DIR = savedConfigDir; + } else { + delete process.env.SENTRY_CONFIG_DIR; + } await cleanupTestDir(testConfigDir); }); diff --git a/test/lib/version-check.test.ts b/test/lib/version-check.test.ts index 6f52af54..d4e3b9cc 100644 --- a/test/lib/version-check.test.ts +++ b/test/lib/version-check.test.ts @@ -47,9 +47,11 @@ describe("shouldSuppressNotification", () => { describe("getUpdateNotification", () => { let testConfigDir: string; + let savedConfigDir: string | undefined; let savedNoUpdateCheck: string | undefined; beforeEach(async () => { + savedConfigDir = process.env.SENTRY_CONFIG_DIR; testConfigDir = await createTestConfigDir("test-version-notif-"); process.env.SENTRY_CONFIG_DIR = testConfigDir; // Save and clear the env var to test real implementation @@ -58,7 +60,11 @@ describe("getUpdateNotification", () => { }); afterEach(async () => { - delete process.env.SENTRY_CONFIG_DIR; + if (savedConfigDir !== undefined) { + process.env.SENTRY_CONFIG_DIR = savedConfigDir; + } else { + delete process.env.SENTRY_CONFIG_DIR; + } // Restore the env var if (savedNoUpdateCheck !== undefined) { process.env.SENTRY_CLI_NO_UPDATE_CHECK = savedNoUpdateCheck; @@ -116,9 +122,11 @@ describe("abortPendingVersionCheck", () => { describe("maybeCheckForUpdateInBackground", () => { let testConfigDir: string; + let savedConfigDir: string | undefined; let savedNoUpdateCheck: string | undefined; beforeEach(async () => { + savedConfigDir = process.env.SENTRY_CONFIG_DIR; testConfigDir = await createTestConfigDir("test-version-bg-"); process.env.SENTRY_CONFIG_DIR = testConfigDir; // Save and clear the env var to test real implementation @@ -129,7 +137,11 @@ describe("maybeCheckForUpdateInBackground", () => { afterEach(async () => { // Abort any pending check to clean up abortPendingVersionCheck(); - delete process.env.SENTRY_CONFIG_DIR; + if (savedConfigDir !== undefined) { + process.env.SENTRY_CONFIG_DIR = savedConfigDir; + } else { + delete process.env.SENTRY_CONFIG_DIR; + } // Restore the env var if (savedNoUpdateCheck !== undefined) { process.env.SENTRY_CLI_NO_UPDATE_CHECK = savedNoUpdateCheck; From 39cf2a3030c5fb63aeeaca4b0889b57822a506e0 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 13 Feb 2026 11:56:32 +0000 Subject: [PATCH 20/27] fix: include --platform flag in pagination next-page hints When --platform is active in org-all mode, the next-page hints now include the flag so that following the hint preserves the filter and matches the correct cursor context key. --- src/commands/project/list.ts | 10 ++++++++-- test/commands/project/list.test.ts | 22 ++++++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/commands/project/list.ts b/src/commands/project/list.ts index b53b96ac..0a91af51 100644 --- a/src/commands/project/list.ts +++ b/src/commands/project/list.ts @@ -421,6 +421,12 @@ export type OrgAllOptions = { cursor: string | undefined; }; +/** Build the CLI hint for fetching the next page, preserving active flags. */ +function nextPageHint(org: string, platform?: string): string { + const base = `sentry project list ${org}/ -c last`; + return platform ? `${base} --platform ${platform}` : base; +} + /** * Handle org-all mode (e.g., sentry/). * Uses cursor pagination for efficient page-by-page listing. @@ -460,7 +466,7 @@ export async function handleOrgAll(options: OrgAllOptions): Promise { if (filtered.length === 0) { if (hasMore) { stdout.write( - `No matching projects on this page. Try the next page: sentry project list ${org}/ -c last\n` + `No matching projects on this page. Try the next page: ${nextPageHint(org, flags.platform)}\n` ); } else if (flags.platform) { stdout.write( @@ -476,7 +482,7 @@ export async function handleOrgAll(options: OrgAllOptions): Promise { if (hasMore) { stdout.write(`\nShowing ${filtered.length} projects (more available)\n`); - stdout.write(`Next page: sentry project list ${org}/ -c last\n`); + stdout.write(`Next page: ${nextPageHint(org, flags.platform)}\n`); } else { stdout.write(`\nShowing ${filtered.length} projects\n`); } diff --git a/test/commands/project/list.test.ts b/test/commands/project/list.test.ts index 32e27cd2..dd6e5cd7 100644 --- a/test/commands/project/list.test.ts +++ b/test/commands/project/list.test.ts @@ -671,6 +671,7 @@ describe("handleOrgAll", () => { const text = output(); expect(text).toContain("No matching projects on this page"); expect(text).toContain("-c last"); + expect(text).toContain("--platform rust"); }); test("empty page without hasMore shows no projects", async () => { @@ -724,6 +725,27 @@ describe("handleOrgAll", () => { const text = output(); expect(text).toContain("more available"); expect(text).toContain("-c last"); + expect(text).not.toContain("--platform"); + }); + + test("hasMore with platform includes --platform in hint", async () => { + globalThis.fetch = mockProjectFetch(sampleProjects, { + hasMore: true, + nextCursor: "1735689600000:100:0", + }); + const { writer, output } = createCapture(); + + await handleOrgAll({ + stdout: writer, + org: "test-org", + flags: { limit: 30, json: false, platform: "python" }, + contextKey: "type:org:test-org:platform:python", + cursor: undefined, + }); + + const text = output(); + expect(text).toContain("--platform python"); + expect(text).toContain("-c last"); }); }); From 5bc9dd60f5dd1a588380b4c39154298269f0398b Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 13 Feb 2026 12:10:31 +0000 Subject: [PATCH 21/27] fix: wrap fast-path pagination in error handler and restore env var correctly - Extract fetchPaginatedSafe() to handle non-auth API errors (403, network) in the single-org fast path, matching fetchOrgProjectsSafe behavior. Reduces handleAutoDetect complexity below lint threshold. - Fix createIsolatedDbContext cleanup to restore original env var value (including undefined) instead of leaking fallback path. - Add tests for fast-path error handling (non-auth swallowed, AuthError propagated). --- src/commands/project/list.ts | 39 +++++++++++++++++++++++------- test/commands/project/list.test.ts | 38 +++++++++++++++++++++++++++++ test/model-based/helpers.ts | 12 ++++++--- 3 files changed, 77 insertions(+), 12 deletions(-) diff --git a/src/commands/project/list.ts b/src/commands/project/list.ts index 0a91af51..4a45c48f 100644 --- a/src/commands/project/list.ts +++ b/src/commands/project/list.ts @@ -279,6 +279,29 @@ export function displayProjectTable( writeRows({ stdout, projects, orgWidth, slugWidth, nameWidth }); } +/** + * Fetch a single page of projects from one org, with error handling + * that mirrors `fetchOrgProjectsSafe` — re-throws auth errors but + * silently returns empty for other failures (403, network errors). + */ +async function fetchPaginatedSafe( + org: string, + limit: number +): Promise<{ projects: ProjectWithOrg[]; nextCursor?: string }> { + try { + const response = await listProjectsPaginated(org, { perPage: limit }); + return { + projects: response.data.map((p) => ({ ...p, orgSlug: org })), + nextCursor: response.nextCursor, + }; + } catch (error) { + if (error instanceof AuthError) { + throw error; + } + return { projects: [] }; + } +} + /** * Handle auto-detect mode: resolve orgs from config/DSN, fetch all projects, * apply client-side filtering and limiting. @@ -302,15 +325,13 @@ export async function handleAutoDetect( let nextCursor: string | undefined; // Fast path: single org, no platform filter — fetch only one page - const canUsePaginated = orgsToFetch.length === 1 && !flags.platform; - - if (canUsePaginated) { - const org = orgsToFetch[0] as string; - const response = await listProjectsPaginated(org, { - perPage: flags.limit, - }); - allProjects = response.data.map((p) => ({ ...p, orgSlug: org })); - nextCursor = response.nextCursor; + if (orgsToFetch.length === 1 && !flags.platform) { + const result = await fetchPaginatedSafe( + orgsToFetch[0] as string, + flags.limit + ); + allProjects = result.projects; + nextCursor = result.nextCursor; } else if (orgsToFetch.length > 0) { const results = await Promise.all(orgsToFetch.map(fetchOrgProjectsSafe)); allProjects = results.flat(); diff --git a/test/commands/project/list.test.ts b/test/commands/project/list.test.ts index dd6e5cd7..6a7db028 100644 --- a/test/commands/project/list.test.ts +++ b/test/commands/project/list.test.ts @@ -1336,6 +1336,44 @@ describe("handleAutoDetect", () => { expect(text).not.toContain("--limit"); }); + test("fast path: non-auth API errors return empty results instead of throwing", async () => { + await setDefaults("test-org"); + // Mock returns 403 for projects endpoint (stale org, no access) + // @ts-expect-error - partial mock + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + if (req.url.includes("/projects/")) { + return new Response(JSON.stringify({ detail: "Forbidden" }), { + status: 403, + }); + } + return new Response(JSON.stringify([]), { status: 200 }); + }; + const { writer, output } = createCapture(); + + await handleAutoDetect(writer, "/tmp/test-project", { + limit: 30, + json: true, + }); + + const parsed = JSON.parse(output()); + expect(parsed).toEqual([]); + }); + + test("fast path: AuthError still propagates", async () => { + await setDefaults("test-org"); + // Clear auth so getAuthToken() throws AuthError before any fetch + clearAuth(); + const { writer } = createCapture(); + + await expect( + handleAutoDetect(writer, "/tmp/test-project", { + limit: 30, + json: true, + }) + ).rejects.toThrow(AuthError); + }); + test("slow path: uses full fetch when platform filter is active", async () => { // Set default org — but platform filter forces slow path await setDefaults("test-org"); diff --git a/test/model-based/helpers.ts b/test/model-based/helpers.ts index 9dcbee9a..395d4c93 100644 --- a/test/model-based/helpers.ts +++ b/test/model-based/helpers.ts @@ -19,7 +19,9 @@ import { CONFIG_DIR_ENV_VAR, closeDatabase } from "../../src/lib/db/index.js"; * @returns Cleanup function to call after test completes */ export function createIsolatedDbContext(): () => void { - let testBaseDir = process.env[CONFIG_DIR_ENV_VAR]; + const originalEnvValue = process.env[CONFIG_DIR_ENV_VAR]; + + let testBaseDir = originalEnvValue; if (!testBaseDir) { // Fallback: create a temp base dir (matches preload.ts pattern) testBaseDir = join(homedir(), `.sentry-cli-test-model-${process.pid}`); @@ -40,8 +42,12 @@ export function createIsolatedDbContext(): () => void { return () => { closeDatabase(); - // Restore original base dir for next test - process.env[CONFIG_DIR_ENV_VAR] = testBaseDir; + // Restore the original env var value, preserving undefined if it was unset + if (originalEnvValue === undefined) { + delete process.env[CONFIG_DIR_ENV_VAR]; + } else { + process.env[CONFIG_DIR_ENV_VAR] = originalEnvValue; + } }; } From 95d364f9a797199af7ae539ef327e2fd389b0b99 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 13 Feb 2026 17:18:01 +0000 Subject: [PATCH 22/27] fix: clamp MAX_PAGINATION_PAGES to minimum of 1 --- src/lib/api-client.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index 6b922a3d..b83c273b 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -405,8 +405,10 @@ function extractLinkAttr(segment: string, attr: string): string | undefined { * covers even the largest organizations. Override with SENTRY_MAX_PAGINATION_PAGES * env var for edge cases. */ -const MAX_PAGINATION_PAGES = - Number(process.env.SENTRY_MAX_PAGINATION_PAGES) || 50; +const MAX_PAGINATION_PAGES = Math.max( + 1, + Number(process.env.SENTRY_MAX_PAGINATION_PAGES) || 50 +); /** * Paginated API response with cursor metadata. From 1d72034b1048b90938996703727f18edf2acbdca Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Mon, 16 Feb 2026 15:48:42 +0000 Subject: [PATCH 23/27] fix(test): update mock to return 404 for single-project endpoint findProjectsBySlug now uses getProject (SDK retrieveAProject) per org instead of listing all projects. The mock was returning an array for the /projects/{org}/{project}/ endpoint, causing the SDK to parse it incorrectly. Return 404 for non-existent project detail requests. --- test/commands/issue/utils.test.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/test/commands/issue/utils.test.ts b/test/commands/issue/utils.test.ts index 80e6682e..13241e9b 100644 --- a/test/commands/issue/utils.test.ts +++ b/test/commands/issue/utils.test.ts @@ -445,7 +445,16 @@ describe("resolveOrgAndIssueId", () => { ); } - // listProjects - return projects that don't match "nonexistent" + // getProject (single project detail) — "nonexistent" doesn't exist + // URL pattern: /projects/{org}/{project}/ + if (url.match(/\/projects\/[^/]+\/[^/]+/)) { + return new Response(JSON.stringify({ detail: "Not found" }), { + status: 404, + }); + } + + // listProjects — return projects that don't match "nonexistent" + // URL pattern: /organizations/{org}/projects/ if (url.includes("/projects/")) { return new Response( JSON.stringify([ From 178ba3199e55f5dce188e10571ba9ea20513f57a Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 17 Feb 2026 00:37:29 +0000 Subject: [PATCH 24/27] revert test/model-based/helpers.ts to main's version Remove dead fallback code for missing SENTRY_CONFIG_DIR (preload.ts always sets it) and the 'delete process.env' pattern that violates AGENTS.md rules. --- test/model-based/helpers.ts | 21 ++++----------------- 1 file changed, 4 insertions(+), 17 deletions(-) diff --git a/test/model-based/helpers.ts b/test/model-based/helpers.ts index 395d4c93..4b0f66af 100644 --- a/test/model-based/helpers.ts +++ b/test/model-based/helpers.ts @@ -5,7 +5,6 @@ */ import { mkdirSync } from "node:fs"; -import { homedir } from "node:os"; import { join } from "node:path"; import { CONFIG_DIR_ENV_VAR, closeDatabase } from "../../src/lib/db/index.js"; @@ -13,20 +12,12 @@ import { CONFIG_DIR_ENV_VAR, closeDatabase } from "../../src/lib/db/index.js"; * Create an isolated database context for model-based tests. * Each test run gets its own SQLite database to avoid interference. * - * Falls back to creating a temp directory if CONFIG_DIR_ENV_VAR is not set, - * which can happen due to test ordering/worker isolation in CI. - * * @returns Cleanup function to call after test completes */ export function createIsolatedDbContext(): () => void { - const originalEnvValue = process.env[CONFIG_DIR_ENV_VAR]; - - let testBaseDir = originalEnvValue; + const testBaseDir = process.env[CONFIG_DIR_ENV_VAR]; if (!testBaseDir) { - // Fallback: create a temp base dir (matches preload.ts pattern) - testBaseDir = join(homedir(), `.sentry-cli-test-model-${process.pid}`); - mkdirSync(testBaseDir, { recursive: true }); - process.env[CONFIG_DIR_ENV_VAR] = testBaseDir; + throw new Error(`${CONFIG_DIR_ENV_VAR} not set - run tests via bun test`); } // Close any existing database connection @@ -42,12 +33,8 @@ export function createIsolatedDbContext(): () => void { return () => { closeDatabase(); - // Restore the original env var value, preserving undefined if it was unset - if (originalEnvValue === undefined) { - delete process.env[CONFIG_DIR_ENV_VAR]; - } else { - process.env[CONFIG_DIR_ENV_VAR] = originalEnvValue; - } + // Restore original base dir for next test + process.env[CONFIG_DIR_ENV_VAR] = testBaseDir; }; } From 6c5b301175984d942d97dd9585802e2084bbb1c1 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 17 Feb 2026 00:54:21 +0000 Subject: [PATCH 25/27] scope pagination cursors by Sentry host to support multi-instance logins Include the Sentry base URL in buildContextKey so cursors from different instances (SaaS vs self-hosted) are never mixed when multiple logins are supported. --- src/commands/project/list.ts | 13 +++++++--- test/commands/project/list.test.ts | 41 ++++++++++++++++++------------ 2 files changed, 34 insertions(+), 20 deletions(-) diff --git a/src/commands/project/list.ts b/src/commands/project/list.ts index 4a45c48f..da9b44b7 100644 --- a/src/commands/project/list.ts +++ b/src/commands/project/list.ts @@ -38,6 +38,7 @@ import { writeJson, } from "../../lib/formatters/index.js"; import { resolveAllTargets } from "../../lib/resolve-target.js"; +import { getApiBaseUrl } from "../../lib/sentry-client.js"; import type { SentryProject, Writer } from "../../types/index.js"; /** Command key for pagination cursor storage */ @@ -173,13 +174,17 @@ export function writeRows(options: WriteRowsOptions): void { * Captures the query parameters that affect result ordering, * so cursors from different queries are not accidentally reused. * - * Format: `type:[:][|platform:]` + * Includes the Sentry host so cursors from different instances + * (SaaS vs self-hosted) are never mixed. + * + * Format: `host:|type:[:][|platform:]` */ export function buildContextKey( parsed: ParsedOrgProject, - flags: { platform?: string } + flags: { platform?: string }, + host: string ): string { - const parts: string[] = []; + const parts: string[] = [`host:${host}`]; switch (parsed.type) { case "org-all": parts.push(`type:org:${parsed.org}`); @@ -659,7 +664,7 @@ export const listCommand = buildCommand({ ); } - const contextKey = buildContextKey(parsed, flags); + const contextKey = buildContextKey(parsed, flags, getApiBaseUrl()); const cursor = resolveCursor(flags.cursor, contextKey); switch (parsed.type) { diff --git a/test/commands/project/list.test.ts b/test/commands/project/list.test.ts index f459963f..1310ae6d 100644 --- a/test/commands/project/list.test.ts +++ b/test/commands/project/list.test.ts @@ -114,42 +114,44 @@ const platformArb = constantFrom( // Tests describe("buildContextKey", () => { - test("org-all mode produces type:org:", () => { + const host = "https://sentry.io"; + + test("org-all mode produces host:|type:org:", () => { fcAssert( property(slugArb, (org) => { const parsed: ParsedOrgProject = { type: "org-all", org }; - const key = buildContextKey(parsed, {}); - expect(key).toBe(`type:org:${org}`); + const key = buildContextKey(parsed, {}, host); + expect(key).toBe(`host:${host}|type:org:${org}`); }), { numRuns: DEFAULT_NUM_RUNS } ); }); - test("auto-detect mode produces 'type:auto'", () => { + test("auto-detect mode produces host + type:auto", () => { const parsed: ParsedOrgProject = { type: "auto-detect" }; - expect(buildContextKey(parsed, {})).toBe("type:auto"); + expect(buildContextKey(parsed, {}, host)).toBe(`host:${host}|type:auto`); }); - test("explicit mode produces type:explicit:/", () => { + test("explicit mode produces host + type:explicit:/", () => { fcAssert( property(tuple(slugArb, slugArb), ([org, project]) => { const parsed: ParsedOrgProject = { type: "explicit", org, project }; - const key = buildContextKey(parsed, {}); - expect(key).toBe(`type:explicit:${org}/${project}`); + const key = buildContextKey(parsed, {}, host); + expect(key).toBe(`host:${host}|type:explicit:${org}/${project}`); }), { numRuns: DEFAULT_NUM_RUNS } ); }); - test("project-search mode produces type:search:", () => { + test("project-search mode produces host + type:search:", () => { fcAssert( property(slugArb, (projectSlug) => { const parsed: ParsedOrgProject = { type: "project-search", projectSlug, }; - const key = buildContextKey(parsed, {}); - expect(key).toBe(`type:search:${projectSlug}`); + const key = buildContextKey(parsed, {}, host); + expect(key).toBe(`host:${host}|type:search:${projectSlug}`); }), { numRuns: DEFAULT_NUM_RUNS } ); @@ -159,19 +161,26 @@ describe("buildContextKey", () => { fcAssert( property(tuple(slugArb, platformArb), ([org, platform]) => { const parsed: ParsedOrgProject = { type: "org-all", org }; - const key = buildContextKey(parsed, { platform }); - expect(key).toBe(`type:org:${org}|platform:${platform}`); + const key = buildContextKey(parsed, { platform }, host); + expect(key).toBe(`host:${host}|type:org:${org}|platform:${platform}`); }), { numRuns: DEFAULT_NUM_RUNS } ); }); - test("no platform flag means no pipe in key", () => { + test("different hosts produce different keys for same org", () => { fcAssert( property(slugArb, (org) => { const parsed: ParsedOrgProject = { type: "org-all", org }; - const key = buildContextKey(parsed, {}); - expect(key).not.toContain("|"); + const saas = buildContextKey(parsed, {}, "https://sentry.io"); + const selfHosted = buildContextKey( + parsed, + {}, + "https://sentry.example.com" + ); + expect(saas).not.toBe(selfHosted); + expect(saas).toStartWith("host:https://sentry.io|"); + expect(selfHosted).toStartWith("host:https://sentry.example.com|"); }), { numRuns: DEFAULT_NUM_RUNS } ); From 4bd38dfb208fd5524727e7072e12cabea1830761 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 17 Feb 2026 00:59:51 +0000 Subject: [PATCH 26/27] include hasMore metadata in auto-detect JSON output Auto-detect mode now wraps JSON output in a {data, hasMore, hint} envelope so JSON consumers can detect truncation. When results are incomplete, the hint field tells consumers which paginated command to use for full results. Also extracted fetchAutoDetectProjects helper to reduce function complexity. --- src/commands/project/list.ts | 59 +++++++++++++++++++----------- test/commands/project/list.test.ts | 52 +++++++++++++++++++------- 2 files changed, 77 insertions(+), 34 deletions(-) diff --git a/src/commands/project/list.ts b/src/commands/project/list.ts index da9b44b7..2f96b2db 100644 --- a/src/commands/project/list.ts +++ b/src/commands/project/list.ts @@ -308,13 +308,37 @@ async function fetchPaginatedSafe( } /** - * Handle auto-detect mode: resolve orgs from config/DSN, fetch all projects, - * apply client-side filtering and limiting. + * Fetch projects for auto-detect mode. * * Optimization: when targeting a single org without platform filter, uses * single-page pagination (`perPage=limit`) to avoid fetching all projects. * Multi-org or filtered queries still require full fetch + client-side slicing. */ +async function fetchAutoDetectProjects( + orgs: string[], + flags: ListFlags +): Promise<{ projects: ProjectWithOrg[]; nextCursor?: string }> { + if (orgs.length === 1 && !flags.platform) { + return fetchPaginatedSafe(orgs[0] as string, flags.limit); + } + if (orgs.length > 0) { + const results = await Promise.all(orgs.map(fetchOrgProjectsSafe)); + return { projects: results.flat() }; + } + return { projects: await fetchAllOrgProjects() }; +} + +/** Build a pagination hint for auto-detect JSON output. */ +function autoDetectPaginationHint(orgs: string[]): string { + return orgs.length === 1 + ? `sentry project list ${orgs[0]}/ --json` + : "sentry project list / --json"; +} + +/** + * Handle auto-detect mode: resolve orgs from config/DSN, fetch all projects, + * apply client-side filtering and limiting. + */ export async function handleAutoDetect( stdout: Writer, cwd: string, @@ -326,31 +350,24 @@ export async function handleAutoDetect( skippedSelfHosted, } = await resolveOrgsForAutoDetect(cwd); - let allProjects: ProjectWithOrg[]; - let nextCursor: string | undefined; - - // Fast path: single org, no platform filter — fetch only one page - if (orgsToFetch.length === 1 && !flags.platform) { - const result = await fetchPaginatedSafe( - orgsToFetch[0] as string, - flags.limit - ); - allProjects = result.projects; - nextCursor = result.nextCursor; - } else if (orgsToFetch.length > 0) { - const results = await Promise.all(orgsToFetch.map(fetchOrgProjectsSafe)); - allProjects = results.flat(); - } else { - allProjects = await fetchAllOrgProjects(); - } + const { projects: allProjects, nextCursor } = await fetchAutoDetectProjects( + orgsToFetch, + flags + ); const filtered = filterByPlatform(allProjects, flags.platform); const limitCount = orgsToFetch.length > 1 ? flags.limit * orgsToFetch.length : flags.limit; const limited = filtered.slice(0, limitCount); + const hasMore = filtered.length > limited.length || !!nextCursor; + if (flags.json) { - writeJson(stdout, limited); + const output: Record = { data: limited, hasMore }; + if (hasMore) { + output.hint = autoDetectPaginationHint(orgsToFetch); + } + writeJson(stdout, output); return; } @@ -362,7 +379,7 @@ export async function handleAutoDetect( displayProjectTable(stdout, limited); - if (filtered.length > limited.length || nextCursor) { + if (hasMore) { if (nextCursor && orgsToFetch.length === 1) { const org = orgsToFetch[0] as string; stdout.write( diff --git a/test/commands/project/list.test.ts b/test/commands/project/list.test.ts index 1310ae6d..dd72744c 100644 --- a/test/commands/project/list.test.ts +++ b/test/commands/project/list.test.ts @@ -1218,7 +1218,7 @@ describe("handleAutoDetect", () => { expect(text).toContain("backend"); }); - test("--json outputs array", async () => { + test("--json outputs envelope with hasMore", async () => { globalThis.fetch = mockProjectFetch(sampleProjects); const { writer, output } = createCapture(); @@ -1228,8 +1228,9 @@ describe("handleAutoDetect", () => { }); const parsed = JSON.parse(output()); - expect(Array.isArray(parsed)).toBe(true); - expect(parsed).toHaveLength(2); + expect(parsed).toHaveProperty("data"); + expect(parsed).toHaveProperty("hasMore", false); + expect(parsed.data).toHaveLength(2); }); test("empty results shows no projects message", async () => { @@ -1244,7 +1245,7 @@ describe("handleAutoDetect", () => { expect(output()).toContain("No projects found"); }); - test("respects --limit flag", async () => { + test("respects --limit flag and indicates truncation", async () => { const manyProjects = Array.from({ length: 5 }, (_, i) => makeProject({ id: String(i), slug: `proj-${i}`, name: `Project ${i}` }) ); @@ -1257,7 +1258,9 @@ describe("handleAutoDetect", () => { }); const parsed = JSON.parse(output()); - expect(parsed).toHaveLength(2); + expect(parsed.data).toHaveLength(2); + expect(parsed.hasMore).toBe(true); + expect(parsed.hint).toBeString(); }); test("respects --platform flag", async () => { @@ -1271,8 +1274,9 @@ describe("handleAutoDetect", () => { }); const parsed = JSON.parse(output()); - expect(parsed).toHaveLength(1); - expect(parsed[0].platform).toBe("python"); + expect(parsed.data).toHaveLength(1); + expect(parsed.data[0].platform).toBe("python"); + expect(parsed.hasMore).toBe(false); }); test("shows limit message when more projects exist", async () => { @@ -1304,10 +1308,10 @@ describe("handleAutoDetect", () => { }); const parsed = JSON.parse(output()); - expect(Array.isArray(parsed)).toBe(true); - expect(parsed).toHaveLength(2); + expect(parsed.data).toHaveLength(2); // Verify orgSlug is attached - expect(parsed[0].orgSlug).toBe("test-org"); + expect(parsed.data[0].orgSlug).toBe("test-org"); + expect(parsed.hasMore).toBe(false); }); test("fast path: shows truncation message when server has more results", async () => { @@ -1329,6 +1333,26 @@ describe("handleAutoDetect", () => { expect(text).not.toContain("--limit"); }); + test("fast path: JSON includes hasMore and hint when server has more results", async () => { + await setDefaults("test-org"); + globalThis.fetch = mockProjectFetch(sampleProjects, { + hasMore: true, + nextCursor: "1735689600000:0:0", + }); + const { writer, output } = createCapture(); + + await handleAutoDetect(writer, "/tmp/test-project", { + limit: 30, + json: true, + }); + + const parsed = JSON.parse(output()); + expect(parsed.hasMore).toBe(true); + expect(parsed.data).toHaveLength(2); + expect(parsed.hint).toContain("test-org/"); + expect(parsed.hint).toContain("--json"); + }); + test("fast path: non-auth API errors return empty results instead of throwing", async () => { await setDefaults("test-org"); // Mock returns 403 for projects endpoint (stale org, no access) @@ -1350,7 +1374,8 @@ describe("handleAutoDetect", () => { }); const parsed = JSON.parse(output()); - expect(parsed).toEqual([]); + expect(parsed.data).toEqual([]); + expect(parsed.hasMore).toBe(false); }); test("fast path: AuthError still propagates", async () => { @@ -1380,7 +1405,8 @@ describe("handleAutoDetect", () => { }); const parsed = JSON.parse(output()); - expect(parsed).toHaveLength(1); - expect(parsed[0].platform).toBe("python"); + expect(parsed.data).toHaveLength(1); + expect(parsed.data[0].platform).toBe("python"); + expect(parsed.hasMore).toBe(false); }); }); From 13d44d121744c753c15416038d896c61c31b4184 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 17 Feb 2026 01:06:02 +0000 Subject: [PATCH 27/27] verify slug match in findProjectsBySlug to prevent ID resolution The API endpoint accepts project_id_or_slug, so numeric inputs could resolve by ID and return a project with a different slug. Add a post-fetch check to ensure the returned project actually matches the requested slug. --- src/lib/api-client.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index bebac04c..3e095fca 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -704,6 +704,11 @@ export async function findProjectsBySlug( orgs.map(async (org) => { try { const project = await getProject(org.slug, projectSlug); + // The API accepts project_id_or_slug, so a numeric input could + // resolve by ID. Verify the returned slug actually matches. + if (project.slug !== projectSlug) { + return null; + } return { ...project, orgSlug: org.slug }; } catch (error) { if (error instanceof AuthError) {