Skip to content
Merged
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.

89 changes: 89 additions & 0 deletions src/api/base-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,82 @@ 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.
* If `maxItems` is provided, stops paginating once enough items are collected.
*/
protected async listAll<TData>(
path: string,
params?: Record<string, string>,
maxItems?: number,
): 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 && (!maxItems || allData.length < maxItems)) {
response = await this.request<TData>(this.resolveNextPageUrl(response.links.next));
if (Array.isArray(response.data)) {
allData.push(...response.data);
}
}

if (maxItems && allData.length > maxItems) {
return allData.slice(0, maxItems);
}

return allData;
}

/**
* Find the first matching item across a paginated list endpoint.
*/
protected async findInList<TData>(
path: string,
matches: (item: TData) => boolean,
params?: Record<string, string>,
maxItems?: number,
): Promise<TData | undefined> {
let scannedItems = 0;
let response = await this.get<TData>(path, params);

while (true) {
if (!Array.isArray(response.data)) {
if (!maxItems || scannedItems < maxItems) {
return matches(response.data) ? response.data : undefined;
}

return undefined;
}

for (const item of response.data) {
scannedItems += 1;

if (matches(item)) {
return item;
}

if (maxItems && scannedItems >= maxItems) {
return undefined;
}
}

if (!response.links?.next) {
return undefined;
}

response = await this.request<TData>(
this.resolveNextPageUrl(response.links.next),
);
}
}

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

return payload as APIResponse<TData, TIncluded>;
}

private resolveNextPageUrl(url: string): string {
const nextUrl = new URL(url);
const baseOrigin = new URL(this.baseUrl).origin;

if (nextUrl.origin !== baseOrigin) {
throw new Error(
`Unexpected pagination origin: ${nextUrl.origin}. Expected ${baseOrigin}.`,
);
}

return nextUrl.toString();
}
}
45 changes: 39 additions & 6 deletions src/api/resources/builds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import type { CiBuildAction, CiBuildRun } from '../types.js';
* Build run endpoints.
*/
export class BuildsClient extends BaseAPIClient {
static readonly buildLocatorScanLimit = 2000;

/**
* Get a build run by id.
*/
Expand All @@ -14,17 +16,48 @@ export class BuildsClient extends BaseAPIClient {
}

/**
* List recent build runs for a workflow.
* List build runs for a workflow, paginating through all results.
* Optionally limit the total number of build runs returned.
*/
async listForWorkflow(workflowId: string, limit?: number): Promise<CiBuildRun[]> {
const response = await this.get<CiBuildRun[]>(
return this.listAll<CiBuildRun>(
`/v1/ciWorkflows/${workflowId}/buildRuns`,
{
...(limit ? { limit: String(limit) } : {}),
},
{ limit: '200' },
limit,
);
}

return response.data;
/**
* Find a build run with a specific build number for a workflow.
*/
async findByNumberForWorkflow(
workflowId: string,
buildNumber: number,
maxItems: number = BuildsClient.buildLocatorScanLimit,
): Promise<CiBuildRun | undefined> {
return this.findInList<CiBuildRun>(
`/v1/ciWorkflows/${workflowId}/buildRuns`,
(buildRun) => buildRun.attributes.number === buildNumber,
{ limit: '200' },
maxItems,
);
}

/**
* Find the latest failing build run for a workflow.
*/
async findLatestFailingForWorkflow(
workflowId: string,
maxItems: number = BuildsClient.buildLocatorScanLimit,
): Promise<CiBuildRun | undefined> {
return this.findInList<CiBuildRun>(
`/v1/ciWorkflows/${workflowId}/buildRuns`,
(buildRun) =>
buildRun.attributes.completionStatus === 'FAILED' ||
buildRun.attributes.completionStatus === 'ERRORED',
{ limit: '200' },
maxItems,
);
}

/**
Expand Down
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 });
}
}
}
10 changes: 5 additions & 5 deletions src/tools/build-runs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,21 +22,21 @@ 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 build runs. Use limit to cap the number of results returned.',
inputSchema: {
workflowId: z.string(),
limit: z.number().int().positive().max(200).optional(),
status: z.enum(['all', 'failed', 'pending', 'running', 'succeeded']).optional(),
limit: z.number().int().min(1).max(500).optional().describe('Maximum number of build runs to return. Defaults to 20 if not specified.'),
},
},
async ({
workflowId,
limit,
status,
limit,
}: {
workflowId: string;
limit?: number;
status?: BuildRunStatusFilter;
limit?: number;
}) => {
try {
const buildRuns = sortBuildRuns(
Expand Down Expand Up @@ -99,4 +99,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(
}
},
);
}
}
24 changes: 13 additions & 11 deletions src/utils/build-locator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,47 +59,49 @@ export async function resolveBuildLocator(
input: BuildLocatorInput,
): Promise<CiBuildRun> {
const locator = validateBuildLocator(input);
const workflowId = locator.workflowId!;

if (locator.buildRunId) {
return client.builds.getById(locator.buildRunId);
}

const buildRuns = sortBuildRuns(
await client.builds.listForWorkflow(locator.workflowId!, 100),
);

if (locator.buildNumber !== undefined) {
const matchingBuildRun = buildRuns.find(
(buildRun) => buildRun.attributes.number === locator.buildNumber,
const matchingBuildRun = await client.builds.findByNumberForWorkflow(
workflowId,
locator.buildNumber,
);

if (!matchingBuildRun) {
throw new Error(
`No build run found for workflow ${locator.workflowId} with build number ${locator.buildNumber}.`,
`No build run found for workflow ${workflowId} with build number ${locator.buildNumber}.`,
);
}

return matchingBuildRun;
}

if (locator.buildSelector === 'latestFailing') {
const latestFailingBuildRun = buildRuns.find((buildRun) =>
isFailureStatus(buildRun.attributes.completionStatus),
const latestFailingBuildRun = await client.builds.findLatestFailingForWorkflow(
workflowId,
);

if (!latestFailingBuildRun) {
throw new Error(
`No failing build runs found for workflow ${locator.workflowId}.`,
`No failing build runs found for workflow ${workflowId}.`,
);
}

return latestFailingBuildRun;
}

const buildRuns = sortBuildRuns(
await client.builds.listForWorkflow(workflowId, 1),
);

const latestBuildRun = buildRuns[0];

if (!latestBuildRun) {
throw new Error(`No build runs found for workflow ${locator.workflowId}.`);
throw new Error(`No build runs found for workflow ${workflowId}.`);
}

return latestBuildRun;
Expand Down
Loading
Loading