Skip to main content
Glama
build-configuration-resolver.ts21.9 kB
/** * Build Configuration Resolver for TeamCity * * Provides intelligent resolution of TeamCity build configurations from various * context clues including IDs, names, commits, PRs, and issue keys. */ import type { Logger } from 'winston'; import type { BuildType } from '@/teamcity-client'; import type { TeamCityUnifiedClient } from './types/client'; /** * Resolved build configuration with normalized data */ export interface ResolvedBuildConfiguration { id: string; name: string; projectId: string; projectName: string; webUrl?: string; description?: string; paused: boolean; templateFlag: boolean; allowPersonalBuilds: boolean; vcsRootIds?: string[]; parameters?: Record<string, string>; } /** * Resolution context for finding build configurations */ export interface ResolutionContext { commitHash?: string; pullRequestNumber?: string; issueKey?: string; branch?: string; projectHint?: string; additionalContext?: string; } /** * Options for name-based resolution */ export interface NameResolutionOptions { projectName: string; buildTypeName: string; additionalContext?: string; } /** * Fuzzy match result */ export interface BuildConfigurationMatch { configuration: ResolvedBuildConfiguration; score: number; matchedOn: string[]; } /** * Resolver configuration options */ export interface ResolverOptions { fuzzyMatchThreshold?: number; maxCacheSize?: number; cacheEnabled?: boolean; } /** * Batch resolution request */ export interface BatchResolutionRequest { type: 'id' | 'name' | 'context'; value: string | NameResolutionOptions | ResolutionContext; } /** * Custom error classes */ export class BuildConfigurationNotFoundError extends Error { constructor( message: string, public readonly searchCriteria?: unknown ) { super(message); this.name = 'BuildConfigurationNotFoundError'; } } export class AmbiguousBuildConfigurationError extends Error { constructor( message: string, public readonly candidates: ResolvedBuildConfiguration[], public readonly suggestions: string[] ) { super(message); this.name = 'AmbiguousBuildConfigurationError'; } } export class BuildConfigurationPermissionError extends Error { constructor( message: string, public readonly configurationId?: string ) { super(message); this.name = 'BuildConfigurationPermissionError'; } } /** * Cache implementation for build configurations */ export class BuildConfigurationCache { private cache: Map<string, { data: ResolvedBuildConfiguration; timestamp: number }>; private readonly ttl: number; private readonly maxSize: number; constructor(options: { ttl?: number; maxSize?: number } = {}) { this.cache = new Map(); this.ttl = options.ttl ?? 300000; // 5 minutes default this.maxSize = options.maxSize ?? 1000; } get(key: string): ResolvedBuildConfiguration | undefined { const entry = this.cache.get(key); if (!entry) { return undefined; } if (Date.now() - entry.timestamp > this.ttl) { this.cache.delete(key); return undefined; } return entry.data; } set(key: string, data: ResolvedBuildConfiguration): void { // Implement LRU eviction if at capacity if (this.cache.size >= this.maxSize && !this.cache.has(key)) { const firstKey = this.cache.keys().next().value; if (firstKey !== undefined) { this.cache.delete(firstKey); } } this.cache.set(key, { data, timestamp: Date.now(), }); } clear(): void { this.cache.clear(); } delete(key: string): boolean { return this.cache.delete(key); } } /** * Main resolver class */ export class BuildConfigurationResolver { private client: TeamCityUnifiedClient; private logger: Logger; private cache: BuildConfigurationCache; private fuzzyMatchThreshold: number; constructor(config: { client: TeamCityUnifiedClient; logger: Logger; cache?: BuildConfigurationCache; options?: ResolverOptions; }) { this.client = config.client; this.logger = config.logger; this.cache = config.cache ?? new BuildConfigurationCache(); this.fuzzyMatchThreshold = config.options?.fuzzyMatchThreshold ?? 0.7; } /** * Resolve build configuration by exact ID */ async resolveByConfigurationId( configurationId: string, _options?: { checkPermissions?: boolean } ): Promise<ResolvedBuildConfiguration> { // Check cache first const cacheKey = `id:${configurationId}`; const cached = this.cache.get(cacheKey); if (cached) { this.logger.debug(`Build configuration ${configurationId} resolved from cache`); return cached; } try { const response = await this.client.modules.buildTypes.getBuildType( configurationId, 'id,name,projectId,projectName,webUrl,description,paused,templateFlag,settings,parameters,vcs-root-entries' ); const buildType = response.data; const resolved = this.normalizeBuildType(buildType); this.cache.set(cacheKey, resolved); return resolved; } catch (error) { const err = error as { response?: { status?: number } }; if (err.response?.status === 404) { throw new BuildConfigurationNotFoundError( `Build configuration with ID '${configurationId}' not found` ); } if (err.response?.status === 403) { throw new BuildConfigurationPermissionError( `Access denied to build configuration '${configurationId}'`, configurationId ); } const errorWithMessage = error as { message?: string }; if (errorWithMessage.message?.includes('ECONNREFUSED') === true) { throw new Error('Failed to connect to TeamCity server'); } throw error; } } /** * Resolve by project and build type names */ async resolveByName(options: NameResolutionOptions): Promise<ResolvedBuildConfiguration> { const cacheKey = `name:${options.projectName}:${options.buildTypeName}`; const cached = this.cache.get(cacheKey); if (cached) { return cached; } // Fetch all build types (with caching) const allBuildTypes = await this.getAllBuildTypes(); // Score all build types const scoredCandidates = allBuildTypes .map((bt) => { const projectScore = this.fuzzyMatch(options.projectName, bt.projectName ?? ''); const projectIdScore = this.fuzzyMatch(options.projectName, bt.projectId ?? ''); const nameScore = this.fuzzyMatch(options.buildTypeName, bt.name ?? ''); // Use the best project match (projectName or projectId) const bestProjectScore = Math.max(projectScore, projectIdScore); // Both project and name must have some match if (bestProjectScore < 0.1 || nameScore < 0.1) { return null; } // Calculate combined score, heavily weighting exact matches let totalScore = bestProjectScore * nameScore; // Boost for exact matches if (projectScore === 1.0 && nameScore === 1.0) { totalScore = 2.0; // Highest priority } else if (projectScore === 1.0 || nameScore === 1.0) { totalScore = 1.5 + totalScore * 0.5; // High priority } return { buildType: bt, score: totalScore, projectScore: bestProjectScore, nameScore, }; }) .filter((candidate): candidate is NonNullable<typeof candidate> => candidate !== null) .sort((a, b) => b.score - a.score); if (scoredCandidates.length === 0) { throw new BuildConfigurationNotFoundError( `No build configuration found matching project '${options.projectName}' and build type '${options.buildTypeName}'`, options ); } // If the top candidate has a significantly higher score, use it const topCandidate = scoredCandidates[0]; if ( scoredCandidates.length === 1 || (topCandidate && topCandidate.score > (scoredCandidates[1]?.score ?? -Infinity) * 1.5) ) { if (!topCandidate) { throw new Error('Unexpected empty candidate list'); } const resolved = this.normalizeBuildType(topCandidate.buildType); this.cache.set(cacheKey, resolved); return resolved; } // Multiple close matches - try to resolve with additional context const topCandidates = scoredCandidates.slice(0, 3); // Top 3 candidates if (options.additionalContext) { const ctx = options.additionalContext.toLowerCase(); const contextFiltered = topCandidates.filter( (candidate) => (candidate.buildType.description ?? '').toLowerCase().includes(ctx) || (candidate.buildType.name ?? '').toLowerCase().includes(ctx) ); if (contextFiltered.length === 1) { const only = contextFiltered[0]; if (!only) { throw new Error('Unexpected empty filtered candidate'); } const resolved = this.normalizeBuildType(only.buildType); this.cache.set(cacheKey, resolved); return resolved; } } // Still ambiguous const normalizedCandidates = topCandidates.map((c) => this.normalizeBuildType(c.buildType)); throw new AmbiguousBuildConfigurationError( `Multiple build configurations match. Please be more specific.`, normalizedCandidates, normalizedCandidates.map((c) => c.id) ); } /** * Resolve from various context clues */ async resolveFromContext(context: ResolutionContext): Promise<ResolvedBuildConfiguration> { const allBuildTypes = await this.getAllBuildTypes(); let candidates = [...allBuildTypes]; // Filter by project hint if (context.projectHint) { const hintLower = context.projectHint.toLowerCase(); candidates = candidates.filter((bt) => { const nameLower = (bt.projectName ?? '').toLowerCase(); const idLower = (bt.projectId ?? '').toLowerCase(); return nameLower.includes(hintLower) ? true : idLower.includes(hintLower); }); } // Filter by pull request support if (context.pullRequestNumber) { const prCandidates = candidates.filter((bt) => { const params = bt.parameters?.property ?? []; return params.some((p) => Boolean(p.name?.toLowerCase().match(/pull_request|\bpr\b/))); }); if (prCandidates.length > 0) { candidates = prCandidates; } } // Filter by issue key prefix if (context.issueKey) { const prefix = context.issueKey.split('-')[0]?.toLowerCase() ?? ''; const issueCandidates = candidates.filter( (bt) => (bt.projectName ?? '').toLowerCase().includes(prefix) || (bt.projectId ?? '').toLowerCase().includes(prefix) || (bt.name ?? '').toLowerCase().includes(prefix) ); if (issueCandidates.length > 0) { candidates = issueCandidates; } } // Filter by branch name patterns if (context.branch) { const branchLower = context.branch.toLowerCase(); const branchCandidates = candidates.filter((bt) => { const nameLower = (bt.name ?? '').toLowerCase(); const projectLower = (bt.projectName ?? '').toLowerCase(); // Check for platform-specific builds (ios, android, etc.) if ( branchLower.includes('ios') && (nameLower.includes('ios') || projectLower.includes('ios')) ) { return true; } if ( branchLower.includes('android') && (nameLower.includes('android') || projectLower.includes('android')) ) { return true; } if ( branchLower.includes('web') && (nameLower.includes('web') || projectLower.includes('web')) ) { return true; } return false; }); if (branchCandidates.length > 0) { candidates = branchCandidates; } } if (candidates.length === 0) { throw new BuildConfigurationNotFoundError( 'No build configuration found matching the provided context', context ); } if (candidates.length === 1) { const first = candidates[0]; return this.normalizeBuildType(first as BuildType); } // If still multiple, prefer the "main" or "default" build const defaultCandidates = candidates.filter( (bt) => (bt.name ?? '').toLowerCase().includes('main') || (bt.name ?? '').toLowerCase().includes('default') || (bt.name ?? '').toLowerCase().includes('build') ); if (defaultCandidates.length === 1) { const firstDefault = defaultCandidates[0]; return this.normalizeBuildType(firstDefault as BuildType); } // Return the first one with a warning this.logger.warn(`Multiple build configurations match context, returning first match`, { context, candidates: candidates.map((c) => c.id), }); return this.normalizeBuildType(candidates[0] as BuildType); } /** * Find configurations using fuzzy string matching */ async findFuzzyMatches(query: string): Promise<BuildConfigurationMatch[]> { const allBuildTypes = await this.getAllBuildTypes(); const matches: BuildConfigurationMatch[] = []; for (const buildType of allBuildTypes) { const matchedOn: string[] = []; let totalScore = 0; let matchCount = 0; // Match against name const nameScore = this.fuzzyMatch(query, buildType.name ?? ''); if (nameScore > this.fuzzyMatchThreshold) { matchedOn.push('name'); totalScore += nameScore * 2; // Weight name matches higher matchCount++; } // Match against project name const projectScore = this.fuzzyMatch(query, buildType.projectName ?? ''); if (projectScore > this.fuzzyMatchThreshold) { matchedOn.push('projectName'); totalScore += projectScore; matchCount++; } // Match against description const descScore = this.fuzzyMatch(query, buildType.description ?? ''); if (descScore > this.fuzzyMatchThreshold) { matchedOn.push('description'); totalScore += descScore * 0.5; // Weight description lower matchCount++; } if (matchCount > 0) { matches.push({ configuration: this.normalizeBuildType(buildType), score: totalScore / (matchCount + 1), // Average score matchedOn, }); } } // Sort by score descending matches.sort((a, b) => b.score - a.score); return matches; } /** * Resolve multiple configurations in batch */ async resolveBatch( requests: BatchResolutionRequest[], options?: { allowPartialFailure?: boolean } ): Promise<ResolvedBuildConfiguration[]> { const promises = requests.map(async (request) => { try { let result: ResolvedBuildConfiguration; switch (request.type) { case 'id': result = await this.resolveByConfigurationId(request.value as string); break; case 'name': result = await this.resolveByName(request.value as NameResolutionOptions); break; case 'context': result = await this.resolveFromContext(request.value as ResolutionContext); break; default: throw new Error(`Unknown resolution type: ${request.type}`); } return result; } catch (error) { if (options?.allowPartialFailure) { this.logger.warn(`Failed to resolve build configuration`, { request, error }); return null; } throw error; } }); const resolvedResults = await Promise.all(promises); return resolvedResults.filter((r): r is ResolvedBuildConfiguration => r !== null); } /** * Main resolve method that intelligently determines the resolution strategy */ async resolve(input: string): Promise<ResolvedBuildConfiguration> { // Try as exact ID first if (input.match(/^[A-Za-z0-9_]+$/)) { try { return await this.resolveByConfigurationId(input); } catch (error) { // Not an ID, try other strategies } } // Try as a context (commit SHA, PR#, issue key) const isSha = /^[a-f0-9]{7,40}$/.test(input); const isPR = /^PR#\d+$/.test(input); const isIssue = /^[A-Z]+-\d+$/.test(input); if (isSha || isPR || isIssue) { try { return await this.resolveFromContext({ commitHash: input.match(/^[a-f0-9]{7,40}$/) ? input : undefined, pullRequestNumber: input.match(/^PR#(\d+)$/) ? input.substring(3) : undefined, issueKey: input.match(/^[A-Z]+-\d+$/) ? input : undefined, }); } catch (error) { // Not a context, try name } } // Try as a name (with optional project prefix) const parts = input.split('::'); if (parts.length === 2) { const projectName = parts[0] ?? ''; const buildTypeName = parts[1] ?? ''; return await this.resolveByName({ projectName, buildTypeName, }); } else { // Use the input as build type name without project return await this.resolveByName({ projectName: '', buildTypeName: input, }); } } /** * Get all build types with caching */ private async getAllBuildTypes(): Promise<BuildType[]> { const cacheKey = 'all:buildTypes'; const cached = this.cache.get(cacheKey); if (cached) { // This should not happen - getAllBuildTypes results are not cached this way // in the current implementation. Skip cache for now. } const response = await this.client.modules.buildTypes.getAllBuildTypes( undefined, 'buildType(id,name,projectId,projectName,webUrl,description,paused,templateFlag,settings,parameters,vcs-root-entries)' ); const data = response.data; return data.buildType ?? []; } /** * Normalize build type data to resolved configuration */ private normalizeBuildType(buildType: Partial<BuildType>): ResolvedBuildConfiguration { if (!buildType.id) { throw new Error('Invalid build configuration data: missing ID'); } const settings = buildType.settings?.property ?? []; const allowPersonalBuilds = settings.some( (s) => s.name === 'allowPersonalBuildTriggering' && s.value === 'true' ); const parameters: Record<string, string> = {}; if (buildType.parameters?.property) { for (const param of buildType.parameters.property) { if (param.name && param.value) { parameters[param.name] = param.value; } } } const vcsRootIds: string[] = []; if (buildType['vcs-root-entries']?.['vcs-root-entry']) { for (const entry of buildType['vcs-root-entries']['vcs-root-entry']) { if (entry['vcs-root']?.id) { vcsRootIds.push(entry['vcs-root'].id); } } } return { id: buildType.id, name: buildType.name ?? 'Unknown', projectId: buildType.projectId ?? '', projectName: buildType.projectName ?? '', webUrl: buildType.webUrl, description: buildType.description, paused: buildType.paused ?? false, templateFlag: buildType.templateFlag ?? false, allowPersonalBuilds, vcsRootIds: vcsRootIds.length > 0 ? vcsRootIds : undefined, parameters: Object.keys(parameters).length > 0 ? parameters : undefined, }; } /** * Fuzzy string matching */ private fuzzyMatch(query: string, target: string): number { const queryLower = query.toLowerCase().trim(); const targetLower = target.toLowerCase().trim(); // Exact match if (queryLower === targetLower) { return 1.0; } // Contains match { const contains = targetLower.includes(queryLower) ? true : queryLower.includes(targetLower); if (contains) { const longer = Math.max(queryLower.length, targetLower.length); const shorter = Math.min(queryLower.length, targetLower.length); return (shorter / longer) * 0.9; } } // Token-based matching const queryTokens = queryLower.split(/[\s_\-.]+/); const targetTokens = targetLower.split(/[\s_\-.]+/); let matches = 0; for (const qToken of queryTokens) { for (const tToken of targetTokens) { if (qToken === tToken || tToken.includes(qToken) || qToken.includes(tToken)) { matches++; break; } } } if (matches > 0) { return (matches / Math.max(queryTokens.length, targetTokens.length)) * 0.8; } // Levenshtein distance for close matches const distance = this.levenshteinDistance(queryLower, targetLower); const maxLength = Math.max(queryLower.length, targetLower.length); const similarity = 1 - distance / maxLength; return similarity * 0.7; } /** * Calculate Levenshtein distance between two strings */ private levenshteinDistance(str1: string, str2: string): number { const matrix: number[][] = []; for (let i = 0; i <= str2.length; i++) { matrix[i] = [i]; } for (let j = 0; j <= str1.length; j++) { (matrix[0] as number[])[j] = j; } for (let i = 1; i <= str2.length; i++) { for (let j = 1; j <= str1.length; j++) { if (str2.charAt(i - 1) === str1.charAt(j - 1)) { (matrix[i] as number[])[j] = (matrix[i - 1] as number[])[j - 1] as number; } else { (matrix[i] as number[])[j] = Math.min( ((matrix[i - 1] as number[])[j - 1] as number) + 1, // substitution ((matrix[i] as number[])[j - 1] as number) + 1, // insertion ((matrix[i - 1] as number[])[j] as number) + 1 // deletion ); } } } return (matrix[str2.length] as number[])[str1.length] as number; } }

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