Skip to main content
Glama
build-step-manager.ts15.7 kB
/** * BuildStepManager - Manages build steps in TeamCity configurations * * Provides functionality to: * - List all build steps in a configuration * - Create new build steps with various runner types * - Update existing build steps * - Delete build steps * - Reorder build steps */ import type { Step } from '@/teamcity-client/models/step'; import type { Steps } from '@/teamcity-client/models/steps'; import { BuildConfigurationNotFoundError, BuildStepNotFoundError, PermissionDeniedError, TeamCityAPIError, ValidationError, } from '@/teamcity/errors'; import type { TeamCityClientAdapter } from './client-adapter'; /** * Options for listing build steps */ export interface BuildStepManagerOptions { configId: string; } /** * Options for creating a build step */ export interface BuildStepCreateOptions { configId: string; name: string; type: RunnerType; enabled?: boolean; properties?: Record<string, string>; parameters?: Record<string, string>; } /** * Options for updating a build step */ export interface BuildStepUpdateOptions { configId: string; stepId: string; name?: string; enabled?: boolean; properties?: Record<string, string>; parameters?: Record<string, string>; } /** * Options for deleting a build step */ export interface BuildStepDeleteOptions { configId: string; stepId: string; } /** * Options for reordering build steps */ export interface BuildStepReorderOptions { configId: string; stepOrder: string[]; } /** * Supported TeamCity runner types */ export type RunnerType = | 'simpleRunner' // Command line/shell scripts | 'Maven2' // Maven build runner | 'gradle-runner' // Gradle build runner | 'MSBuild' // MSBuild runner for .NET | 'dotnet' // .NET CLI runner | 'nodejs-runner' // Node.js runner | 'Docker' // Docker command runner | 'python' // Python runner | 'cargo' // Rust cargo runner | 'kotlinScript'; // Kotlin script runner /** * Build step representation */ export interface BuildStep { id: string; name: string; type: RunnerType; enabled: boolean; parameters: Record<string, string>; executionMode?: 'default' | 'always' | 'onlyIfAllPreviousPassed'; } /** * Result for listing build steps */ export interface BuildStepListResult { success: boolean; steps: BuildStep[]; configId: string; } /** * Result for build step operations */ export interface BuildStepOperationResult { success: boolean; step?: BuildStep; steps?: BuildStep[]; message?: string; } /** * Required parameters for each runner type */ const RUNNER_REQUIRED_PARAMS: Record<string, string[]> = { simpleRunner: ['script.content'], Maven2: ['goals'], 'gradle-runner': ['gradle.tasks'], MSBuild: ['msbuild.project'], dotnet: ['dotnet.command'], 'nodejs-runner': ['nodejs.script'], Docker: ['docker.command'], python: ['python.script'], cargo: ['cargo.command'], kotlinScript: ['kotlinScript.content'], }; interface StepListResponse { step?: unknown; } const isRecord = (value: unknown): value is Record<string, unknown> => { return typeof value === 'object' && value !== null; }; /** * Manages build steps in TeamCity configurations */ export class BuildStepManager { constructor(private readonly client: TeamCityClientAdapter) {} /** * List all build steps in a configuration */ async listBuildSteps(options: BuildStepManagerOptions): Promise<BuildStepListResult> { try { const response = await this.client.modules.buildTypes.getAllBuildSteps( options.configId, 'count,step(id,name,type,disabled,properties(property(name,value)),parameters(property(name,value)))' ); const payload = this.ensureStepListResponse( response.data, options.configId, 'list build steps' ); const steps = this.parseStepList(payload.step, options.configId, 'list build steps'); return { success: true, steps, configId: options.configId, }; } catch (error) { throw this.handleError(error, 'list build steps'); } } /** * Create a new build step */ async createBuildStep(options: BuildStepCreateOptions): Promise<BuildStepOperationResult> { // Validate runner type if (!this.isValidRunnerType(options.type)) { throw new ValidationError(`Invalid runner type: ${options.type as string}`, { field: 'type', value: options.type, validValues: Object.keys(RUNNER_REQUIRED_PARAMS), }); } // Validate required parameters this.validateRunnerParameters(options.type, options.properties ?? {}); try { const stepData = this.buildStepData(options); const response = await this.client.modules.buildTypes.addBuildStepToBuildType( options.configId, undefined, stepData as Step ); const step = this.parseStep(response.data, { configId: options.configId, operation: 'create build step', }); return { success: true, step, message: `Build step '${step.name}' created successfully`, }; } catch (error) { throw this.handleError(error, 'create build step'); } } /** * Update an existing build step */ async updateBuildStep(options: BuildStepUpdateOptions): Promise<BuildStepOperationResult> { try { const updateData: Record<string, unknown> = {}; if (options.name !== undefined) { updateData['name'] = options.name; } if (options.enabled !== undefined) { updateData['disabled'] = !options.enabled; } if (options.properties) { updateData['properties'] = { property: Object.entries(options.properties).map(([name, value]) => ({ name, value, })), }; } if (options.parameters) { updateData['parameters'] = { property: Object.entries(options.parameters).map(([name, value]) => ({ name, value, })), }; } const response = await this.client.modules.buildTypes.replaceBuildStep( options.configId, options.stepId, undefined, updateData as Step ); const step = this.parseStep(response.data, { configId: options.configId, operation: 'update build step', stepId: options.stepId, }); return { success: true, step, message: `Build step '${step.name}' updated successfully`, }; } catch (error) { throw this.handleError(error, 'update build step', options.stepId); } } /** * Delete a build step */ async deleteBuildStep(options: BuildStepDeleteOptions): Promise<BuildStepOperationResult> { try { await this.client.modules.buildTypes.deleteBuildStep(options.configId, options.stepId); return { success: true, message: `Build step '${options.stepId}' deleted successfully`, }; } catch (error) { throw this.handleError(error, 'delete build step', options.stepId); } } /** * Reorder build steps */ async reorderBuildSteps(options: BuildStepReorderOptions): Promise<BuildStepOperationResult> { try { // First, get existing steps to validate the new order const existingSteps = await this.listBuildSteps({ configId: options.configId }); const existingIds = new Set(existingSteps.steps.map((s) => s.id)); // Validate all step IDs exist for (const stepId of options.stepOrder) { if (!existingIds.has(stepId)) { throw new ValidationError(`Build step '${stepId}' not found in configuration`, { field: 'stepOrder', value: options.stepOrder, validValues: Array.from(existingIds), }); } } // Build reorder request const reorderData: Steps = { step: options.stepOrder.map((id) => ({ id })), count: options.stepOrder.length, }; const response = await this.client.modules.buildTypes.replaceAllBuildSteps( options.configId, undefined, reorderData ); const payload = this.ensureStepListResponse( response.data, options.configId, 'reorder build steps' ); const steps = this.parseStepList(payload.step, options.configId, 'reorder build steps'); return { success: true, steps, message: 'Build steps reordered successfully', }; } catch (error) { throw this.handleError(error, 'reorder build steps'); } } /** * Parse step list from API response */ private ensureStepListResponse( data: unknown, configId: string, operation: string ): StepListResponse { if (!isRecord(data)) { throw new TeamCityAPIError( 'TeamCity returned a non-object step list response', 'INVALID_RESPONSE', undefined, { configId, operation } ); } const response = data as StepListResponse; const { step } = response; if (step !== undefined && !Array.isArray(step) && !isRecord(step)) { throw new TeamCityAPIError( 'TeamCity step list response contains an invalid step payload', 'INVALID_RESPONSE', undefined, { configId, operation } ); } return response; } private parseStepList(stepNode: unknown, configId: string, operation: string): BuildStep[] { if (stepNode == null) { return []; } const steps = Array.isArray(stepNode) ? stepNode : [stepNode]; return steps.map((step, index) => this.parseStep(step, { configId, operation, index, }) ); } /** * Parse individual step from API response */ private parseStep( step: unknown, context: { configId: string; operation: string; stepId?: string; index?: number } ): BuildStep { if (!isRecord(step)) { throw new TeamCityAPIError( 'TeamCity returned a non-object build step entry', 'INVALID_RESPONSE', undefined, { ...context } ); } const stepData = step as Record<string, unknown>; const { id, name, type, disabled, properties, executionMode } = stepData; if (typeof id !== 'string' || typeof type !== 'string') { throw new TeamCityAPIError( 'TeamCity build step entry is missing required identifiers', 'INVALID_RESPONSE', undefined, { ...context, receivedKeys: Object.keys(stepData) } ); } return { id, name: typeof name === 'string' && name.length > 0 ? name : 'Unnamed Step', type: type as RunnerType, enabled: disabled !== true, parameters: this.parseRunnerProperties(type, properties, { configId: context.configId, operation: context.operation, }), executionMode: (typeof executionMode === 'string' ? executionMode : 'default') as BuildStep['executionMode'], }; } /** * Parse runner properties based on runner type */ private parseRunnerProperties( type: string, properties: unknown, context: { configId: string; operation: string } ): Record<string, string> { if (properties == null) { return {}; } if (!isRecord(properties)) { throw new TeamCityAPIError( 'TeamCity build step entry contains invalid properties payload', 'INVALID_RESPONSE', undefined, context ); } const propsData = properties as { property?: unknown }; if (propsData.property == null) { return {}; } const props = Array.isArray(propsData.property) ? propsData.property : [propsData.property]; const result: Record<string, string> = {}; for (const prop of props) { if (!isRecord(prop)) { throw new TeamCityAPIError( 'TeamCity build step property entry is not an object', 'INVALID_RESPONSE', undefined, context ); } const { name, value } = prop as { name?: string; value?: string }; if (typeof name === 'string' && name !== '' && typeof value === 'string') { result[name] = value; } } return result; } /** * Validate runner type */ private isValidRunnerType(type: string): type is RunnerType { return type in RUNNER_REQUIRED_PARAMS; } /** * Validate runner parameters */ private validateRunnerParameters(type: RunnerType, properties: Record<string, string>): void { const requiredParams = RUNNER_REQUIRED_PARAMS[type] ?? []; for (const param of requiredParams) { const value = properties[param]; if (value == null || value === '') { throw new ValidationError(`Missing required parameter '${param}' for ${type} runner`, { field: param, runnerType: type, requiredParameters: requiredParams, }); } } } /** * Build step data for API request */ private buildStepData(options: BuildStepCreateOptions): Record<string, unknown> { const data: Record<string, unknown> = { name: options.name, type: options.type, }; if (options.enabled !== undefined) { data['disabled'] = !options.enabled; } if (options.properties) { data['properties'] = { property: Object.entries(options.properties).map(([name, value]) => ({ name, value, })), }; } if (options.parameters) { data['parameters'] = { property: Object.entries(options.parameters).map(([name, value]) => ({ name, value, })), }; } return data; } /** * Handle API errors */ private handleError(error: unknown, operation: string, stepId?: string): never { if (error instanceof ValidationError) { throw error; } const axiosError = error as { response?: { status: number; data?: { message?: string } }; message?: string; }; if (axiosError.response) { const status = axiosError.response.status; const message = axiosError.response.data?.message ?? axiosError.message ?? 'Unknown error'; switch (status) { case 404: if ( stepId !== null && stepId !== undefined && stepId !== '' && typeof message === 'string' && message.toLowerCase().includes('step') ) { throw new BuildStepNotFoundError(`Build step '${stepId}' not found`, stepId); } else { throw new BuildConfigurationNotFoundError('Build configuration not found', ''); } case 403: throw new PermissionDeniedError(`Permission denied to ${operation}`, operation); case 401: throw new TeamCityAPIError('Authentication required', 'AUTHENTICATION_ERROR', 401, { operation, }); case 409: throw new TeamCityAPIError( `Conflict while trying to ${operation}: ${message}`, 'CONFLICT_ERROR', 409, { operation } ); default: throw new TeamCityAPIError( `Failed to ${operation}: ${message}`, `ERROR_${status}`, status, undefined, undefined, error instanceof Error ? error : undefined ); } } throw new TeamCityAPIError( `Failed to ${operation}: ${error instanceof Error ? error.message : 'Unknown error'}`, 'INTERNAL_ERROR', 500, { operation }, undefined, error instanceof Error ? error : undefined ); } }

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