Skip to main content
Glama
project-manager.ts12.7 kB
/** * Project Manager for TeamCity * * Manages listing, filtering, and navigating TeamCity projects * with support for hierarchy traversal. */ import type { Logger } from 'winston'; import type { Project, Projects } from '@/teamcity-client'; import type { TeamCityUnifiedClient } from './types/client'; /** * Managed project with normalized fields */ export interface ManagedProject { id: string; name: string; parentProjectId?: string; description?: string; webUrl?: string; archived: boolean; href?: string; buildTypesCount?: number; subprojectsCount?: number; parameters?: Record<string, string>; level?: number; // Hierarchy level (0 = root) path?: string[]; // Path from root to this project } /** * Project filters */ export interface ProjectFilters { namePattern?: string; archived?: boolean; parentProjectId?: string; hasBuilds?: boolean; maxDepth?: number; } /** * Sorting options */ export interface ProjectSort { by?: 'name' | 'id' | 'level'; order?: 'asc' | 'desc'; } /** * Pagination options */ export interface ProjectPagination { page?: number; pageSize?: number; } /** * Paginated projects response */ export interface PaginatedProjects { projects: ManagedProject[]; pagination: { page: number; pageSize: number; totalCount: number; totalPages: number; hasNext: boolean; hasPrevious: boolean; }; } /** * Project hierarchy node */ export interface ProjectHierarchyNode { project: ManagedProject; children: ProjectHierarchyNode[]; } /** * Manager for TeamCity projects */ export class ProjectManager { private projectCache: Map<string, ManagedProject> = new Map(); constructor( private readonly client: TeamCityUnifiedClient, private readonly logger: Logger ) {} /** * List projects with filtering and pagination */ async listProjects( options: { filters?: ProjectFilters; sort?: ProjectSort; pagination?: ProjectPagination; includeStatistics?: boolean; } = {} ): Promise<PaginatedProjects> { const { filters = {}, sort = { by: 'name', order: 'asc' }, pagination = { page: 1, pageSize: 50 }, includeStatistics = false, } = options; try { // Build locator for API const locator = this.buildLocator(filters); // Fetch projects const response = await this.client.modules.projects.getAllProjects( locator, this.buildFieldsSpec(includeStatistics) ); // Normalize projects let projects = await this.normalizeProjects(response.data); // Apply additional filters projects = this.applyFilters(projects, filters); // Sort projects projects = this.sortProjects(projects, sort); // Apply pagination const paginatedResult = this.paginate(projects, pagination); return paginatedResult; } catch (error) { this.logger.error('Failed to list projects', { error, options }); throw error; } } /** * Get project hierarchy starting from a root */ async getProjectHierarchy( rootProjectId: string = '_Root', maxDepth: number = 10 ): Promise<ProjectHierarchyNode> { try { const visited = new Set<string>(); return await this.buildHierarchyNode(rootProjectId, 0, maxDepth, visited, []); } catch (error) { this.logger.error('Failed to get project hierarchy', { error, rootProjectId }); throw error; } } /** * Get all ancestor projects (path from root) */ async getProjectAncestors(projectId: string): Promise<ManagedProject[]> { try { const ancestors: ManagedProject[] = []; let currentId: string | undefined = projectId; while (currentId != null && currentId !== '_Root') { // Sequential ancestor traversal to preserve order // eslint-disable-next-line no-await-in-loop const project = await this.getProject(currentId); if (!project) { break; } ancestors.unshift(project); // Add to beginning currentId = project.parentProjectId; } return ancestors; } catch (error) { this.logger.error('Failed to get project ancestors', { error, projectId }); throw error; } } /** * Get all descendant projects */ async getProjectDescendants(projectId: string, maxDepth: number = 10): Promise<ManagedProject[]> { try { const descendants: ManagedProject[] = []; const visited = new Set<string>(); await this.collectDescendants(projectId, descendants, 0, maxDepth, visited); return descendants; } catch (error) { this.logger.error('Failed to get project descendants', { error, projectId }); throw error; } } /** * Get single project by ID */ private async getProject(projectId: string): Promise<ManagedProject | null> { // Check cache first if (this.projectCache.has(projectId)) { const cached = this.projectCache.get(projectId) ?? null; if (cached) return cached; } try { const response = await this.client.modules.projects.getProject( projectId, this.buildFieldsSpec(true) ); const project = this.normalizeProject(response.data); this.projectCache.set(projectId, project); return project; } catch (error: unknown) { const axiosError = error as { response?: { status: number } }; if (axiosError.response?.status === 404) { return null; } throw error; } } /** * Build hierarchy node recursively */ private async buildHierarchyNode( projectId: string, level: number, maxDepth: number, visited: Set<string>, path: string[] ): Promise<ProjectHierarchyNode> { if (visited.has(projectId) || level > maxDepth) { throw new Error(`Circular reference or max depth exceeded for project ${projectId}`); } visited.add(projectId); const project = await this.getProject(projectId); if (!project) { throw new Error(`Project ${projectId} not found`); } project.level = level; project.path = [...path, projectId]; const children: ProjectHierarchyNode[] = []; if (level < maxDepth) { // Get subprojects const subprojectsResponse = await this.client.modules.projects.getAllSubprojectsOrdered( projectId, 'id,name' ); const subprojects = subprojectsResponse.data.project ?? []; for (const subproject of subprojects) { if (subproject.id != null) { // Sequential recursion to avoid wide concurrent calls // eslint-disable-next-line no-await-in-loop const childNode = await this.buildHierarchyNode( subproject.id, level + 1, maxDepth, visited, project.path ); children.push(childNode); } } } return { project, children }; } /** * Collect descendants recursively */ private async collectDescendants( projectId: string, descendants: ManagedProject[], level: number, maxDepth: number, visited: Set<string> ): Promise<void> { if (visited.has(projectId) || level > maxDepth) { return; } visited.add(projectId); const subprojectsResponse = await this.client.modules.projects.getAllSubprojectsOrdered( projectId, this.buildFieldsSpec(false) ); const subprojects = subprojectsResponse.data.project ?? []; for (const subproject of subprojects) { if (subproject.id) { const normalized = this.normalizeProject(subproject); normalized.level = level + 1; descendants.push(normalized); // Depth-first collection to maintain ordering; sequential recursion expected // eslint-disable-next-line no-await-in-loop await this.collectDescendants(subproject.id, descendants, level + 1, maxDepth, visited); } } } /** * Build locator string for API */ private buildLocator(filters: ProjectFilters): string | undefined { const parts: string[] = []; if (filters.archived !== undefined) { parts.push(`archived:${filters.archived}`); } if (filters.parentProjectId) { parts.push(`affectedProject:(id:${filters.parentProjectId})`); } return parts.length > 0 ? parts.join(',') : undefined; } /** * Build fields specification */ private buildFieldsSpec(includeStatistics: boolean): string { const baseFields = [ 'id', 'name', 'parentProjectId', 'description', 'webUrl', 'archived', 'href', ]; if (includeStatistics) { baseFields.push('buildTypes(count)', 'projects(count)', 'parameters(property(name,value))'); } return `project(${baseFields.join(',')})`; } /** * Normalize projects from API response */ private async normalizeProjects(response: Projects): Promise<ManagedProject[]> { const projects = response.project ?? []; return projects.map((p) => this.normalizeProject(p)); } /** * Normalize single project */ private normalizeProject(project: Partial<Project>): ManagedProject { const normalized: ManagedProject = { id: project.id ?? '', name: project.name ?? '', parentProjectId: project.parentProjectId, description: project.description, webUrl: project.webUrl, archived: project.archived ?? false, href: project.href, }; // Add statistics if present if (project.buildTypes?.count !== undefined) { normalized.buildTypesCount = project.buildTypes.count; } if (project.projects?.count !== undefined) { normalized.subprojectsCount = project.projects.count; } // Add parameters if present if (project.parameters?.property) { normalized.parameters = {}; for (const param of project.parameters.property) { if (param.name != null && param.value != null) { normalized.parameters[param.name] = param.value; } } } return normalized; } /** * Apply additional filters */ private applyFilters(projects: ManagedProject[], filters: ProjectFilters): ManagedProject[] { let filtered = [...projects]; // Filter by name pattern if (filters.namePattern) { const pattern = filters.namePattern.toLowerCase(); if (pattern.includes('*')) { const regex = new RegExp(`^${pattern.replace(/\*/g, '.*').replace(/\?/g, '.')}$`, 'i'); filtered = filtered.filter((p) => regex.test(p.name)); } else { filtered = filtered.filter((p) => p.name.toLowerCase().includes(pattern)); } } // Filter by build presence if (filters.hasBuilds !== undefined) { filtered = filtered.filter((p) => { const hasBuilds = p.buildTypesCount != null && p.buildTypesCount > 0; return filters.hasBuilds === true ? hasBuilds : !hasBuilds; }); } // Filter by depth level if (filters.maxDepth !== undefined && filtered.some((p) => p.level !== undefined)) { const maxDepth = filters.maxDepth as number; filtered = filtered.filter((p) => p.level === undefined || p.level <= maxDepth); } return filtered; } /** * Sort projects */ private sortProjects(projects: ManagedProject[], sort: ProjectSort): ManagedProject[] { const sorted = [...projects]; const { by = 'name', order = 'asc' } = sort; sorted.sort((a, b) => { let comparison = 0; switch (by) { case 'name': comparison = a.name.localeCompare(b.name); break; case 'id': comparison = a.id.localeCompare(b.id); break; case 'level': comparison = (a.level ?? 0) - (b.level ?? 0); break; default: comparison = 0; } return order === 'asc' ? comparison : -comparison; }); return sorted; } /** * Apply pagination */ private paginate(projects: ManagedProject[], pagination: ProjectPagination): PaginatedProjects { const { page = 1, pageSize = 50 } = pagination; const totalCount = projects.length; const totalPages = Math.ceil(totalCount / pageSize); const startIndex = (page - 1) * pageSize; const endIndex = startIndex + pageSize; const paginatedProjects = projects.slice(startIndex, endIndex); return { projects: paginatedProjects, pagination: { page, pageSize, totalCount, totalPages, hasNext: page < totalPages, hasPrevious: page > 1, }, }; } /** * Clear project cache */ clearCache(): void { this.projectCache.clear(); } }

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