get_build_results
Retrieve comprehensive build results from TeamCity including tests, artifacts, changes, and statistics for detailed CI/CD analysis and troubleshooting.
Instructions
Get detailed results of a build including tests, artifacts, changes, and statistics
Input Schema
TableJSON Schema
| Name | Required | Description | Default |
|---|---|---|---|
| artifactFilter | No | Filter artifacts by name/path pattern | |
| buildId | Yes | Build ID | |
| includeArtifacts | No | Include artifacts listing and metadata | |
| includeChanges | No | Include VCS changes | |
| includeDependencies | No | Include dependency builds | |
| includeStatistics | No | Include build statistics | |
| maxArtifactSize | No | Max artifact content size (bytes) when inlining |
Implementation Reference
- The core handler function that fetches comprehensive TeamCity build results, including build summary, optional artifacts (with base64 content or download handles), statistics, changes, and dependencies. Uses caching for finished builds and parallel fetches.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 ); } }
- Input schema/options for the getBuildResults tool, controlling which data to include and how to handle artifacts.export interface BuildResultsOptions { includeArtifacts?: boolean; includeStatistics?: boolean; includeChanges?: boolean; includeDependencies?: boolean; artifactFilter?: string; downloadArtifacts?: string[]; maxArtifactSize?: number; artifactEncoding?: ArtifactEncoding; }
- The BuildResultsManager class containing all supporting logic, private fetch methods, caching, error handling, and data transformation for build results retrieval.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); } } }