Merge main into feature/mcp-server-support

Resolved conflicts:
- apps/server/src/index.ts: merged MCP and Pipeline routes
- apps/ui/src/lib/http-api-client.ts: merged MCP and Pipeline APIs
- apps/ui/src/store/app-store.ts: merged type imports

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Kacper
2025-12-28 15:48:19 +01:00
31 changed files with 3791 additions and 67 deletions

View File

@@ -52,6 +52,8 @@ import { createBacklogPlanRoutes } from './routes/backlog-plan/index.js';
import { cleanupStaleValidations } from './routes/github/routes/validation-common.js';
import { createMCPRoutes } from './routes/mcp/index.js';
import { MCPTestService } from './services/mcp-test-service.js';
import { createPipelineRoutes } from './routes/pipeline/index.js';
import { pipelineService } from './services/pipeline-service.js';
// Load environment variables
dotenv.config();
@@ -166,6 +168,7 @@ app.use('/api/github', createGitHubRoutes(events, settingsService));
app.use('/api/context', createContextRoutes(settingsService));
app.use('/api/backlog-plan', createBacklogPlanRoutes(events, settingsService));
app.use('/api/mcp', createMCPRoutes(mcpTestService));
app.use('/api/pipeline', createPipelineRoutes(pipelineService));
// Create HTTP server
const server = createServer(app);

View File

@@ -0,0 +1,21 @@
/**
* Common utilities for pipeline routes
*
* Provides logger and error handling utilities shared across all pipeline endpoints.
*/
import { createLogger } from '@automaker/utils';
import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js';
/** Logger instance for pipeline-related operations */
export const logger = createLogger('Pipeline');
/**
* Extract user-friendly error message from error objects
*/
export { getErrorMessageShared as getErrorMessage };
/**
* Log error with automatic logger binding
*/
export const logError = createLogError(logger);

View File

@@ -0,0 +1,77 @@
/**
* Pipeline routes - HTTP API for pipeline configuration management
*
* Provides endpoints for:
* - Getting pipeline configuration
* - Saving pipeline configuration
* - Adding, updating, deleting, and reordering pipeline steps
*
* All endpoints use handler factories that receive the PipelineService instance.
* Mounted at /api/pipeline in the main server.
*/
import { Router } from 'express';
import type { PipelineService } from '../../services/pipeline-service.js';
import { validatePathParams } from '../../middleware/validate-paths.js';
import { createGetConfigHandler } from './routes/get-config.js';
import { createSaveConfigHandler } from './routes/save-config.js';
import { createAddStepHandler } from './routes/add-step.js';
import { createUpdateStepHandler } from './routes/update-step.js';
import { createDeleteStepHandler } from './routes/delete-step.js';
import { createReorderStepsHandler } from './routes/reorder-steps.js';
/**
* Create pipeline router with all endpoints
*
* Endpoints:
* - POST /config - Get pipeline configuration
* - POST /config/save - Save entire pipeline configuration
* - POST /steps/add - Add a new pipeline step
* - POST /steps/update - Update an existing pipeline step
* - POST /steps/delete - Delete a pipeline step
* - POST /steps/reorder - Reorder pipeline steps
*
* @param pipelineService - Instance of PipelineService for file I/O
* @returns Express Router configured with all pipeline endpoints
*/
export function createPipelineRoutes(pipelineService: PipelineService): Router {
const router = Router();
// Get pipeline configuration
router.post(
'/config',
validatePathParams('projectPath'),
createGetConfigHandler(pipelineService)
);
// Save entire pipeline configuration
router.post(
'/config/save',
validatePathParams('projectPath'),
createSaveConfigHandler(pipelineService)
);
// Pipeline step operations
router.post(
'/steps/add',
validatePathParams('projectPath'),
createAddStepHandler(pipelineService)
);
router.post(
'/steps/update',
validatePathParams('projectPath'),
createUpdateStepHandler(pipelineService)
);
router.post(
'/steps/delete',
validatePathParams('projectPath'),
createDeleteStepHandler(pipelineService)
);
router.post(
'/steps/reorder',
validatePathParams('projectPath'),
createReorderStepsHandler(pipelineService)
);
return router;
}

View File

