Skip to main content
Glama
build-config-navigator.ts17.4 kB
/** * BuildConfigNavigator - Navigate and discover TeamCity build configurations * Provides comprehensive build configuration listing, filtering, and metadata extraction */ import { debug, error as logError } from '@/utils/logger'; import { type BuildTypeProperty, type BuildTypeVcsRootEntry, type BuildTypesResponse, type ProjectData, isBuildTypesResponse, isProjectData, } from './types/api-responses'; import type { TeamCityUnifiedClient } from './types/client'; export interface BuildConfigNavigatorParams { projectId?: string; projectIds?: string[]; namePattern?: string; includeVcsRoots?: boolean; includeParameters?: boolean; includeProjectHierarchy?: boolean; viewMode?: 'list' | 'project-grouped'; vcsRootFilter?: { url?: string; branch?: string; vcsName?: string; }; statusFilter?: { lastBuildStatus?: 'SUCCESS' | 'FAILURE' | 'ERROR' | 'UNKNOWN'; paused?: boolean; hasRecentActivity?: boolean; activeSince?: Date; }; sortBy?: 'name' | 'project' | 'lastModified'; sortOrder?: 'asc' | 'desc'; pagination?: { limit?: number; offset?: number; }; } export interface BuildConfig { id: string; name: string; projectId: string; projectName: string; description?: string; href?: string; webUrl?: string; vcsRoots?: VcsRoot[]; parameters?: Record<string, string>; projectHierarchy?: ProjectInfo[]; lastBuildDate?: string; lastBuildStatus?: string; paused?: boolean; } export interface VcsRoot { id: string; name: string; vcsName: string; url?: string; branch?: string; } export interface ProjectInfo { id: string; name: string; } export interface ProjectGroup { projectId: string; projectName: string; buildConfigs: BuildConfig[]; } export interface BuildConfigNavigatorResult { buildConfigs: BuildConfig[]; totalCount: number; hasMore: boolean; viewMode: 'list' | 'project-grouped'; groupedByProject?: Record<string, ProjectGroup>; } interface CacheEntry { data: BuildConfigNavigatorResult; timestamp: number; } export class BuildConfigNavigator { private client: TeamCityUnifiedClient; private cache: Map<string, CacheEntry> = new Map(); private readonly cacheTtlMs = 120000; // 120 seconds private readonly maxCacheSize = 100; constructor(client: TeamCityUnifiedClient) { this.client = client; } /** * List build configurations with optional filtering and metadata extraction */ async listBuildConfigs( params: BuildConfigNavigatorParams = {} ): Promise<BuildConfigNavigatorResult> { const cacheKey = this.generateCacheKey(params); // Check cache first const cachedEntry = this.cache.get(cacheKey); if (cachedEntry != null && Date.now() - cachedEntry.timestamp < this.cacheTtlMs) { debug('Cache hit for build configurations', { cacheKey }); return cachedEntry.data; } try { const locator = this.buildLocator(params); const fields = this.buildFields(params); debug('Fetching build configurations', { locator }); const response = await this.client.modules.buildTypes.getAllBuildTypes(locator, fields); if (response.data == null || !isBuildTypesResponse(response.data)) { throw new Error('Invalid API response from TeamCity'); } const buildConfigs = await this.processBuildConfigs(response.data, params); const totalCount = response.data.count ?? buildConfigs.length; const result: BuildConfigNavigatorResult = { buildConfigs, totalCount, hasMore: this.calculateHasMore(buildConfigs.length, totalCount, params.pagination), viewMode: params.viewMode ?? 'list', groupedByProject: params.viewMode === 'project-grouped' ? this.groupBuildConfigsByProject(buildConfigs) : undefined, }; // Cache successful results this.cacheResult(cacheKey, result); return result; } catch (error) { logError( 'Failed to fetch build configurations', error instanceof Error ? error : new Error(String(error)), { locator: this.buildLocator(params), } ); throw this.transformError(error, params); } } /** * Build TeamCity API locator string based on parameters */ private buildLocator(params: BuildConfigNavigatorParams): string { const locatorParts: string[] = []; if (params.projectId) { locatorParts.push(`project:${params.projectId}`); } else if (params.projectIds && params.projectIds.length > 0) { locatorParts.push(`project:(${params.projectIds.join(',')})`); } if (params.pagination) { if (params.pagination.limit !== undefined) { locatorParts.push(`count:${params.pagination.limit}`); } if (params.pagination.offset !== undefined) { locatorParts.push(`start:${params.pagination.offset}`); } } return locatorParts.join(','); } /** * Build TeamCity API fields string based on what metadata is requested */ private buildFields(params: BuildConfigNavigatorParams): string { const baseFields = '$long'; const needsAdditionalFields = params.includeVcsRoots === true || params.includeParameters === true || params.vcsRootFilter !== undefined || params.statusFilter !== undefined || params.sortBy === 'lastModified'; if (!needsAdditionalFields) { return baseFields; } const additionalFields: string[] = []; if (params.includeVcsRoots === true || params.vcsRootFilter !== undefined) { additionalFields.push( 'vcs-root-entries(vcs-root(id,name,vcsName,properties(property(name,value))))' ); } if (params.includeParameters === true) { additionalFields.push('parameters(property(name,value,type))'); } if (params.statusFilter !== undefined || params.sortBy === 'lastModified') { additionalFields.push('lastBuildDate,lastBuildStatus,paused'); } return additionalFields.length > 0 ? `${baseFields},${additionalFields.join(',')}` : baseFields; } /** * Process raw API response into structured BuildConfig objects */ private async processBuildConfigs( data: BuildTypesResponse, params: BuildConfigNavigatorParams ): Promise<BuildConfig[]> { const buildTypes = data.buildType ?? []; let buildConfigs: BuildConfig[] = []; for (const buildType of buildTypes) { const buildConfig: BuildConfig = { id: buildType.id ?? '', name: buildType.name ?? '', projectId: buildType.projectId ?? buildType.project?.id ?? '', projectName: buildType.projectName ?? buildType.project?.name ?? '', description: buildType.description, href: buildType.href, webUrl: buildType.webUrl, lastBuildDate: buildType.lastBuildDate, lastBuildStatus: buildType.lastBuildStatus, paused: buildType.paused, }; // Apply name pattern filtering if specified if (params.namePattern && !this.matchesPattern(buildConfig.name, params.namePattern)) { continue; } // Extract VCS roots if requested or needed for filtering const needVcsRoots = params.includeVcsRoots === true ? true : params.vcsRootFilter != null; if (needVcsRoots && buildType['vcs-root-entries']) { buildConfig.vcsRoots = this.extractVcsRoots(buildType['vcs-root-entries']); // Apply VCS root filtering if specified if ( params.vcsRootFilter && !this.matchesVcsRootFilter(buildConfig.vcsRoots, params.vcsRootFilter) ) { continue; } } // Extract parameters if requested if (params.includeParameters && buildType.parameters) { buildConfig.parameters = this.extractParameters(buildType.parameters); } // Extract project hierarchy if requested if (params.includeProjectHierarchy) { // Intentional sequential per-item hierarchy fetch; keeps API usage simple in list flows // eslint-disable-next-line no-await-in-loop buildConfig.projectHierarchy = await this.extractProjectHierarchy(buildConfig.projectId); } // Apply status filtering if specified if (params.statusFilter && !this.matchesStatusFilter(buildConfig, params.statusFilter)) { continue; } buildConfigs.push(buildConfig); } // Apply sorting if specified if (params.sortBy) { buildConfigs = this.sortBuildConfigs(buildConfigs, params.sortBy, params.sortOrder ?? 'asc'); } // Note: Pagination is already applied at the API level via the locator // (see buildLocator method which adds count and start parameters) // So we don't need to apply client-side pagination here return buildConfigs; } /** * Check if a name matches a pattern (supports wildcards) */ private matchesPattern(name: string, pattern: string): boolean { if (!pattern.includes('*')) { return name.toLowerCase().includes(pattern.toLowerCase()); } // Convert wildcard pattern to regex const regexPattern = pattern .replace(/[.+?^${}()|[\]\\]/g, '\\$&') // Escape special chars except * .replace(/\*/g, '.*'); // Convert * to .* const regex = new RegExp(`^${regexPattern}$`, 'i'); return regex.test(name); } /** * Check if VCS roots match the filter criteria */ private matchesVcsRootFilter( vcsRoots: VcsRoot[] | undefined, filter: NonNullable<BuildConfigNavigatorParams['vcsRootFilter']> ): boolean { if (!vcsRoots || vcsRoots.length === 0) { return false; } return vcsRoots.some((vcsRoot) => { if (filter.url && !vcsRoot.url?.includes(filter.url)) { return false; } if (filter.branch && vcsRoot.branch !== filter.branch) { return false; } if (filter.vcsName && vcsRoot.vcsName !== filter.vcsName) { return false; } return true; }); } /** * Check if build config matches status filter criteria */ private matchesStatusFilter( buildConfig: BuildConfig, filter: NonNullable<BuildConfigNavigatorParams['statusFilter']> ): boolean { if (filter.lastBuildStatus && buildConfig.lastBuildStatus !== filter.lastBuildStatus) { return false; } if (filter.paused !== undefined && buildConfig.paused !== filter.paused) { return false; } if (filter.hasRecentActivity !== undefined) { const hasActivity = Boolean(buildConfig.lastBuildDate); if (filter.hasRecentActivity !== hasActivity) { return false; } } if (filter.activeSince && buildConfig.lastBuildDate) { const lastBuildDate = new Date(buildConfig.lastBuildDate); if (lastBuildDate < filter.activeSince) { return false; } } return true; } /** * Sort build configurations by specified field */ private sortBuildConfigs( buildConfigs: BuildConfig[], sortBy: NonNullable<BuildConfigNavigatorParams['sortBy']>, sortOrder: 'asc' | 'desc' ): BuildConfig[] { const sorted = [...buildConfigs].sort((a, b) => { let compareValue = 0; switch (sortBy) { case 'name': compareValue = a.name.localeCompare(b.name); break; case 'project': compareValue = a.projectName.localeCompare(b.projectName); if (compareValue === 0) { // Secondary sort by name within same project compareValue = a.name.localeCompare(b.name); } break; case 'lastModified': if (!a.lastBuildDate && !b.lastBuildDate) { compareValue = 0; } else if (!a.lastBuildDate) { compareValue = 1; } else if (!b.lastBuildDate) { compareValue = -1; } else { compareValue = new Date(b.lastBuildDate).getTime() - new Date(a.lastBuildDate).getTime(); } break; } return sortOrder === 'asc' ? compareValue : -compareValue; }); return sorted; } /** * Extract VCS root information from build type data */ private extractVcsRoots(vcsRootEntries: { 'vcs-root-entry'?: BuildTypeVcsRootEntry[]; }): VcsRoot[] { const vcsRoots: VcsRoot[] = []; const entries = vcsRootEntries['vcs-root-entry'] ?? []; for (const entry of entries) { const vcsRoot = entry['vcs-root']; if (vcsRoot) { const properties = vcsRoot.properties?.property ?? []; const propertiesMap = properties.reduce<Record<string, string | undefined>>((acc, prop) => { if (prop.name) { acc[prop.name] = prop.value; } return acc; }, {}); vcsRoots.push({ id: vcsRoot.id ?? '', name: vcsRoot.name ?? '', vcsName: vcsRoot.vcsName ?? '', url: propertiesMap['url'], branch: propertiesMap['branch'], }); } } return vcsRoots; } /** * Extract build parameters from build type data */ private extractParameters(parametersData: { property?: BuildTypeProperty[]; }): Record<string, string> { const parameters: Record<string, string> = {}; const properties = parametersData.property ?? []; for (const property of properties) { parameters[property.name] = property.value; } return parameters; } /** * Extract project hierarchy path for a given project */ private async extractProjectHierarchy(projectId: string): Promise<ProjectInfo[]> { try { const response = await this.client.modules.projects.getProject( projectId, '$short,parentProject($short)' ); // Build hierarchy from root to current project const ancestors: ProjectInfo[] = []; let current: ProjectData | undefined = isProjectData(response.data) ? response.data : undefined; while (current?.id && current.name) { ancestors.unshift({ id: current.id, name: current.name, }); current = current.parentProject; } return ancestors; } catch (error) { debug('Failed to extract project hierarchy', { projectId, error }); return [{ id: projectId, name: projectId }]; } } /** * Group build configurations by project */ private groupBuildConfigsByProject(buildConfigs: BuildConfig[]): Record<string, ProjectGroup> { const grouped: Record<string, ProjectGroup> = {}; for (const config of buildConfigs) { let group = grouped[config.projectId]; if (!group) { group = { projectId: config.projectId, projectName: config.projectName, buildConfigs: [], }; grouped[config.projectId] = group; } group.buildConfigs.push(config); } return grouped; } /** * Calculate if there are more results available */ private calculateHasMore( currentCount: number, totalCount: number, pagination?: { limit?: number; offset?: number } ): boolean { if (!pagination?.limit) { return false; } const offset = pagination.offset ?? 0; return currentCount === pagination.limit && totalCount > offset + currentCount; } /** * Transform API errors into user-friendly messages */ private transformError(error: unknown, params: BuildConfigNavigatorParams): Error { const axiosError = error as { response?: { status?: number }; name?: string; message?: string }; if (axiosError?.response?.status === 401) { return new Error('Authentication failed - please check your TeamCity token'); } if (axiosError?.response?.status === 403) { return new Error('Permission denied - you do not have access to build configurations'); } if (axiosError?.response?.status === 404) { if (params.projectId) { return new Error(`Project ${params.projectId} not found`); } return new Error('Build configurations not found'); } if (axiosError?.name === 'ECONNABORTED' || axiosError?.message?.includes('timeout')) { return new Error('Request timed out - TeamCity server may be overloaded'); } if (axiosError?.response?.status && axiosError.response.status >= 500) { return new Error(`TeamCity API error: ${axiosError.message ?? 'Internal server error'}`); } return new Error(`TeamCity API error: ${axiosError?.message ?? 'Unknown error'}`); } /** * Generate cache key for request parameters */ private generateCacheKey(params: BuildConfigNavigatorParams): string { return JSON.stringify(params); } /** * Cache successful results with TTL */ private cacheResult(cacheKey: string, result: BuildConfigNavigatorResult): void { // Clean up cache if it gets too large if (this.cache.size >= this.maxCacheSize) { // Remove oldest entries to make room for one new entry const entriesToRemove = this.cache.size - this.maxCacheSize + 1; const oldestKeys = Array.from(this.cache.keys()).slice(0, entriesToRemove); for (const key of oldestKeys) { this.cache.delete(key); } } this.cache.set(cacheKey, { data: result, timestamp: Date.now(), }); debug('Cached build configurations result', { cacheKey, resultCount: result.buildConfigs.length, }); } }

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