Skip to main content
Glama
artifact-manager.ts18 kB
/** * ArtifactManager - Advanced artifact management for TeamCity builds */ import type { Readable } from 'node:stream'; import { type AxiosResponse, isAxiosError } from 'axios'; import { debug as logDebug } from '@/utils/logger'; import type { TeamCityClientAdapter } from './client-adapter'; import { TeamCityAPIError } from './errors'; import { toBuildLocator } from './utils/build-locator'; export interface ArtifactInfo { name: string; path: string; size: number; modificationTime?: string; downloadUrl: string; isDirectory?: boolean; } export interface ArtifactListOptions { nameFilter?: string; pathFilter?: string; extension?: string; minSize?: number; maxSize?: number; includeNested?: boolean; limit?: number; offset?: number; forceRefresh?: boolean; } export interface ArtifactDownloadOptions { encoding?: 'base64' | 'text' | 'buffer' | 'stream'; maxSize?: number; } export interface ArtifactContent { name: string; path: string; size: number; content?: string | Buffer | Readable; mimeType?: string; error?: string; } interface CacheEntry { artifacts: ArtifactInfo[]; timestamp: number; } interface ArtifactFile { name?: string; fullName?: string; size?: number; href?: string; modificationTime?: string; children?: ArtifactFileResponse; } interface ArtifactFileResponse { file?: ArtifactFile[]; } const isRecord = (value: unknown): value is Record<string, unknown> => { return typeof value === 'object' && value !== null; }; export class ArtifactManager { private readonly client: TeamCityClientAdapter; private cache: Map<string, CacheEntry> = new Map(); private static readonly cacheTtlMs = 60000; // 1 minute private static readonly defaultLimit = 100; private static readonly maxLimit = 1000; private static readonly artifactRetryAttempts = 10; private static readonly artifactRetryDelayMs = 1000; constructor(client: TeamCityClientAdapter) { this.client = client; } private getBaseUrl(): string { const baseUrl = this.client.getApiConfig().baseUrl; return baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl; } /** * List artifacts for a build */ async listArtifacts(buildId: string, options: ArtifactListOptions = {}): Promise<ArtifactInfo[]> { // Check cache unless force refresh const cacheKey = this.getCacheKey(buildId, options); if (!options.forceRefresh) { const cached = this.getFromCache(cacheKey); if (cached) { return cached; } } try { const buildLocator = toBuildLocator(buildId); // Fetch artifacts from API const response = await this.client.modules.builds.getFilesListOfBuild( buildLocator, undefined, undefined, 'file(name,fullName,size,modificationTime,href,children(file(name,fullName,size,modificationTime,href)))' ); const baseUrl = this.getBaseUrl(); const artifactPayload = this.ensureArtifactListingResponse(response.data, buildId); let artifacts = this.parseArtifacts(artifactPayload, buildId, options.includeNested, baseUrl); // Apply filters artifacts = this.applyFilters(artifacts, options); // Apply pagination if (options.limit ?? options.offset) { artifacts = this.paginate( artifacts, options.offset ?? 0, options.limit ?? ArtifactManager.defaultLimit ); } // Cache the result this.cacheResult(cacheKey, artifacts); return artifacts; } catch (error) { const err = error as { response?: { status?: number }; message?: string }; if (err.response?.status === 401) { throw new Error('Authentication failed: Invalid TeamCity token'); } if (err.response?.status === 404) { throw new Error(`Build not found: ${buildId}`); } const errMsg = err.message ?? String(error); throw new Error(`Failed to fetch artifacts: ${errMsg}`); } } /** * Download a specific artifact */ async downloadArtifact( buildId: string, artifactPath: string, options: ArtifactDownloadOptions = {} ): Promise<ArtifactContent> { let artifact: ArtifactInfo | undefined; for (let attempt = 1; attempt <= ArtifactManager.artifactRetryAttempts; attempt += 1) { // eslint-disable-next-line no-await-in-loop const artifacts = await this.listArtifacts(buildId, { forceRefresh: attempt > 1 }); const listSample = artifacts.slice(0, 5).map((entry) => entry.path); logDebug('artifact-manager.downloadArtifact.list', { buildId, requested: artifactPath, availableCount: artifacts.length, sample: listSample, includeNested: false, attempt, }); artifact = artifacts.find((a) => a.path === artifactPath || a.name === artifactPath); if (!artifact) { // eslint-disable-next-line no-await-in-loop const nestedArtifacts = await this.listArtifacts(buildId, { includeNested: true, forceRefresh: true, }); const nestedSample = nestedArtifacts.slice(0, 5).map((entry) => entry.path); logDebug('artifact-manager.downloadArtifact.listNested', { buildId, requested: artifactPath, availableCount: nestedArtifacts.length, sample: nestedSample, attempt, }); artifact = nestedArtifacts.find((a) => a.path === artifactPath || a.name === artifactPath); } if (artifact) { break; } if (attempt < ArtifactManager.artifactRetryAttempts) { // eslint-disable-next-line no-await-in-loop await this.delay(ArtifactManager.artifactRetryDelayMs); } } if (!artifact) { logDebug('artifact-manager.downloadArtifact.miss', { buildId, requested: artifactPath, attempts: ArtifactManager.artifactRetryAttempts, }); throw new Error(`Artifact not found: ${artifactPath}`); } // Check size limit if (options.maxSize && artifact.size > options.maxSize) { throw new Error( `Artifact size exceeds maximum allowed size: ${artifact.size} > ${options.maxSize}` ); } try { const encoding = options.encoding ?? 'buffer'; if (encoding === 'text') { const response = await this.client.downloadArtifactContent<string>(buildId, artifact.path, { responseType: 'text', }); const axiosResponse = response as AxiosResponse<unknown>; const { data, headers } = axiosResponse; if (typeof data !== 'string') { throw new Error('Artifact download returned a non-text payload when text was expected'); } const mimeType = typeof headers?.['content-type'] === 'string' ? headers['content-type'] : undefined; return { name: artifact.name, path: artifact.path, size: artifact.size, content: data, mimeType, }; } if (encoding === 'stream') { const response = await this.client.downloadArtifactContent<Readable>( buildId, artifact.path, { responseType: 'stream', } ); const axiosResponse = response as AxiosResponse<unknown>; const stream = axiosResponse.data; if (!this.isReadableStream(stream)) { throw new Error( 'Artifact download returned a non-stream payload when stream was requested' ); } const mimeType = typeof axiosResponse.headers?.['content-type'] === 'string' ? axiosResponse.headers['content-type'] : undefined; return { name: artifact.name, path: artifact.path, size: artifact.size, content: stream, mimeType, }; } const response = await this.client.downloadArtifactContent<ArrayBuffer>( buildId, artifact.path, { responseType: 'arraybuffer', } ); const axiosResponse = response as AxiosResponse<unknown>; const buffer = this.ensureBinaryBuffer(axiosResponse.data); let content: string | Buffer; if (encoding === 'base64') { content = buffer.toString('base64'); } else { content = buffer; } return { name: artifact.name, path: artifact.path, size: artifact.size, content, mimeType: typeof axiosResponse.headers?.['content-type'] === 'string' ? axiosResponse.headers['content-type'] : undefined, }; } catch (error) { let errMsg: string; if (isAxiosError(error)) { const status = error.response?.status; const data = error.response?.data; let detail: string | undefined; if (typeof data === 'string') { detail = data; } else if (data !== undefined && data !== null && typeof data === 'object') { try { detail = JSON.stringify(data); } catch { detail = '[unserializable response body]'; } } errMsg = `HTTP ${status ?? 'unknown'}${detail ? `: ${detail}` : ''}`; } else { errMsg = error instanceof Error ? error.message : 'Unknown error'; } throw new Error(`Failed to download artifact: ${errMsg}`); } } /** * Download multiple artifacts */ async downloadMultipleArtifacts( buildId: string, artifactPaths: string[], options: ArtifactDownloadOptions = {} ): Promise<ArtifactContent[]> { const downloadOptions = { encoding: (options.encoding ?? 'base64') as ArtifactDownloadOptions['encoding'], maxSize: options.maxSize, }; const results: ArtifactContent[] = []; for (const path of artifactPaths) { try { // eslint-disable-next-line no-await-in-loop const artifact = await this.downloadArtifact(buildId, path, downloadOptions); results.push(artifact); } catch (error) { const reason = error as { message?: string } | Error | string; const message = reason instanceof Error ? reason.message : typeof reason === 'object' && reason?.message ? String(reason.message) : String(reason ?? 'Unknown error'); const fallbackName = path ?? 'unknown'; logDebug('artifact-manager.downloadMultipleArtifacts.error', { buildId, requested: fallbackName, encoding: downloadOptions.encoding, error: message, }); results.push({ name: fallbackName, path: fallbackName, size: 0, error: message, }); } } return results; } /** * Parse artifacts from API response */ private ensureArtifactListingResponse(data: unknown, buildId: string): ArtifactFileResponse { if (!isRecord(data)) { throw new TeamCityAPIError( 'TeamCity returned a non-object artifact listing response', 'INVALID_RESPONSE', undefined, { buildId } ); } const payload = data as ArtifactFileResponse; const { file } = payload; if (file !== undefined && !Array.isArray(file)) { throw new TeamCityAPIError( 'TeamCity artifact listing response contains a non-array file field', 'INVALID_RESPONSE', undefined, { buildId } ); } if (Array.isArray(file)) { file.forEach((entry, index) => { if (!isRecord(entry)) { throw new TeamCityAPIError( 'TeamCity artifact listing response contains a non-object file entry', 'INVALID_RESPONSE', undefined, { buildId, index } ); } }); } return payload; } private parseArtifacts( data: ArtifactFileResponse, buildId: string, includeNested: boolean | undefined, baseUrl: string, parentSegments: string[] = [] ): ArtifactInfo[] { const artifacts: ArtifactInfo[] = []; const files = data.file ?? []; for (const file of files) { const pathSegments = this.buildArtifactSegments(file, parentSegments); const resolvedPath = pathSegments.join('/'); const isDirectory = Boolean(file.children); if (isDirectory) { if (includeNested && file.children) { const nested = this.parseArtifacts( file.children, buildId, includeNested, baseUrl, pathSegments ); artifacts.push(...nested); } continue; } if (!resolvedPath) { continue; } artifacts.push({ name: file.name ?? pathSegments[pathSegments.length - 1] ?? '', path: resolvedPath, size: file.size ?? 0, modificationTime: file.modificationTime ?? '', downloadUrl: `${baseUrl}/app/rest/builds/id:${buildId}/artifacts/content/${this.encodeArtifactPath(pathSegments)}`, isDirectory: false, }); } return artifacts; } private buildArtifactSegments(file: ArtifactFile, parentSegments: string[]): string[] { const fullName = typeof file.fullName === 'string' ? file.fullName : undefined; const name = typeof file.name === 'string' ? file.name : undefined; const segmentsFromFullName = fullName ? fullName.split('/').filter((segment) => segment.length > 0) : []; if (segmentsFromFullName.length === 0) { if (name && name.length > 0) { return [...parentSegments, name]; } return [...parentSegments]; } if (parentSegments.length === 0) { return segmentsFromFullName; } if (this.segmentsStartWithParent(segmentsFromFullName, parentSegments)) { return segmentsFromFullName; } return [...parentSegments, ...segmentsFromFullName]; } private segmentsStartWithParent(segments: string[], parent: string[]): boolean { if (parent.length === 0 || segments.length < parent.length) { return false; } for (let i = 0; i < parent.length; i += 1) { if (segments[i] !== parent[i]) { return false; } } return true; } private encodeArtifactPath(segments: string[]): string { return segments.map((segment) => encodeURIComponent(segment)).join('/'); } private ensureBinaryBuffer(payload: unknown): Buffer { if (Buffer.isBuffer(payload)) { return payload; } if (payload instanceof ArrayBuffer) { return Buffer.from(payload); } throw new Error('Artifact download returned unexpected binary payload type'); } private isReadableStream(value: unknown): value is Readable { if (value == null || typeof value !== 'object') { return false; } const candidate = value as Readable; return typeof candidate.pipe === 'function'; } /** * Apply filters to artifacts */ private applyFilters(artifacts: ArtifactInfo[], options: ArtifactListOptions): ArtifactInfo[] { let filtered = artifacts; // Filter by name pattern if (options.nameFilter) { const regex = this.globToRegex(options.nameFilter); filtered = filtered.filter((a) => regex.test(a.name)); } // Filter by path pattern if (options.pathFilter) { const regex = this.globToRegex(options.pathFilter); filtered = filtered.filter((a) => regex.test(a.path)); } // Filter by extension if (options.extension) { const ext = options.extension.startsWith('.') ? options.extension : `.${options.extension}`; filtered = filtered.filter((a) => a.name.endsWith(ext)); } // Filter by size range if (options.minSize !== undefined) { const minSize = options.minSize as number; filtered = filtered.filter((a) => a.size >= minSize); } if (options.maxSize !== undefined) { const maxSize = options.maxSize as number; filtered = filtered.filter((a) => a.size <= maxSize); } return filtered; } /** * Convert glob pattern to regex */ private globToRegex(pattern: string): RegExp { const escaped = pattern .replace(/[.+^${}()|[\]\\]/g, '\\$&') .replace(/\*/g, '.*') .replace(/\?/g, '.'); return new RegExp(`^${escaped}$`); } /** * Paginate results */ private paginate(artifacts: ArtifactInfo[], offset: number, limit: number): ArtifactInfo[] { const effectiveLimit = Math.min(limit, ArtifactManager.maxLimit); return artifacts.slice(offset, offset + effectiveLimit); } /** * Generate cache key */ private getCacheKey(buildId: string, options: ArtifactListOptions): string { const { forceRefresh: _forceRefresh, ...cacheOptions } = options; return `${buildId}:${JSON.stringify(cacheOptions)}`; } /** * Get from cache if valid */ private getFromCache(key: string): ArtifactInfo[] | null { const entry = this.cache.get(key); if (!entry) { return null; } const age = Date.now() - entry.timestamp; if (age > ArtifactManager.cacheTtlMs) { this.cache.delete(key); return null; } return entry.artifacts; } /** * Cache artifacts */ private cacheResult(key: string, artifacts: ArtifactInfo[]): void { this.cache.set(key, { artifacts, timestamp: Date.now(), }); // Clean old entries this.cleanCache(); } /** * Remove expired cache entries */ private cleanCache(): void { const now = Date.now(); const expired: string[] = []; for (const [key, entry] of this.cache.entries()) { if (now - entry.timestamp > ArtifactManager.cacheTtlMs) { expired.push(key); } } for (const key of expired) { this.cache.delete(key); } } private async delay(ms: number): Promise<void> { await new Promise((resolve) => setTimeout(resolve, ms)); } }

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