import { Injectable, Logger } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { exec } from "node:child_process"; import { promisify } from "node:util"; import { CIOperationError, FailureCategory, FailureDiagnosis, PipelineInfo, PipelineStatus, PipelineWaitOptions, PipelineWaitResult, } from "./types"; const execAsync = promisify(exec); /** * Service for managing Woodpecker CI operations */ @Injectable() export class CIOperationsService { private readonly logger = new Logger(CIOperationsService.name); private readonly woodpeckerServer: string; private readonly woodpeckerToken: string; private readonly defaultTimeout: number; private readonly defaultPollInterval: number; constructor(private readonly configService: ConfigService) { this.woodpeckerServer = this.configService.get("orchestrator.ci.woodpeckerServer") ?? process.env.WOODPECKER_SERVER ?? ""; this.woodpeckerToken = this.configService.get("orchestrator.ci.woodpeckerToken") ?? process.env.WOODPECKER_TOKEN ?? ""; this.defaultTimeout = this.configService.get("orchestrator.ci.timeout", 1800); this.defaultPollInterval = this.configService.get("orchestrator.ci.pollInterval", 10); if (!this.woodpeckerServer || !this.woodpeckerToken) { this.logger.warn( "Woodpecker CI not configured. Set WOODPECKER_SERVER and WOODPECKER_TOKEN environment variables." ); } } /** * Check if Woodpecker CI is configured */ isConfigured(): boolean { return Boolean(this.woodpeckerServer && this.woodpeckerToken); } /** * Get the latest pipeline for a repository */ async getLatestPipeline(repo: string): Promise { if (!this.isConfigured()) { throw new CIOperationError("Woodpecker CI is not configured", "getLatestPipeline"); } try { this.logger.debug(`Getting latest pipeline for ${repo}`); const { stdout } = await this.execWoodpecker(["pipeline", "ls", repo, "--limit", "1"]); const pipelines = this.parsePipelineList(stdout); if (pipelines.length === 0) { this.logger.debug(`No pipeline found for ${repo}`); return null; } return pipelines[0]; } catch (error) { this.logger.error(`Failed to get latest pipeline: ${String(error)}`); throw new CIOperationError( `Failed to get latest pipeline for ${repo}`, "getLatestPipeline", error as Error ); } } /** * Get specific pipeline by number */ async getPipeline(repo: string, pipelineNumber: number): Promise { if (!this.isConfigured()) { throw new CIOperationError("Woodpecker CI is not configured", "getPipeline"); } try { this.logger.debug(`Getting pipeline #${pipelineNumber.toString()} for ${repo}`); const { stdout } = await this.execWoodpecker([ "pipeline", "info", repo, pipelineNumber.toString(), ]); return this.parsePipelineInfo(stdout, pipelineNumber); } catch (error) { this.logger.error(`Failed to get pipeline: ${String(error)}`); throw new CIOperationError( `Failed to get pipeline #${pipelineNumber.toString()} for ${repo}`, "getPipeline", error as Error ); } } /** * Wait for pipeline to complete */ async waitForPipeline( repo: string, pipelineNumber: number, options: PipelineWaitOptions = {} ): Promise { if (!this.isConfigured()) { throw new CIOperationError("Woodpecker CI is not configured", "waitForPipeline"); } const timeout = options.timeout ?? this.defaultTimeout; const pollInterval = options.pollInterval ?? this.defaultPollInterval; const fetchLogsOnFailure = options.fetchLogsOnFailure ?? true; this.logger.log( `Waiting for pipeline #${pipelineNumber.toString()} in ${repo} (timeout: ${timeout.toString()}s)` ); const startTime = Date.now(); const timeoutMs = timeout * 1000; while (Date.now() - startTime < timeoutMs) { const pipeline = await this.getPipeline(repo, pipelineNumber); if ( pipeline.status === PipelineStatus.SUCCESS || pipeline.status === PipelineStatus.FAILURE || pipeline.status === PipelineStatus.KILLED || pipeline.status === PipelineStatus.ERROR ) { // Pipeline completed const success = pipeline.status === PipelineStatus.SUCCESS; const result: PipelineWaitResult = { success, pipeline, }; if (!success && fetchLogsOnFailure) { this.logger.log( `Pipeline #${pipelineNumber.toString()} failed, fetching logs for diagnosis` ); result.logs = await this.getPipelineLogs(repo, pipelineNumber); result.diagnosis = this.diagnoseFail(result.logs); } if (success) { this.logger.log(`✓ Pipeline #${pipelineNumber.toString()} succeeded`); } else { this.logger.warn( `✗ Pipeline #${pipelineNumber.toString()} failed with status: ${pipeline.status}` ); } return result; } // Still running or pending await this.delay(pollInterval * 1000); } // Timeout const pipeline = await this.getPipeline(repo, pipelineNumber); throw new CIOperationError( `Pipeline #${pipelineNumber.toString()} did not complete within ${timeout.toString()}s (status: ${pipeline.status})`, "waitForPipeline" ); } /** * Get pipeline logs */ async getPipelineLogs(repo: string, pipelineNumber: number, step?: string): Promise { if (!this.isConfigured()) { throw new CIOperationError("Woodpecker CI is not configured", "getPipelineLogs"); } try { this.logger.debug(`Getting logs for pipeline #${pipelineNumber.toString()}`); const args = ["log", "show", repo, pipelineNumber.toString()]; if (step) { args.push(step); } const { stdout } = await this.execWoodpecker(args); return stdout; } catch (error) { this.logger.error(`Failed to get pipeline logs: ${String(error)}`); throw new CIOperationError( `Failed to get logs for pipeline #${pipelineNumber.toString()}`, "getPipelineLogs", error as Error ); } } /** * Diagnose pipeline failure from logs */ private diagnoseFail(logs: string): FailureDiagnosis { // Check for common failure patterns if (/eslint|lint.*error/i.test(logs)) { return { category: FailureCategory.LINT, message: "Linting errors detected", suggestion: "Run 'pnpm lint' locally to identify and fix linting issues", details: this.extractErrors(logs, /error.*$/gim), }; } if (/type.*error|typescript.*error|tsc.*error/i.test(logs)) { return { category: FailureCategory.TYPE_CHECK, message: "TypeScript type errors detected", suggestion: "Run 'pnpm typecheck' locally to identify type errors", details: this.extractErrors(logs, /error TS\d+:.*$/gim), }; } if (/test.*fail|tests.*fail|vitest.*fail/i.test(logs)) { return { category: FailureCategory.TEST, message: "Test failures detected", suggestion: "Run 'pnpm test' locally to identify failing tests", details: this.extractErrors(logs, /FAIL.*$/gim), }; } if (/build.*fail|compilation.*fail/i.test(logs)) { return { category: FailureCategory.BUILD, message: "Build errors detected", suggestion: "Run 'pnpm build' locally to identify build issues", details: this.extractErrors(logs, /error.*$/gim), }; } if (/secret|security|vulnerability/i.test(logs)) { return { category: FailureCategory.SECURITY, message: "Security check failed", suggestion: "Review security scan results and remove any hardcoded secrets", details: this.extractErrors(logs, /.*secret.*|.*security.*/gim), }; } return { category: FailureCategory.UNKNOWN, message: "Pipeline failed with unknown error", suggestion: "Review full pipeline logs to identify the issue", }; } /** * Extract error messages from logs using regex */ private extractErrors(logs: string, pattern: RegExp): string { const matches = logs.match(pattern); if (!matches || matches.length === 0) { return ""; } // Return first 10 matches to avoid overwhelming output return matches.slice(0, 10).join("\n"); } /** * Parse pipeline list output from Woodpecker CLI */ private parsePipelineList(output: string): PipelineInfo[] { const lines = output.split("\n").filter((line) => line.trim()); // Skip header line and empty lines const dataLines = lines.slice(1).filter((line) => !line.startsWith("Number")); return dataLines .map((line) => { // Parse table format: Number | Status | Event | Branch | Commit | ... const parts = line.split("|").map((p) => p.trim()); if (parts.length < 5) { return null; } const number = parseInt(parts[0], 10); if (isNaN(number)) { return null; } return { number, status: this.parseStatus(parts[1]), event: parts[2], branch: parts[3], commit: parts[4], }; }) .filter((p): p is PipelineInfo => p !== null); } /** * Parse pipeline info output from Woodpecker CLI */ private parsePipelineInfo(output: string, pipelineNumber: number): PipelineInfo { const lines = output.split("\n"); let status = PipelineStatus.PENDING; let event = ""; let branch = ""; let commit = ""; let started: number | undefined; let finished: number | undefined; let error: string | undefined; for (const line of lines) { const trimmed = line.trim(); if (trimmed.toLowerCase().includes("status:")) { const statusText = trimmed.split(":")[1]?.trim() ?? ""; status = this.parseStatus(statusText); } else if (trimmed.toLowerCase().includes("event:")) { event = trimmed.split(":")[1]?.trim() ?? ""; } else if (trimmed.toLowerCase().includes("branch:")) { branch = trimmed.split(":")[1]?.trim() ?? ""; } else if (trimmed.toLowerCase().includes("commit:")) { commit = trimmed.split(":")[1]?.trim() ?? ""; } else if (trimmed.toLowerCase().includes("started:")) { const startedText = trimmed.split(":")[1]?.trim() ?? ""; started = Date.parse(startedText); } else if (trimmed.toLowerCase().includes("finished:")) { const finishedText = trimmed.split(":")[1]?.trim() ?? ""; finished = Date.parse(finishedText); } else if (trimmed.toLowerCase().includes("error:")) { error = trimmed.split(":")[1]?.trim() ?? ""; } } return { number: pipelineNumber, status, event, branch, commit, started, finished, error, }; } /** * Parse status string to PipelineStatus enum */ private parseStatus(statusText: string): PipelineStatus { const normalized = statusText.toLowerCase().trim(); switch (normalized) { case "success": return PipelineStatus.SUCCESS; case "failure": case "failed": return PipelineStatus.FAILURE; case "running": return PipelineStatus.RUNNING; case "pending": return PipelineStatus.PENDING; case "killed": case "cancelled": return PipelineStatus.KILLED; case "error": return PipelineStatus.ERROR; case "skipped": return PipelineStatus.SKIPPED; case "blocked": return PipelineStatus.BLOCKED; default: this.logger.warn(`Unknown pipeline status: ${statusText}`); return PipelineStatus.PENDING; } } /** * Execute Woodpecker CLI command */ private async execWoodpecker(args: string[]): Promise<{ stdout: string; stderr: string }> { const env = { ...process.env, WOODPECKER_SERVER: this.woodpeckerServer, WOODPECKER_TOKEN: this.woodpeckerToken, }; const command = `woodpecker ${args.join(" ")}`; this.logger.debug(`Executing: ${command}`); try { return await execAsync(command, { env }); } catch (error) { if (error instanceof Error && "stderr" in error) { this.logger.error(`Woodpecker CLI error: ${(error as { stderr: string }).stderr}`); } throw error; } } /** * Delay helper for polling */ private delay(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } }