/**
* Build Status Manager for TeamCity
* Handles querying and monitoring build status information
*/
import type { TeamCityClientAdapter } from './client-adapter';
import { BuildAccessDeniedError, BuildNotFoundError } from './errors';
/**
* Options for querying build status
*/
export interface BuildStatusOptions {
buildId?: string;
buildNumber?: string;
buildTypeId?: string;
branch?: string;
includeTests?: boolean;
includeProblems?: boolean;
forceRefresh?: boolean;
}
/**
* Build status result
*/
export interface BuildStatusResult {
buildId: string;
buildNumber?: string;
buildTypeId?: string;
state: 'queued' | 'running' | 'finished' | 'failed' | 'canceled';
status?: 'SUCCESS' | 'FAILURE' | 'ERROR' | 'UNKNOWN';
statusText?: string;
percentageComplete: number;
currentStageText?: string;
branchName?: string;
webUrl?: string;
queuedDate?: Date;
startDate?: Date;
finishDate?: Date;
elapsedSeconds?: number;
estimatedTotalSeconds?: number;
estimatedStartTime?: Date;
queuePosition?: number;
failureReason?: string;
canceledBy?: string;
canceledDate?: Date;
testSummary?: TestSummary;
problems?: BuildProblem[];
}
/**
* Test execution summary
*/
export interface TestSummary {
total: number;
passed: number;
failed: number;
ignored: number;
muted?: number;
newFailed?: number;
}
/**
* Build problem information
*/
export interface BuildProblem {
type: string;
identity: string;
description: string;
}
type BuildApiBuild = {
id?: string | number;
number?: string;
state?: string;
status?: string;
statusText?: string;
buildTypeId?: string;
branchName?: string;
webUrl?: string;
percentageComplete?: number;
queuedDate?: string;
startDate?: string;
finishDate?: string;
canceled?: boolean;
failureReason?: string;
testOccurrences?: {
count?: number;
passed?: number;
failed?: number;
ignored?: number;
muted?: number;
newFailed?: number;
};
problemOccurrences?: {
problemOccurrence?: Array<{
type?: string;
identity?: string;
details?: string;
description?: string;
}>;
};
'running-info'?: {
currentStageText?: string;
elapsedSeconds?: number;
estimatedTotalSeconds?: number;
percentageComplete?: number;
};
'queued-info'?: {
position?: number;
estimatedStartTime?: string;
};
canceledInfo?: {
user?: { username?: string };
timestamp?: string;
};
};
/**
* Cache entry for build status
*/
interface CacheEntry {
result: BuildStatusResult;
timestamp: number;
}
/**
* Build Status Manager implementation
*/
export class BuildStatusManager {
private client: TeamCityClientAdapter;
private cache: Map<string, CacheEntry>;
private readonly cacheTtl = 5 * 60 * 1000; // 5 minutes
constructor(client: TeamCityClientAdapter) {
this.client = client;
this.cache = new Map();
}
/**
* Get build status by ID or number
*/
async getBuildStatus(options: BuildStatusOptions): Promise<BuildStatusResult> {
// Validate input
if (!options.buildId && !options.buildNumber) {
throw new Error('Either buildId or buildNumber must be provided');
}
if (options.buildNumber && !options.buildTypeId) {
throw new Error('Build type ID is required when querying by build number');
}
// Check cache for completed builds
const cacheKey = this.getCacheKey(options);
if (!options.forceRefresh) {
const cached = this.getCachedResult(cacheKey);
if (cached) {
return cached;
}
}
try {
let buildData: BuildApiBuild | undefined;
if (options.buildId) {
// Query by build ID
const response = await this.client.builds.getBuild(
`id:${options.buildId}`,
this.getFieldSelection(options)
);
buildData = response.data as BuildApiBuild;
} else {
// Query by build number and type using direct build locator
const locator = this.buildLocator(options);
const response = await this.client.builds.getBuild(
locator,
this.getFieldSelection(options)
);
buildData = response.data as BuildApiBuild;
}
if (buildData == null) {
throw new BuildNotFoundError('Build data is undefined');
}
// Transform response to standardized format
const result = this.transformBuildResponse(buildData as BuildApiBuild, options);
// Cache if build is completed
if (result.state === 'finished' || result.state === 'canceled') {
this.setCachedResult(cacheKey, result);
}
return result;
} catch (error: unknown) {
// Handle specific error cases
if (
error != null &&
typeof error === 'object' &&
'response' in error &&
(error as { response?: { status?: number } }).response?.status === 404
) {
throw new BuildNotFoundError(`Build not found: ${options.buildId ?? options.buildNumber}`);
}
if (
error != null &&
typeof error === 'object' &&
'response' in error &&
(error as { response?: { status?: number } }).response?.status === 403
) {
throw new BuildAccessDeniedError(
`Access denied to build: ${options.buildId ?? options.buildNumber}`
);
}
// Re-throw other errors
throw error;
}
}
/**
* Get build status using custom locator
*/
async getBuildStatusByLocator(locator: string): Promise<BuildStatusResult> {
try {
const response = await this.client.builds.getMultipleBuilds(
locator,
this.getFieldSelection({})
);
const data = response.data as { build?: BuildApiBuild[] };
if (!Array.isArray(data.build) || data.build.length === 0) {
throw new BuildNotFoundError(`No builds found for locator: ${locator}`);
}
const firstBuild = data.build[0];
if (!firstBuild) {
throw new BuildNotFoundError(`No builds found for locator: ${locator}`);
}
return this.transformBuildResponse(firstBuild as BuildApiBuild, {});
} catch (error: unknown) {
if (
error != null &&
typeof error === 'object' &&
'response' in error &&
(error as { response?: { status?: number } }).response?.status === 404
) {
throw new BuildNotFoundError(`No builds found for locator: ${locator}`);
}
throw error;
}
}
/**
* Clear the status cache
*/
clearCache(): void {
this.cache.clear();
}
/**
* Build locator string for querying
*/
private buildLocator(options: BuildStatusOptions): string {
const parts: string[] = [];
if (options.buildTypeId) {
parts.push(`buildType:(id:${options.buildTypeId})`);
}
if (options.buildNumber) {
parts.push(`number:${options.buildNumber}`);
}
if (options.branch) {
parts.push(`branch:${options.branch}`);
}
return parts.join(',');
}
/**
* Get field selection string
*/
private getFieldSelection(options: BuildStatusOptions): string {
// Start with minimal fields required for basic status
const baseFields = ['id', 'number', 'state', 'status', 'statusText'];
// Always include essential fields for proper transformation
baseFields.push(
'buildTypeId',
'branchName',
'webUrl',
'percentageComplete',
'queuedDate',
'startDate',
'finishDate',
'canceled',
'failureReason',
'running-info',
'queued-info',
'canceledInfo'
);
if (options.includeTests) {
baseFields.push('testOccurrences');
}
if (options.includeProblems) {
baseFields.push('problemOccurrences');
}
return baseFields.join(',');
}
/**
* Transform TeamCity response to standardized format
*/
private transformBuildResponse(
build: {
id?: string | number;
number?: string;
state?: string;
status?: string;
statusText?: string;
buildTypeId?: string;
branchName?: string;
webUrl?: string;
percentageComplete?: number;
queuedDate?: string;
startDate?: string;
finishDate?: string;
canceled?: boolean;
failureReason?: string;
testOccurrences?: {
count?: number;
passed?: number;
failed?: number;
ignored?: number;
muted?: number;
newFailed?: number;
};
problemOccurrences?: {
problemOccurrence?: Array<{
type?: string;
identity?: string;
details?: string;
description?: string;
}>;
};
'running-info'?: {
currentStageText?: string;
elapsedSeconds?: number;
estimatedTotalSeconds?: number;
percentageComplete?: number;
};
'queued-info'?: {
position?: number;
estimatedStartTime?: string;
};
canceledInfo?: {
user?: { username?: string };
timestamp?: string;
};
},
options: BuildStatusOptions
): BuildStatusResult {
// Determine build state with fallback
let state: BuildStatusResult['state'];
if (!build.state) {
state = 'queued'; // Default state if undefined
} else if (build.state === 'finished' && build.canceled) {
state = 'canceled';
} else if (
build.state === 'queued' ||
build.state === 'running' ||
build.state === 'finished' ||
build.state === 'failed' ||
build.state === 'canceled'
) {
state = build.state;
} else {
// Handle any unexpected state values
state = 'queued';
}
// Calculate elapsed time
let elapsedSeconds: number | undefined;
if (build.startDate) {
const startTime = this.parseDate(build.startDate).getTime();
if (build.finishDate) {
const finishTime = this.parseDate(build.finishDate).getTime();
elapsedSeconds = Math.floor((finishTime - startTime) / 1000);
} else if (state === 'running') {
elapsedSeconds = Math.floor((Date.now() - startTime) / 1000);
}
}
// Extract running info
const runningInfo = build['running-info'];
const queuedInfo = build['queued-info'];
// Build result object
const result: BuildStatusResult = {
buildId: String(build.id),
buildNumber: build.number,
buildTypeId: build.buildTypeId,
state,
status: build.status as 'SUCCESS' | 'FAILURE' | 'ERROR' | 'UNKNOWN' | undefined,
statusText: build.statusText,
percentageComplete: this.getPercentageComplete(state, build),
branchName: build.branchName,
webUrl: build.webUrl,
failureReason: build.failureReason,
};
// Add dates
if (build.queuedDate) {
result.queuedDate = this.parseDate(build.queuedDate);
}
if (build.startDate) {
result.startDate = this.parseDate(build.startDate);
}
if (build.finishDate) {
result.finishDate = this.parseDate(build.finishDate);
}
// Add running info
if (runningInfo) {
result.currentStageText = runningInfo.currentStageText;
result.elapsedSeconds = runningInfo.elapsedSeconds ?? elapsedSeconds;
result.estimatedTotalSeconds = runningInfo.estimatedTotalSeconds;
if (runningInfo.percentageComplete !== undefined) {
result.percentageComplete = runningInfo.percentageComplete;
}
} else if (elapsedSeconds !== undefined) {
result.elapsedSeconds = elapsedSeconds;
}
// Add queued info
if (queuedInfo) {
result.queuePosition = queuedInfo.position;
if (queuedInfo.estimatedStartTime) {
result.estimatedStartTime = this.parseDate(queuedInfo.estimatedStartTime);
}
}
// Add canceled info
if (build.canceledInfo) {
result.canceledBy = build.canceledInfo.user?.username;
if (build.canceledInfo.timestamp) {
result.canceledDate = this.parseDate(build.canceledInfo.timestamp);
}
}
// Add test summary if requested
if (options.includeTests && build.testOccurrences) {
result.testSummary = {
total: build.testOccurrences.count ?? 0,
passed: build.testOccurrences.passed ?? 0,
failed: build.testOccurrences.failed ?? 0,
ignored: build.testOccurrences.ignored ?? 0,
muted: build.testOccurrences.muted,
newFailed: build.testOccurrences.newFailed,
};
}
// Add problems if requested
if (options.includeProblems && build.problemOccurrences?.problemOccurrence) {
result.problems = build.problemOccurrences.problemOccurrence.map(
(problem: {
type?: string;
identity?: string;
details?: string;
description?: string;
}) => ({
type: problem.type ?? 'unknown',
identity: problem.identity ?? 'unknown',
description: problem.details ?? problem.description ?? '',
})
);
}
return result;
}
/**
* Get percentage complete for build
*/
private getPercentageComplete(
state: string,
build: {
percentageComplete?: number;
'running-info'?: { percentageComplete?: number };
}
): number {
if (state === 'finished' || state === 'canceled') {
return 100;
}
if (state === 'queued') {
return 0;
}
return build.percentageComplete ?? build['running-info']?.percentageComplete ?? 0;
}
/**
* Parse TeamCity date string
*/
private parseDate(dateString: string): Date {
// TeamCity format: 20250829T100000+0000
return new Date(
dateString
.replace(/(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})([+-]\d{4})/, '$1-$2-$3T$4:$5:$6$7')
.replace(/([+-]\d{2})(\d{2})$/, '$1:$2')
);
}
/**
* Get cache key for build query
*/
private getCacheKey(options: BuildStatusOptions): string {
if (options.buildId) {
return `id:${options.buildId}`;
}
return `num:${options.buildTypeId}:${options.buildNumber}:${options.branch ?? 'default'}`;
}
/**
* Get cached result if valid
*/
private getCachedResult(key: string): BuildStatusResult | null {
const entry = this.cache.get(key);
if (!entry) {
return null;
}
// Check if cache entry is still valid
if (Date.now() - entry.timestamp > this.cacheTtl) {
this.cache.delete(key);
return null;
}
return entry.result;
}
/**
* Set cached result
*/
private setCachedResult(key: string, result: BuildStatusResult): void {
this.cache.set(key, {
result,
timestamp: Date.now(),
});
}
}