Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 2 additions & 5 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

31 changes: 30 additions & 1 deletion src/api/base-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,35 @@ export class BaseAPIClient {
return this.request<TData>(url.toString());
}

/**
* Fetch all pages of a paginated list endpoint.
* Follows `links.next` until no more pages are available.
*/
protected async listAll<TData>(
path: string,
params?: Record<string, string>,
): Promise<TData[]> {
const allData: TData[] = [];

let response = await this.get<TData>(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<TData>(response.links.next);
if (Array.isArray(response.data)) {
allData.push(...response.data);
}
}

return allData;
}

protected async patch<TData, TBody>(
path: string,
body: TBody,
Expand Down Expand Up @@ -107,4 +136,4 @@ export class BaseAPIClient {

return payload as APIResponse<TData, TIncluded>;
}
}
}
14 changes: 5 additions & 9 deletions src/api/resources/builds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<CiBuildRun[]> {
const response = await this.get<CiBuildRun[]>(
async listForWorkflow(workflowId: string): Promise<CiBuildRun[]> {
return this.listAll<CiBuildRun>(
`/v1/ciWorkflows/${workflowId}/buildRuns`,
{
...(limit ? { limit: String(limit) } : {}),
},
{ limit: '200' },
);

return response.data;
}

/**
Expand All @@ -37,4 +33,4 @@ export class BuildsClient extends BaseAPIClient {

return response.data;
}
}
}
12 changes: 4 additions & 8 deletions src/api/resources/products.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<CiProduct[]> {
const response = await this.get<CiProduct[]>('/v1/ciProducts', {
...(limit ? { limit: String(limit) } : {}),
});

return response.data;
async list(): Promise<CiProduct[]> {
return this.listAll<CiProduct>('/v1/ciProducts', { limit: '200' });
}
}
}
14 changes: 5 additions & 9 deletions src/api/resources/workflows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,13 @@ type WorkflowAttributeUpdate = Partial<CiWorkflow['attributes']>;
*/
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<CiWorkflow[]> {
const response = await this.get<CiWorkflow[]>(
async listForProduct(productId: string): Promise<CiWorkflow[]> {
return this.listAll<CiWorkflow>(
`/v1/ciProducts/${productId}/workflows`,
{
...(limit ? { limit: String(limit) } : {}),
},
{ limit: '200' },
);

return response.data;
}

/**
Expand Down Expand Up @@ -122,4 +118,4 @@ export class WorkflowsClient extends BaseAPIClient {
): Promise<CiWorkflow> {
return this.updateById(workflowId, { actions });
}
}
}
8 changes: 2 additions & 6 deletions src/tools/build-runs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
),
);

Expand Down Expand Up @@ -99,4 +95,4 @@ function filterBuildRuns(
return buildRuns.filter(
(buildRun) => buildRun.attributes.completionStatus === 'SUCCEEDED',
);
}
}
18 changes: 7 additions & 11 deletions src/tools/discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => ({
Expand All @@ -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({
Expand Down Expand Up @@ -91,4 +87,4 @@ export function registerDiscoveryTools(
}
},
);
}
}
2 changes: 1 addition & 1 deletion src/utils/build-locator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion tests/build-locator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,4 +128,4 @@ function createBuildRun(
},
},
};
}
}
123 changes: 123 additions & 0 deletions tests/pagination.test.ts
Original file line number Diff line number Diff line change
@@ -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<unknown[]>[] = [];
private callIndex = 0;

constructor(responses: APIResponse<unknown[]>[]) {
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<TData, TIncluded = never>(
_url: string,
_init?: RequestInit,
): Promise<APIResponse<TData, TIncluded>> {
const response = this.responses[this.callIndex] as unknown as APIResponse<TData, TIncluded>;
this.callIndex++;
return response;
}

public async testListAll(path: string, params?: Record<string, string>): Promise<unknown[]> {
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'],
);
});