diff --git a/bun.lock b/bun.lock index ead0d2419..88f617c34 100644 --- a/bun.lock +++ b/bun.lock @@ -9,7 +9,7 @@ "@biomejs/biome": "2.3.8", "@clack/prompts": "^0.11.0", "@mastra/client-js": "^1.4.0", - "@sentry/api": "^0.113.0", + "@sentry/api": "^0.133.0", "@sentry/node-core": "10.50.0", "@sentry/sqlish": "^1.0.0", "@stricli/auto-complete": "^1.2.4", @@ -175,7 +175,7 @@ "@peggyjs/from-mem": ["@peggyjs/from-mem@3.1.3", "", { "dependencies": { "semver": "7.7.4" } }, "sha512-LLlgtfXIaeYXoOYovOI0spLM8ZXaqkAlmcRRrLzHJzLMqkU6Sw0R4KMoCoHx1PjaP815pSCBlS+BN6aD8t1Jgg=="], - "@sentry/api": ["@sentry/api@0.113.0", "", {}, "sha512-28W0Oykb/O+6kH8F+OEd8070N4z7ctawlyUtEvnNZNlaLviDC9Is1X/0JiK2Xb9y2ZNbkWf+/H1y5hXr0WTIOw=="], + "@sentry/api": ["@sentry/api@0.133.0", "", {}, "sha512-flfRUm9T9xgyNEWQqCiNv8wX4QlqOt63tM8dRMbeo26zD4+xEaL4KiaEnPC/W5rXCKg/03FBJysw7rEIuhiedQ=="], "@sentry/core": ["@sentry/core@10.50.0", "", {}, "sha512-J4A+vzUO3adl0TkFCjaN1+4miamrjHiEIYuLHiuu1lmAjq5WIVw32ObvAh4yMwNtxyaEMosTrrh5M6f12XSJFg=="], diff --git a/package.json b/package.json index acbc28168..3fef5672d 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "@biomejs/biome": "2.3.8", "@clack/prompts": "^0.11.0", "@mastra/client-js": "^1.4.0", - "@sentry/api": "^0.113.0", + "@sentry/api": "^0.133.0", "@sentry/node-core": "10.50.0", "@sentry/sqlish": "^1.0.0", "@stricli/auto-complete": "^1.2.4", diff --git a/src/lib/api/events.ts b/src/lib/api/events.ts index adec3f022..22e2f5c91 100644 --- a/src/lib/api/events.ts +++ b/src/lib/api/events.ts @@ -237,9 +237,7 @@ export async function listIssueEvents( }); const paginated = unwrapPaginatedResult( - result as - | { data: IssueEvent[]; error: undefined } - | { data: undefined; error: unknown }, + result, "Failed to list issue events" ); diff --git a/src/lib/api/infrastructure.ts b/src/lib/api/infrastructure.ts index b8407598a..275dcf47b 100644 --- a/src/lib/api/infrastructure.ts +++ b/src/lib/api/infrastructure.ts @@ -15,6 +15,7 @@ import { extractRequiredScopes } from "../api-scope.js"; import { getActiveEnvVarName, isEnvTokenActive } from "../db/auth.js"; import { getEnv } from "../env.js"; import { ApiError, AuthError, stringifyUnknown } from "../errors.js"; +import { logger } from "../logger.js"; import { resolveOrgRegion } from "../region.js"; import { getApiBaseUrl, @@ -188,8 +189,17 @@ export function unwrapPaginatedResult( ): PaginatedResponse { const response = (result as { response?: Response }).response; const data = unwrapResult(result, context); - const { nextCursor } = parseLinkHeader(response?.headers.get("link") ?? null); - return { data, nextCursor }; + const { nextCursor, prevCursor } = parseLinkHeader( + response?.headers.get("link") ?? null + ); + const out: PaginatedResponse = { data }; + if (nextCursor !== undefined) { + out.nextCursor = nextCursor; + } + if (prevCursor !== undefined) { + out.prevCursor = prevCursor; + } + return out; } /** @@ -270,6 +280,8 @@ export type PaginatedResponse = { data: T; /** Cursor for fetching the next page (undefined if no more pages) */ nextCursor?: string; + /** Cursor for the previous page (undefined on the first page) */ + prevCursor?: string; }; /** @@ -316,7 +328,11 @@ export async function autoPaginate( cursor = result.nextCursor; } - // Safety limit reached — return what we have, no nextCursor + // Safety limit reached — warn and return what we have, no nextCursor + logger.warn( + `Pagination limit reached (${MAX_PAGINATION_PAGES} pages, ${allRows.length} items). ` + + "Results may be incomplete." + ); return { data: allRows.slice(0, limit) }; } diff --git a/src/lib/api/projects.ts b/src/lib/api/projects.ts index 9bae30aac..86c369ae2 100644 --- a/src/lib/api/projects.ts +++ b/src/lib/api/projects.ts @@ -27,7 +27,6 @@ import { } from "../db/project-cache.js"; import { getCachedOrganizations } from "../db/regions.js"; import { type AuthGuardSuccess, withAuthGuard } from "../errors.js"; -import { logger } from "../logger.js"; import { getApiBaseUrl } from "../sentry-client.js"; import { buildProjectUrl } from "../sentry-urls.js"; import { isAllDigits } from "../utils.js"; @@ -35,6 +34,7 @@ import { isAllDigits } from "../utils.js"; import { API_MAX_PER_PAGE, apiRequestToRegion, + autoPaginate, getOrgSdkConfig, MAX_PAGINATION_PAGES, ORG_FANOUT_CONCURRENCY, @@ -54,38 +54,23 @@ import { getUserRegions, listOrganizations } from "./organizations.js"; */ export async function listProjects(orgSlug: string): Promise { const config = await getOrgSdkConfig(orgSlug); - const allResults: SentryProject[] = []; - let cursor: string | undefined; - for (let page = 0; page < MAX_PAGINATION_PAGES; page++) { + const { data: allResults } = await autoPaginate(async (cursor) => { const result = await listAnOrganization_sProjects({ ...config, path: { organization_id_or_slug: orgSlug }, - // per_page is supported by Sentry's pagination framework at runtime - // but not yet in the OpenAPI spec - query: { cursor, per_page: API_MAX_PER_PAGE } as { cursor?: string }, + query: { cursor, per_page: API_MAX_PER_PAGE } as { + cursor?: string; + per_page?: number; + }, }); - - const { data, nextCursor } = unwrapPaginatedResult( + return unwrapPaginatedResult( result as | { data: SentryProject[]; error: undefined } | { data: undefined; error: unknown }, "Failed to list projects" ); - allResults.push(...data); - - if (!nextCursor) { - break; - } - cursor = nextCursor; - - if (page === MAX_PAGINATION_PAGES - 1) { - logger.warn( - `Pagination limit reached (${MAX_PAGINATION_PAGES} pages, ${allResults.length} items). ` + - "Results may be incomplete for this organization." - ); - } - } + }, MAX_PAGINATION_PAGES * API_MAX_PER_PAGE); // Populate project cache for shell completions (best-effort). // Mirrors how listOrganizations() calls setOrgRegions(). @@ -121,7 +106,7 @@ export async function listProjectsPaginated( query: { cursor: options.cursor, per_page: options.perPage ?? API_MAX_PER_PAGE, - } as { cursor?: string }, + } as { cursor?: string; per_page?: number }, }); return unwrapPaginatedResult( diff --git a/src/lib/api/releases.ts b/src/lib/api/releases.ts index 0a7f737b5..ee64aae4f 100644 --- a/src/lib/api/releases.ts +++ b/src/lib/api/releases.ts @@ -84,7 +84,17 @@ export async function listReleasesPaginated( environment: options.environment, statsPeriod: options.statsPeriod, status: options.status, - } as { cursor?: string }, + } as { + cursor?: string; + per_page?: number; + query?: string; + sort?: string; + health?: number; + project?: number[]; + environment?: string[]; + statsPeriod?: string; + status?: string; + }, }); return unwrapPaginatedResult( diff --git a/src/lib/api/repositories.ts b/src/lib/api/repositories.ts index bbb0ffcbf..b563fe7e3 100644 --- a/src/lib/api/repositories.ts +++ b/src/lib/api/repositories.ts @@ -12,6 +12,7 @@ import { logger } from "../logger.js"; import { API_MAX_PER_PAGE, + autoPaginate, getOrgSdkConfig, MAX_PAGINATION_PAGES, type PaginatedResponse, @@ -59,12 +60,10 @@ export async function listRepositoriesPaginated( const result = await listAnOrganization_sRepositories({ ...config, path: { organization_id_or_slug: orgSlug }, - // per_page is supported by Sentry's pagination framework at runtime - // but not yet in the OpenAPI spec query: { cursor: options.cursor, per_page: options.perPage ?? 25, - } as { cursor?: string }, + } as { cursor?: string; per_page?: number }, }); return unwrapPaginatedResult( @@ -79,8 +78,8 @@ export async function listRepositoriesPaginated( * List **all** repositories in an organization by walking every page. * * Used by the offline repo cache and anywhere else we need the complete - * set (not just the first page). Stops at {@link MAX_PAGINATION_PAGES} - * as a safety net for pathological cases. + * set (not just the first page). Bounded by `autoPaginate`'s + * {@link MAX_PAGINATION_PAGES} safety limit. * * @param orgSlug - Organization slug * @returns All Sentry-registered repositories across all pages @@ -88,24 +87,15 @@ export async function listRepositoriesPaginated( export async function listAllRepositories( orgSlug: string ): Promise { - const all: SentryRepository[] = []; - let cursor: string | undefined; - for (let page = 0; page < MAX_PAGINATION_PAGES; page++) { - const { data, nextCursor } = await listRepositoriesPaginated(orgSlug, { - cursor, - perPage: API_MAX_PER_PAGE, - }); - all.push(...data); - if (!nextCursor) { - return all; - } - cursor = nextCursor; - } - log.warn( - `Stopped paginating repositories for '${orgSlug}' after ${MAX_PAGINATION_PAGES} pages — ` + - "some repos may be missing from the cache." + const { data } = await autoPaginate( + (cursor) => + listRepositoriesPaginated(orgSlug, { + cursor, + perPage: API_MAX_PER_PAGE, + }), + MAX_PAGINATION_PAGES * API_MAX_PER_PAGE ); - return all; + return data; } /** diff --git a/src/lib/api/teams.ts b/src/lib/api/teams.ts index 20181d903..32001998e 100644 --- a/src/lib/api/teams.ts +++ b/src/lib/api/teams.ts @@ -57,20 +57,13 @@ export async function listTeamsPaginated( const result = await listAnOrganization_sTeams({ ...config, path: { organization_id_or_slug: orgSlug }, - // per_page is supported by Sentry's pagination framework at runtime - // but not yet in the OpenAPI spec query: { cursor: options.cursor, per_page: options.perPage ?? 25, - } as { cursor?: string }, + } as { cursor?: string; per_page?: number }, }); - return unwrapPaginatedResult( - result as - | { data: SentryTeam[]; error: undefined } - | { data: undefined; error: unknown }, - "Failed to list teams" - ); + return unwrapPaginatedResult(result, "Failed to list teams"); } /**