Skip to main content
Glama
branch-discovery-manager.ts10 kB
/** * BranchDiscoveryManager - Discovers branches from TeamCity build history */ import type { VcsRoot } from '@/teamcity-client/models'; import { debug, error as logError } from '@/utils/logger'; import type { TeamCityUnifiedClient } from './types/client'; export interface BranchInfo { name: string; displayName: string; isDefault: boolean; isActive: boolean; buildCount: number; lastBuild?: { id: string; number: string; status: string; date: string; webUrl?: string; }; firstSeenDate?: string; lastActivityDate?: string; vcsRoot?: { id: string; name: string; url: string; }; } export interface DiscoveryOptions { limit?: number; fromDate?: Date; toDate?: Date; includeVcsInfo?: boolean; } export class BranchDiscoveryManager { constructor(private readonly client: TeamCityUnifiedClient) {} /** * Discover branches from build history for a specific build configuration */ async discoverBranchesFromHistory( buildTypeId: string, options: DiscoveryOptions = {} ): Promise<BranchInfo[]> { try { const { limit = 1000, fromDate, toDate, includeVcsInfo = false } = options; // Build the locator string for querying builds const locatorParts = [ `buildType:(id:${buildTypeId})`, 'branch:(policy:ALL_BRANCHES)', `count:${limit}`, ]; if (fromDate ?? toDate) { const dateConditions = []; if (fromDate) { dateConditions.push(`date:[${fromDate.toISOString()}]`); } if (toDate) { dateConditions.push(`date:[,${toDate.toISOString()}]`); } if (fromDate && toDate) { locatorParts.push(`startDate:(date:[${fromDate.toISOString()},${toDate.toISOString()}])`); } else if (fromDate) { locatorParts.push(`startDate:(date:[${fromDate.toISOString()},])`); } else if (toDate) { locatorParts.push(`startDate:(date:[,${toDate.toISOString()}])`); } } const locator = locatorParts.join(','); const fields = 'build(id,buildTypeId,branchName,number,status,startDate,finishDate,webUrl,revisions(revision(vcsRootInstance(id,name,vcsRootId))))'; debug(`Discovering branches for buildType: ${buildTypeId} with locator: ${locator}`); // Query builds from TeamCity const response = await this.client.modules.builds.getMultipleBuilds(locator, fields); const buildsResponse = response.data; const builds = buildsResponse.build ?? []; // Process builds to extract branch information const branchMap = new Map<string, BranchInfo>(); for (const build of builds) { if (build.branchName == null) { continue; } const branchName = build.branchName; const existingBranch = branchMap.get(branchName); if (!existingBranch) { // First time seeing this branch const branchInfo: BranchInfo = { name: branchName, displayName: this.parseBranchDisplayName(branchName), isDefault: branchName === '<default>' || branchName === 'master' || branchName === 'main', isActive: false, // Will be determined later buildCount: 1, lastBuild: { id: build.id?.toString() ?? '', number: build.number ?? '', status: build.status ?? 'UNKNOWN', date: build.startDate ?? '', webUrl: build.webUrl, }, firstSeenDate: build.startDate, lastActivityDate: build.startDate, }; // Add VCS root information if requested and available // Note: Simplified for now - VCS root details would need additional API calls if ( includeVcsInfo === true && build.revisions?.revision != null && build.revisions.revision.length > 0 ) { const revision = build.revisions.revision[0]; if (revision?.['vcs-root-instance'] != null) { const vcsRootInstance = revision['vcs-root-instance']; branchInfo.vcsRoot = { id: vcsRootInstance['vcs-root-id'] ?? '', name: vcsRootInstance.name ?? '', url: '', // Would need additional API call to get URL }; } } branchMap.set(branchName, branchInfo); } else { // Update existing branch info existingBranch.buildCount++; // Update last build if this one is more recent if (build.startDate != null) { if (existingBranch.lastActivityDate == null) { existingBranch.lastBuild = { id: build.id?.toString() ?? '', number: build.number ?? '', status: build.status ?? 'UNKNOWN', date: build.startDate ?? '', webUrl: build.webUrl, }; existingBranch.lastActivityDate = build.startDate; } else { const buildDate = this.parseDate(build.startDate); const lastActivityDate = this.parseDate(existingBranch.lastActivityDate); if (buildDate > lastActivityDate) { existingBranch.lastBuild = { id: build.id?.toString() ?? '', number: build.number ?? '', status: build.status ?? 'UNKNOWN', date: build.startDate ?? '', webUrl: build.webUrl, }; existingBranch.lastActivityDate = build.startDate; } } } // Update first seen date if this one is older if (build.startDate != null) { if (!existingBranch.firstSeenDate) { existingBranch.firstSeenDate = build.startDate; } else { const buildDate = this.parseDate(build.startDate); const firstSeenDate = this.parseDate(existingBranch.firstSeenDate); if (buildDate < firstSeenDate) { existingBranch.firstSeenDate = build.startDate; } } } } } // Convert map to array and detect activity const branches = Array.from(branchMap.values()); // Detect branch activity return branches.map((branch) => this.detectBranchActivity(branch)); } catch (err) { const error = err as Error; logError('Failed to discover branches from history', error); throw new Error(`Failed to discover branches from history: ${error.message}`); } } /** * Enrich branch information with latest build data */ async enrichBranchWithBuildInfo(branch: BranchInfo, buildTypeId: string): Promise<BranchInfo> { try { const locator = `buildType:(id:${buildTypeId}),branch:(name:${branch.name}),count:1`; const fields = 'build(id,number,status,startDate,finishDate,webUrl)'; const response = await this.client.modules.builds.getMultipleBuilds(locator, fields); const buildsResponse = response.data; const builds = buildsResponse.build ?? []; if (builds.length > 0) { const latestBuild = builds[0]; if (latestBuild != null) { // Return a new object instead of mutating the parameter return { ...branch, lastBuild: { id: latestBuild.id?.toString() ?? '', number: latestBuild.number ?? '', status: latestBuild.status ?? 'UNKNOWN', date: latestBuild.startDate ?? '', webUrl: latestBuild.webUrl, }, buildCount: buildsResponse.count ?? 1, lastActivityDate: latestBuild.startDate, }; } } return branch; } catch (err) { debug(`Failed to enrich branch ${branch.name}: ${err}`); return branch; } } /** * Detect if a branch is active based on recent activity */ detectBranchActivity(branch: BranchInfo, thresholdDays: number = 30): BranchInfo { if (!branch.lastActivityDate) { branch.isActive = false; return branch; } const now = new Date(); const lastActivity = this.parseDate(branch.lastActivityDate); const daysSinceActivity = (now.getTime() - lastActivity.getTime()) / (1000 * 60 * 60 * 24); branch.isActive = daysSinceActivity <= thresholdDays; return branch; } /** * Parse branch name to create a display-friendly version */ parseBranchDisplayName(branchName: string): string { // Handle Git refs if (branchName.startsWith('refs/heads/')) { return branchName.replace('refs/heads/', ''); } if (branchName.startsWith('refs/tags/')) { return branchName.replace('refs/tags/', ''); } // Handle pull requests const prMatch = branchName.match(/^pull\/(\d+)\/(head|merge)$/); if (prMatch) { return `PR #${prMatch[1]}`; } // Handle merge requests (GitLab style) const mrMatch = branchName.match(/^merge-requests\/(\d+)\/(head|merge)$/); if (mrMatch) { return `MR #${mrMatch[1]}`; } // Handle special branches if (branchName === '<default>') { return 'default'; } // Return as-is for other patterns return branchName; } /** * Parse TeamCity date format to JavaScript Date */ 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') ); } /** * Extract VCS URL from VcsRoot object */ private extractVcsUrl(vcsRoot: VcsRoot): string { if (!vcsRoot.properties?.property) { return ''; } const urlProperty = vcsRoot.properties.property.find( (p) => p.name === 'url' || p.name === 'repositoryURL' ); return urlProperty?.value ?? ''; } }

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