@@ -0,0 +1,54 @@
/**
* POST /api/pipeline/steps/add - Add a new pipeline step
*
* Adds a new step to the pipeline configuration.
*
* Request body: { projectPath: string, step: { name, order, instructions, colorClass } }
* Response: { success: true, step: PipelineStep }
*/
import type { Request, Response } from 'express';
import type { PipelineService } from '../../../services/pipeline-service.js';
import type { PipelineStep } from '@automaker/types';
import { getErrorMessage, logError } from '../common.js';
export function createAddStepHandler(pipelineService: PipelineService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, step } = req.body as {
projectPath: string;
step: Omit<PipelineStep, 'id' | 'createdAt' | 'updatedAt'>;
};
if (!projectPath) {
res.status(400).json({ success: false, error: 'projectPath is required' });
return;
}
if (!step) {
res.status(400).json({ success: false, error: 'step is required' });
return;
}
if (!step.name) {
res.status(400).json({ success: false, error: 'step.name is required' });
return;
}
if (step.instructions === undefined) {
res.status(400).json({ success: false, error: 'step.instructions is required' });
return;
}
const newStep = await pipelineService.addStep(projectPath, step);
res.json({
success: true,
step: newStep,
});
} catch (error) {
logError(error, 'Add pipeline step failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,42 @@
/**
* POST /api/pipeline/steps/delete - Delete a pipeline step
*
* Removes a step from the pipeline configuration.
*
* Request body: { projectPath: string, stepId: string }
* Response: { success: true }
*/
import type { Request, Response } from 'express';
import type { PipelineService } from '../../../services/pipeline-service.js';
import { getErrorMessage, logError } from '../common.js';
export function createDeleteStepHandler(pipelineService: PipelineService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, stepId } = req.body as {
projectPath: string;
stepId: string;
};
if (!projectPath) {
res.status(400).json({ success: false, error: 'projectPath is required' });
return;
}
if (!stepId) {
res.status(400).json({ success: false, error: 'stepId is required' });
return;
}
await pipelineService.deleteStep(projectPath, stepId);
res.json({
success: true,
});
} catch (error) {
logError(error, 'Delete pipeline step failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,35 @@
/**
* POST /api/pipeline/config - Get pipeline configuration
*
* Returns the pipeline configuration for a project.
*
* Request body: { projectPath: string }
* Response: { success: true, config: PipelineConfig }
*/
import type { Request, Response } from 'express';
import type { PipelineService } from '../../../services/pipeline-service.js';
import { getErrorMessage, logError } from '../common.js';
export function createGetConfigHandler(pipelineService: PipelineService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath } = req.body;
if (!projectPath) {
res.status(400).json({ success: false, error: 'projectPath is required' });
return;
}
const config = await pipelineService.getPipelineConfig(projectPath);
res.json({
success: true,
config,
});
} catch (error) {
logError(error, 'Get pipeline config failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,42 @@
/**
* POST /api/pipeline/steps/reorder - Reorder pipeline steps
*
* Reorders the steps in the pipeline configuration.
*
* Request body: { projectPath: string, stepIds: string[] }
* Response: { success: true }
*/
import type { Request, Response } from 'express';
import type { PipelineService } from '../../../services/pipeline-service.js';
import { getErrorMessage, logError } from '../common.js';
export function createReorderStepsHandler(pipelineService: PipelineService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, stepIds } = req.body as {
projectPath: string;
stepIds: string[];
};
if (!projectPath) {
res.status(400).json({ success: false, error: 'projectPath is required' });
return;
}
if (!stepIds || !Array.isArray(stepIds)) {
res.status(400).json({ success: false, error: 'stepIds array is required' });
return;
}
await pipelineService.reorderSteps(projectPath, stepIds);
res.json({
success: true,
});
} catch (error) {
logError(error, 'Reorder pipeline steps failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,43 @@
/**
* POST /api/pipeline/config/save - Save entire pipeline configuration
*
* Saves the complete pipeline configuration for a project.
*
* Request body: { projectPath: string, config: PipelineConfig }
* Response: { success: true }
*/
import type { Request, Response } from 'express';
import type { PipelineService } from '../../../services/pipeline-service.js';
import type { PipelineConfig } from '@automaker/types';
import { getErrorMessage, logError } from '../common.js';
export function createSaveConfigHandler(pipelineService: PipelineService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, config } = req.body as {
projectPath: string;
config: PipelineConfig;
};
if (!projectPath) {
res.status(400).json({ success: false, error: 'projectPath is required' });
return;
}
if (!config) {
res.status(400).json({ success: false, error: 'config is required' });
return;
}
await pipelineService.savePipelineConfig(projectPath, config);
res.json({
success: true,
});
} catch (error) {
logError(error, 'Save pipeline config failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,50 @@
/**
* POST /api/pipeline/steps/update - Update an existing pipeline step
*
* Updates a step in the pipeline configuration.
*
* Request body: { projectPath: string, stepId: string, updates: Partial<PipelineStep> }
* Response: { success: true, step: PipelineStep }
*/
import type { Request, Response } from 'express';
import type { PipelineService } from '../../../services/pipeline-service.js';
import type { PipelineStep } from '@automaker/types';
import { getErrorMessage, logError } from '../common.js';
export function createUpdateStepHandler(pipelineService: PipelineService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, stepId, updates } = req.body as {
projectPath: string;
stepId: string;
updates: Partial<Omit<PipelineStep, 'id' | 'createdAt'>>;
};
if (!projectPath) {
res.status(400).json({ success: false, error: 'projectPath is required' });
return;
}
if (!stepId) {
res.status(400).json({ success: false, error: 'stepId is required' });
return;
}
if (!updates || Object.keys(updates).length === 0) {
res.status(400).json({ success: false, error: 'updates is required' });
return;
}
const updatedStep = await pipelineService.updateStep(projectPath, stepId, updates);
res.json({
success: true,
step: updatedStep,
});
} catch (error) {
logError(error, 'Update pipeline step failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -10,7 +10,7 @@
*/
import { ProviderFactory } from '../providers/provider-factory.js';
import type { ExecuteOptions, Feature } from '@automaker/types';
import type { ExecuteOptions, Feature, PipelineConfig, PipelineStep } from '@automaker/types';
import {
buildPromptWithImages,
isAbortError,
@@ -32,6 +32,7 @@ import {
} from '../lib/sdk-options.js';
import { FeatureLoader } from './feature-loader.js';
import type { SettingsService } from './settings-service.js';
import { pipelineService, PipelineService } from './pipeline-service.js';
import {
getAutoLoadClaudeMdSetting,
getEnableSandboxModeSetting,
@@ -633,6 +634,23 @@ export class AutoModeService {
}
);
// Check for pipeline steps and execute them
const pipelineConfig = await pipelineService.getPipelineConfig(projectPath);
const sortedSteps = [...(pipelineConfig?.steps || [])].sort((a, b) => a.order - b.order);
if (sortedSteps.length > 0) {
// Execute pipeline steps sequentially
await this.executePipelineSteps(
projectPath,
featureId,
feature,
sortedSteps,
workDir,
abortController,
autoLoadClaudeMd
);
}
// Determine final status based on testing mode:
// - skipTests=false (automated testing): go directly to 'verified' (no manual verify needed)
// - skipTests=true (manual verification): go to 'waiting_approval' for manual review
@@ -676,6 +694,143 @@ export class AutoModeService {
}
}
/**
* Execute pipeline steps sequentially after initial feature implementation
*/
private async executePipelineSteps(
projectPath: string,
featureId: string,
feature: Feature,
steps: PipelineStep[],
workDir: string,
abortController: AbortController,
autoLoadClaudeMd: boolean
): Promise<void> {
console.log(`[AutoMode] Executing ${steps.length} pipeline step(s) for feature ${featureId}`);
// Load context files once
const contextResult = await loadContextFiles({
projectPath,
fsModule: secureFs as Parameters<typeof loadContextFiles>[0]['fsModule'],
});
const contextFilesPrompt = filterClaudeMdFromContext(contextResult, autoLoadClaudeMd);
// Load previous agent output for context continuity
const featureDir = getFeatureDir(projectPath, featureId);
const contextPath = path.join(featureDir, 'agent-output.md');
let previousContext = '';
try {
previousContext = (await secureFs.readFile(contextPath, 'utf-8')) as string;
} catch {
// No previous context
}
for (let i = 0; i < steps.length; i++) {
const step = steps[i];
const pipelineStatus = `pipeline_${step.id}`;
// Update feature status to current pipeline step
await this.updateFeatureStatus(projectPath, featureId, pipelineStatus);
this.emitAutoModeEvent('auto_mode_progress', {
featureId,
content: `Starting pipeline step ${i + 1}/${steps.length}: ${step.name}`,
projectPath,
});
this.emitAutoModeEvent('pipeline_step_started', {
featureId,
stepId: step.id,
stepName: step.name,
stepIndex: i,
totalSteps: steps.length,
projectPath,
});
// Build prompt for this pipeline step
const prompt = this.buildPipelineStepPrompt(step, feature, previousContext);
// Get model from feature
const model = resolveModelString(feature.model, DEFAULT_MODELS.claude);
// Run the agent for this pipeline step
await this.runAgent(
workDir,
featureId,
prompt,
abortController,
projectPath,
undefined, // no images for pipeline steps
model,
{
projectPath,
planningMode: 'skip', // Pipeline steps don't need planning
requirePlanApproval: false,
previousContent: previousContext,
systemPrompt: contextFilesPrompt || undefined,
autoLoadClaudeMd,
}
);
// Load updated context for next step
try {
previousContext = (await secureFs.readFile(contextPath, 'utf-8')) as string;
} catch {
// No context update
}
this.emitAutoModeEvent('pipeline_step_complete', {
featureId,
stepId: step.id,
stepName: step.name,
stepIndex: i,
totalSteps: steps.length,
projectPath,
});
console.log(
`[AutoMode] Pipeline step ${i + 1}/${steps.length} (${step.name}) completed for feature ${featureId}`
);
}
console.log(`[AutoMode] All pipeline steps completed for feature ${featureId}`);
}
/**
* Build the prompt for a pipeline step
*/
private buildPipelineStepPrompt(
step: PipelineStep,
feature: Feature,
previousContext: string
): string {
let prompt = `## Pipeline Step: ${step.name}
This is an automated pipeline step following the initial feature implementation.
### Feature Context
${this.buildFeaturePrompt(feature)}
`;
if (previousContext) {
prompt += `### Previous Work
The following is the output from the previous work on this feature:
${previousContext}
`;
}
prompt += `### Pipeline Step Instructions
${step.instructions}
### Task
Complete the pipeline step instructions above. Review the previous work and apply the required changes or actions.`;
return prompt;
}
/**
* Stop a specific feature
*/

View File

@@ -0,0 +1,320 @@
/**
* Pipeline Service - Handles reading/writing pipeline configuration
*
* Provides persistent storage for:
* - Pipeline configuration ({projectPath}/.automaker/pipeline.json)
*/
import path from 'path';
import { createLogger } from '@automaker/utils';
import * as secureFs from '../lib/secure-fs.js';
import { ensureAutomakerDir } from '@automaker/platform';
import type { PipelineConfig, PipelineStep, FeatureStatusWithPipeline } from '@automaker/types';
const logger = createLogger('PipelineService');
// Default empty pipeline config
const DEFAULT_PIPELINE_CONFIG: PipelineConfig = {
version: 1,
steps: [],
};
/**
* Atomic file write - write to temp file then rename
*/
async function atomicWriteJson(filePath: string, data: unknown): Promise<void> {
const tempPath = `${filePath}.tmp.${Date.now()}`;
const content = JSON.stringify(data, null, 2);
try {
await secureFs.writeFile(tempPath, content, 'utf-8');
await secureFs.rename(tempPath, filePath);
} catch (error) {
// Clean up temp file if it exists
try {
await secureFs.unlink(tempPath);
} catch {
// Ignore cleanup errors
}
throw error;
}
}
/**
* Safely read JSON file with fallback to default
*/
async function readJsonFile<T>(filePath: string, defaultValue: T): Promise<T> {
try {
const content = (await secureFs.readFile(filePath, 'utf-8')) as string;
return JSON.parse(content) as T;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return defaultValue;
}
logger.error(`Error reading ${filePath}:`, error);
return defaultValue;
}
}
/**
* Generate a unique ID for pipeline steps
*/
function generateStepId(): string {
return `step_${Date.now().toString(36)}_${Math.random().toString(36).substring(2, 8)}`;
}
/**
* Get the pipeline config file path for a project
*/
function getPipelineConfigPath(projectPath: string): string {
return path.join(projectPath, '.automaker', 'pipeline.json');
}
/**
* PipelineService - Manages pipeline configuration for workflow automation
*
* Handles reading and writing pipeline config to JSON files with atomic operations.
* Pipeline steps define custom columns that appear between "in_progress" and
* "waiting_approval/verified" columns in the kanban board.
*/
export class PipelineService {
/**
* Get pipeline configuration for a project
*
* @param projectPath - Absolute path to the project
* @returns Promise resolving to PipelineConfig (empty steps array if no config exists)
*/
async getPipelineConfig(projectPath: string): Promise<PipelineConfig> {
const configPath = getPipelineConfigPath(projectPath);
const config = await readJsonFile<PipelineConfig>(configPath, DEFAULT_PIPELINE_CONFIG);
// Ensure version is set
return {
...DEFAULT_PIPELINE_CONFIG,
...config,
};
}
/**
* Save entire pipeline configuration
*
* @param projectPath - Absolute path to the project
* @param config - Complete PipelineConfig to save
*/
async savePipelineConfig(projectPath: string, config: PipelineConfig): Promise<void> {
await ensureAutomakerDir(projectPath);
const configPath = getPipelineConfigPath(projectPath);
await atomicWriteJson(configPath, config);
logger.info(`Pipeline config saved for project: ${projectPath}`);
}
/**
* Add a new pipeline step
*
* @param projectPath - Absolute path to the project
* @param step - Step data (without id, createdAt, updatedAt)
* @returns Promise resolving to the created PipelineStep
*/
async addStep(
projectPath: string,
step: Omit<PipelineStep, 'id' | 'createdAt' | 'updatedAt'>
): Promise<PipelineStep> {
const config = await this.getPipelineConfig(projectPath);
const now = new Date().toISOString();
const newStep: PipelineStep = {
...step,
id: generateStepId(),
createdAt: now,
updatedAt: now,
};
config.steps.push(newStep);
// Normalize order values
config.steps.sort((a, b) => a.order - b.order);
config.steps.forEach((s, index) => {
s.order = index;
});
await this.savePipelineConfig(projectPath, config);
logger.info(`Pipeline step added: ${newStep.name} (${newStep.id})`);
return newStep;
}
/**
* Update an existing pipeline step
*
* @param projectPath - Absolute path to the project
* @param stepId - ID of the step to update
* @param updates - Partial step data to merge
*/
async updateStep(
projectPath: string,
stepId: string,
updates: Partial<Omit<PipelineStep, 'id' | 'createdAt'>>
): Promise<PipelineStep> {
const config = await this.getPipelineConfig(projectPath);
const stepIndex = config.steps.findIndex((s) => s.id === stepId);
if (stepIndex === -1) {
throw new Error(`Pipeline step not found: ${stepId}`);
}
config.steps[stepIndex] = {
...config.steps[stepIndex],
...updates,
updatedAt: new Date().toISOString(),
};
await this.savePipelineConfig(projectPath, config);
logger.info(`Pipeline step updated: ${stepId}`);
return config.steps[stepIndex];
}
/**
* Delete a pipeline step
*
* @param projectPath - Absolute path to the project
* @param stepId - ID of the step to delete
*/
async deleteStep(projectPath: string, stepId: string): Promise<void> {
const config = await this.getPipelineConfig(projectPath);
const stepIndex = config.steps.findIndex((s) => s.id === stepId);
if (stepIndex === -1) {
throw new Error(`Pipeline step not found: ${stepId}`);
}
config.steps.splice(stepIndex, 1);
// Normalize order values after deletion
config.steps.forEach((s, index) => {
s.order = index;
});
await this.savePipelineConfig(projectPath, config);
logger.info(`Pipeline step deleted: ${stepId}`);
}
/**
* Reorder pipeline steps
*
* @param projectPath - Absolute path to the project
* @param stepIds - Array of step IDs in the desired order
*/
async reorderSteps(projectPath: string, stepIds: string[]): Promise<void> {
const config = await this.getPipelineConfig(projectPath);
// Validate all step IDs exist
const existingIds = new Set(config.steps.map((s) => s.id));
for (const id of stepIds) {
if (!existingIds.has(id)) {
throw new Error(`Pipeline step not found: ${id}`);
}
}
// Create a map for quick lookup
const stepMap = new Map(config.steps.map((s) => [s.id, s]));
// Reorder steps based on stepIds array
config.steps = stepIds.map((id, index) => {
const step = stepMap.get(id)!;
return { ...step, order: index, updatedAt: new Date().toISOString() };
});
await this.savePipelineConfig(projectPath, config);
logger.info(`Pipeline steps reordered`);
}
/**
* Get the next status in the pipeline flow
*
* Determines what status a feature should transition to based on current status.
* Flow: in_progress -> pipeline_step_0 -> pipeline_step_1 -> ... -> final status
*
* @param currentStatus - Current feature status
* @param config - Pipeline configuration (or null if no pipeline)
* @param skipTests - Whether to skip tests (affects final status)
* @returns The next status in the pipeline flow
*/
getNextStatus(
currentStatus: FeatureStatusWithPipeline,
config: PipelineConfig | null,
skipTests: boolean
): FeatureStatusWithPipeline {
const steps = config?.steps || [];
// Sort steps by order
const sortedSteps = [...steps].sort((a, b) => a.order - b.order);
// If no pipeline steps, use original logic
if (sortedSteps.length === 0) {
if (currentStatus === 'in_progress') {
return skipTests ? 'waiting_approval' : 'verified';
}
return currentStatus;
}
// Coming from in_progress -> go to first pipeline step
if (currentStatus === 'in_progress') {
return `pipeline_${sortedSteps[0].id}`;
}
// Coming from a pipeline step -> go to next step or final status
if (currentStatus.startsWith('pipeline_')) {
const currentStepId = currentStatus.replace('pipeline_', '');
const currentIndex = sortedSteps.findIndex((s) => s.id === currentStepId);
if (currentIndex === -1) {
// Step not found, go to final status
return skipTests ? 'waiting_approval' : 'verified';
}
if (currentIndex < sortedSteps.length - 1) {
// Go to next step
return `pipeline_${sortedSteps[currentIndex + 1].id}`;
}
// Last step completed, go to final status
return skipTests ? 'waiting_approval' : 'verified';
}
// For other statuses, don't change
return currentStatus;
}
/**
* Get a specific pipeline step by ID
*
* @param projectPath - Absolute path to the project
* @param stepId - ID of the step to retrieve
* @returns The pipeline step or null if not found
*/
async getStep(projectPath: string, stepId: string): Promise<PipelineStep | null> {
const config = await this.getPipelineConfig(projectPath);
return config.steps.find((s) => s.id === stepId) || null;
}
/**
* Check if a status is a pipeline status
*/
isPipelineStatus(status: FeatureStatusWithPipeline): boolean {
return status.startsWith('pipeline_');
}
/**
* Extract step ID from a pipeline status
*/
getStepIdFromStatus(status: FeatureStatusWithPipeline): string | null {
if (!this.isPipelineStatus(status)) {
return null;
}
return status.replace('pipeline_', '');
}
}
// Export singleton instance
export const pipelineService = new PipelineService();

View File

@@ -0,0 +1,499 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { Request, Response } from 'express';
import { createGetConfigHandler } from '@/routes/pipeline/routes/get-config.js';
import { createSaveConfigHandler } from '@/routes/pipeline/routes/save-config.js';
import { createAddStepHandler } from '@/routes/pipeline/routes/add-step.js';
import { createUpdateStepHandler } from '@/routes/pipeline/routes/update-step.js';
import { createDeleteStepHandler } from '@/routes/pipeline/routes/delete-step.js';
import { createReorderStepsHandler } from '@/routes/pipeline/routes/reorder-steps.js';
import type { PipelineService } from '@/services/pipeline-service.js';
import type { PipelineConfig, PipelineStep } from '@automaker/types';
import { createMockExpressContext } from '../../utils/mocks.js';
describe('pipeline routes', () => {
let mockPipelineService: PipelineService;
let req: Request;
let res: Response;
beforeEach(() => {
vi.clearAllMocks();
mockPipelineService = {
getPipelineConfig: vi.fn(),
savePipelineConfig: vi.fn(),
addStep: vi.fn(),
updateStep: vi.fn(),
deleteStep: vi.fn(),
reorderSteps: vi.fn(),
} as any;
const context = createMockExpressContext();
req = context.req;
res = context.res;
});
describe('get-config', () => {
it('should return pipeline config successfully', async () => {
const config: PipelineConfig = {
version: 1,
steps: [],
};
vi.mocked(mockPipelineService.getPipelineConfig).mockResolvedValue(config);
req.body = { projectPath: '/test/project' };
const handler = createGetConfigHandler(mockPipelineService);
await handler(req, res);
expect(mockPipelineService.getPipelineConfig).toHaveBeenCalledWith('/test/project');
expect(res.json).toHaveBeenCalledWith({
success: true,
config,
});
});
it('should return 400 if projectPath is missing', async () => {
req.body = {};
const handler = createGetConfigHandler(mockPipelineService);
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: 'projectPath is required',
});
expect(mockPipelineService.getPipelineConfig).not.toHaveBeenCalled();
});
it('should handle errors gracefully', async () => {
const error = new Error('Read failed');
vi.mocked(mockPipelineService.getPipelineConfig).mockRejectedValue(error);
req.body = { projectPath: '/test/project' };
const handler = createGetConfigHandler(mockPipelineService);
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: 'Read failed',
});
});
});
describe('save-config', () => {
it('should save pipeline config successfully', async () => {
const config: PipelineConfig = {
version: 1,
steps: [
{
id: 'step1',
name: 'Step 1',
order: 0,
instructions: 'Instructions',
colorClass: 'blue',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
],
};
vi.mocked(mockPipelineService.savePipelineConfig).mockResolvedValue(undefined);
req.body = { projectPath: '/test/project', config };
const handler = createSaveConfigHandler(mockPipelineService);
await handler(req, res);
expect(mockPipelineService.savePipelineConfig).toHaveBeenCalledWith('/test/project', config);
expect(res.json).toHaveBeenCalledWith({
success: true,
});
});
it('should return 400 if projectPath is missing', async () => {
req.body = { config: { version: 1, steps: [] } };
const handler = createSaveConfigHandler(mockPipelineService);
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: 'projectPath is required',
});
});
it('should return 400 if config is missing', async () => {
req.body = { projectPath: '/test/project' };
const handler = createSaveConfigHandler(mockPipelineService);
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: 'config is required',
});
});
it('should handle errors gracefully', async () => {
const error = new Error('Save failed');
vi.mocked(mockPipelineService.savePipelineConfig).mockRejectedValue(error);
req.body = {
projectPath: '/test/project',
config: { version: 1, steps: [] },
};
const handler = createSaveConfigHandler(mockPipelineService);
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: 'Save failed',
});
});
});
describe('add-step', () => {
it('should add step successfully', async () => {
const stepData = {
name: 'New Step',
order: 0,
instructions: 'Do something',
colorClass: 'blue',
};
const newStep: PipelineStep = {
...stepData,
id: 'step1',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
};
vi.mocked(mockPipelineService.addStep).mockResolvedValue(newStep);
req.body = { projectPath: '/test/project', step: stepData };
const handler = createAddStepHandler(mockPipelineService);
await handler(req, res);
expect(mockPipelineService.addStep).toHaveBeenCalledWith('/test/project', stepData);
expect(res.json).toHaveBeenCalledWith({
success: true,
step: newStep,
});
});
it('should return 400 if projectPath is missing', async () => {
req.body = { step: { name: 'Step', order: 0, instructions: 'Do', colorClass: 'blue' } };
const handler = createAddStepHandler(mockPipelineService);
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: 'projectPath is required',
});
});
it('should return 400 if step is missing', async () => {
req.body = { projectPath: '/test/project' };
const handler = createAddStepHandler(mockPipelineService);
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: 'step is required',
});
});
it('should return 400 if step.name is missing', async () => {
req.body = {
projectPath: '/test/project',
step: { order: 0, instructions: 'Do', colorClass: 'blue' },
};
const handler = createAddStepHandler(mockPipelineService);
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: 'step.name is required',
});
});
it('should return 400 if step.instructions is missing', async () => {
req.body = {
projectPath: '/test/project',
step: { name: 'Step', order: 0, colorClass: 'blue' },
};
const handler = createAddStepHandler(mockPipelineService);
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: 'step.instructions is required',
});
});
it('should handle errors gracefully', async () => {
const error = new Error('Add failed');
vi.mocked(mockPipelineService.addStep).mockRejectedValue(error);
req.body = {
projectPath: '/test/project',
step: { name: 'Step', order: 0, instructions: 'Do', colorClass: 'blue' },
};
const handler = createAddStepHandler(mockPipelineService);
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: 'Add failed',
});
});
});
describe('update-step', () => {
it('should update step successfully', async () => {
const updates = {
name: 'Updated Name',
instructions: 'Updated instructions',
};
const updatedStep: PipelineStep = {
id: 'step1',
name: 'Updated Name',
order: 0,
instructions: 'Updated instructions',
colorClass: 'blue',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-02T00:00:00.000Z',
};
vi.mocked(mockPipelineService.updateStep).mockResolvedValue(updatedStep);
req.body = { projectPath: '/test/project', stepId: 'step1', updates };
const handler = createUpdateStepHandler(mockPipelineService);
await handler(req, res);
expect(mockPipelineService.updateStep).toHaveBeenCalledWith(
'/test/project',
'step1',
updates
);
expect(res.json).toHaveBeenCalledWith({
success: true,
step: updatedStep,
});
});
it('should return 400 if projectPath is missing', async () => {
req.body = { stepId: 'step1', updates: { name: 'New' } };
const handler = createUpdateStepHandler(mockPipelineService);
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: 'projectPath is required',
});
});
it('should return 400 if stepId is missing', async () => {
req.body = { projectPath: '/test/project', updates: { name: 'New' } };
const handler = createUpdateStepHandler(mockPipelineService);
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: 'stepId is required',
});
});
it('should return 400 if updates is missing', async () => {
req.body = { projectPath: '/test/project', stepId: 'step1' };
const handler = createUpdateStepHandler(mockPipelineService);
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: 'updates is required',
});
});
it('should return 400 if updates is empty object', async () => {
req.body = { projectPath: '/test/project', stepId: 'step1', updates: {} };
const handler = createUpdateStepHandler(mockPipelineService);
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: 'updates is required',
});
});
it('should handle errors gracefully', async () => {
const error = new Error('Update failed');
vi.mocked(mockPipelineService.updateStep).mockRejectedValue(error);
req.body = {
projectPath: '/test/project',
stepId: 'step1',
updates: { name: 'New' },
};
const handler = createUpdateStepHandler(mockPipelineService);
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: 'Update failed',
});
});
});
describe('delete-step', () => {
it('should delete step successfully', async () => {
vi.mocked(mockPipelineService.deleteStep).mockResolvedValue(undefined);
req.body = { projectPath: '/test/project', stepId: 'step1' };
const handler = createDeleteStepHandler(mockPipelineService);
await handler(req, res);
expect(mockPipelineService.deleteStep).toHaveBeenCalledWith('/test/project', 'step1');
expect(res.json).toHaveBeenCalledWith({
success: true,
});
});
it('should return 400 if projectPath is missing', async () => {
req.body = { stepId: 'step1' };
const handler = createDeleteStepHandler(mockPipelineService);
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: 'projectPath is required',
});
});
it('should return 400 if stepId is missing', async () => {
req.body = { projectPath: '/test/project' };
const handler = createDeleteStepHandler(mockPipelineService);
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: 'stepId is required',
});
});
it('should handle errors gracefully', async () => {
const error = new Error('Delete failed');
vi.mocked(mockPipelineService.deleteStep).mockRejectedValue(error);
req.body = { projectPath: '/test/project', stepId: 'step1' };
const handler = createDeleteStepHandler(mockPipelineService);
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: 'Delete failed',
});
});
});
describe('reorder-steps', () => {
it('should reorder steps successfully', async () => {
vi.mocked(mockPipelineService.reorderSteps).mockResolvedValue(undefined);
req.body = { projectPath: '/test/project', stepIds: ['step2', 'step1', 'step3'] };
const handler = createReorderStepsHandler(mockPipelineService);
await handler(req, res);
expect(mockPipelineService.reorderSteps).toHaveBeenCalledWith('/test/project', [
'step2',
'step1',
'step3',
]);
expect(res.json).toHaveBeenCalledWith({
success: true,
});
});
it('should return 400 if projectPath is missing', async () => {
req.body = { stepIds: ['step1', 'step2'] };
const handler = createReorderStepsHandler(mockPipelineService);
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: 'projectPath is required',
});
});
it('should return 400 if stepIds is missing', async () => {
req.body = { projectPath: '/test/project' };
const handler = createReorderStepsHandler(mockPipelineService);
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: 'stepIds array is required',
});
});
it('should return 400 if stepIds is not an array', async () => {
req.body = { projectPath: '/test/project', stepIds: 'not-an-array' };
const handler = createReorderStepsHandler(mockPipelineService);
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: 'stepIds array is required',
});
});
it('should handle errors gracefully', async () => {
const error = new Error('Reorder failed');
vi.mocked(mockPipelineService.reorderSteps).mockRejectedValue(error);
req.body = { projectPath: '/test/project', stepIds: ['step1', 'step2'] };
const handler = createReorderStepsHandler(mockPipelineService);
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: 'Reorder failed',
});
});
});
});

