mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-03 21:03:08 +00:00
Merge pull request #637 from AutoMaker-Org/feature/v0.13.0rc-1768936017583-e6ni
feat: implement pipeline step exclusion functionality
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@automaker/server",
|
"name": "@automaker/server",
|
||||||
"version": "0.12.0",
|
"version": "0.13.0",
|
||||||
"description": "Backend server for Automaker - provides API for both web and Electron modes",
|
"description": "Backend server for Automaker - provides API for both web and Electron modes",
|
||||||
"author": "AutoMaker Team",
|
"author": "AutoMaker Team",
|
||||||
"license": "SEE LICENSE IN LICENSE",
|
"license": "SEE LICENSE IN LICENSE",
|
||||||
|
|||||||
@@ -16,6 +16,16 @@ export type {
|
|||||||
ProviderMessage,
|
ProviderMessage,
|
||||||
InstallationStatus,
|
InstallationStatus,
|
||||||
ModelDefinition,
|
ModelDefinition,
|
||||||
|
AgentDefinition,
|
||||||
|
ReasoningEffort,
|
||||||
|
SystemPromptPreset,
|
||||||
|
ConversationMessage,
|
||||||
|
ContentBlock,
|
||||||
|
ValidationResult,
|
||||||
|
McpServerConfig,
|
||||||
|
McpStdioServerConfig,
|
||||||
|
McpSSEServerConfig,
|
||||||
|
McpHttpServerConfig,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
|
|
||||||
// Claude provider
|
// Claude provider
|
||||||
|
|||||||
@@ -19,4 +19,7 @@ export type {
|
|||||||
InstallationStatus,
|
InstallationStatus,
|
||||||
ValidationResult,
|
ValidationResult,
|
||||||
ModelDefinition,
|
ModelDefinition,
|
||||||
|
AgentDefinition,
|
||||||
|
ReasoningEffort,
|
||||||
|
SystemPromptPreset,
|
||||||
} from '@automaker/types';
|
} from '@automaker/types';
|
||||||
|
|||||||
@@ -1281,7 +1281,11 @@ export class AutoModeService {
|
|||||||
|
|
||||||
// Check for pipeline steps and execute them
|
// Check for pipeline steps and execute them
|
||||||
const pipelineConfig = await pipelineService.getPipelineConfig(projectPath);
|
const pipelineConfig = await pipelineService.getPipelineConfig(projectPath);
|
||||||
const sortedSteps = [...(pipelineConfig?.steps || [])].sort((a, b) => a.order - b.order);
|
// Filter out excluded pipeline steps and sort by order
|
||||||
|
const excludedStepIds = new Set(feature.excludedPipelineSteps || []);
|
||||||
|
const sortedSteps = [...(pipelineConfig?.steps || [])]
|
||||||
|
.sort((a, b) => a.order - b.order)
|
||||||
|
.filter((step) => !excludedStepIds.has(step.id));
|
||||||
|
|
||||||
if (sortedSteps.length > 0) {
|
if (sortedSteps.length > 0) {
|
||||||
// Execute pipeline steps sequentially
|
// Execute pipeline steps sequentially
|
||||||
@@ -1743,15 +1747,76 @@ Complete the pipeline step instructions above. Review the previous work and appl
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const featureId = feature.id;
|
const featureId = feature.id;
|
||||||
|
|
||||||
const sortedSteps = [...pipelineConfig.steps].sort((a, b) => a.order - b.order);
|
// Sort all steps first
|
||||||
|
const allSortedSteps = [...pipelineConfig.steps].sort((a, b) => a.order - b.order);
|
||||||
|
|
||||||
// Validate step index
|
// Get the current step we're resuming from (using the index from unfiltered list)
|
||||||
if (startFromStepIndex < 0 || startFromStepIndex >= sortedSteps.length) {
|
if (startFromStepIndex < 0 || startFromStepIndex >= allSortedSteps.length) {
|
||||||
throw new Error(`Invalid step index: ${startFromStepIndex}`);
|
throw new Error(`Invalid step index: ${startFromStepIndex}`);
|
||||||
}
|
}
|
||||||
|
const currentStep = allSortedSteps[startFromStepIndex];
|
||||||
|
|
||||||
// Get steps to execute (from startFromStepIndex onwards)
|
// Filter out excluded pipeline steps
|
||||||
const stepsToExecute = sortedSteps.slice(startFromStepIndex);
|
const excludedStepIds = new Set(feature.excludedPipelineSteps || []);
|
||||||
|
|
||||||
|
// Check if the current step is excluded
|
||||||
|
// If so, use getNextStatus to find the appropriate next step
|
||||||
|
if (excludedStepIds.has(currentStep.id)) {
|
||||||
|
console.log(
|
||||||
|
`[AutoMode] Current step ${currentStep.id} is excluded for feature ${featureId}, finding next valid step`
|
||||||
|
);
|
||||||
|
const nextStatus = pipelineService.getNextStatus(
|
||||||
|
`pipeline_${currentStep.id}`,
|
||||||
|
pipelineConfig,
|
||||||
|
feature.skipTests ?? false,
|
||||||
|
feature.excludedPipelineSteps
|
||||||
|
);
|
||||||
|
|
||||||
|
// If next status is not a pipeline step, feature is done
|
||||||
|
if (!pipelineService.isPipelineStatus(nextStatus)) {
|
||||||
|
await this.updateFeatureStatus(projectPath, featureId, nextStatus);
|
||||||
|
this.emitAutoModeEvent('auto_mode_feature_complete', {
|
||||||
|
featureId,
|
||||||
|
featureName: feature.title,
|
||||||
|
branchName: feature.branchName ?? null,
|
||||||
|
passes: true,
|
||||||
|
message: 'Pipeline completed (remaining steps excluded)',
|
||||||
|
projectPath,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the next step and update the start index
|
||||||
|
const nextStepId = pipelineService.getStepIdFromStatus(nextStatus);
|
||||||
|
const nextStepIndex = allSortedSteps.findIndex((s) => s.id === nextStepId);
|
||||||
|
if (nextStepIndex === -1) {
|
||||||
|
throw new Error(`Next step ${nextStepId} not found in pipeline config`);
|
||||||
|
}
|
||||||
|
startFromStepIndex = nextStepIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get steps to execute (from startFromStepIndex onwards, excluding excluded steps)
|
||||||
|
const stepsToExecute = allSortedSteps
|
||||||
|
.slice(startFromStepIndex)
|
||||||
|
.filter((step) => !excludedStepIds.has(step.id));
|
||||||
|
|
||||||
|
// If no steps left to execute, complete the feature
|
||||||
|
if (stepsToExecute.length === 0) {
|
||||||
|
const finalStatus = feature.skipTests ? 'waiting_approval' : 'verified';
|
||||||
|
await this.updateFeatureStatus(projectPath, featureId, finalStatus);
|
||||||
|
this.emitAutoModeEvent('auto_mode_feature_complete', {
|
||||||
|
featureId,
|
||||||
|
featureName: feature.title,
|
||||||
|
branchName: feature.branchName ?? null,
|
||||||
|
passes: true,
|
||||||
|
message: 'Pipeline completed (all remaining steps excluded)',
|
||||||
|
projectPath,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the filtered steps for counting
|
||||||
|
const sortedSteps = allSortedSteps.filter((step) => !excludedStepIds.has(step.id));
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`[AutoMode] Resuming pipeline for feature ${featureId} from step ${startFromStepIndex + 1}/${sortedSteps.length}`
|
`[AutoMode] Resuming pipeline for feature ${featureId} from step ${startFromStepIndex + 1}/${sortedSteps.length}`
|
||||||
|
|||||||
@@ -234,51 +234,75 @@ export class PipelineService {
|
|||||||
*
|
*
|
||||||
* Determines what status a feature should transition to based on current status.
|
* Determines what status a feature should transition to based on current status.
|
||||||
* Flow: in_progress -> pipeline_step_0 -> pipeline_step_1 -> ... -> final status
|
* Flow: in_progress -> pipeline_step_0 -> pipeline_step_1 -> ... -> final status
|
||||||
|
* Steps in the excludedStepIds array will be skipped.
|
||||||
*
|
*
|
||||||
* @param currentStatus - Current feature status
|
* @param currentStatus - Current feature status
|
||||||
* @param config - Pipeline configuration (or null if no pipeline)
|
* @param config - Pipeline configuration (or null if no pipeline)
|
||||||
* @param skipTests - Whether to skip tests (affects final status)
|
* @param skipTests - Whether to skip tests (affects final status)
|
||||||
|
* @param excludedStepIds - Optional array of step IDs to skip
|
||||||
* @returns The next status in the pipeline flow
|
* @returns The next status in the pipeline flow
|
||||||
*/
|
*/
|
||||||
getNextStatus(
|
getNextStatus(
|
||||||
currentStatus: FeatureStatusWithPipeline,
|
currentStatus: FeatureStatusWithPipeline,
|
||||||
config: PipelineConfig | null,
|
config: PipelineConfig | null,
|
||||||
skipTests: boolean
|
skipTests: boolean,
|
||||||
|
excludedStepIds?: string[]
|
||||||
): FeatureStatusWithPipeline {
|
): FeatureStatusWithPipeline {
|
||||||
const steps = config?.steps || [];
|
const steps = config?.steps || [];
|
||||||
|
const exclusions = new Set(excludedStepIds || []);
|
||||||
|
|
||||||
// Sort steps by order
|
// Sort steps by order and filter out excluded steps
|
||||||
const sortedSteps = [...steps].sort((a, b) => a.order - b.order);
|
const sortedSteps = [...steps]
|
||||||
|
.sort((a, b) => a.order - b.order)
|
||||||
|
.filter((step) => !exclusions.has(step.id));
|
||||||
|
|
||||||
// If no pipeline steps, use original logic
|
// If no pipeline steps (or all excluded), use original logic
|
||||||
if (sortedSteps.length === 0) {
|
if (sortedSteps.length === 0) {
|
||||||
if (currentStatus === 'in_progress') {
|
// If coming from in_progress or already in a pipeline step, go to final status
|
||||||
|
if (currentStatus === 'in_progress' || currentStatus.startsWith('pipeline_')) {
|
||||||
return skipTests ? 'waiting_approval' : 'verified';
|
return skipTests ? 'waiting_approval' : 'verified';
|
||||||
}
|
}
|
||||||
return currentStatus;
|
return currentStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Coming from in_progress -> go to first pipeline step
|
// Coming from in_progress -> go to first non-excluded pipeline step
|
||||||
if (currentStatus === 'in_progress') {
|
if (currentStatus === 'in_progress') {
|
||||||
return `pipeline_${sortedSteps[0].id}`;
|
return `pipeline_${sortedSteps[0].id}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Coming from a pipeline step -> go to next step or final status
|
// Coming from a pipeline step -> go to next non-excluded step or final status
|
||||||
if (currentStatus.startsWith('pipeline_')) {
|
if (currentStatus.startsWith('pipeline_')) {
|
||||||
const currentStepId = currentStatus.replace('pipeline_', '');
|
const currentStepId = currentStatus.replace('pipeline_', '');
|
||||||
const currentIndex = sortedSteps.findIndex((s) => s.id === currentStepId);
|
const currentIndex = sortedSteps.findIndex((s) => s.id === currentStepId);
|
||||||
|
|
||||||
if (currentIndex === -1) {
|
if (currentIndex === -1) {
|
||||||
// Step not found, go to final status
|
// Current step not found in filtered list (might be excluded or invalid)
|
||||||
|
// Find next valid step after this one from the original sorted list
|
||||||
|
const allSortedSteps = [...steps].sort((a, b) => a.order - b.order);
|
||||||
|
const originalIndex = allSortedSteps.findIndex((s) => s.id === currentStepId);
|
||||||
|
|
||||||
|
if (originalIndex === -1) {
|
||||||
|
// Step truly doesn't exist, go to final status
|
||||||
|
return skipTests ? 'waiting_approval' : 'verified';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the next non-excluded step after the current one
|
||||||
|
for (let i = originalIndex + 1; i < allSortedSteps.length; i++) {
|
||||||
|
if (!exclusions.has(allSortedSteps[i].id)) {
|
||||||
|
return `pipeline_${allSortedSteps[i].id}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No more non-excluded steps, go to final status
|
||||||
return skipTests ? 'waiting_approval' : 'verified';
|
return skipTests ? 'waiting_approval' : 'verified';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentIndex < sortedSteps.length - 1) {
|
if (currentIndex < sortedSteps.length - 1) {
|
||||||
// Go to next step
|
// Go to next non-excluded step
|
||||||
return `pipeline_${sortedSteps[currentIndex + 1].id}`;
|
return `pipeline_${sortedSteps[currentIndex + 1].id}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Last step completed, go to final status
|
// Last non-excluded step completed, go to final status
|
||||||
return skipTests ? 'waiting_approval' : 'verified';
|
return skipTests ? 'waiting_approval' : 'verified';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -788,6 +788,367 @@ describe('pipeline-service.ts', () => {
|
|||||||
const nextStatus = pipelineService.getNextStatus('in_progress', config, false);
|
const nextStatus = pipelineService.getNextStatus('in_progress', config, false);
|
||||||
expect(nextStatus).toBe('pipeline_step1'); // Should use step1 (order 0), not step2
|
expect(nextStatus).toBe('pipeline_step1'); // Should use step1 (order 0), not step2
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('with exclusions', () => {
|
||||||
|
it('should skip excluded 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',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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('in_progress', config, false, ['step1']);
|
||||||
|
expect(nextStatus).toBe('pipeline_step2'); // Should skip step1 and go to step2
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should skip excluded step when moving between steps', () => {
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextStatus = pipelineService.getNextStatus('pipeline_step1', config, false, [
|
||||||
|
'step2',
|
||||||
|
]);
|
||||||
|
expect(nextStatus).toBe('pipeline_step3'); // Should skip step2 and go to step3
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should go to final status when all remaining steps are excluded', () => {
|
||||||
|
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, [
|
||||||
|
'step2',
|
||||||
|
]);
|
||||||
|
expect(nextStatus).toBe('verified'); // No more steps after exclusion
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should go to waiting_approval when all remaining steps excluded and skipTests is true', () => {
|
||||||
|
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, true, ['step2']);
|
||||||
|
expect(nextStatus).toBe('waiting_approval');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should go to final status when all steps are excluded 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',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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('in_progress', config, false, [
|
||||||
|
'step1',
|
||||||
|
'step2',
|
||||||
|
]);
|
||||||
|
expect(nextStatus).toBe('verified');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty exclusions array like no exclusions', () => {
|
||||||
|
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 handle undefined exclusions like no exclusions', () => {
|
||||||
|
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, undefined);
|
||||||
|
expect(nextStatus).toBe('pipeline_step1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should skip multiple excluded steps in sequence', () => {
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'step4',
|
||||||
|
name: 'Step 4',
|
||||||
|
order: 3,
|
||||||
|
instructions: 'Instructions',
|
||||||
|
colorClass: 'yellow',
|
||||||
|
createdAt: '2024-01-01T00:00:00.000Z',
|
||||||
|
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Exclude step2 and step3
|
||||||
|
const nextStatus = pipelineService.getNextStatus('pipeline_step1', config, false, [
|
||||||
|
'step2',
|
||||||
|
'step3',
|
||||||
|
]);
|
||||||
|
expect(nextStatus).toBe('pipeline_step4'); // Should skip step2 and step3
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle exclusion of non-existent step IDs 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',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Exclude a non-existent step - should have no effect
|
||||||
|
const nextStatus = pipelineService.getNextStatus('in_progress', config, false, [
|
||||||
|
'nonexistent',
|
||||||
|
]);
|
||||||
|
expect(nextStatus).toBe('pipeline_step1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should find next valid step when current step becomes excluded mid-flow', () => {
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Feature is at step1 but step1 is now excluded - should find next valid step
|
||||||
|
const nextStatus = pipelineService.getNextStatus('pipeline_step1', config, false, [
|
||||||
|
'step1',
|
||||||
|
'step2',
|
||||||
|
]);
|
||||||
|
expect(nextStatus).toBe('pipeline_step3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should go to final status when current step is excluded and no steps remain', () => {
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Feature is at step1 but both steps are excluded
|
||||||
|
const nextStatus = pipelineService.getNextStatus('pipeline_step1', config, false, [
|
||||||
|
'step1',
|
||||||
|
'step2',
|
||||||
|
]);
|
||||||
|
expect(nextStatus).toBe('verified');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getStep', () => {
|
describe('getStep', () => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@automaker/ui",
|
"name": "@automaker/ui",
|
||||||
"version": "0.12.0",
|
"version": "0.13.0",
|
||||||
"description": "An autonomous AI development studio that helps you build software faster using AI-powered agents",
|
"description": "An autonomous AI development studio that helps you build software faster using AI-powered agents",
|
||||||
"homepage": "https://github.com/AutoMaker-Org/automaker",
|
"homepage": "https://github.com/AutoMaker-Org/automaker",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -1489,6 +1489,7 @@ export function BoardView() {
|
|||||||
branchSuggestions={branchSuggestions}
|
branchSuggestions={branchSuggestions}
|
||||||
branchCardCounts={branchCardCounts}
|
branchCardCounts={branchCardCounts}
|
||||||
currentBranch={currentWorktreeBranch || undefined}
|
currentBranch={currentWorktreeBranch || undefined}
|
||||||
|
projectPath={currentProject?.path}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Board Background Modal */}
|
{/* Board Background Modal */}
|
||||||
@@ -1538,6 +1539,7 @@ export function BoardView() {
|
|||||||
isMaximized={isMaximized}
|
isMaximized={isMaximized}
|
||||||
parentFeature={spawnParentFeature}
|
parentFeature={spawnParentFeature}
|
||||||
allFeatures={hookFeatures}
|
allFeatures={hookFeatures}
|
||||||
|
projectPath={currentProject?.path}
|
||||||
// When setting is enabled and a non-main worktree is selected, pass its branch to default to 'custom' work mode
|
// When setting is enabled and a non-main worktree is selected, pass its branch to default to 'custom' work mode
|
||||||
selectedNonMainWorktreeBranch={
|
selectedNonMainWorktreeBranch={
|
||||||
addFeatureUseSelectedWorktreeBranch && currentWorktreePath !== null
|
addFeatureUseSelectedWorktreeBranch && currentWorktreePath !== null
|
||||||
@@ -1568,6 +1570,7 @@ export function BoardView() {
|
|||||||
currentBranch={currentWorktreeBranch || undefined}
|
currentBranch={currentWorktreeBranch || undefined}
|
||||||
isMaximized={isMaximized}
|
isMaximized={isMaximized}
|
||||||
allFeatures={hookFeatures}
|
allFeatures={hookFeatures}
|
||||||
|
projectPath={currentProject?.path}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Agent Output Modal */}
|
{/* Agent Output Modal */}
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ import { memo, useEffect, useMemo, useState } from 'react';
|
|||||||
import { Feature, useAppStore } from '@/store/app-store';
|
import { Feature, useAppStore } from '@/store/app-store';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
import { AlertCircle, Lock, Hand, Sparkles } from 'lucide-react';
|
import { AlertCircle, Lock, Hand, Sparkles, SkipForward } from 'lucide-react';
|
||||||
import { getBlockingDependencies } from '@automaker/dependency-resolver';
|
import { getBlockingDependencies } from '@automaker/dependency-resolver';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
import { usePipelineConfig } from '@/hooks/queries/use-pipeline';
|
||||||
|
|
||||||
/** Uniform badge style for all card badges */
|
/** Uniform badge style for all card badges */
|
||||||
const uniformBadgeClass =
|
const uniformBadgeClass =
|
||||||
@@ -51,9 +52,13 @@ export const CardBadges = memo(function CardBadges({ feature }: CardBadgesProps)
|
|||||||
|
|
||||||
interface PriorityBadgesProps {
|
interface PriorityBadgesProps {
|
||||||
feature: Feature;
|
feature: Feature;
|
||||||
|
projectPath?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PriorityBadges = memo(function PriorityBadges({ feature }: PriorityBadgesProps) {
|
export const PriorityBadges = memo(function PriorityBadges({
|
||||||
|
feature,
|
||||||
|
projectPath,
|
||||||
|
}: PriorityBadgesProps) {
|
||||||
const { enableDependencyBlocking, features } = useAppStore(
|
const { enableDependencyBlocking, features } = useAppStore(
|
||||||
useShallow((state) => ({
|
useShallow((state) => ({
|
||||||
enableDependencyBlocking: state.enableDependencyBlocking,
|
enableDependencyBlocking: state.enableDependencyBlocking,
|
||||||
@@ -62,6 +67,9 @@ export const PriorityBadges = memo(function PriorityBadges({ feature }: Priority
|
|||||||
);
|
);
|
||||||
const [currentTime, setCurrentTime] = useState(() => Date.now());
|
const [currentTime, setCurrentTime] = useState(() => Date.now());
|
||||||
|
|
||||||
|
// Fetch pipeline config to check if there are pipelines to exclude
|
||||||
|
const { data: pipelineConfig } = usePipelineConfig(projectPath);
|
||||||
|
|
||||||
// Calculate blocking dependencies (if feature is in backlog and has incomplete dependencies)
|
// Calculate blocking dependencies (if feature is in backlog and has incomplete dependencies)
|
||||||
const blockingDependencies = useMemo(() => {
|
const blockingDependencies = useMemo(() => {
|
||||||
if (!enableDependencyBlocking || feature.status !== 'backlog') {
|
if (!enableDependencyBlocking || feature.status !== 'backlog') {
|
||||||
@@ -108,7 +116,19 @@ export const PriorityBadges = memo(function PriorityBadges({ feature }: Priority
|
|||||||
const showManualVerification =
|
const showManualVerification =
|
||||||
feature.skipTests && !feature.error && feature.status === 'backlog';
|
feature.skipTests && !feature.error && feature.status === 'backlog';
|
||||||
|
|
||||||
const showBadges = feature.priority || showManualVerification || isBlocked || isJustFinished;
|
// Check if feature has excluded pipeline steps
|
||||||
|
const excludedStepCount = feature.excludedPipelineSteps?.length || 0;
|
||||||
|
const totalPipelineSteps = pipelineConfig?.steps?.length || 0;
|
||||||
|
const hasPipelineExclusions =
|
||||||
|
excludedStepCount > 0 && totalPipelineSteps > 0 && feature.status === 'backlog';
|
||||||
|
const allPipelinesExcluded = hasPipelineExclusions && excludedStepCount >= totalPipelineSteps;
|
||||||
|
|
||||||
|
const showBadges =
|
||||||
|
feature.priority ||
|
||||||
|
showManualVerification ||
|
||||||
|
isBlocked ||
|
||||||
|
isJustFinished ||
|
||||||
|
hasPipelineExclusions;
|
||||||
|
|
||||||
if (!showBadges) {
|
if (!showBadges) {
|
||||||
return null;
|
return null;
|
||||||
@@ -227,6 +247,39 @@ export const PriorityBadges = memo(function PriorityBadges({ feature }: Priority
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Pipeline exclusion badge */}
|
||||||
|
{hasPipelineExclusions && (
|
||||||
|
<TooltipProvider delayDuration={200}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
uniformBadgeClass,
|
||||||
|
allPipelinesExcluded
|
||||||
|
? 'bg-violet-500/20 border-violet-500/50 text-violet-500'
|
||||||
|
: 'bg-violet-500/10 border-violet-500/30 text-violet-400'
|
||||||
|
)}
|
||||||
|
data-testid={`pipeline-exclusion-badge-${feature.id}`}
|
||||||
|
>
|
||||||
|
<SkipForward className="w-3.5 h-3.5" />
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom" className="text-xs max-w-[250px]">
|
||||||
|
<p className="font-medium mb-1">
|
||||||
|
{allPipelinesExcluded
|
||||||
|
? 'All pipelines skipped'
|
||||||
|
: `${excludedStepCount} of ${totalPipelineSteps} pipeline${totalPipelineSteps !== 1 ? 's' : ''} skipped`}
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{allPipelinesExcluded
|
||||||
|
? 'This feature will skip all custom pipeline steps'
|
||||||
|
: 'Some custom pipeline steps will be skipped for this feature'}
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -236,7 +236,7 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Priority and Manual Verification badges */}
|
{/* Priority and Manual Verification badges */}
|
||||||
<PriorityBadges feature={feature} />
|
<PriorityBadges feature={feature} projectPath={currentProject?.path} />
|
||||||
|
|
||||||
{/* Card Header */}
|
{/* Card Header */}
|
||||||
<CardHeaderSection
|
<CardHeaderSection
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import {
|
|||||||
AncestorContextSection,
|
AncestorContextSection,
|
||||||
EnhanceWithAI,
|
EnhanceWithAI,
|
||||||
EnhancementHistoryButton,
|
EnhancementHistoryButton,
|
||||||
|
PipelineExclusionControls,
|
||||||
type BaseHistoryEntry,
|
type BaseHistoryEntry,
|
||||||
} from '../shared';
|
} from '../shared';
|
||||||
import type { WorkMode } from '../shared';
|
import type { WorkMode } from '../shared';
|
||||||
@@ -101,6 +102,7 @@ type FeatureData = {
|
|||||||
requirePlanApproval: boolean;
|
requirePlanApproval: boolean;
|
||||||
dependencies?: string[];
|
dependencies?: string[];
|
||||||
childDependencies?: string[]; // Feature IDs that should depend on this feature
|
childDependencies?: string[]; // Feature IDs that should depend on this feature
|
||||||
|
excludedPipelineSteps?: string[]; // Pipeline step IDs to skip for this feature
|
||||||
workMode: WorkMode;
|
workMode: WorkMode;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -118,6 +120,10 @@ interface AddFeatureDialogProps {
|
|||||||
isMaximized: boolean;
|
isMaximized: boolean;
|
||||||
parentFeature?: Feature | null;
|
parentFeature?: Feature | null;
|
||||||
allFeatures?: Feature[];
|
allFeatures?: Feature[];
|
||||||
|
/**
|
||||||
|
* Path to the current project for loading pipeline config.
|
||||||
|
*/
|
||||||
|
projectPath?: string;
|
||||||
/**
|
/**
|
||||||
* When a non-main worktree is selected in the board header, this will be set to that worktree's branch.
|
* When a non-main worktree is selected in the board header, this will be set to that worktree's branch.
|
||||||
* When set, the dialog will default to 'custom' work mode with this branch pre-filled.
|
* When set, the dialog will default to 'custom' work mode with this branch pre-filled.
|
||||||
@@ -151,6 +157,7 @@ export function AddFeatureDialog({
|
|||||||
isMaximized,
|
isMaximized,
|
||||||
parentFeature = null,
|
parentFeature = null,
|
||||||
allFeatures = [],
|
allFeatures = [],
|
||||||
|
projectPath,
|
||||||
selectedNonMainWorktreeBranch,
|
selectedNonMainWorktreeBranch,
|
||||||
forceCurrentBranchMode,
|
forceCurrentBranchMode,
|
||||||
}: AddFeatureDialogProps) {
|
}: AddFeatureDialogProps) {
|
||||||
@@ -194,6 +201,9 @@ export function AddFeatureDialog({
|
|||||||
const [parentDependencies, setParentDependencies] = useState<string[]>([]);
|
const [parentDependencies, setParentDependencies] = useState<string[]>([]);
|
||||||
const [childDependencies, setChildDependencies] = useState<string[]>([]);
|
const [childDependencies, setChildDependencies] = useState<string[]>([]);
|
||||||
|
|
||||||
|
// Pipeline exclusion state
|
||||||
|
const [excludedPipelineSteps, setExcludedPipelineSteps] = useState<string[]>([]);
|
||||||
|
|
||||||
// Get defaults from store
|
// Get defaults from store
|
||||||
const {
|
const {
|
||||||
defaultPlanningMode,
|
defaultPlanningMode,
|
||||||
@@ -242,6 +252,9 @@ export function AddFeatureDialog({
|
|||||||
// Reset dependency selections
|
// Reset dependency selections
|
||||||
setParentDependencies([]);
|
setParentDependencies([]);
|
||||||
setChildDependencies([]);
|
setChildDependencies([]);
|
||||||
|
|
||||||
|
// Reset pipeline exclusions (all pipelines enabled by default)
|
||||||
|
setExcludedPipelineSteps([]);
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
open,
|
open,
|
||||||
@@ -336,6 +349,7 @@ export function AddFeatureDialog({
|
|||||||
requirePlanApproval,
|
requirePlanApproval,
|
||||||
dependencies: finalDependencies,
|
dependencies: finalDependencies,
|
||||||
childDependencies: childDependencies.length > 0 ? childDependencies : undefined,
|
childDependencies: childDependencies.length > 0 ? childDependencies : undefined,
|
||||||
|
excludedPipelineSteps: excludedPipelineSteps.length > 0 ? excludedPipelineSteps : undefined,
|
||||||
workMode,
|
workMode,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -362,6 +376,7 @@ export function AddFeatureDialog({
|
|||||||
setDescriptionHistory([]);
|
setDescriptionHistory([]);
|
||||||
setParentDependencies([]);
|
setParentDependencies([]);
|
||||||
setChildDependencies([]);
|
setChildDependencies([]);
|
||||||
|
setExcludedPipelineSteps([]);
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -704,6 +719,16 @@ export function AddFeatureDialog({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Pipeline Exclusion Controls */}
|
||||||
|
<div className="pt-2">
|
||||||
|
<PipelineExclusionControls
|
||||||
|
projectPath={projectPath}
|
||||||
|
excludedPipelineSteps={excludedPipelineSteps}
|
||||||
|
onExcludedStepsChange={setExcludedPipelineSteps}
|
||||||
|
testIdPrefix="add-feature-pipeline"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import {
|
|||||||
PlanningModeSelect,
|
PlanningModeSelect,
|
||||||
EnhanceWithAI,
|
EnhanceWithAI,
|
||||||
EnhancementHistoryButton,
|
EnhancementHistoryButton,
|
||||||
|
PipelineExclusionControls,
|
||||||
type EnhancementMode,
|
type EnhancementMode,
|
||||||
} from '../shared';
|
} from '../shared';
|
||||||
import type { WorkMode } from '../shared';
|
import type { WorkMode } from '../shared';
|
||||||
@@ -67,6 +68,7 @@ interface EditFeatureDialogProps {
|
|||||||
requirePlanApproval: boolean;
|
requirePlanApproval: boolean;
|
||||||
dependencies?: string[];
|
dependencies?: string[];
|
||||||
childDependencies?: string[]; // Feature IDs that should depend on this feature
|
childDependencies?: string[]; // Feature IDs that should depend on this feature
|
||||||
|
excludedPipelineSteps?: string[]; // Pipeline step IDs to skip for this feature
|
||||||
},
|
},
|
||||||
descriptionHistorySource?: 'enhance' | 'edit',
|
descriptionHistorySource?: 'enhance' | 'edit',
|
||||||
enhancementMode?: EnhancementMode,
|
enhancementMode?: EnhancementMode,
|
||||||
@@ -78,6 +80,7 @@ interface EditFeatureDialogProps {
|
|||||||
currentBranch?: string;
|
currentBranch?: string;
|
||||||
isMaximized: boolean;
|
isMaximized: boolean;
|
||||||
allFeatures: Feature[];
|
allFeatures: Feature[];
|
||||||
|
projectPath?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EditFeatureDialog({
|
export function EditFeatureDialog({
|
||||||
@@ -90,6 +93,7 @@ export function EditFeatureDialog({
|
|||||||
currentBranch,
|
currentBranch,
|
||||||
isMaximized,
|
isMaximized,
|
||||||
allFeatures,
|
allFeatures,
|
||||||
|
projectPath,
|
||||||
}: EditFeatureDialogProps) {
|
}: EditFeatureDialogProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [editingFeature, setEditingFeature] = useState<Feature | null>(feature);
|
const [editingFeature, setEditingFeature] = useState<Feature | null>(feature);
|
||||||
@@ -146,6 +150,11 @@ export function EditFeatureDialog({
|
|||||||
return allFeatures.filter((f) => f.dependencies?.includes(feature.id)).map((f) => f.id);
|
return allFeatures.filter((f) => f.dependencies?.includes(feature.id)).map((f) => f.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Pipeline exclusion state
|
||||||
|
const [excludedPipelineSteps, setExcludedPipelineSteps] = useState<string[]>(
|
||||||
|
feature?.excludedPipelineSteps ?? []
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setEditingFeature(feature);
|
setEditingFeature(feature);
|
||||||
if (feature) {
|
if (feature) {
|
||||||
@@ -171,6 +180,8 @@ export function EditFeatureDialog({
|
|||||||
.map((f) => f.id);
|
.map((f) => f.id);
|
||||||
setChildDependencies(childDeps);
|
setChildDependencies(childDeps);
|
||||||
setOriginalChildDependencies(childDeps);
|
setOriginalChildDependencies(childDeps);
|
||||||
|
// Reset pipeline exclusion state
|
||||||
|
setExcludedPipelineSteps(feature.excludedPipelineSteps ?? []);
|
||||||
} else {
|
} else {
|
||||||
setEditFeaturePreviewMap(new Map());
|
setEditFeaturePreviewMap(new Map());
|
||||||
setDescriptionChangeSource(null);
|
setDescriptionChangeSource(null);
|
||||||
@@ -179,6 +190,7 @@ export function EditFeatureDialog({
|
|||||||
setParentDependencies([]);
|
setParentDependencies([]);
|
||||||
setChildDependencies([]);
|
setChildDependencies([]);
|
||||||
setOriginalChildDependencies([]);
|
setOriginalChildDependencies([]);
|
||||||
|
setExcludedPipelineSteps([]);
|
||||||
}
|
}
|
||||||
}, [feature, allFeatures]);
|
}, [feature, allFeatures]);
|
||||||
|
|
||||||
@@ -232,6 +244,7 @@ export function EditFeatureDialog({
|
|||||||
workMode,
|
workMode,
|
||||||
dependencies: parentDependencies,
|
dependencies: parentDependencies,
|
||||||
childDependencies: childDepsChanged ? childDependencies : undefined,
|
childDependencies: childDepsChanged ? childDependencies : undefined,
|
||||||
|
excludedPipelineSteps: excludedPipelineSteps.length > 0 ? excludedPipelineSteps : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Determine if description changed and what source to use
|
// Determine if description changed and what source to use
|
||||||
@@ -618,6 +631,16 @@ export function EditFeatureDialog({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Pipeline Exclusion Controls */}
|
||||||
|
<div className="pt-2">
|
||||||
|
<PipelineExclusionControls
|
||||||
|
projectPath={projectPath}
|
||||||
|
excludedPipelineSteps={excludedPipelineSteps}
|
||||||
|
onExcludedStepsChange={setExcludedPipelineSteps}
|
||||||
|
testIdPrefix="edit-feature-pipeline"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,13 @@ import { Label } from '@/components/ui/label';
|
|||||||
import { AlertCircle } from 'lucide-react';
|
import { AlertCircle } from 'lucide-react';
|
||||||
import { modelSupportsThinking } from '@/lib/utils';
|
import { modelSupportsThinking } from '@/lib/utils';
|
||||||
import { Feature, ModelAlias, ThinkingLevel, PlanningMode } from '@/store/app-store';
|
import { Feature, ModelAlias, ThinkingLevel, PlanningMode } from '@/store/app-store';
|
||||||
import { TestingTabContent, PrioritySelect, PlanningModeSelect, WorkModeSelector } from '../shared';
|
import {
|
||||||
|
TestingTabContent,
|
||||||
|
PrioritySelect,
|
||||||
|
PlanningModeSelect,
|
||||||
|
WorkModeSelector,
|
||||||
|
PipelineExclusionControls,
|
||||||
|
} from '../shared';
|
||||||
import type { WorkMode } from '../shared';
|
import type { WorkMode } from '../shared';
|
||||||
import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults/phase-model-selector';
|
import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults/phase-model-selector';
|
||||||
import { isCursorModel, isClaudeModel, type PhaseModelEntry } from '@automaker/types';
|
import { isCursorModel, isClaudeModel, type PhaseModelEntry } from '@automaker/types';
|
||||||
@@ -28,6 +34,7 @@ interface MassEditDialogProps {
|
|||||||
branchSuggestions: string[];
|
branchSuggestions: string[];
|
||||||
branchCardCounts?: Record<string, number>;
|
branchCardCounts?: Record<string, number>;
|
||||||
currentBranch?: string;
|
currentBranch?: string;
|
||||||
|
projectPath?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ApplyState {
|
interface ApplyState {
|
||||||
@@ -38,11 +45,13 @@ interface ApplyState {
|
|||||||
priority: boolean;
|
priority: boolean;
|
||||||
skipTests: boolean;
|
skipTests: boolean;
|
||||||
branchName: boolean;
|
branchName: boolean;
|
||||||
|
excludedPipelineSteps: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getMixedValues(features: Feature[]): Record<string, boolean> {
|
function getMixedValues(features: Feature[]): Record<string, boolean> {
|
||||||
if (features.length === 0) return {};
|
if (features.length === 0) return {};
|
||||||
const first = features[0];
|
const first = features[0];
|
||||||
|
const firstExcludedSteps = JSON.stringify(first.excludedPipelineSteps || []);
|
||||||
return {
|
return {
|
||||||
model: !features.every((f) => f.model === first.model),
|
model: !features.every((f) => f.model === first.model),
|
||||||
thinkingLevel: !features.every((f) => f.thinkingLevel === first.thinkingLevel),
|
thinkingLevel: !features.every((f) => f.thinkingLevel === first.thinkingLevel),
|
||||||
@@ -53,6 +62,9 @@ function getMixedValues(features: Feature[]): Record<string, boolean> {
|
|||||||
priority: !features.every((f) => f.priority === first.priority),
|
priority: !features.every((f) => f.priority === first.priority),
|
||||||
skipTests: !features.every((f) => f.skipTests === first.skipTests),
|
skipTests: !features.every((f) => f.skipTests === first.skipTests),
|
||||||
branchName: !features.every((f) => f.branchName === first.branchName),
|
branchName: !features.every((f) => f.branchName === first.branchName),
|
||||||
|
excludedPipelineSteps: !features.every(
|
||||||
|
(f) => JSON.stringify(f.excludedPipelineSteps || []) === firstExcludedSteps
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,6 +123,7 @@ export function MassEditDialog({
|
|||||||
branchSuggestions,
|
branchSuggestions,
|
||||||
branchCardCounts,
|
branchCardCounts,
|
||||||
currentBranch,
|
currentBranch,
|
||||||
|
projectPath,
|
||||||
}: MassEditDialogProps) {
|
}: MassEditDialogProps) {
|
||||||
const [isApplying, setIsApplying] = useState(false);
|
const [isApplying, setIsApplying] = useState(false);
|
||||||
|
|
||||||
@@ -123,6 +136,7 @@ export function MassEditDialog({
|
|||||||
priority: false,
|
priority: false,
|
||||||
skipTests: false,
|
skipTests: false,
|
||||||
branchName: false,
|
branchName: false,
|
||||||
|
excludedPipelineSteps: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Field values
|
// Field values
|
||||||
@@ -146,6 +160,11 @@ export function MassEditDialog({
|
|||||||
return getInitialValue(selectedFeatures, 'branchName', '') as string;
|
return getInitialValue(selectedFeatures, 'branchName', '') as string;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Pipeline exclusion state
|
||||||
|
const [excludedPipelineSteps, setExcludedPipelineSteps] = useState<string[]>(() => {
|
||||||
|
return getInitialValue(selectedFeatures, 'excludedPipelineSteps', []) as string[];
|
||||||
|
});
|
||||||
|
|
||||||
// Calculate mixed values
|
// Calculate mixed values
|
||||||
const mixedValues = useMemo(() => getMixedValues(selectedFeatures), [selectedFeatures]);
|
const mixedValues = useMemo(() => getMixedValues(selectedFeatures), [selectedFeatures]);
|
||||||
|
|
||||||
@@ -160,6 +179,7 @@ export function MassEditDialog({
|
|||||||
priority: false,
|
priority: false,
|
||||||
skipTests: false,
|
skipTests: false,
|
||||||
branchName: false,
|
branchName: false,
|
||||||
|
excludedPipelineSteps: false,
|
||||||
});
|
});
|
||||||
setModel(getInitialValue(selectedFeatures, 'model', 'claude-sonnet') as ModelAlias);
|
setModel(getInitialValue(selectedFeatures, 'model', 'claude-sonnet') as ModelAlias);
|
||||||
setThinkingLevel(getInitialValue(selectedFeatures, 'thinkingLevel', 'none') as ThinkingLevel);
|
setThinkingLevel(getInitialValue(selectedFeatures, 'thinkingLevel', 'none') as ThinkingLevel);
|
||||||
@@ -172,6 +192,10 @@ export function MassEditDialog({
|
|||||||
const initialBranchName = getInitialValue(selectedFeatures, 'branchName', '') as string;
|
const initialBranchName = getInitialValue(selectedFeatures, 'branchName', '') as string;
|
||||||
setBranchName(initialBranchName);
|
setBranchName(initialBranchName);
|
||||||
setWorkMode(initialBranchName ? 'custom' : 'current');
|
setWorkMode(initialBranchName ? 'custom' : 'current');
|
||||||
|
// Reset pipeline exclusions
|
||||||
|
setExcludedPipelineSteps(
|
||||||
|
getInitialValue(selectedFeatures, 'excludedPipelineSteps', []) as string[]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}, [open, selectedFeatures]);
|
}, [open, selectedFeatures]);
|
||||||
|
|
||||||
@@ -190,6 +214,10 @@ export function MassEditDialog({
|
|||||||
// For 'custom' mode, use the specified branch name
|
// For 'custom' mode, use the specified branch name
|
||||||
updates.branchName = workMode === 'custom' ? branchName : '';
|
updates.branchName = workMode === 'custom' ? branchName : '';
|
||||||
}
|
}
|
||||||
|
if (applyState.excludedPipelineSteps) {
|
||||||
|
updates.excludedPipelineSteps =
|
||||||
|
excludedPipelineSteps.length > 0 ? excludedPipelineSteps : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
if (Object.keys(updates).length === 0) {
|
if (Object.keys(updates).length === 0) {
|
||||||
onClose();
|
onClose();
|
||||||
@@ -353,6 +381,23 @@ export function MassEditDialog({
|
|||||||
testIdPrefix="mass-edit-work-mode"
|
testIdPrefix="mass-edit-work-mode"
|
||||||
/>
|
/>
|
||||||
</FieldWrapper>
|
</FieldWrapper>
|
||||||
|
|
||||||
|
{/* Pipeline Exclusion */}
|
||||||
|
<FieldWrapper
|
||||||
|
label="Pipeline Steps"
|
||||||
|
isMixed={mixedValues.excludedPipelineSteps}
|
||||||
|
willApply={applyState.excludedPipelineSteps}
|
||||||
|
onApplyChange={(apply) =>
|
||||||
|
setApplyState((prev) => ({ ...prev, excludedPipelineSteps: apply }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<PipelineExclusionControls
|
||||||
|
projectPath={projectPath}
|
||||||
|
excludedPipelineSteps={excludedPipelineSteps}
|
||||||
|
onExcludedStepsChange={setExcludedPipelineSteps}
|
||||||
|
testIdPrefix="mass-edit-pipeline"
|
||||||
|
/>
|
||||||
|
</FieldWrapper>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
|
|||||||
@@ -11,3 +11,4 @@ export * from './planning-mode-select';
|
|||||||
export * from './ancestor-context-section';
|
export * from './ancestor-context-section';
|
||||||
export * from './work-mode-selector';
|
export * from './work-mode-selector';
|
||||||
export * from './enhancement';
|
export * from './enhancement';
|
||||||
|
export * from './pipeline-exclusion-controls';
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { GitBranch, Workflow } from 'lucide-react';
|
||||||
|
import { usePipelineConfig } from '@/hooks/queries/use-pipeline';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface PipelineExclusionControlsProps {
|
||||||
|
projectPath: string | undefined;
|
||||||
|
excludedPipelineSteps: string[];
|
||||||
|
onExcludedStepsChange: (excludedSteps: string[]) => void;
|
||||||
|
testIdPrefix?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component for selecting which custom pipeline steps should be excluded for a feature.
|
||||||
|
* Each pipeline step is shown as a toggleable switch, defaulting to enabled (included).
|
||||||
|
* Disabling a step adds it to the exclusion list.
|
||||||
|
*/
|
||||||
|
export function PipelineExclusionControls({
|
||||||
|
projectPath,
|
||||||
|
excludedPipelineSteps,
|
||||||
|
onExcludedStepsChange,
|
||||||
|
testIdPrefix = 'pipeline-exclusion',
|
||||||
|
disabled = false,
|
||||||
|
}: PipelineExclusionControlsProps) {
|
||||||
|
const { data: pipelineConfig, isLoading } = usePipelineConfig(projectPath);
|
||||||
|
|
||||||
|
// Sort steps by order
|
||||||
|
const sortedSteps = [...(pipelineConfig?.steps || [])].sort((a, b) => a.order - b.order);
|
||||||
|
|
||||||
|
// If no pipeline steps exist or loading, don't render anything
|
||||||
|
if (isLoading || sortedSteps.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleStep = (stepId: string) => {
|
||||||
|
const isCurrentlyExcluded = excludedPipelineSteps.includes(stepId);
|
||||||
|
if (isCurrentlyExcluded) {
|
||||||
|
// Remove from exclusions (enable the step)
|
||||||
|
onExcludedStepsChange(excludedPipelineSteps.filter((id) => id !== stepId));
|
||||||
|
} else {
|
||||||
|
// Add to exclusions (disable the step)
|
||||||
|
onExcludedStepsChange([...excludedPipelineSteps, stepId]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const allExcluded = sortedSteps.every((step) => excludedPipelineSteps.includes(step.id));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Workflow className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<Label className="text-sm font-medium">Custom Pipeline Steps</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{sortedSteps.map((step) => {
|
||||||
|
const isIncluded = !excludedPipelineSteps.includes(step.id);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={step.id}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center justify-between gap-3 px-3 py-2 rounded-md border',
|
||||||
|
isIncluded
|
||||||
|
? 'border-border/50 bg-muted/30'
|
||||||
|
: 'border-border/30 bg-muted/10 opacity-60'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'w-2 h-2 rounded-full flex-shrink-0',
|
||||||
|
step.colorClass || 'bg-gray-400'
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
backgroundColor: step.colorClass?.startsWith('#') ? step.colorClass : undefined,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'text-sm truncate',
|
||||||
|
isIncluded ? 'text-foreground' : 'text-muted-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{step.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={isIncluded}
|
||||||
|
onCheckedChange={() => toggleStep(step.id)}
|
||||||
|
disabled={disabled}
|
||||||
|
data-testid={`${testIdPrefix}-step-${step.id}`}
|
||||||
|
aria-label={`${isIncluded ? 'Disable' : 'Enable'} ${step.name} pipeline step`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{allExcluded && (
|
||||||
|
<p className="text-xs text-muted-foreground flex items-center gap-1.5">
|
||||||
|
<GitBranch className="w-3.5 h-3.5" />
|
||||||
|
All pipeline steps disabled. Feature will skip directly to verification.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Enabled steps will run after implementation. Disable steps to skip them for this feature.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -392,6 +392,7 @@ export function GraphViewPage() {
|
|||||||
currentBranch={currentWorktreeBranch || undefined}
|
currentBranch={currentWorktreeBranch || undefined}
|
||||||
isMaximized={false}
|
isMaximized={false}
|
||||||
allFeatures={hookFeatures}
|
allFeatures={hookFeatures}
|
||||||
|
projectPath={currentProject?.path}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Add Feature Dialog (for spawning) */}
|
{/* Add Feature Dialog (for spawning) */}
|
||||||
@@ -414,6 +415,7 @@ export function GraphViewPage() {
|
|||||||
isMaximized={false}
|
isMaximized={false}
|
||||||
parentFeature={spawnParentFeature}
|
parentFeature={spawnParentFeature}
|
||||||
allFeatures={hookFeatures}
|
allFeatures={hookFeatures}
|
||||||
|
projectPath={currentProject?.path}
|
||||||
// When setting is enabled and a non-main worktree is selected, pass its branch to default to 'custom' work mode
|
// When setting is enabled and a non-main worktree is selected, pass its branch to default to 'custom' work mode
|
||||||
selectedNonMainWorktreeBranch={
|
selectedNonMainWorktreeBranch={
|
||||||
addFeatureUseSelectedWorktreeBranch && currentWorktreePath !== null
|
addFeatureUseSelectedWorktreeBranch && currentWorktreePath !== null
|
||||||
|
|||||||
@@ -28,6 +28,29 @@ import type {
|
|||||||
InstallationStatus,
|
InstallationStatus,
|
||||||
ValidationResult,
|
ValidationResult,
|
||||||
ModelDefinition,
|
ModelDefinition,
|
||||||
|
AgentDefinition,
|
||||||
|
ReasoningEffort,
|
||||||
|
SystemPromptPreset,
|
||||||
|
McpServerConfig,
|
||||||
|
McpStdioServerConfig,
|
||||||
|
McpSSEServerConfig,
|
||||||
|
McpHttpServerConfig,
|
||||||
|
} from '@automaker/types';
|
||||||
|
```
|
||||||
|
|
||||||
|
### Codex CLI Types
|
||||||
|
|
||||||
|
Types for Codex CLI integration.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import type {
|
||||||
|
CodexSandboxMode,
|
||||||
|
CodexApprovalPolicy,
|
||||||
|
CodexCliConfig,
|
||||||
|
CodexAuthStatus,
|
||||||
|
CodexEventType,
|
||||||
|
CodexItemType,
|
||||||
|
CodexEvent,
|
||||||
} from '@automaker/types';
|
} from '@automaker/types';
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ export interface Feature {
|
|||||||
// Branch info - worktree path is derived at runtime from branchName
|
// Branch info - worktree path is derived at runtime from branchName
|
||||||
branchName?: string; // Name of the feature branch (undefined = use current worktree)
|
branchName?: string; // Name of the feature branch (undefined = use current worktree)
|
||||||
skipTests?: boolean;
|
skipTests?: boolean;
|
||||||
|
excludedPipelineSteps?: string[]; // Array of pipeline step IDs to skip for this feature
|
||||||
thinkingLevel?: ThinkingLevel;
|
thinkingLevel?: ThinkingLevel;
|
||||||
reasoningEffort?: ReasoningEffort;
|
reasoningEffort?: ReasoningEffort;
|
||||||
planningMode?: PlanningMode;
|
planningMode?: PlanningMode;
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ export type {
|
|||||||
McpHttpServerConfig,
|
McpHttpServerConfig,
|
||||||
AgentDefinition,
|
AgentDefinition,
|
||||||
ReasoningEffort,
|
ReasoningEffort,
|
||||||
|
// System prompt configuration for CLAUDE.md auto-loading
|
||||||
|
SystemPromptPreset,
|
||||||
} from './provider.js';
|
} from './provider.js';
|
||||||
|
|
||||||
// Provider constants and utilities
|
// Provider constants and utilities
|
||||||
@@ -34,6 +36,10 @@ export type {
|
|||||||
CodexApprovalPolicy,
|
CodexApprovalPolicy,
|
||||||
CodexCliConfig,
|
CodexCliConfig,
|
||||||
CodexAuthStatus,
|
CodexAuthStatus,
|
||||||
|
// Event types for CLI event parsing
|
||||||
|
CodexEventType,
|
||||||
|
CodexItemType,
|
||||||
|
CodexEvent,
|
||||||
} from './codex.js';
|
} from './codex.js';
|
||||||
export * from './codex-models.js';
|
export * from './codex-models.js';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "automaker",
|
"name": "automaker",
|
||||||
"version": "0.12.0rc",
|
"version": "0.13.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22.0.0 <23.0.0"
|
"node": ">=22.0.0 <23.0.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user