feat(#344): Add CI operations service to orchestrator
- Add CIOperationsService for Woodpecker CI integration - Add types for pipeline status, failure diagnosis - Add waitForPipeline with auto-diagnosis on failure - Add getPipelineLogs for log retrieval - Integrate CIModule into orchestrator app Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,7 @@ import { HealthModule } from "./api/health/health.module";
|
||||
import { AgentsModule } from "./api/agents/agents.module";
|
||||
import { CoordinatorModule } from "./coordinator/coordinator.module";
|
||||
import { BudgetModule } from "./budget/budget.module";
|
||||
import { CIModule } from "./ci";
|
||||
import { orchestratorConfig } from "./config/orchestrator.config";
|
||||
|
||||
/**
|
||||
@@ -47,6 +48,7 @@ import { orchestratorConfig } from "./config/orchestrator.config";
|
||||
AgentsModule,
|
||||
CoordinatorModule,
|
||||
BudgetModule,
|
||||
CIModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
423
apps/orchestrator/src/ci/ci-operations.service.ts
Normal file
423
apps/orchestrator/src/ci/ci-operations.service.ts
Normal file
@@ -0,0 +1,423 @@
|
||||
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<string>("orchestrator.ci.woodpeckerServer") ??
|
||||
process.env.WOODPECKER_SERVER ??
|
||||
"";
|
||||
this.woodpeckerToken =
|
||||
this.configService.get<string>("orchestrator.ci.woodpeckerToken") ??
|
||||
process.env.WOODPECKER_TOKEN ??
|
||||
"";
|
||||
this.defaultTimeout = this.configService.get<number>("orchestrator.ci.timeout", 1800);
|
||||
this.defaultPollInterval = this.configService.get<number>("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<PipelineInfo | null> {
|
||||
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<PipelineInfo> {
|
||||
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<PipelineWaitResult> {
|
||||
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<string> {
|
||||
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<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
}
|
||||
8
apps/orchestrator/src/ci/ci.module.ts
Normal file
8
apps/orchestrator/src/ci/ci.module.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { CIOperationsService } from "./ci-operations.service";
|
||||
|
||||
@Module({
|
||||
providers: [CIOperationsService],
|
||||
exports: [CIOperationsService],
|
||||
})
|
||||
export class CIModule {}
|
||||
3
apps/orchestrator/src/ci/index.ts
Normal file
3
apps/orchestrator/src/ci/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./ci.module";
|
||||
export * from "./ci-operations.service";
|
||||
export * from "./types";
|
||||
85
apps/orchestrator/src/ci/types/ci-operations.types.ts
Normal file
85
apps/orchestrator/src/ci/types/ci-operations.types.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* CI pipeline status
|
||||
*/
|
||||
export enum PipelineStatus {
|
||||
PENDING = "pending",
|
||||
RUNNING = "running",
|
||||
SUCCESS = "success",
|
||||
FAILURE = "failure",
|
||||
KILLED = "killed",
|
||||
ERROR = "error",
|
||||
SKIPPED = "skipped",
|
||||
BLOCKED = "blocked",
|
||||
}
|
||||
|
||||
/**
|
||||
* Pipeline information
|
||||
*/
|
||||
export interface PipelineInfo {
|
||||
number: number;
|
||||
status: PipelineStatus;
|
||||
event: string;
|
||||
branch: string;
|
||||
commit: string;
|
||||
started?: number;
|
||||
finished?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* CI failure diagnosis result
|
||||
*/
|
||||
export interface FailureDiagnosis {
|
||||
category: FailureCategory;
|
||||
message: string;
|
||||
suggestion: string;
|
||||
details?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Common CI failure categories
|
||||
*/
|
||||
export enum FailureCategory {
|
||||
LINT = "lint",
|
||||
TYPE_CHECK = "type-check",
|
||||
TEST = "test",
|
||||
BUILD = "build",
|
||||
SECURITY = "security",
|
||||
UNKNOWN = "unknown",
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for waiting on pipeline completion
|
||||
*/
|
||||
export interface PipelineWaitOptions {
|
||||
/** Timeout in seconds (default: 1800 = 30 minutes) */
|
||||
timeout?: number;
|
||||
/** Poll interval in seconds (default: 10) */
|
||||
pollInterval?: number;
|
||||
/** Whether to fetch logs on failure (default: true) */
|
||||
fetchLogsOnFailure?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of waiting for pipeline
|
||||
*/
|
||||
export interface PipelineWaitResult {
|
||||
success: boolean;
|
||||
pipeline: PipelineInfo;
|
||||
logs?: string;
|
||||
diagnosis?: FailureDiagnosis;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom error for CI operations
|
||||
*/
|
||||
export class CIOperationError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly operation: string,
|
||||
public readonly cause?: Error
|
||||
) {
|
||||
super(message);
|
||||
this.name = "CIOperationError";
|
||||
}
|
||||
}
|
||||
1
apps/orchestrator/src/ci/types/index.ts
Normal file
1
apps/orchestrator/src/ci/types/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./ci-operations.types";
|
||||
Reference in New Issue
Block a user