View File

@@ -0,0 +1,860 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import fs from 'fs/promises';
import path from 'path';
import os from 'os';
import { PipelineService } from '@/services/pipeline-service.js';
import type { PipelineConfig, PipelineStep } from '@automaker/types';
// Mock secure-fs
vi.mock('@/lib/secure-fs.js', () => ({
readFile: vi.fn(),
writeFile: vi.fn(),
rename: vi.fn(),
unlink: vi.fn(),
}));
// Mock ensureAutomakerDir
vi.mock('@automaker/platform', () => ({
ensureAutomakerDir: vi.fn(),
}));
import * as secureFs from '@/lib/secure-fs.js';
import { ensureAutomakerDir } from '@automaker/platform';
describe('pipeline-service.ts', () => {
let testProjectDir: string;
let pipelineService: PipelineService;
beforeEach(async () => {
testProjectDir = path.join(os.tmpdir(), `pipeline-test-${Date.now()}`);
await fs.mkdir(testProjectDir, { recursive: true });
pipelineService = new PipelineService();
vi.clearAllMocks();
});
afterEach(async () => {
try {
await fs.rm(testProjectDir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
});
describe('getPipelineConfig', () => {
it('should return default config when file does not exist', async () => {
const error = new Error('File not found') as NodeJS.ErrnoException;
error.code = 'ENOENT';
vi.mocked(secureFs.readFile).mockRejectedValue(error);
const config = await pipelineService.getPipelineConfig(testProjectDir);
expect(config).toEqual({
version: 1,
steps: [],
});
});
it('should read and return existing config', async () => {
const existingConfig: PipelineConfig = {
version: 1,
steps: [
{
id: 'step1',
name: 'Test Step',
order: 0,
instructions: 'Do something',
colorClass: 'blue',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
],
};
const configPath = path.join(testProjectDir, '.automaker', 'pipeline.json');
vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any);
const config = await pipelineService.getPipelineConfig(testProjectDir);
expect(secureFs.readFile).toHaveBeenCalledWith(configPath, 'utf-8');
expect(config).toEqual(existingConfig);
});
it('should merge with defaults for missing properties', async () => {
const partialConfig = {
steps: [
{
id: 'step1',
name: 'Test Step',
order: 0,
instructions: 'Do something',
colorClass: 'blue',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
],
};
const configPath = path.join(testProjectDir, '.automaker', 'pipeline.json');
vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(partialConfig) as any);
const config = await pipelineService.getPipelineConfig(testProjectDir);
expect(config.version).toBe(1);
expect(config.steps).toHaveLength(1);
});
it('should handle read errors gracefully', async () => {
const error = new Error('Read error');
vi.mocked(secureFs.readFile).mockRejectedValue(error);
const config = await pipelineService.getPipelineConfig(testProjectDir);
// Should return default config on error
expect(config).toEqual({
version: 1,
steps: [],
});
});
});
describe('savePipelineConfig', () => {
it('should save config to file', async () => {
const config: PipelineConfig = {
version: 1,
steps: [
{
id: 'step1',
name: 'Test Step',
order: 0,
instructions: 'Do something',
colorClass: 'blue',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
],
};
vi.mocked(ensureAutomakerDir).mockResolvedValue(undefined);
vi.mocked(secureFs.writeFile).mockResolvedValue(undefined);
vi.mocked(secureFs.rename).mockResolvedValue(undefined);
await pipelineService.savePipelineConfig(testProjectDir, config);
expect(ensureAutomakerDir).toHaveBeenCalledWith(testProjectDir);
expect(secureFs.writeFile).toHaveBeenCalled();
expect(secureFs.rename).toHaveBeenCalled();
});
it('should use atomic write pattern', async () => {
const config: PipelineConfig = {
version: 1,
steps: [],
};
vi.mocked(ensureAutomakerDir).mockResolvedValue(undefined);
vi.mocked(secureFs.writeFile).mockResolvedValue(undefined);
vi.mocked(secureFs.rename).mockResolvedValue(undefined);
await pipelineService.savePipelineConfig(testProjectDir, config);
const writeCall = vi.mocked(secureFs.writeFile).mock.calls[0];
const tempPath = writeCall[0] as string;
expect(tempPath).toContain('.tmp.');
expect(tempPath).toContain('pipeline.json');
});
it('should clean up temp file on write error', async () => {
const config: PipelineConfig = {
version: 1,
steps: [],
};
vi.mocked(ensureAutomakerDir).mockResolvedValue(undefined);
vi.mocked(secureFs.writeFile).mockRejectedValue(new Error('Write failed'));
vi.mocked(secureFs.unlink).mockResolvedValue(undefined);
await expect(pipelineService.savePipelineConfig(testProjectDir, config)).rejects.toThrow(
'Write failed'
);
expect(secureFs.unlink).toHaveBeenCalled();
});
});
describe('addStep', () => {
it('should add a new step to config', async () => {
const error = new Error('File not found') as NodeJS.ErrnoException;
error.code = 'ENOENT';
vi.mocked(secureFs.readFile).mockRejectedValue(error);
vi.mocked(ensureAutomakerDir).mockResolvedValue(undefined);
vi.mocked(secureFs.writeFile).mockResolvedValue(undefined);
vi.mocked(secureFs.rename).mockResolvedValue(undefined);
const stepData = {
name: 'New Step',
order: 0,
instructions: 'Do something',
colorClass: 'blue',
};
const newStep = await pipelineService.addStep(testProjectDir, stepData);
expect(newStep.name).toBe('New Step');
expect(newStep.id).toMatch(/^step_/);
expect(newStep.createdAt).toBeDefined();
expect(newStep.updatedAt).toBeDefined();
expect(newStep.createdAt).toBe(newStep.updatedAt);
});
it('should normalize order values after adding step', async () => {
const existingConfig: PipelineConfig = {
version: 1,
steps: [
{
id: 'step1',
name: 'Step 1',
order: 5, // Out of order
instructions: 'Do something',
colorClass: 'blue',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
],
};
vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any);
vi.mocked(ensureAutomakerDir).mockResolvedValue(undefined);
vi.mocked(secureFs.writeFile).mockResolvedValue(undefined);
vi.mocked(secureFs.rename).mockResolvedValue(undefined);
const stepData = {
name: 'New Step',
order: 10, // Out of order
instructions: 'Do something',
colorClass: 'red',
};
await pipelineService.addStep(testProjectDir, stepData);
const writeCall = vi.mocked(secureFs.writeFile).mock.calls[0];
const savedConfig = JSON.parse(writeCall[1] as string) as PipelineConfig;
expect(savedConfig.steps[0].order).toBe(0);
expect(savedConfig.steps[1].order).toBe(1);
});
it('should sort steps by order before normalizing', async () => {
const existingConfig: PipelineConfig = {
version: 1,
steps: [
{
id: 'step1',
name: 'Step 1',
order: 2,
instructions: 'Do something',
colorClass: 'blue',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
{
id: 'step2',
name: 'Step 2',
order: 0,
instructions: 'Do something else',
colorClass: 'green',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
],
};
vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any);
vi.mocked(ensureAutomakerDir).mockResolvedValue(undefined);
vi.mocked(secureFs.writeFile).mockResolvedValue(undefined);
vi.mocked(secureFs.rename).mockResolvedValue(undefined);
const stepData = {
name: 'New Step',
order: 1,
instructions: 'Do something',
colorClass: 'red',
};
await pipelineService.addStep(testProjectDir, stepData);
const writeCall = vi.mocked(secureFs.writeFile).mock.calls[0];
const savedConfig = JSON.parse(writeCall[1] as string) as PipelineConfig;
// Should be sorted: step2 (order 0), newStep (order 1), step1 (order 2)
expect(savedConfig.steps[0].id).toBe('step2');
expect(savedConfig.steps[0].order).toBe(0);
expect(savedConfig.steps[1].order).toBe(1);
expect(savedConfig.steps[2].id).toBe('step1');
expect(savedConfig.steps[2].order).toBe(2);
});
});
describe('updateStep', () => {
it('should update an existing step', async () => {
const existingConfig: PipelineConfig = {
version: 1,
steps: [
{
id: 'step1',
name: 'Old Name',
order: 0,
instructions: 'Old instructions',
colorClass: 'blue',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
],
};
vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any);
vi.mocked(ensureAutomakerDir).mockResolvedValue(undefined);
vi.mocked(secureFs.writeFile).mockResolvedValue(undefined);
vi.mocked(secureFs.rename).mockResolvedValue(undefined);
const updates = {
name: 'New Name',
instructions: 'New instructions',
};
const updatedStep = await pipelineService.updateStep(testProjectDir, 'step1', updates);
expect(updatedStep.name).toBe('New Name');
expect(updatedStep.instructions).toBe('New instructions');
expect(updatedStep.id).toBe('step1');
expect(updatedStep.createdAt).toBe('2024-01-01T00:00:00.000Z');
expect(updatedStep.updatedAt).not.toBe('2024-01-01T00:00:00.000Z');
});
it('should throw error if step not found', async () => {
const existingConfig: PipelineConfig = {
version: 1,
steps: [],
};
vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any);
await expect(
pipelineService.updateStep(testProjectDir, 'nonexistent', { name: 'New' })
).rejects.toThrow('Pipeline step not found: nonexistent');
});
it('should preserve createdAt when updating', async () => {
const existingConfig: PipelineConfig = {
version: 1,
steps: [
{
id: 'step1',
name: 'Step',
order: 0,
instructions: 'Instructions',
colorClass: 'blue',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
],
};
vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any);
vi.mocked(ensureAutomakerDir).mockResolvedValue(undefined);
vi.mocked(secureFs.writeFile).mockResolvedValue(undefined);
vi.mocked(secureFs.rename).mockResolvedValue(undefined);
const updatedStep = await pipelineService.updateStep(testProjectDir, 'step1', {
name: 'Updated',
});
expect(updatedStep.createdAt).toBe('2024-01-01T00:00:00.000Z');
});
});
describe('deleteStep', () => {
it('should delete an existing step', async () => {
const existingConfig: PipelineConfig = {
version: 1,
steps: [
{
id: 'step1',
name: 'Step 1',
order: 0,
instructions: 'Instructions',
colorClass: 'blue',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
{
id: 'step2',
name: 'Step 2',
order: 1,
instructions: 'Instructions',
colorClass: 'green',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
],
};
vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any);
vi.mocked(ensureAutomakerDir).mockResolvedValue(undefined);
vi.mocked(secureFs.writeFile).mockResolvedValue(undefined);
vi.mocked(secureFs.rename).mockResolvedValue(undefined);
await pipelineService.deleteStep(testProjectDir, 'step1');
const writeCall = vi.mocked(secureFs.writeFile).mock.calls[0];
const savedConfig = JSON.parse(writeCall[1] as string) as PipelineConfig;
expect(savedConfig.steps).toHaveLength(1);
expect(savedConfig.steps[0].id).toBe('step2');
expect(savedConfig.steps[0].order).toBe(0); // Normalized
});
it('should throw error if step not found', async () => {
const existingConfig: PipelineConfig = {
version: 1,
steps: [],
};
vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any);
await expect(pipelineService.deleteStep(testProjectDir, 'nonexistent')).rejects.toThrow(
'Pipeline step not found: nonexistent'
);
});
it('should normalize order values after deletion', async () => {
const existingConfig: PipelineConfig = {
version: 1,
steps: [
{
id: 'step1',
name: 'Step 1',
order: 0,
instructions: 'Instructions',
colorClass: 'blue',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
{
id: 'step2',
name: 'Step 2',
order: 5, // Out of order
instructions: 'Instructions',
colorClass: 'green',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
{
id: 'step3',
name: 'Step 3',
order: 10, // Out of order
instructions: 'Instructions',
colorClass: 'red',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
],
};
vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any);
vi.mocked(ensureAutomakerDir).mockResolvedValue(undefined);
vi.mocked(secureFs.writeFile).mockResolvedValue(undefined);
vi.mocked(secureFs.rename).mockResolvedValue(undefined);
await pipelineService.deleteStep(testProjectDir, 'step2');
const writeCall = vi.mocked(secureFs.writeFile).mock.calls[0];
const savedConfig = JSON.parse(writeCall[1] as string) as PipelineConfig;
expect(savedConfig.steps).toHaveLength(2);
expect(savedConfig.steps[0].order).toBe(0);
expect(savedConfig.steps[1].order).toBe(1);
});
});
describe('reorderSteps', () => {
it('should reorder steps according to stepIds array', async () => {
const existingConfig: PipelineConfig = {
version: 1,
steps: [
{
id: 'step1',
name: 'Step 1',
order: 0,
instructions: 'Instructions',
colorClass: 'blue',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
{
id: 'step2',
name: 'Step 2',
order: 1,
instructions: 'Instructions',
colorClass: 'green',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
{
id: 'step3',
name: 'Step 3',
order: 2,
instructions: 'Instructions',
colorClass: 'red',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
],
};
vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any);
vi.mocked(ensureAutomakerDir).mockResolvedValue(undefined);
vi.mocked(secureFs.writeFile).mockResolvedValue(undefined);
vi.mocked(secureFs.rename).mockResolvedValue(undefined);
await pipelineService.reorderSteps(testProjectDir, ['step3', 'step1', 'step2']);
const writeCall = vi.mocked(secureFs.writeFile).mock.calls[0];
const savedConfig = JSON.parse(writeCall[1] as string) as PipelineConfig;
expect(savedConfig.steps[0].id).toBe('step3');
expect(savedConfig.steps[0].order).toBe(0);
expect(savedConfig.steps[1].id).toBe('step1');
expect(savedConfig.steps[1].order).toBe(1);
expect(savedConfig.steps[2].id).toBe('step2');
expect(savedConfig.steps[2].order).toBe(2);
});
it('should update updatedAt timestamp for reordered steps', async () => {
const existingConfig: PipelineConfig = {
version: 1,
steps: [
{
id: 'step1',
name: 'Step 1',
order: 0,
instructions: 'Instructions',
colorClass: 'blue',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
{
id: 'step2',
name: 'Step 2',
order: 1,
instructions: 'Instructions',
colorClass: 'green',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
],
};
vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any);
vi.mocked(ensureAutomakerDir).mockResolvedValue(undefined);
vi.mocked(secureFs.writeFile).mockResolvedValue(undefined);
vi.mocked(secureFs.rename).mockResolvedValue(undefined);
await pipelineService.reorderSteps(testProjectDir, ['step2', 'step1']);
const writeCall = vi.mocked(secureFs.writeFile).mock.calls[0];
const savedConfig = JSON.parse(writeCall[1] as string) as PipelineConfig;
expect(savedConfig.steps[0].updatedAt).not.toBe('2024-01-01T00:00:00.000Z');
expect(savedConfig.steps[1].updatedAt).not.toBe('2024-01-01T00:00:00.000Z');
});
it('should throw error if step ID not found', async () => {
const existingConfig: PipelineConfig = {
version: 1,
steps: [
{
id: 'step1',
name: 'Step 1',
order: 0,
instructions: 'Instructions',
colorClass: 'blue',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
],
};
vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any);
await expect(
pipelineService.reorderSteps(testProjectDir, ['step1', 'nonexistent'])
).rejects.toThrow('Pipeline step not found: nonexistent');
});
it('should allow partial reordering (filtering steps)', async () => {
const existingConfig: PipelineConfig = {
version: 1,
steps: [
{
id: 'step1',
name: 'Step 1',
order: 0,
instructions: 'Instructions',
colorClass: 'blue',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
{
id: 'step2',
name: 'Step 2',
order: 1,
instructions: 'Instructions',
colorClass: 'green',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
],
};
vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any);
vi.mocked(ensureAutomakerDir).mockResolvedValue(undefined);
vi.mocked(secureFs.writeFile).mockResolvedValue(undefined);
vi.mocked(secureFs.rename).mockResolvedValue(undefined);
await pipelineService.reorderSteps(testProjectDir, ['step1']);
const writeCall = vi.mocked(secureFs.writeFile).mock.calls[0];
const savedConfig = JSON.parse(writeCall[1] as string) as PipelineConfig;
// Should only keep step1, effectively filtering out step2
expect(savedConfig.steps).toHaveLength(1);
expect(savedConfig.steps[0].id).toBe('step1');
expect(savedConfig.steps[0].order).toBe(0);
});
});
describe('getNextStatus', () => {
it('should return waiting_approval when no pipeline and skipTests is true', () => {
const nextStatus = pipelineService.getNextStatus('in_progress', null, true);
expect(nextStatus).toBe('waiting_approval');
});
it('should return verified when no pipeline and skipTests is false', () => {
const nextStatus = pipelineService.getNextStatus('in_progress', null, false);
expect(nextStatus).toBe('verified');
});
it('should return first pipeline step when coming from in_progress', () => {
const config: PipelineConfig = {
version: 1,
steps: [
{
id: 'step1',
name: 'Step 1',
order: 0,
instructions: 'Instructions',
colorClass: 'blue',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
],
};
const nextStatus = pipelineService.getNextStatus('in_progress', config, false);
expect(nextStatus).toBe('pipeline_step1');
});
it('should go to next pipeline step when in middle of pipeline', () => {
const config: PipelineConfig = {
version: 1,
steps: [
{
id: 'step1',
name: 'Step 1',
order: 0,
instructions: 'Instructions',
colorClass: 'blue',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
{
id: 'step2',
name: 'Step 2',
order: 1,
instructions: 'Instructions',
colorClass: 'green',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
],
};
const nextStatus = pipelineService.getNextStatus('pipeline_step1', config, false);
expect(nextStatus).toBe('pipeline_step2');
});
it('should go to final status when completing last pipeline step', () => {
const config: PipelineConfig = {
version: 1,
steps: [
{
id: 'step1',
name: 'Step 1',
order: 0,
instructions: 'Instructions',
colorClass: 'blue',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
],
};
const nextStatus = pipelineService.getNextStatus('pipeline_step1', config, false);
expect(nextStatus).toBe('verified');
});
it('should go to waiting_approval when completing last step with skipTests', () => {
const config: PipelineConfig = {
version: 1,
steps: [
{
id: 'step1',
name: 'Step 1',
order: 0,
instructions: 'Instructions',
colorClass: 'blue',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
],
};
const nextStatus = pipelineService.getNextStatus('pipeline_step1', config, true);
expect(nextStatus).toBe('waiting_approval');
});
it('should handle invalid pipeline step ID gracefully', () => {
const config: PipelineConfig = {
version: 1,
steps: [
{
id: 'step1',
name: 'Step 1',
order: 0,
instructions: 'Instructions',
colorClass: 'blue',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
],
};
const nextStatus = pipelineService.getNextStatus('pipeline_nonexistent', config, false);
expect(nextStatus).toBe('verified');
});
it('should preserve other statuses unchanged', () => {
const config: PipelineConfig = {
version: 1,
steps: [],
};
expect(pipelineService.getNextStatus('backlog', config, false)).toBe('backlog');
expect(pipelineService.getNextStatus('waiting_approval', config, false)).toBe(
'waiting_approval'
);
expect(pipelineService.getNextStatus('verified', config, false)).toBe('verified');
expect(pipelineService.getNextStatus('completed', config, false)).toBe('completed');
});
it('should sort steps by order when determining next status', () => {
const config: PipelineConfig = {
version: 1,
steps: [
{
id: 'step2',
name: 'Step 2',
order: 1,
instructions: 'Instructions',
colorClass: 'green',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
{
id: 'step1',
name: 'Step 1',
order: 0,
instructions: 'Instructions',
colorClass: 'blue',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
],
};
const nextStatus = pipelineService.getNextStatus('in_progress', config, false);
expect(nextStatus).toBe('pipeline_step1'); // Should use step1 (order 0), not step2
});
});
describe('getStep', () => {
it('should return step by ID', async () => {
const existingConfig: PipelineConfig = {
version: 1,
steps: [
{
id: 'step1',
name: 'Step 1',
order: 0,
instructions: 'Instructions',
colorClass: 'blue',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
],
};
vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any);
const step = await pipelineService.getStep(testProjectDir, 'step1');
expect(step).not.toBeNull();
expect(step?.id).toBe('step1');
expect(step?.name).toBe('Step 1');
});
it('should return null if step not found', async () => {
const existingConfig: PipelineConfig = {
version: 1,
steps: [],
};
vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any);
const step = await pipelineService.getStep(testProjectDir, 'nonexistent');
expect(step).toBeNull();
});
});
describe('isPipelineStatus', () => {
it('should return true for pipeline statuses', () => {
expect(pipelineService.isPipelineStatus('pipeline_step1')).toBe(true);
expect(pipelineService.isPipelineStatus('pipeline_abc123')).toBe(true);
});
it('should return false for non-pipeline statuses', () => {
expect(pipelineService.isPipelineStatus('in_progress')).toBe(false);
expect(pipelineService.isPipelineStatus('waiting_approval')).toBe(false);
expect(pipelineService.isPipelineStatus('verified')).toBe(false);
expect(pipelineService.isPipelineStatus('backlog')).toBe(false);
expect(pipelineService.isPipelineStatus('completed')).toBe(false);
});
});
describe('getStepIdFromStatus', () => {
it('should extract step ID from pipeline status', () => {
expect(pipelineService.getStepIdFromStatus('pipeline_step1')).toBe('step1');
expect(pipelineService.getStepIdFromStatus('pipeline_abc123')).toBe('abc123');
});
it('should return null for non-pipeline statuses', () => {
expect(pipelineService.getStepIdFromStatus('in_progress')).toBeNull();
expect(pipelineService.getStepIdFromStatus('waiting_approval')).toBeNull();
expect(pipelineService.getStepIdFromStatus('verified')).toBeNull();
});
});
});