Skip to main content
Glama
build-results-manager.ts28.9 kB
/** * BuildResultsManager - Manages comprehensive build results retrieval */ import type { AxiosError, AxiosResponse } from 'axios'; import { warn } from '@/utils/logger'; import { TeamCityAPIError, TeamCityNotFoundError } from './errors'; import type { TeamCityUnifiedClient } from './types/client'; import { toBuildLocator } from './utils/build-locator'; type ArtifactEncoding = 'base64' | 'stream'; interface ArtifactDownloadHandle { tool: 'download_build_artifact'; args: { buildId: string; artifactPath: string; encoding?: 'stream'; maxSize?: number; }; } export interface BuildResultsOptions { includeArtifacts?: boolean; includeStatistics?: boolean; includeChanges?: boolean; includeDependencies?: boolean; artifactFilter?: string; downloadArtifacts?: string[]; maxArtifactSize?: number; artifactEncoding?: ArtifactEncoding; } export interface BuildResult { build: { id: number; number: string; status: string; state: string; buildTypeId: string; projectId?: string; branchName?: string; startDate?: string; finishDate?: string; duration?: number; queuedDate?: string; triggered?: { type: string; user?: string; date: string; }; statusText: string; webUrl: string; }; artifacts?: Array<{ name: string; path: string; size: number; modificationTime: string; downloadUrl: string; content?: string; downloadHandle?: ArtifactDownloadHandle; }>; statistics?: { buildDuration?: number; testCount?: number; passedTests?: number; failedTests?: number; ignoredTests?: number; codeCoverage?: number; [key: string]: unknown; }; changes?: Array<{ revision: string; author: string; date: string; comment: string; files: Array<{ path: string; changeType: string; }>; }>; dependencies?: Array<{ buildId: number; buildNumber: string; buildTypeId: string; status: string; }>; } interface CacheEntry { result: BuildResult; timestamp: number; } interface TeamCityArtifact { name: string; fullName?: string; size?: number; modificationTime?: string; href?: string; content?: { href?: string }; children?: { file?: TeamCityArtifact[] }; } interface TeamCityChange { version: string; username: string; date: string; comment?: string; files?: { file?: Array<{ name: string; changeType?: string; }>; }; } interface BuildSummaryResponse { id: number | string; number: string; status: string; state: string; buildTypeId: string; statusText?: string; webUrl: string; projectId?: string; branchName?: string; startDate?: string; finishDate?: string; queuedDate?: string; triggered?: { type: string; date: string; user?: { username?: string; name?: string }; }; } interface ArtifactListResponse { file?: TeamCityArtifact[]; } interface StatisticsResponse { property?: Array<{ name: string; value: string }>; } interface ChangesResponse { change?: TeamCityChange[]; } interface DependenciesResponse { build?: Array<{ id?: unknown; number?: unknown; buildTypeId?: unknown; status?: unknown; }>; } const isRecord = (value: unknown): value is Record<string, unknown> => { return typeof value === 'object' && value !== null; }; export class BuildResultsManager { private client: TeamCityUnifiedClient; private cache: Map<string, CacheEntry> = new Map(); private static readonly cacheTtlMs = 10 * 60 * 1000; // 10 minutes private static readonly defaultMaxArtifactSize = 1024 * 1024; // 1MB private static readonly fields = 'id,number,status,state,buildTypeId,projectId,branchName,startDate,finishDate,queuedDate,statusText,href,webUrl,triggered'; constructor(client: TeamCityUnifiedClient) { this.client = client; } /** * Get comprehensive build results */ async getBuildResults(buildId: string, options: BuildResultsOptions = {}): Promise<BuildResult> { // Check cache for completed builds const cacheKey = this.getCacheKey(buildId, options); const cached = this.getFromCache(cacheKey); if (cached) { return cached; } try { // Fetch build summary const buildData = await this.fetchBuildSummary(buildId); // Start parallel fetches for additional data const promises: Promise<unknown>[] = []; if (options.includeArtifacts) { promises.push(this.fetchArtifacts(buildId, options)); } if (options.includeStatistics) { promises.push(this.fetchStatistics(buildId)); } if (options.includeChanges) { promises.push(this.fetchChanges(buildId)); } if (options.includeDependencies) { promises.push(this.fetchDependencies(buildId)); } // Wait for all parallel fetches const results = await Promise.allSettled(promises); // Build the result object const result: BuildResult = { build: this.transformBuildData(buildData), }; // Process parallel fetch results let resultIndex = 0; if (options.includeArtifacts) { const artifactResult = results[resultIndex++]; if (artifactResult) { result.artifacts = artifactResult.status === 'fulfilled' ? (artifactResult.value as BuildResult['artifacts']) : []; } } if (options.includeStatistics) { const statsResult = results[resultIndex++]; if (statsResult) { result.statistics = statsResult.status === 'fulfilled' ? (statsResult.value as BuildResult['statistics']) : {}; } } if (options.includeChanges) { const changesResult = results[resultIndex++]; if (changesResult) { result.changes = changesResult.status === 'fulfilled' ? (changesResult.value as BuildResult['changes']) : []; } } if (options.includeDependencies) { const depsResult = results[resultIndex++]; if (depsResult) { result.dependencies = depsResult.status === 'fulfilled' ? (depsResult.value as BuildResult['dependencies']) : []; } } // Cache if build is completed if ((buildData as { state?: string }).state === 'finished') { this.cacheResult(cacheKey, result); } return result; } catch (error: unknown) { if (error instanceof TeamCityAPIError) { if (error.statusCode === 404) { throw new TeamCityNotFoundError('Build', buildId, error.requestId, error); } throw error; } if (this.isAxiosNotFound(error)) { const axiosError = error as AxiosError; const apiError = TeamCityAPIError.fromAxiosError(axiosError); if (apiError.statusCode === 404) { throw new TeamCityNotFoundError('Build', buildId, apiError.requestId, apiError); } throw apiError; } const message = error instanceof Error ? error.message : String(error); if (/not found/i.test(message)) { throw new TeamCityNotFoundError( 'Build', buildId, undefined, error instanceof Error ? error : undefined ); } throw new TeamCityAPIError( `Failed to fetch build results: ${message}`, 'GET_BUILD_RESULTS_FAILED', undefined, undefined, undefined, error instanceof Error ? error : undefined ); } } /** * Fetch build summary data */ private async fetchBuildSummary(buildId: string): Promise<BuildSummaryResponse> { const response = await this.client.modules.builds.getBuild( toBuildLocator(buildId), BuildResultsManager.fields ); return this.ensureBuildSummary(response.data, buildId); } /** * Transform build data to result format */ private transformBuildData(buildData: BuildSummaryResponse): BuildResult['build'] { const build: BuildResult['build'] = { id: typeof buildData.id === 'string' ? Number.parseInt(buildData.id, 10) : buildData.id, number: buildData.number, status: buildData.status, state: buildData.state, buildTypeId: buildData.buildTypeId, statusText: buildData.statusText ?? '', webUrl: buildData.webUrl, }; // Add optional fields if (buildData.projectId) { build.projectId = buildData.projectId; } if (buildData.branchName) { build.branchName = buildData.branchName; } if (buildData.startDate) { build.startDate = buildData.startDate; } if (buildData.finishDate) { build.finishDate = buildData.finishDate; } if (buildData.queuedDate) { build.queuedDate = buildData.queuedDate; } // Calculate duration if dates available if (buildData.startDate && buildData.finishDate) { const start = this.parseTeamCityDate(buildData.startDate); const finish = this.parseTeamCityDate(buildData.finishDate); build.duration = finish - start; } // Add trigger information if (buildData.triggered) { const triggered: BuildResult['build']['triggered'] = { type: buildData.triggered.type, date: buildData.triggered.date, }; const triggeredUser = buildData.triggered.user; if (triggeredUser) { const username = triggeredUser.username ?? triggeredUser.name; if (username) { triggered.user = username; } } build.triggered = triggered; } return build; } private ensureBuildSummary(data: unknown, buildId: string): BuildSummaryResponse { if (!isRecord(data)) { throw new TeamCityAPIError( 'TeamCity returned a non-object build summary response', 'INVALID_RESPONSE', undefined, { buildId, expected: 'object with build fields', receivedType: typeof data } ); } const summary = data as Record<string, unknown>; const { id, number, status, state, buildTypeId, webUrl, triggered } = summary; if ( (typeof id !== 'number' && typeof id !== 'string') || typeof number !== 'string' || typeof status !== 'string' || typeof state !== 'string' || typeof buildTypeId !== 'string' || typeof webUrl !== 'string' ) { throw new TeamCityAPIError( 'TeamCity build summary response is missing required fields', 'INVALID_RESPONSE', undefined, { buildId, receivedKeys: Object.keys(summary) } ); } let normalizedTriggered: BuildSummaryResponse['triggered']; if (triggered !== undefined && triggered !== null) { if (!isRecord(triggered)) { throw new TeamCityAPIError( 'TeamCity build summary response contains an invalid triggered payload', 'INVALID_RESPONSE', undefined, { buildId, receivedType: typeof triggered } ); } const { type, date, user } = triggered as Record<string, unknown>; if (typeof type !== 'string' || typeof date !== 'string') { throw new TeamCityAPIError( 'TeamCity build summary response contains an invalid triggered payload', 'INVALID_RESPONSE', undefined, { buildId } ); } if (user !== undefined && user !== null && !isRecord(user)) { throw new TeamCityAPIError( 'TeamCity build summary response contains an invalid trigger user payload', 'INVALID_RESPONSE', undefined, { buildId } ); } let normalizedUser: { username?: string; name?: string } | undefined; if (user !== undefined && user !== null) { const userRecord = user as Record<string, unknown>; const username = userRecord['username']; const name = userRecord['name']; const normalizedUsername = typeof username === 'string' ? username : undefined; const normalizedName = typeof name === 'string' ? name : undefined; if (normalizedUsername !== undefined || normalizedName !== undefined) { normalizedUser = {}; if (normalizedUsername) { normalizedUser.username = normalizedUsername; } if (normalizedName) { normalizedUser.name = normalizedName; } } } normalizedTriggered = { type, date, ...(normalizedUser ? { user: normalizedUser } : {}), }; } const normalized: BuildSummaryResponse = { id: id as number | string, number: number as string, status: status as string, state: state as string, buildTypeId: buildTypeId as string, statusText: typeof summary['statusText'] === 'string' ? (summary['statusText'] as string) : undefined, webUrl: webUrl as string, projectId: typeof summary['projectId'] === 'string' ? (summary['projectId'] as string) : undefined, branchName: typeof summary['branchName'] === 'string' ? (summary['branchName'] as string) : undefined, startDate: typeof summary['startDate'] === 'string' ? (summary['startDate'] as string) : undefined, finishDate: typeof summary['finishDate'] === 'string' ? (summary['finishDate'] as string) : undefined, queuedDate: typeof summary['queuedDate'] === 'string' ? (summary['queuedDate'] as string) : undefined, triggered: normalizedTriggered, }; return normalized; } /** * Fetch build artifacts */ private async fetchArtifacts( buildId: string, options: BuildResultsOptions ): Promise<BuildResult['artifacts']> { try { const encoding: ArtifactEncoding = options.artifactEncoding ?? 'base64'; const response = await this.client.modules.builds.getFilesListOfBuild( toBuildLocator(buildId) ); const artifactListing = this.ensureArtifactListResponse(response.data, buildId); let artifacts = artifactListing.file ?? []; // Filter artifacts if pattern provided if (options.artifactFilter) { artifacts = this.filterArtifacts(artifacts, options.artifactFilter); } // Transform artifact data const result = await Promise.all( artifacts.map(async (artifact: TeamCityArtifact) => { const artifactPath = artifact.fullName ?? artifact.name; const downloadHref = artifact.content?.href ?? `/app/rest/builds/id:${buildId}/artifacts/content/${artifactPath}`; const shouldInlineContent = encoding === 'base64' && (options.downloadArtifacts?.length ? options.downloadArtifacts.includes(artifact.name) || options.downloadArtifacts.includes(artifactPath) : true); const artifactData: { name: string; path: string; size: number; modificationTime: string; downloadUrl: string; content?: string; downloadHandle?: ArtifactDownloadHandle; } = { name: artifact.name, path: artifactPath, size: artifact.size ?? 0, modificationTime: artifact.modificationTime ?? '', downloadUrl: this.buildAbsoluteUrl(downloadHref), }; // Download content if requested and small enough if (shouldInlineContent) { const maxSize = options.maxArtifactSize ?? BuildResultsManager.defaultMaxArtifactSize; if ((artifact.size ?? 0) <= maxSize) { try { const contentResponse = await this.downloadArtifactContent(buildId, artifactPath); // Convert to base64 artifactData.content = Buffer.from(contentResponse).toString('base64'); } catch (err) { // Ignore download errors } } } else if (encoding === 'stream') { artifactData.downloadHandle = { tool: 'download_build_artifact', args: { buildId, artifactPath, encoding: 'stream', ...(options.maxArtifactSize ? { maxSize: options.maxArtifactSize } : {}), }, }; } return artifactData; }) ); return result; } catch (error) { warn('Failed to fetch artifacts', { error: error instanceof Error ? error.message : error, buildId, expected: 'file[]', }); return []; } } /** * Filter artifacts by pattern */ private filterArtifacts(artifacts: TeamCityArtifact[], pattern: string): TeamCityArtifact[] { // Convert glob pattern to regex const regex = new RegExp( `^${pattern.replace(/\./g, '\\.').replace(/\*/g, '.*').replace(/\?/g, '.')}$` ); return artifacts.filter((a) => regex.test(a.name)); } private ensureArtifactListResponse(data: unknown, buildId: string): ArtifactListResponse { if (!isRecord(data)) { throw new TeamCityAPIError( 'TeamCity returned a non-object artifact list response', 'INVALID_RESPONSE', undefined, { buildId, expected: 'object with file[]' } ); } const { file } = data as ArtifactListResponse; if (file !== undefined && !Array.isArray(file)) { throw new TeamCityAPIError( 'TeamCity artifact list response contains a non-array file field', 'INVALID_RESPONSE', undefined, { buildId, receivedType: typeof file } ); } return data as ArtifactListResponse; } private ensureStatisticsResponse(data: unknown, buildId: string): StatisticsResponse { if (!isRecord(data)) { throw new TeamCityAPIError( 'TeamCity returned a non-object statistics response', 'INVALID_RESPONSE', undefined, { buildId, expected: 'object with property[]' } ); } const { property } = data as { property?: unknown }; if (property === undefined) { return {} as StatisticsResponse; } if (!Array.isArray(property)) { throw new TeamCityAPIError( 'TeamCity statistics response contains a non-array property field', 'INVALID_RESPONSE', undefined, { buildId, receivedType: typeof property } ); } property.forEach((entry, index) => { if (!isRecord(entry)) { throw new TeamCityAPIError( 'TeamCity statistics response contains a non-object property entry', 'INVALID_RESPONSE', undefined, { buildId, index } ); } const { name, value } = entry as Record<string, unknown>; if (typeof name !== 'string' || typeof value !== 'string') { throw new TeamCityAPIError( 'TeamCity statistics response property entry is missing required fields', 'INVALID_RESPONSE', undefined, { buildId, index, receivedKeys: Object.keys(entry) } ); } }); return { property: property as Array<{ name: string; value: string }> }; } private ensureChangesResponse(data: unknown, buildId: string): ChangesResponse { if (!isRecord(data)) { throw new TeamCityAPIError( 'TeamCity returned a non-object changes response', 'INVALID_RESPONSE', undefined, { buildId, expected: 'object with change[]' } ); } const { change } = data as ChangesResponse; if (change !== undefined && !Array.isArray(change)) { throw new TeamCityAPIError( 'TeamCity changes response contains a non-array change field', 'INVALID_RESPONSE', undefined, { buildId, receivedType: typeof change } ); } return data as ChangesResponse; } private ensureDependenciesResponse(data: unknown, buildId: string): DependenciesResponse { if (!isRecord(data)) { throw new TeamCityAPIError( 'TeamCity returned a non-object dependencies response', 'INVALID_RESPONSE', undefined, { buildId, expected: 'object with build[]' } ); } const { build } = data as DependenciesResponse; if (build !== undefined && !Array.isArray(build)) { throw new TeamCityAPIError( 'TeamCity dependencies response contains a non-array build field', 'INVALID_RESPONSE', undefined, { buildId, receivedType: typeof build } ); } if (Array.isArray(build)) { build.forEach((entry, index) => { if (!isRecord(entry)) { throw new TeamCityAPIError( 'TeamCity dependencies response contains a non-object build entry', 'INVALID_RESPONSE', undefined, { buildId, index } ); } const { id, number, buildTypeId, status } = entry as Record<string, unknown>; if ( (typeof id !== 'number' && typeof id !== 'string') || typeof number !== 'string' || typeof buildTypeId !== 'string' || typeof status !== 'string' ) { throw new TeamCityAPIError( 'TeamCity dependencies response is missing required fields on build entry', 'INVALID_RESPONSE', undefined, { buildId, index, receivedKeys: Object.keys(entry) } ); } if (typeof id === 'string' && Number.isNaN(Number.parseInt(id, 10))) { throw new TeamCityAPIError( 'TeamCity dependencies response contains a non-numeric id value', 'INVALID_RESPONSE', undefined, { buildId, index, receivedValue: id } ); } }); } return data as DependenciesResponse; } /** * Fetch build statistics */ private async fetchStatistics(buildId: string): Promise<BuildResult['statistics']> { try { const response = await this.client.modules.builds.getBuildStatisticValues( toBuildLocator(buildId) ); const payload = this.ensureStatisticsResponse(response.data, buildId); const properties: Array<{ name: string; value: string }> = payload.property ?? []; const stats: BuildResult['statistics'] = {}; for (const prop of properties) { switch (prop.name) { case 'BuildDuration': stats.buildDuration = parseInt(prop.value, 10); break; case 'TestCount': stats.testCount = parseInt(prop.value, 10); break; case 'PassedTestCount': stats.passedTests = parseInt(prop.value, 10); break; case 'FailedTestCount': stats.failedTests = parseInt(prop.value, 10); break; case 'IgnoredTestCount': stats.ignoredTests = parseInt(prop.value, 10); break; case 'CodeCoverageL': case 'CodeCoverageB': if (!stats.codeCoverage || parseFloat(prop.value) > stats.codeCoverage) { stats.codeCoverage = parseFloat(prop.value); } break; default: // Store other statistics as-is stats[prop.name] = prop.value; } } return stats; } catch (error) { warn('Failed to fetch statistics', { error: error instanceof Error ? error.message : error, buildId, expected: 'property[]', }); return {}; } } /** * Fetch VCS changes */ private async fetchChanges(buildId: string): Promise<BuildResult['changes']> { try { const response = await this.client.modules.changes.getAllChanges(`build:(id:${buildId})`); const changePayload = this.ensureChangesResponse(response.data, buildId); const changes = changePayload.change ?? []; return changes.map((change: TeamCityChange) => ({ revision: change.version, author: change.username, date: change.date, comment: change.comment ?? '', files: (change.files?.file ?? []).map((file) => ({ path: file.name, changeType: file.changeType ?? 'edited', })), })); } catch (error) { warn('Failed to fetch changes', { error: error instanceof Error ? error.message : error, buildId, expected: 'change[]', }); return []; } } /** * Fetch build dependencies */ private async fetchDependencies(buildId: string): Promise<BuildResult['dependencies']> { try { const response = await this.client.modules.builds.getAllBuilds( `snapshotDependency:(to:(id:${buildId}))`, 'build(id,number,buildTypeId,status)' ); const depsData = this.ensureDependenciesResponse(response.data, buildId); const builds = depsData.build ?? []; return builds.map((build) => ({ buildId: typeof build.id === 'string' ? Number.parseInt(build.id, 10) : (build.id as number), buildNumber: build.number as string, buildTypeId: build.buildTypeId as string, status: build.status as string, })); } catch (error) { warn('Failed to fetch dependencies', { error: error instanceof Error ? error.message : error, buildId, expected: 'build[]', }); return []; } } /** * Resolve absolute URLs using the shared TeamCity client base URL */ private buildAbsoluteUrl(path: string): string { if (/^https?:/i.test(path)) { return path; } const baseUrl = this.getBaseUrl(); if (path.startsWith('/')) { return `${baseUrl}${path}`; } return `${baseUrl}/${path}`; } private getBaseUrl(): string { const baseUrl = this.client.getApiConfig().baseUrl; return baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl; } private async downloadArtifactContent( buildId: string, artifactPath: string ): Promise<ArrayBufferLike> { const normalizedPath = artifactPath .split('/') .map((segment) => encodeURIComponent(segment)) .join('/'); const buildLocator = toBuildLocator(buildId); const response = await this.client.modules.builds.downloadFileOfBuild( `content/${normalizedPath}`, buildLocator, undefined, undefined, { responseType: 'arraybuffer' } ); const axiosResponse = response as AxiosResponse<unknown>; const { data } = axiosResponse; if (data instanceof ArrayBuffer) { return data.slice(0); } if (Buffer.isBuffer(data)) { return data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength); } throw new Error('Artifact download returned unexpected binary payload type'); } /** * Parse TeamCity date format */ private parseTeamCityDate(dateStr: string): number { // TeamCity format: yyyyMMdd'T'HHmmss+ZZZZ const match = dateStr.match(/(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})/); if (!match) { return Date.parse(dateStr); } const [, year, month, day, hour, minute, second] = match; if (!year || !month || !day || !hour || !minute || !second) { return Date.parse(dateStr); } return new Date( parseInt(year, 10), parseInt(month, 10) - 1, parseInt(day, 10), parseInt(hour, 10), parseInt(minute, 10), parseInt(second, 10) ).getTime(); } /** * Generate cache key */ private getCacheKey(buildId: string, options: BuildResultsOptions): string { return `${buildId}:${JSON.stringify(options)}`; } private isAxiosNotFound(error: unknown): error is AxiosError { const axiosError = error as AxiosError | undefined; return Boolean(axiosError?.response?.status === 404); } /** * Get from cache if valid */ private getFromCache(key: string): BuildResult | null { const entry = this.cache.get(key); if (!entry) { return null; } const age = Date.now() - entry.timestamp; if (age > BuildResultsManager.cacheTtlMs) { this.cache.delete(key); return null; } return entry.result; } /** * Cache a result */ private cacheResult(key: string, result: BuildResult): 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 > BuildResultsManager.cacheTtlMs) { expired.push(key); } } for (const key of expired) { this.cache.delete(key); } } }

Implementation Reference

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