diff --git a/package-lock.json b/package-lock.json index cc695a4..94fdfac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@thatfactory/xcode-cloud-mcp", - "version": "0.2.0", + "version": "0.5.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@thatfactory/xcode-cloud-mcp", - "version": "0.2.0", + "version": "0.5.1", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.28.0", @@ -924,7 +924,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -1163,7 +1162,6 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.9.tgz", "integrity": "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA==", "license": "MIT", - "peer": true, "engines": { "node": ">=16.9.0" } @@ -1881,7 +1879,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/src/api/base-client.ts b/src/api/base-client.ts index f3902ba..be35ce5 100644 --- a/src/api/base-client.ts +++ b/src/api/base-client.ts @@ -36,6 +36,35 @@ export class BaseAPIClient { return this.request(url.toString()); } + /** + * Fetch all pages of a paginated list endpoint. + * Follows `links.next` until no more pages are available. + */ + protected async listAll( + path: string, + params?: Record, + ): Promise { + const allData: TData[] = []; + + let response = await this.get(path, params); + if (Array.isArray(response.data)) { + allData.push(...response.data); + } else { + return [response.data]; + } + + while (response.links?.next) { + // The next URL is a full absolute URL from Apple's API + // Use request() directly since the URL is already absolute + response = await this.request(response.links.next); + if (Array.isArray(response.data)) { + allData.push(...response.data); + } + } + + return allData; + } + protected async patch( path: string, body: TBody, @@ -107,4 +136,4 @@ export class BaseAPIClient { return payload as APIResponse; } -} +} \ No newline at end of file diff --git a/src/api/resources/builds.ts b/src/api/resources/builds.ts index c7c02d9..53e23eb 100644 --- a/src/api/resources/builds.ts +++ b/src/api/resources/builds.ts @@ -14,17 +14,13 @@ export class BuildsClient extends BaseAPIClient { } /** - * List recent build runs for a workflow. + * List all build runs for a workflow, paginating through all results. */ - async listForWorkflow(workflowId: string, limit?: number): Promise { - const response = await this.get( + async listForWorkflow(workflowId: string): Promise { + return this.listAll( `/v1/ciWorkflows/${workflowId}/buildRuns`, - { - ...(limit ? { limit: String(limit) } : {}), - }, + { limit: '200' }, ); - - return response.data; } /** @@ -37,4 +33,4 @@ export class BuildsClient extends BaseAPIClient { return response.data; } -} +} \ No newline at end of file diff --git a/src/api/resources/products.ts b/src/api/resources/products.ts index bbfdb5f..5665a63 100644 --- a/src/api/resources/products.ts +++ b/src/api/resources/products.ts @@ -6,13 +6,9 @@ import type { CiProduct } from '../types.js'; */ export class ProductsClient extends BaseAPIClient { /** - * List Xcode Cloud products. + * List all Xcode Cloud products, paginating through all results. */ - async list(limit?: number): Promise { - const response = await this.get('/v1/ciProducts', { - ...(limit ? { limit: String(limit) } : {}), - }); - - return response.data; + async list(): Promise { + return this.listAll('/v1/ciProducts', { limit: '200' }); } -} +} \ No newline at end of file diff --git a/src/api/resources/workflows.ts b/src/api/resources/workflows.ts index f1b118d..843d459 100644 --- a/src/api/resources/workflows.ts +++ b/src/api/resources/workflows.ts @@ -12,17 +12,13 @@ type WorkflowAttributeUpdate = Partial; */ export class WorkflowsClient extends BaseAPIClient { /** - * List workflows belonging to a product. + * List all workflows belonging to a product, paginating through all results. */ - async listForProduct(productId: string, limit?: number): Promise { - const response = await this.get( + async listForProduct(productId: string): Promise { + return this.listAll( `/v1/ciProducts/${productId}/workflows`, - { - ...(limit ? { limit: String(limit) } : {}), - }, + { limit: '200' }, ); - - return response.data; } /** @@ -122,4 +118,4 @@ export class WorkflowsClient extends BaseAPIClient { ): Promise { return this.updateById(workflowId, { actions }); } -} +} \ No newline at end of file diff --git a/src/tools/build-runs.ts b/src/tools/build-runs.ts index 5ad161a..45d6cb5 100644 --- a/src/tools/build-runs.ts +++ b/src/tools/build-runs.ts @@ -22,27 +22,23 @@ export function registerBuildRunTools( 'list_build_runs', { description: - 'List recent build runs for a workflow, optionally filtered by outcome.', + 'List recent build runs for a workflow, optionally filtered by outcome. Automatically paginates through all build runs.', inputSchema: { workflowId: z.string(), - limit: z.number().int().positive().max(200).optional(), status: z.enum(['all', 'failed', 'pending', 'running', 'succeeded']).optional(), }, }, async ({ workflowId, - limit, status, }: { workflowId: string; - limit?: number; status?: BuildRunStatusFilter; }) => { try { const buildRuns = sortBuildRuns( await client.builds.listForWorkflow( parseIdentifier(workflowId, 'workflow'), - limit ?? 20, ), ); @@ -99,4 +95,4 @@ function filterBuildRuns( return buildRuns.filter( (buildRun) => buildRun.attributes.completionStatus === 'SUCCEEDED', ); -} +} \ No newline at end of file diff --git a/src/tools/discovery.ts b/src/tools/discovery.ts index d436990..620f097 100644 --- a/src/tools/discovery.ts +++ b/src/tools/discovery.ts @@ -16,14 +16,12 @@ export function registerDiscoveryTools( 'list_products', { description: - 'List Xcode Cloud products available to the configured App Store Connect account.', - inputSchema: { - limit: z.number().int().positive().max(200).optional(), - }, + 'List Xcode Cloud products available to the configured App Store Connect account. Automatically paginates through all results.', + inputSchema: {}, }, - async ({ limit }: { limit?: number }) => { + async () => { try { - const products = await client.products.list(limit); + const products = await client.products.list(); return jsonResponse({ products: products.map((product) => ({ @@ -42,17 +40,15 @@ export function registerDiscoveryTools( server.registerTool( 'list_workflows', { - description: 'List workflows for a given Xcode Cloud product.', + description: 'List workflows for a given Xcode Cloud product. Automatically paginates through all results.', inputSchema: { productId: z.string(), - limit: z.number().int().positive().max(200).optional(), }, }, - async ({ productId, limit }: { productId: string; limit?: number }) => { + async ({ productId }: { productId: string }) => { try { const workflows = await client.workflows.listForProduct( parseIdentifier(productId, 'product'), - limit, ); return jsonResponse({ @@ -91,4 +87,4 @@ export function registerDiscoveryTools( } }, ); -} +} \ No newline at end of file diff --git a/src/utils/build-locator.ts b/src/utils/build-locator.ts index 8b46b6b..1dba0f5 100644 --- a/src/utils/build-locator.ts +++ b/src/utils/build-locator.ts @@ -65,7 +65,7 @@ export async function resolveBuildLocator( } const buildRuns = sortBuildRuns( - await client.builds.listForWorkflow(locator.workflowId!, 100), + await client.builds.listForWorkflow(locator.workflowId!), ); if (locator.buildNumber !== undefined) { diff --git a/tests/build-locator.test.ts b/tests/build-locator.test.ts index 4b23961..2e30130 100644 --- a/tests/build-locator.test.ts +++ b/tests/build-locator.test.ts @@ -128,4 +128,4 @@ function createBuildRun( }, }, }; -} +} \ No newline at end of file diff --git a/tests/pagination.test.ts b/tests/pagination.test.ts new file mode 100644 index 0000000..3bdaf32 --- /dev/null +++ b/tests/pagination.test.ts @@ -0,0 +1,123 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { BaseAPIClient } from '../src/api/base-client.js'; +import type { APIResponse } from '../src/api/types.js'; + +/** + * Testable subclass that overrides request() to return mock data + * without making real HTTP calls. Since both get() and listAll() + * (for subsequent pages) call request(), this is the single point + * of interception needed. + */ +class TestableClient extends BaseAPIClient { + private responses: APIResponse[] = []; + private callIndex = 0; + + constructor(responses: APIResponse[]) { + super( + { + keyId: 'test', + issuerId: 'test', + privateKey: '-----BEGIN EC PRIVATE KEY-----\nMHQCAQEEIOBR1JO5H6GH5RF2U8DT0SP9UAXKKO7H5JF7E6U3L2VJoAcGBSuBBAAi\nZW2CAQBEAPnJ5VqvF2i0QzY3N7Y4C7Q3L8KM0N9QF2L8GK5V0Y5H2X1D4W7R9T6M\n-----END EC PRIVATE KEY-----', + }, + 'https://api.appstoreconnect.apple.com', + ); + this.responses = responses; + } + + protected override async request( + _url: string, + _init?: RequestInit, + ): Promise> { + const response = this.responses[this.callIndex] as unknown as APIResponse; + this.callIndex++; + return response; + } + + public async testListAll(path: string, params?: Record): Promise { + return this.listAll(path, params); + } +} + +test('listAll collects items from a single page with no next link', async () => { + const client = new TestableClient([ + { + data: [{ id: '1' }, { id: '2' }], + links: { self: 'https://api.appstoreconnect.apple.com/v1/test' }, + }, + ]); + + const result = await client.testListAll('/v1/test'); + + assert.equal(result.length, 2); + assert.equal((result[0] as { id: string }).id, '1'); + assert.equal((result[1] as { id: string }).id, '2'); +}); + +test('listAll follows pagination links across multiple pages', async () => { + const client = new TestableClient([ + { + data: [{ id: '1' }, { id: '2' }], + links: { + self: 'https://api.appstoreconnect.apple.com/v1/test?limit=200', + next: 'https://api.appstoreconnect.apple.com/v1/test?cursor=page2&limit=200', + }, + }, + { + data: [{ id: '3' }, { id: '4' }], + links: { + self: 'https://api.appstoreconnect.apple.com/v1/test?cursor=page2&limit=200', + next: 'https://api.appstoreconnect.apple.com/v1/test?cursor=page3&limit=200', + }, + }, + { + data: [{ id: '5' }], + links: { self: 'https://api.appstoreconnect.apple.com/v1/test?cursor=page3&limit=200' }, + }, + ]); + + const result = await client.testListAll('/v1/test'); + + assert.equal(result.length, 5); + assert.deepEqual( + result.map((item) => (item as { id: string }).id), + ['1', '2', '3', '4', '5'], + ); +}); + +test('listAll returns empty array when first page has empty data', async () => { + const client = new TestableClient([ + { + data: [], + links: { self: 'https://api.appstoreconnect.apple.com/v1/test' }, + }, + ]); + + const result = await client.testListAll('/v1/test'); + + assert.equal(result.length, 0); +}); + +test('listAll handles two pages correctly', async () => { + const client = new TestableClient([ + { + data: [{ id: 'a' }], + links: { + self: 'https://api.appstoreconnect.apple.com/v1/test', + next: 'https://api.appstoreconnect.apple.com/v1/test?cursor=2', + }, + }, + { + data: [{ id: 'b' }], + links: { self: 'https://api.appstoreconnect.apple.com/v1/test?cursor=2' }, + }, + ]); + + const result = await client.testListAll('/v1/test'); + + assert.equal(result.length, 2); + assert.deepEqual( + result.map((item) => (item as { id: string }).id), + ['a', 'b'], + ); +}); \ No newline at end of file