Skip to main content
Glama
artifacts.ts10.8 kB
import axios from 'axios'; import JSZip from 'jszip'; import { WebApi } from 'azure-devops-node-api'; import { BuildArtifact, ArtifactResource, } from 'azure-devops-node-api/interfaces/BuildInterfaces'; import { ContainerItemType, FileContainerItem, } from 'azure-devops-node-api/interfaces/FileContainerInterfaces'; import { GetArtifactExpandOptions } from 'azure-devops-node-api/interfaces/PipelinesInterfaces'; import { PipelineArtifactItem, PipelineRunArtifact } from './types'; interface ArtifactContainerInfo { containerId?: number; rootPath?: string; } const MAX_ITEMS_PER_ARTIFACT = 200; function extractContainerInfo( resource?: ArtifactResource, ): ArtifactContainerInfo { const data = resource?.data; if (typeof data !== 'string' || data.length === 0) { return {}; } const segments = data.split('/').filter((segment) => segment.length > 0); if (segments.length < 2) { return {}; } const containerId = Number.parseInt(segments[1] ?? '', 10); if (Number.isNaN(containerId)) { return {}; } const rootPath = segments.slice(2).join('/'); return { containerId, rootPath: rootPath.length > 0 ? rootPath : undefined, }; } function mapBuildArtifact(artifact: BuildArtifact): PipelineRunArtifact { const resource = artifact.resource; const { containerId, rootPath } = extractContainerInfo(resource); return { name: artifact.name ?? 'unknown', type: resource?.type, source: artifact.source, downloadUrl: resource?.downloadUrl, resourceUrl: resource?.url, containerId, rootPath, }; } function normalizePathSegment(segment: string): string { return segment.replace(/^[\\/]+|[\\/]+$/g, ''); } function normalizeFullPath(path: string): string { return path.replace(/\\+/g, '/').replace(/^\/+/, ''); } function makeRelativePath(path: string, prefixes: string[]): string { const normalized = normalizeFullPath(path); const filteredPrefixes = prefixes .map((prefix) => normalizePathSegment(prefix)) .filter((prefix) => prefix.length > 0) .sort((a, b) => b.length - a.length); for (const prefix of filteredPrefixes) { if (normalized === prefix) { return ''; } if (normalized.startsWith(`${prefix}/`)) { return normalized.slice(prefix.length + 1); } } return normalized; } function mapContainerItems( items: FileContainerItem[], artifact: PipelineRunArtifact, ): { items: PipelineArtifactItem[]; truncated: boolean } { const basePrefixes = [artifact.rootPath, artifact.name].filter( (value): value is string => typeof value === 'string' && value.length > 0, ); const uniquePaths = new Set<string>(); const mapped: PipelineArtifactItem[] = []; let truncated = false; for (const item of items) { const relative = makeRelativePath(item.path, basePrefixes); if (relative.length === 0) { continue; } if (uniquePaths.has(relative)) { continue; } uniquePaths.add(relative); mapped.push({ path: relative, itemType: item.itemType === ContainerItemType.Folder ? 'folder' : 'file', size: item.fileLength, }); if (mapped.length >= MAX_ITEMS_PER_ARTIFACT) { truncated = true; break; } } mapped.sort((a, b) => a.path.localeCompare(b.path)); return { items: mapped, truncated, }; } async function listContainerItems( connection: WebApi, projectId: string, artifact: PipelineRunArtifact, ): Promise<{ items?: PipelineArtifactItem[]; truncated?: boolean }> { if (typeof artifact.containerId !== 'number') { return {}; } const fileContainerApi = typeof connection.getFileContainerApi === 'function' ? await connection.getFileContainerApi() : null; if (!fileContainerApi || typeof fileContainerApi.getItems !== 'function') { return {}; } const scopeCandidates = [projectId, undefined].filter( (scope, index, array) => array.indexOf(scope) === index, ); const itemPathCandidates = [ artifact.rootPath, artifact.name, undefined, ].filter((value, index, arr) => arr.indexOf(value) === index); for (const scope of scopeCandidates) { for (const itemPath of itemPathCandidates) { try { const items = await fileContainerApi.getItems( artifact.containerId, scope, typeof itemPath === 'string' && itemPath.length > 0 ? itemPath : undefined, ); if (!Array.isArray(items) || items.length === 0) { continue; } const { items: mapped, truncated } = mapContainerItems(items, artifact); if (mapped.length === 0) { continue; } return { items: mapped, truncated, }; } catch { // Swallow and try next combination. } } } return {}; } async function listPipelineArtifactItems( artifact: PipelineRunArtifact, ): Promise<{ items?: PipelineArtifactItem[]; truncated?: boolean }> { const downloadUrl = artifact.signedContentUrl || artifact.downloadUrl || artifact.resourceUrl; if (!downloadUrl) { return {}; } try { const response = await axios.get<ArrayBuffer>(downloadUrl, { responseType: 'arraybuffer', }); const zip = await JSZip.loadAsync(response.data); const basePrefixes = [artifact.name, artifact.rootPath].filter( (value): value is string => typeof value === 'string' && value.length > 0, ); const items: PipelineArtifactItem[] = []; const directories = new Set<string>(); let hitLimit = false; zip.forEach((entryPath, entry) => { if (hitLimit) { return; } const relative = makeRelativePath(entryPath, basePrefixes); if (relative.length === 0) { return; } if (entry.dir) { const folderPath = relative.replace(/\/+$/, ''); if (folderPath.length > 0) { directories.add(folderPath); } return; } // Ensure parent folders are recorded even when the archive omits explicit entries const segments = relative.split('/'); if (segments.length > 1) { for (let i = 1; i < segments.length; i += 1) { const folder = segments.slice(0, i).join('/'); directories.add(folder); } } items.push({ path: relative, itemType: 'file', }); if (items.length >= MAX_ITEMS_PER_ARTIFACT) { hitLimit = true; } }); const folderItems: PipelineArtifactItem[] = Array.from(directories) .filter((folder) => folder.length > 0) .map((folder) => ({ path: folder, itemType: 'folder' })); const combined = [...folderItems, ...items] .filter((entry, index, array) => { const duplicateIndex = array.findIndex( (candidate) => candidate.path === entry.path, ); return duplicateIndex === index; }) .sort((a, b) => a.path.localeCompare(b.path)); const truncated = hitLimit || combined.length > MAX_ITEMS_PER_ARTIFACT; return { items: truncated ? combined.slice(0, MAX_ITEMS_PER_ARTIFACT) : combined, truncated, }; } catch { return {}; } } export async function fetchRunArtifacts( connection: WebApi, projectId: string, runId: number, pipelineId?: number, ): Promise<PipelineRunArtifact[]> { try { const buildApi = await connection.getBuildApi(); if (!buildApi || typeof buildApi.getArtifacts !== 'function') { return []; } const artifacts = await buildApi.getArtifacts(projectId, runId); if (!artifacts || artifacts.length === 0) { return []; } const summaries = artifacts.map(mapBuildArtifact); if (typeof pipelineId === 'number') { const pipelinesApi = await connection.getPipelinesApi(); await Promise.all( summaries.map(async (summary) => { try { const artifactDetails = await pipelinesApi.getArtifact( projectId, pipelineId, runId, summary.name, GetArtifactExpandOptions.SignedContent, ); const signedContentUrl = artifactDetails?.signedContent?.url; if (signedContentUrl) { summary.signedContentUrl = signedContentUrl; } } catch { // Ignore failures fetching signed content; best-effort enrichment. } }), ); } const enriched = await Promise.all( summaries.map(async (artifact) => { const collectors: Array< Promise<{ items?: PipelineArtifactItem[]; truncated?: boolean }> > = []; const artifactType = artifact.type?.toLowerCase(); if ( artifactType === 'container' || typeof artifact.containerId === 'number' ) { collectors.push(listContainerItems(connection, projectId, artifact)); } if (artifactType?.includes('pipelineartifact')) { collectors.push(listPipelineArtifactItems(artifact)); } if (collectors.length === 0) { return artifact; } let aggregatedItems: PipelineArtifactItem[] | undefined; let truncated = false; for (const collector of collectors) { try { const result = await collector; if (!result.items || result.items.length === 0) { continue; } aggregatedItems = aggregatedItems ? [...aggregatedItems, ...result.items] : result.items; truncated = truncated || Boolean(result.truncated); } catch { // Continue to next collector } } if (!aggregatedItems || aggregatedItems.length === 0) { return artifact; } const uniqueItems = Array.from( new Map(aggregatedItems.map((item) => [item.path, item])).values(), ).sort((a, b) => a.path.localeCompare(b.path)); return { ...artifact, items: uniqueItems.length > MAX_ITEMS_PER_ARTIFACT ? uniqueItems.slice(0, MAX_ITEMS_PER_ARTIFACT) : uniqueItems, itemsTruncated: truncated || uniqueItems.length > MAX_ITEMS_PER_ARTIFACT || undefined, }; }), ); return enriched; } catch { return []; } } export function getArtifactContainerInfo( artifact: PipelineRunArtifact, ): ArtifactContainerInfo { return { containerId: artifact.containerId, rootPath: artifact.rootPath, }; } export function parseArtifactContainer( resource?: ArtifactResource, ): ArtifactContainerInfo { return extractContainerInfo(resource); }

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/Tiberriver256/mcp-server-azure-devops'

If you have feedback or need assistance with the MCP directory API, please join our Discord server