Skip to main content
Glama
api-client.ts20.2 kB
/** * Simple TeamCity API Client * Direct API wrapper without dependency injection or complex abstractions */ import axios, { type AxiosInstance, type AxiosResponse, type RawAxiosRequestConfig } from 'axios'; import axiosRetry from 'axios-retry'; import { getTeamCityToken, getTeamCityUrl } from '@/config'; import { addRequestId, logAndTransformError, logResponse, validateConfiguration, } from '@/teamcity/auth'; import { TeamCityAPIError, isRetryableError } from '@/teamcity/errors'; import type { TeamCityApiSurface } from '@/teamcity/types/client'; import { toBuildLocator } from '@/teamcity/utils/build-locator'; import { info } from '@/utils/logger'; import { AgentApi } from './teamcity-client/api/agent-api'; import { AgentPoolApi } from './teamcity-client/api/agent-pool-api'; import { AgentTypeApi } from './teamcity-client/api/agent-type-api'; import { AuditApi } from './teamcity-client/api/audit-api'; import { AvatarApi } from './teamcity-client/api/avatar-api'; import { BuildApi } from './teamcity-client/api/build-api'; import { BuildQueueApi } from './teamcity-client/api/build-queue-api'; import { BuildTypeApi } from './teamcity-client/api/build-type-api'; import { ChangeApi } from './teamcity-client/api/change-api'; import { CloudInstanceApi } from './teamcity-client/api/cloud-instance-api'; import { DeploymentDashboardApi } from './teamcity-client/api/deployment-dashboard-api'; import { GlobalServerSettingsApi } from './teamcity-client/api/global-server-settings-api'; import { GroupApi } from './teamcity-client/api/group-api'; import { HealthApi } from './teamcity-client/api/health-api'; import { InvestigationApi } from './teamcity-client/api/investigation-api'; import { MuteApi } from './teamcity-client/api/mute-api'; import { NodeApi } from './teamcity-client/api/node-api'; import { ProblemApi } from './teamcity-client/api/problem-api'; import { ProblemOccurrenceApi } from './teamcity-client/api/problem-occurrence-api'; import { ProjectApi } from './teamcity-client/api/project-api'; import { RoleApi } from './teamcity-client/api/role-api'; import { RootApi } from './teamcity-client/api/root-api'; import { ServerApi } from './teamcity-client/api/server-api'; import { ServerAuthenticationSettingsApi } from './teamcity-client/api/server-authentication-settings-api'; import { TestApi } from './teamcity-client/api/test-api'; import { TestOccurrenceApi } from './teamcity-client/api/test-occurrence-api'; import { UserApi } from './teamcity-client/api/user-api'; import { VcsRootApi } from './teamcity-client/api/vcs-root-api'; import { VcsRootInstanceApi } from './teamcity-client/api/vcs-root-instance-api'; import { VersionedSettingsApi } from './teamcity-client/api/versioned-settings-api'; import { Configuration } from './teamcity-client/configuration'; export interface TeamCityAPIClientConfig { baseUrl: string; token: string; timeout?: number; } const extractRetryAfterMilliseconds = (value: unknown): number | undefined => { if ( typeof value === 'object' && value !== null && typeof (value as { retryAfter?: unknown }).retryAfter === 'number' ) { return (value as { retryAfter: number }).retryAfter * 1000; } return undefined; }; interface NormalizedClientConfig { baseUrl: string; token: string; timeout?: number; } export class TeamCityAPI { private static instance: TeamCityAPI | undefined; private static instanceConfig: NormalizedClientConfig | undefined; private readonly axiosInstance: AxiosInstance; private readonly config: Configuration; private readonly baseUrl: string; /** Shared axios instance including interceptors and retry configuration. */ public readonly http: AxiosInstance; public readonly agents: AgentApi; public readonly agentPools: AgentPoolApi; public readonly agentTypes: AgentTypeApi; public readonly audit: AuditApi; public readonly avatars: AvatarApi; public readonly builds: BuildApi; public readonly buildQueue: BuildQueueApi; public readonly buildTypes: BuildTypeApi; public readonly changes: ChangeApi; public readonly cloudInstances: CloudInstanceApi; public readonly deploymentDashboards: DeploymentDashboardApi; public readonly globalServerSettings: GlobalServerSettingsApi; public readonly groups: GroupApi; public readonly health: HealthApi; public readonly investigations: InvestigationApi; public readonly mutes: MuteApi; public readonly nodes: NodeApi; public readonly problems: ProblemApi; public readonly problemOccurrences: ProblemOccurrenceApi; public readonly projects: ProjectApi; public readonly roles: RoleApi; public readonly root: RootApi; public readonly server: ServerApi; public readonly serverAuthSettings: ServerAuthenticationSettingsApi; public readonly testMetadata: TestApi; public readonly tests: TestOccurrenceApi; public readonly users: UserApi; public readonly vcsRoots: VcsRootApi; public readonly vcsRootInstances: VcsRootInstanceApi; public readonly versionedSettings: VersionedSettingsApi; /** * Immutable map of generated REST modules keyed by their resource category. * Managers should depend on this surface to access TeamCity endpoints. */ public readonly modules: Readonly<TeamCityApiSurface>; private constructor(config: TeamCityAPIClientConfig) { const basePath = config.baseUrl.replace(/\/$/, ''); const timeout = config.timeout ?? 30000; const validation = validateConfiguration(basePath, config.token); if (!validation.isValid) { throw new Error(`Invalid TeamCity configuration: ${validation.errors.join(', ')}`); } this.baseUrl = basePath; this.axiosInstance = axios.create({ baseURL: basePath, timeout, headers: { Authorization: `Bearer ${config.token}`, Accept: 'application/json', 'Content-Type': 'application/json', }, }); this.http = this.axiosInstance; // Configure retry with exponential backoff and error classification axiosRetry(this.axiosInstance, { retries: 3, retryDelay: (retryCount, error) => { const reqId = (error?.config as { requestId?: string } | undefined)?.requestId; const tcError = TeamCityAPIError.fromAxiosError(error, reqId); // Prefer Retry-After when present (seconds), else exponential backoff const retryAfter = extractRetryAfterMilliseconds(tcError); return retryAfter ?? Math.min(1000 * Math.pow(2, Math.max(0, retryCount - 1)), 8000); }, retryCondition: (error) => { const reqId = (error?.config as { requestId?: string } | undefined)?.requestId; const tcError = TeamCityAPIError.fromAxiosError(error, reqId); return isRetryableError(tcError); }, }); // Attach interceptors: request ID, response logging, and error transform this.axiosInstance.interceptors.request.use((config) => addRequestId(config)); this.axiosInstance.interceptors.response.use(logResponse, logAndTransformError); this.config = new Configuration({ basePath, accessToken: config.token, baseOptions: { timeout, headers: { Authorization: `Bearer ${config.token}`, Accept: 'application/json', }, }, }); this.builds = this.createApi(BuildApi); this.projects = this.createApi(ProjectApi); this.buildTypes = this.createApi(BuildTypeApi); this.buildQueue = this.createApi(BuildQueueApi); this.tests = this.createApi(TestOccurrenceApi); this.testMetadata = this.createApi(TestApi); this.vcsRoots = this.createApi(VcsRootApi); this.vcsRootInstances = this.createApi(VcsRootInstanceApi); this.agents = this.createApi(AgentApi); this.agentPools = this.createApi(AgentPoolApi); this.agentTypes = this.createApi(AgentTypeApi); this.audit = this.createApi(AuditApi); this.avatars = this.createApi(AvatarApi); this.server = this.createApi(ServerApi); this.serverAuthSettings = this.createApi(ServerAuthenticationSettingsApi); this.health = this.createApi(HealthApi); this.changes = this.createApi(ChangeApi); this.problems = this.createApi(ProblemApi); this.problemOccurrences = this.createApi(ProblemOccurrenceApi); this.investigations = this.createApi(InvestigationApi); this.mutes = this.createApi(MuteApi); this.versionedSettings = this.createApi(VersionedSettingsApi); this.roles = this.createApi(RoleApi); this.users = this.createApi(UserApi); this.cloudInstances = this.createApi(CloudInstanceApi); this.deploymentDashboards = this.createApi(DeploymentDashboardApi); this.globalServerSettings = this.createApi(GlobalServerSettingsApi); this.groups = this.createApi(GroupApi); this.nodes = this.createApi(NodeApi); this.root = this.createApi(RootApi); this.modules = Object.freeze({ agents: this.agents, agentPools: this.agentPools, agentTypes: this.agentTypes, audit: this.audit, avatars: this.avatars, builds: this.builds, buildQueue: this.buildQueue, buildTypes: this.buildTypes, changes: this.changes, cloudInstances: this.cloudInstances, deploymentDashboards: this.deploymentDashboards, globalServerSettings: this.globalServerSettings, groups: this.groups, health: this.health, investigations: this.investigations, mutes: this.mutes, nodes: this.nodes, problems: this.problems, problemOccurrences: this.problemOccurrences, projects: this.projects, roles: this.roles, root: this.root, server: this.server, serverAuthSettings: this.serverAuthSettings, tests: this.tests, testMetadata: this.testMetadata, users: this.users, vcsRoots: this.vcsRoots, vcsRootInstances: this.vcsRootInstances, versionedSettings: this.versionedSettings, }); info('TeamCityAPI initialized', { baseUrl: basePath, timeout }); } /** * Get or create singleton instance */ static getInstance(config: TeamCityAPIClientConfig): TeamCityAPI; static getInstance(baseUrl?: string, token?: string): TeamCityAPI; static getInstance(arg1?: string | TeamCityAPIClientConfig, arg2?: string): TeamCityAPI { const requestedConfig = this.normalizeArgs(arg1, arg2); if (requestedConfig) { if (this.instance != null && this.configsEqual(this.instanceConfig, requestedConfig)) { return this.instance; } this.instance = new TeamCityAPI(requestedConfig); this.instanceConfig = requestedConfig; return this.instance; } if (this.instance == null) { const envConfig = this.normalizeConfig({ baseUrl: getTeamCityUrl(), token: getTeamCityToken(), }); this.instance = new TeamCityAPI(envConfig); this.instanceConfig = envConfig; } return this.instance; } /** * Test connection to TeamCity server */ async testConnection(): Promise<boolean> { try { await this.projects.getAllProjects(undefined, '$long,project($short)'); return true; } catch { return false; } } /** * Simple helper methods for common operations */ async listProjects(locator?: string) { const response = await this.projects.getAllProjects(locator); return response.data; } async getProject(projectId: string) { const response = await this.projects.getProject(projectId); return response.data; } async listBuilds(locator?: string) { const response = await this.builds.getAllBuilds(locator); return response.data; } async getBuild(buildId: string) { const response = await this.builds.getBuild(toBuildLocator(buildId)); return response.data; } async triggerBuild(buildTypeId: string, branchName?: string, comment?: string) { const response = await this.buildQueue.addBuildToQueue( false, // moveToTop { buildType: { id: buildTypeId }, branchName, comment: { text: comment }, personal: false, } ); return response.data; } async getBuildLog(buildId: string) { // Fetch raw build log as plain text. Prefer the HTML download endpoint, // then fall back to the REST log endpoint with plain text. // Ensure headers/responseType request text rather than JSON. try { const response = await this.axiosInstance.get(`/downloadBuildLog.html`, { params: { buildId }, headers: { Accept: 'text/plain' }, responseType: 'text', transformResponse: [(data) => data], }); return response.data as string; } catch (primaryError) { // Fallback to REST endpoint (plain text) const response = await this.axiosInstance.get(`/app/rest/builds/id:${buildId}/log`, { params: { plain: true }, headers: { Accept: 'text/plain' }, responseType: 'text', transformResponse: [(data) => data], }); return response.data as string; } } /** * Fetch a chunk of the build log by line range. * Attempts server-side pagination first; falls back to client-side slicing. */ async getBuildLogChunk( buildId: string, options?: { startLine?: number; lineCount?: number } ): Promise<{ lines: string[]; startLine: number; nextStartLine?: number; totalLines?: number; }> { const startLine = options?.startLine ?? 0; const lineCount = options?.lineCount ?? 500; // Try REST endpoint with start/count support (if available) try { const response = await this.axiosInstance.get(`/app/rest/builds/id:${buildId}/log`, { params: { plain: true, start: startLine, count: lineCount, }, headers: { Accept: 'text/plain' }, responseType: 'text', transformResponse: [(data) => data], }); const text = (response.data as string) ?? ''; // Normalize newlines and split into lines consistently const lines = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n'); // Some servers may include an extra trailing empty line if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop(); return { lines, startLine, nextStartLine: lines.length === lineCount ? startLine + lines.length : undefined, }; } catch { // Fallback: fetch full log then slice locally const full = await this.getBuildLog(buildId); const allLines = full.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n'); if (allLines.length > 0 && allLines[allLines.length - 1] === '') allLines.pop(); const start = Math.max(0, Math.min(startLine, allLines.length)); const end = Math.min(allLines.length, start + lineCount); const slice = allLines.slice(start, end); return { lines: slice, startLine: start, nextStartLine: end < allLines.length ? end : undefined, totalLines: allLines.length, }; } } async listBuildTypes(projectId?: string) { const locator = projectId ? `affectedProject:(id:${projectId})` : undefined; const response = await this.buildTypes.getAllBuildTypes(locator); return response.data; } async getBuildType(buildTypeId: string) { const response = await this.buildTypes.getBuildType(buildTypeId); return response.data; } async listTestFailures(buildId: string) { const response = await this.tests.getAllTestOccurrences(`build:(id:${buildId}),status:FAILURE`); return response.data; } async listBuildArtifacts( buildId: string, options?: { basePath?: string; locator?: string; fields?: string; resolveParameters?: boolean; logBuildUsage?: boolean; } ): Promise<AxiosResponse<unknown>> { return this.builds.getFilesListOfBuild( toBuildLocator(buildId), options?.basePath, options?.locator, options?.fields, options?.resolveParameters, options?.logBuildUsage ); } async downloadBuildArtifact<T = ArrayBuffer>( buildId: string, artifactPath: string, options?: RawAxiosRequestConfig ): Promise<AxiosResponse<T>> { const normalizedPath = artifactPath .split('/') .map((segment) => encodeURIComponent(segment)) .join('/'); const requestOptions = { ...(options ?? {}), responseType: (options?.responseType ?? 'arraybuffer') as RawAxiosRequestConfig['responseType'], } as RawAxiosRequestConfig<T>; return this.axiosInstance.get<T>( `/app/rest/builds/id:${buildId}/artifacts/content/${normalizedPath}`, requestOptions ); } async downloadBuildLog<T = string>( buildId: string, options?: RawAxiosRequestConfig<T> ): Promise<AxiosResponse<T>> { const rawParams = (options?.params ?? undefined) as Record<string, unknown> | undefined; const params = rawParams ? { ...rawParams } : {}; if (!Object.prototype.hasOwnProperty.call(params, 'plain')) { params['plain'] = true; } const rawHeaders = (options?.headers ?? undefined) as Record<string, unknown> | undefined; const headers = rawHeaders ? { ...rawHeaders } : {}; const requestOptions: RawAxiosRequestConfig<T> = { ...options, params, headers: { Accept: 'text/plain', ...headers, }, responseType: (options?.responseType ?? 'text') as RawAxiosRequestConfig['responseType'], transformResponse: options?.transformResponse ?? [(data) => data], }; return this.axiosInstance.get<T>(`/app/rest/builds/id:${buildId}/log`, requestOptions); } async getBuildStatistics(buildId: string, fields?: string): Promise<AxiosResponse<unknown>> { return this.builds.getBuildStatisticValues(toBuildLocator(buildId), fields); } async listChangesForBuild(buildId: string, fields?: string): Promise<AxiosResponse<unknown>> { return this.changes.getAllChanges(`build:(id:${buildId})`, fields); } async listSnapshotDependencies(buildId: string): Promise<AxiosResponse<unknown>> { const response = await this.builds.getBuild(toBuildLocator(buildId), 'snapshot-dependencies'); const dependencies = (response.data as { 'snapshot-dependencies'?: unknown })[ 'snapshot-dependencies' ]; if (dependencies == null) { return response; } return { ...response, data: dependencies, }; } getBaseUrl(): string { return this.baseUrl; } async listVcsRoots(projectId?: string) { const locator = projectId ? `affectedProject:(id:${projectId})` : undefined; const response = await this.vcsRoots.getAllVcsRoots(locator); return response.data; } async listAgents() { const response = await this.agents.getAllAgents(); return response.data; } async listAgentPools() { const response = await this.agentPools.getAllAgentPools(); return response.data; } /** * Reset instance (mainly for testing) */ static reset() { this.instance = undefined; this.instanceConfig = undefined; } private createApi<T>( apiCtor: new (configuration: Configuration, basePath?: string, axios?: AxiosInstance) => T ): T { return new apiCtor(this.config, this.baseUrl, this.axiosInstance); } private static normalizeArgs( arg1?: string | TeamCityAPIClientConfig, arg2?: string ): NormalizedClientConfig | undefined { if (arg1 != null && typeof arg1 === 'object') { return this.normalizeConfig(arg1); } if (typeof arg1 === 'string' && typeof arg2 === 'string') { return this.normalizeConfig({ baseUrl: arg1, token: arg2 }); } return undefined; } private static normalizeConfig(config: TeamCityAPIClientConfig): NormalizedClientConfig { return { baseUrl: config.baseUrl.replace(/\/$/, ''), token: config.token, timeout: config.timeout, }; } private static configsEqual(a?: NormalizedClientConfig, b?: NormalizedClientConfig): boolean { if (a == null || b == null) { return false; } return a.baseUrl === b.baseUrl && a.token === b.token && a.timeout === b.timeout; } }

Implementation Reference

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