Skip to main content
Glama
project-navigator.ts19.8 kB
/** * ProjectNavigator - Advanced project discovery and navigation for TeamCity */ import type { Project } from '@/teamcity-client/models/project'; import type { TeamCityClientAdapter } from '@/teamcity/client-adapter'; import { warn } from '@/utils/logger'; export interface ProjectNavigatorParams { mode?: 'list' | 'hierarchy' | 'ancestors' | 'descendants'; projectId?: string; rootProjectId?: string; filters?: { namePattern?: string; archived?: boolean; hasBuilds?: boolean; parentProjectId?: string; maxDepth?: number; }; pagination?: { page?: number; pageSize?: number; }; sort?: { by?: 'name' | 'id' | 'level'; order?: 'asc' | 'desc'; }; includeStatistics?: boolean; maxDepth?: number; } export interface ProjectNavigatorResult { success: boolean; data?: { mode: string; projects?: Array<{ id: string; name: string; description?: string; parentProjectId?: string; parentProjectName?: string; archived: boolean; level: number; href?: string; webUrl?: string; statistics?: { buildConfigurationCount?: number; vcsRootCount?: number; subprojectCount?: number; lastActivity?: string; }; }>; hierarchy?: HierarchyNode; ancestors?: Array<ProjectInfo>; descendants?: Array<ProjectInfo>; totalCount?: number; page?: number; pageSize?: number; hasMore?: boolean; maxDepthReached?: boolean; executionTime?: number; cached?: boolean; }; error?: string; } interface HierarchyNode { id: string; name: string; description?: string; archived: boolean; children?: HierarchyNode[]; statistics?: ProjectStatistics; } interface ProjectInfo { id: string; name: string; description?: string; archived?: boolean; level?: number; parentProjectId?: string; } interface ProjectStatistics { buildConfigurationCount?: number; vcsRootCount?: number; subprojectCount?: number; lastActivity?: string; } interface CacheEntry { data: ProjectNavigatorResult['data']; timestamp: number; } type NavigatorResult = Partial<ProjectNavigatorResult['data']>; interface HierarchyNodeInternal { project: { id: string; name: string; description?: string; archived: boolean; buildTypesCount?: number; subprojectsCount?: number; }; children: HierarchyNodeInternal[]; } export class ProjectNavigator { private readonly client: TeamCityClientAdapter; private cache: Map<string, CacheEntry>; private readonly cacheTtl = 120000; // 120 seconds constructor(client: TeamCityClientAdapter) { this.client = client; this.cache = new Map(); } async listProjects(params: ProjectNavigatorParams = {}): Promise<ProjectNavigatorResult> { const startTime = Date.now(); const mode = params.mode ?? 'list'; try { // Validate parameters const validationError = this.validateParams(params); if (validationError) { return { success: false, error: validationError }; } // Check cache const cacheKey = this.getCacheKey(params); const cachedResult = this.getCachedResult(cacheKey); if (cachedResult != null) { return { success: true, data: { mode: cachedResult.mode ?? 'list', ...cachedResult, cached: true, executionTime: Date.now() - startTime, } as ProjectNavigatorResult['data'], }; } let result: NavigatorResult; switch (mode) { case 'hierarchy': result = await this.getHierarchy(params); break; case 'ancestors': result = await this.getAncestors(params); break; case 'descendants': result = await this.getDescendants(params); break; case 'list': default: result = await this.getList(params); break; } if (!result) { throw new Error('Failed to compute project navigation result'); } result.mode = mode; result.cached = false; result.executionTime = Date.now() - startTime; // Cache the result this.setCachedResult(cacheKey, result); return { success: true, data: { mode: mode || 'list', ...result } as ProjectNavigatorResult['data'], }; } catch (error) { return { success: false, error: this.formatError(error), }; } } private async getList(params: ProjectNavigatorParams): Promise<NavigatorResult> { // Build locator string for API filtering const locatorParts: string[] = []; if (params.filters?.namePattern) { locatorParts.push(`name:${params.filters.namePattern}`); } if (params.filters?.archived !== undefined) { locatorParts.push(`archived:${params.filters.archived}`); } if (params.filters?.parentProjectId) { locatorParts.push(`parentProject:(id:${params.filters.parentProjectId})`); } // Add pagination to locator if (params.pagination) { const page = params.pagination.page ?? 1; const pageSize = params.pagination.pageSize ?? 100; const start = (page - 1) * pageSize; locatorParts.push(`count:${pageSize}`); if (start > 0) { locatorParts.push(`start:${start}`); } } const locator = locatorParts.length > 0 ? locatorParts.join(',') : undefined; const fields = params.includeStatistics ? '$long,buildTypes(count),subprojects(count)' : '$long'; // Use the projects API to list all projects const response = await this.client.modules.projects.getAllProjects(locator, fields); // Get projects from response let projects = response.data.project ?? []; // Apply sorting if requested if (params.sort?.by) { projects = this.sortProjects(projects, params.sort.by, params.sort.order ?? 'asc'); } // Don't apply client-side pagination if we already did server-side const page = params.pagination?.page ?? 1; const pageSize = params.pagination?.pageSize ?? 100; const paginatedProjects = projects; // Already paginated by API return { projects: paginatedProjects.map((p: Project) => ({ id: p.id ?? '', name: p.name ?? '', description: p.description, parentProjectId: p.parentProjectId, archived: p.archived ?? false, level: 0, // Would need to calculate based on hierarchy href: p.href, webUrl: p.webUrl, statistics: params.includeStatistics ? { buildConfigurationCount: p.buildTypes?.count, subprojectCount: p.projects?.count, vcsRootCount: p.vcsRoots?.count, } : undefined, })), totalCount: response.data.count ?? projects.length, page, pageSize, hasMore: paginatedProjects.length === pageSize, }; } private async getHierarchy(params: ProjectNavigatorParams): Promise<NavigatorResult> { const rootId = params.rootProjectId ?? '_Root'; const maxDepth = params.maxDepth ?? 5; // Recursive function to build hierarchy const buildHierarchy = async ( projectId: string, currentDepth = 0, visited = new Set<string>() ): Promise<HierarchyNodeInternal> => { // Prevent circular references if (visited.has(projectId)) { return { project: { id: projectId, name: `[Circular Reference: ${projectId}]`, description: 'Circular reference detected', archived: false, buildTypesCount: 0, subprojectsCount: 0, }, children: [], }; } visited.add(projectId); try { const response = await this.client.modules.projects.getProject(projectId); const project = response.data; const node = { project: { id: project.id ?? projectId, name: project.name ?? `Unknown Project ${projectId}`, description: project.description, archived: project.archived ?? false, buildTypesCount: project.buildTypes?.count ?? 0, subprojectsCount: project.projects?.count ?? 0, }, children: [] as HierarchyNodeInternal[], }; // Load children if not at max depth and has children if (currentDepth < maxDepth && project.projects?.project?.length) { const childVisited = new Set(visited); for (const child of project.projects.project) { if (child.id && !visited.has(child.id)) { // Sequential recursion to avoid uncontrolled concurrency and maintain order // eslint-disable-next-line no-await-in-loop const childNode = await buildHierarchy(child.id, currentDepth + 1, childVisited); node.children.push(childNode); } } } return node; } catch (error) { // Handle non-existent or inaccessible projects return { project: { id: projectId, name: `[Error: ${projectId}]`, description: 'Project not accessible or does not exist', archived: false, buildTypesCount: 0, subprojectsCount: 0, }, children: [], }; } }; const hierarchyNode = await buildHierarchy(rootId, 0); const hierarchy = this.transformHierarchyNode(hierarchyNode); return { hierarchy, maxDepthReached: params.maxDepth ? this.checkMaxDepthReached(hierarchyNode, params.maxDepth) : false, }; } private async getAncestors(params: ProjectNavigatorParams): Promise<NavigatorResult> { if (!params.projectId) { throw new Error('projectId is required for ancestors mode'); } const ancestors: ProjectInfo[] = []; let currentProjectId = params.projectId; const visited = new Set<string>(); // First verify the target project exists and save it let targetProject: Project; try { const targetResponse = await this.client.modules.projects.getProject(currentProjectId); if (targetResponse.data == null) { throw new Error(`Project not found: ${params.projectId}`); } targetProject = targetResponse.data; currentProjectId = targetResponse.data.parentProjectId ?? '_Root'; } catch (error) { throw new Error(`Project not found: ${params.projectId}`); } // Always add root project first ancestors.push({ id: '_Root', name: '<Root project>', description: 'Root project', archived: false, level: 0, parentProjectId: undefined, }); // Walk up the parent chain (excluding root since we already added it) while (currentProjectId && currentProjectId !== '_Root' && !visited.has(currentProjectId)) { visited.add(currentProjectId); try { // Sequential parent traversal up the chain // eslint-disable-next-line no-await-in-loop const response = await this.client.modules.projects.getProject(currentProjectId); const project = response.data; if (project != null) { ancestors.push({ id: project.id ?? currentProjectId, name: project.name ?? `Unknown Project ${currentProjectId}`, description: project.description, archived: project.archived ?? false, level: ancestors.length, parentProjectId: project.parentProjectId, }); currentProjectId = project.parentProjectId ?? '_Root'; } else { break; } } catch (error) { // Stop if we can't find a parent break; } } // Add the target project itself at the end ancestors.push({ id: targetProject.id ?? params.projectId, name: targetProject.name ?? `Unknown Project ${params.projectId}`, description: targetProject.description, archived: targetProject.archived ?? false, level: ancestors.length, parentProjectId: targetProject.parentProjectId, }); return { ancestors }; } private async getDescendants(params: ProjectNavigatorParams): Promise<NavigatorResult> { if (!params.projectId) { throw new Error('projectId is required for descendants mode'); } const maxDepth = params.maxDepth ?? 5; const descendants: ProjectInfo[] = []; const visited = new Set<string>(); // First verify the target project exists try { const targetResponse = await this.client.modules.projects.getProject(params.projectId); if (targetResponse.data == null) { throw new Error(`Project not found: ${params.projectId}`); } } catch (error) { throw new Error(`Project not found: ${params.projectId}`); } // Recursive function to collect all descendants const collectDescendants = async ( projectId: string, currentDepth = 0, parentLevel = 0 ): Promise<void> => { if (currentDepth >= maxDepth || visited.has(projectId)) { return; } visited.add(projectId); try { const response = await this.client.modules.projects.getProject(projectId); const project = response.data; if (project?.projects?.project?.length) { for (const child of project.projects.project) { if (child.id && !visited.has(child.id)) { try { // Get full child project details // eslint-disable-next-line no-await-in-loop const childResponse = await this.client.modules.projects.getProject(child.id); const childProject = childResponse.data; if (childProject != null) { descendants.push({ id: childProject.id ?? child.id, name: childProject.name ?? child.name ?? `Unknown Project ${child.id}`, description: childProject.description, archived: childProject.archived ?? false, level: parentLevel + 1, parentProjectId: projectId, }); // Recursively collect children of this child // eslint-disable-next-line no-await-in-loop await collectDescendants(child.id, currentDepth + 1, parentLevel + 1); } } catch (childError) { // Skip children we can't access, but don't stop the whole operation warn(`Could not access child project ${child.id}:`, { error: childError }); } } } } } catch (error) { // Skip projects we can't access warn(`Could not access project ${projectId}:`, { error }); } }; await collectDescendants(params.projectId, 0, 0); return { descendants, maxDepthReached: maxDepth > 0 && descendants.some((p) => p.level === maxDepth), }; } /** * Transform domain hierarchy node to navigator format */ private transformHierarchyNode(node: HierarchyNodeInternal): HierarchyNode { const transformed: HierarchyNode = { id: node.project.id, name: node.project.name, description: node.project.description, archived: node.project.archived, children: [], // Always include children array }; if (node.project.buildTypesCount ?? node.project.subprojectsCount) { transformed.statistics = { buildConfigurationCount: node.project.buildTypesCount, subprojectCount: node.project.subprojectsCount, }; } // Add children if they exist if (node.children != null && node.children.length > 0) { transformed.children = node.children.map((child) => this.transformHierarchyNode(child)); } return transformed; } /** * Transform domain project to project info format */ private transformToProjectInfo(project: Project & { level?: number }): ProjectInfo { return { id: project.id ?? '', name: project.name ?? '', description: project.description, archived: project.archived, level: project.level, parentProjectId: project.parentProjectId, }; } /** * Check if max depth was reached in hierarchy */ private checkMaxDepthReached( node: HierarchyNodeInternal, maxDepth: number, currentDepth = 0 ): boolean { if (currentDepth >= maxDepth) { return true; } if (node.children != null && node.children.length > 0) { return node.children.some((child) => this.checkMaxDepthReached(child, maxDepth, currentDepth + 1) ); } return false; } private validateParams(params: ProjectNavigatorParams): string | null { if (params.pagination) { if (params.pagination.page && params.pagination.page < 1) { return 'Invalid pagination: page must be >= 1'; } if (params.pagination.pageSize) { if (params.pagination.pageSize < 1 || params.pagination.pageSize > 1000) { return 'Invalid pagination: pageSize must be between 1 and 1000'; } } } if (params.mode === 'ancestors' && !params.projectId) { return 'projectId is required for ancestors mode'; } if (params.mode === 'descendants' && !params.projectId) { return 'projectId is required for descendants mode'; } return null; } private getCacheKey(params: ProjectNavigatorParams): string { return JSON.stringify(params); } private getCachedResult(key: string): NavigatorResult | null { const entry = this.cache.get(key); if (!entry) { return null; } const age = Date.now() - entry.timestamp; if (age > this.cacheTtl) { this.cache.delete(key); return null; } return entry.data; } private setCachedResult(key: string, data: NavigatorResult): void { this.cache.set(key, { data: data as ProjectNavigatorResult['data'], timestamp: Date.now(), }); // Clean old entries if (this.cache.size > 100) { const entries = Array.from(this.cache.entries()); entries.sort((a, b) => a[1].timestamp - b[1].timestamp); for (let i = 0; i < 50 && i < entries.length; i++) { this.cache.delete(entries[i]?.[0] ?? ''); } } } private sortProjects<T extends { name?: string; id?: string; level?: number }>( projects: T[], sortBy: string, order: 'asc' | 'desc' ): T[] { const sorted = [...projects]; sorted.sort((a, b) => { let comparison = 0; switch (sortBy) { 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 === 'desc' ? -comparison : comparison; }); return sorted; } private formatError(error: unknown): string { if (typeof error === 'object' && error !== null) { const err = error as { response?: { status?: number }; message?: string }; if (err.response?.status === 401) { return 'Authentication failed: Please check your TeamCity token'; } if (err.response?.status === 403) { return "Permission denied: You don't have access to these projects"; } if (err.response?.status === 404) { return 'Not found: The requested project does not exist'; } if (err.message) { return err.message; } } return 'An unexpected error occurred'; } }

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