Skip to main content
Glama
build-list-manager.ts11 kB
/** * BuildListManager - Manages build list queries with pagination and caching */ import { errorLogger } from '@/utils/error-logger'; import { BuildQueryBuilder, type BuildStatus } from './build-query-builder'; import { TeamCityAPIError } from './errors'; import type { TeamCityUnifiedClient } from './types/client'; export interface BuildListParams { project?: string; buildType?: string; status?: BuildStatus; branch?: string; tag?: string; sinceDate?: string; untilDate?: string; sinceBuild?: number; running?: boolean; canceled?: boolean; personal?: boolean; failedToStart?: boolean; limit?: number; offset?: number; forceRefresh?: boolean; includeTotalCount?: boolean; } export interface BuildInfo { id: number; buildTypeId: string; number: string; status: string; state: string; branchName?: string; startDate?: string; finishDate?: string; queuedDate?: string; statusText: string; webUrl: string; } export interface BuildListResult { builds: BuildInfo[]; metadata: { count: number; offset: number; limit: number; hasMore: boolean; totalCount?: number; }; } interface CacheEntry { result: BuildListResult; timestamp: number; } interface TeamCityBuildListResponse { build: unknown[]; nextHref?: unknown; count?: unknown; } const isRecord = (value: unknown): value is Record<string, unknown> => { return typeof value === 'object' && value !== null; }; export class BuildListManager { private client: TeamCityUnifiedClient; private cache: Map<string, CacheEntry> = new Map(); private static readonly cacheTtlMs = 30000; // 30 seconds private static readonly defaultLimit = 100; private static readonly maxLimit = 1000; private static readonly fields = 'id,buildTypeId,number,status,state,branchName,startDate,finishDate,queuedDate,statusText,href,webUrl'; constructor(client: TeamCityUnifiedClient) { this.client = client; } /** * List builds with filters and pagination */ async listBuilds(params: BuildListParams): Promise<BuildListResult> { // Generate cache key const cacheKey = this.getCacheKey(params); // Check cache unless force refresh if (!params.forceRefresh) { const cached = this.getFromCache(cacheKey); if (cached) { return cached; } } const locator = this.buildLocator(params); try { // Fetch builds from API const response = await this.client.modules.builds.getMultipleBuilds( locator, BuildListManager.fields ); const payload = this.ensureBuildListResponse(response.data, locator); // Parse response const builds = this.parseBuilds(payload.build, locator); // Get total count if requested let totalCount: number | undefined; if (params.includeTotalCount) { totalCount = await this.getTotalCount(locator); } // Create result const result: BuildListResult = { builds, metadata: { count: builds.length, offset: params.offset ?? 0, limit: params.limit ?? BuildListManager.defaultLimit, hasMore: this.hasMoreResults(payload.nextHref), totalCount, }, }; // Cache result this.cacheResult(cacheKey, result); return result; } catch (error: unknown) { if (error instanceof TeamCityAPIError) { throw error; } // Enhance error messages const errorMessage = error instanceof Error ? error.message : ''; if (errorMessage.includes('Invalid date format')) { throw error; } if (errorMessage.includes('Invalid status value')) { throw error; } const finalMessage = error instanceof Error ? error.message : 'Unknown error'; throw new TeamCityAPIError( `Failed to fetch builds: ${finalMessage}`, 'BUILD_LIST_ERROR', undefined, { locator, } ); } } /** * Build locator string from parameters */ private buildLocator(params: BuildListParams): string { const builder = new BuildQueryBuilder(); // Apply filters builder .withProject(params.project) .withBuildType(params.buildType) .withStatus(params.status) .withBranch(params.branch) .withTag(params.tag) .withSinceDate(params.sinceDate) .withUntilDate(params.untilDate) .withSinceBuild(params.sinceBuild) .withRunning(params.running) .withCanceled(params.canceled) .withPersonal(params.personal) .withFailedToStart(params.failedToStart); // Apply pagination const limit = Math.min( params.limit ?? BuildListManager.defaultLimit, BuildListManager.maxLimit ); builder.withCount(limit); if (params.offset !== undefined && params.offset > 0) { builder.withStart(params.offset); } return builder.build(); } /** * Parse builds from API response */ private parseBuilds(builds: unknown[], locator: string): BuildInfo[] { return builds.map((build, index) => { if (!isRecord(build)) { throw new TeamCityAPIError( 'TeamCity returned a non-object build entry', 'INVALID_RESPONSE', undefined, { locator, index } ); } const { id, buildTypeId, number, status, state, branchName, startDate, finishDate, queuedDate, statusText, webUrl, } = build as Record<string, unknown>; if ( (typeof id !== 'number' && typeof id !== 'string') || typeof buildTypeId !== 'string' || typeof number !== 'string' || typeof status !== 'string' || typeof state !== 'string' || typeof webUrl !== 'string' ) { throw new TeamCityAPIError( 'TeamCity build entry is missing required fields', 'INVALID_RESPONSE', undefined, { locator, index, receivedKeys: Object.keys(build) } ); } return { id: typeof id === 'string' ? parseInt(id, 10) : id, buildTypeId, number, status, state, branchName: typeof branchName === 'string' ? branchName : undefined, startDate: typeof startDate === 'string' ? startDate : undefined, finishDate: typeof finishDate === 'string' ? finishDate : undefined, queuedDate: typeof queuedDate === 'string' ? queuedDate : undefined, statusText: typeof statusText === 'string' ? statusText : '', webUrl, }; }); } private ensureBuildListResponse(data: unknown, locator: string): TeamCityBuildListResponse { if (!isRecord(data)) { throw new TeamCityAPIError( 'TeamCity returned a non-object build list response', 'INVALID_RESPONSE', undefined, { locator, receivedType: typeof data } ); } const record = data as Record<string, unknown>; const build = record['build']; const nextHref = record['nextHref']; const count = record['count']; if (!Array.isArray(build)) { throw new TeamCityAPIError( 'TeamCity build list response is missing a build array', 'INVALID_RESPONSE', undefined, { locator, expected: 'build[]', receivedKeys: Object.keys(data) } ); } return { build, nextHref, count } as TeamCityBuildListResponse; } /** * Check if there are more results available */ private hasMoreResults(nextHref: unknown): boolean { return typeof nextHref === 'string' && nextHref.length > 0; } /** * Get total count of builds matching the locator */ private async getTotalCount(locator: string): Promise<number> { try { // Remove count and start from locator for total count query const countLocator = locator .split(',') .filter((part) => !part.startsWith('count:') && !part.startsWith('start:')) .join(','); const response = await this.client.modules.builds.getAllBuilds( countLocator || undefined, 'count' ); return this.extractCount(response.data, countLocator || '<none>'); } catch (error: unknown) { // Total count is optional, don't fail the main request const errorMessage = error instanceof Error ? error.message : 'Unknown error'; errorLogger.forComponent('BuildListManager').logWarning('Failed to fetch total count', { operation: 'fetchTotalCount', error: errorMessage, }); return 0; } } private extractCount(data: unknown, locator: string): number { if (!isRecord(data)) { throw new TeamCityAPIError( 'TeamCity returned a non-object count response', 'INVALID_RESPONSE', undefined, { locator, expected: 'object with count:number', receivedType: typeof data } ); } const { count } = data as { count?: unknown }; if (count === undefined) { throw new TeamCityAPIError( 'TeamCity count response is missing the count field', 'INVALID_RESPONSE', undefined, { locator, expected: 'count:number' } ); } if (typeof count === 'number') { return count; } if (typeof count === 'string' && count.trim() !== '' && Number.isFinite(Number(count))) { return Number.parseInt(count, 10); } throw new TeamCityAPIError( 'TeamCity count response contains a non-numeric count value', 'INVALID_RESPONSE', undefined, { locator, receivedType: typeof count } ); } /** * Generate cache key from parameters */ private getCacheKey(params: BuildListParams): string { // Exclude forceRefresh and includeTotalCount from cache key const { forceRefresh: _forceRefresh, includeTotalCount: _includeTotalCount, ...cacheParams } = params; return JSON.stringify(cacheParams, Object.keys(cacheParams).sort()); } /** * Get result from cache if valid */ private getFromCache(key: string): BuildListResult | null { const entry = this.cache.get(key); if (!entry) { return null; } const age = Date.now() - entry.timestamp; if (age > BuildListManager.cacheTtlMs) { this.cache.delete(key); return null; } return entry.result; } /** * Cache a result */ private cacheResult(key: string, result: BuildListResult): void { this.cache.set(key, { result, timestamp: Date.now(), }); // Clean old entries this.cleanCache(); } /** * Remove expired cache entries */ private cleanCache(): void { const now = Date.now(); const expired: string[] = []; for (const [key, entry] of this.cache.entries()) { if (now - entry.timestamp > BuildListManager.cacheTtlMs) { expired.push(key); } } for (const key of expired) { this.cache.delete(key); } } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/Daghis/teamcity-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server