feat: implement pipeline step exclusion functionality

- Added support for excluding specific pipeline steps in feature management, allowing users to skip certain steps during execution.
- Introduced a new `PipelineExclusionControls` component for managing exclusions in the UI.
- Updated relevant dialogs and components to handle excluded pipeline steps, including `AddFeatureDialog`, `EditFeatureDialog`, and `MassEditDialog`.
- Enhanced the `getNextStatus` method in `PipelineService` to account for excluded steps when determining the next status in the pipeline flow.
- Updated tests to cover scenarios involving excluded pipeline steps.
This commit is contained in:
Shirone
2026-01-21 08:34:55 +01:00
parent 2ab78dd590
commit a73a57b9a4
17 changed files with 783 additions and 22 deletions

View File

@@ -636,10 +636,12 @@ export function BoardView() {
const result = await api.features.bulkUpdate(currentProject.path, featureIds, finalUpdates);
if (result.success) {
// Update local state
// Update local Zustand state
featureIds.forEach((featureId) => {
updateFeature(featureId, finalUpdates);
});
// Invalidate React Query cache to ensure features are refetched with updated data
loadFeatures();
toast.success(`Updated ${result.updatedCount} features`);
exitSelectionMode();
} else {
@@ -661,6 +663,7 @@ export function BoardView() {
addAndSelectWorktree,
currentWorktreeBranch,
setWorktreeRefreshKey,
loadFeatures,
]
);
@@ -1493,6 +1496,7 @@ export function BoardView() {
branchSuggestions={branchSuggestions}
branchCardCounts={branchCardCounts}
currentBranch={currentWorktreeBranch || undefined}
projectPath={currentProject?.path}
/>
{/* Board Background Modal */}
@@ -1542,6 +1546,7 @@ export function BoardView() {
isMaximized={isMaximized}
parentFeature={spawnParentFeature}
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
selectedNonMainWorktreeBranch={
addFeatureUseSelectedWorktreeBranch && currentWorktreePath !== null
@@ -1572,6 +1577,7 @@ export function BoardView() {
currentBranch={currentWorktreeBranch || undefined}
isMaximized={isMaximized}
allFeatures={hookFeatures}
projectPath={currentProject?.path}
/>
{/* Agent Output Modal */}

View File

@@ -3,9 +3,10 @@ import { memo, useEffect, useMemo, useState } from 'react';
import { Feature, useAppStore } from '@/store/app-store';
import { cn } from '@/lib/utils';
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 { useShallow } from 'zustand/react/shallow';
import { usePipelineConfig } from '@/hooks/queries/use-pipeline';
/** Uniform badge style for all card badges */
const uniformBadgeClass =
@@ -51,9 +52,13 @@ export const CardBadges = memo(function CardBadges({ feature }: CardBadgesProps)
interface PriorityBadgesProps {
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(
useShallow((state) => ({
enableDependencyBlocking: state.enableDependencyBlocking,
@@ -62,6 +67,9 @@ export const PriorityBadges = memo(function PriorityBadges({ feature }: Priority
);
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)
const blockingDependencies = useMemo(() => {
if (!enableDependencyBlocking || feature.status !== 'backlog') {
@@ -108,7 +116,19 @@ export const PriorityBadges = memo(function PriorityBadges({ feature }: Priority
const showManualVerification =
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) {
return null;
@@ -227,6 +247,39 @@ export const PriorityBadges = memo(function PriorityBadges({ feature }: Priority
</Tooltip>
</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>
);
});

View File

@@ -234,7 +234,7 @@ export const KanbanCard = memo(function KanbanCard({
</div>
{/* Priority and Manual Verification badges */}
<PriorityBadges feature={feature} />
<PriorityBadges feature={feature} projectPath={currentProject?.path} />
{/* Card Header */}
<CardHeaderSection

View File

@@ -45,6 +45,7 @@ import {
AncestorContextSection,
EnhanceWithAI,
EnhancementHistoryButton,
PipelineExclusionControls,
type BaseHistoryEntry,
} from '../shared';
import type { WorkMode } from '../shared';
@@ -101,6 +102,7 @@ type FeatureData = {
requirePlanApproval: boolean;
dependencies?: string[];
childDependencies?: string[]; // Feature IDs that should depend on this feature
excludedPipelineSteps?: string[]; // Pipeline step IDs to skip for this feature
workMode: WorkMode;
};
@@ -118,6 +120,10 @@ interface AddFeatureDialogProps {
isMaximized: boolean;
parentFeature?: Feature | null;
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 set, the dialog will default to 'custom' work mode with this branch pre-filled.
@@ -151,6 +157,7 @@ export function AddFeatureDialog({
isMaximized,
parentFeature = null,
allFeatures = [],
projectPath,
selectedNonMainWorktreeBranch,
forceCurrentBranchMode,
}: AddFeatureDialogProps) {
@@ -194,6 +201,9 @@ export function AddFeatureDialog({
const [parentDependencies, setParentDependencies] = useState<string[]>([]);
const [childDependencies, setChildDependencies] = useState<string[]>([]);
// Pipeline exclusion state
const [excludedPipelineSteps, setExcludedPipelineSteps] = useState<string[]>([]);
// Get defaults from store
const { defaultPlanningMode, defaultRequirePlanApproval, useWorktrees, defaultFeatureModel } =
useAppStore();
@@ -234,6 +244,9 @@ export function AddFeatureDialog({
// Reset dependency selections
setParentDependencies([]);
setChildDependencies([]);
// Reset pipeline exclusions (all pipelines enabled by default)
setExcludedPipelineSteps([]);
}
}, [
open,
@@ -328,6 +341,7 @@ export function AddFeatureDialog({
requirePlanApproval,
dependencies: finalDependencies,
childDependencies: childDependencies.length > 0 ? childDependencies : undefined,
excludedPipelineSteps: excludedPipelineSteps.length > 0 ? excludedPipelineSteps : undefined,
workMode,
};
};
@@ -354,6 +368,7 @@ export function AddFeatureDialog({
setDescriptionHistory([]);
setParentDependencies([]);
setChildDependencies([]);
setExcludedPipelineSteps([]);
onOpenChange(false);
};
@@ -696,6 +711,16 @@ export function AddFeatureDialog({
</div>
</div>
)}
{/* Pipeline Exclusion Controls */}
<div className="pt-2">
<PipelineExclusionControls
projectPath={projectPath}
excludedPipelineSteps={excludedPipelineSteps}
onExcludedStepsChange={setExcludedPipelineSteps}
testIdPrefix="add-feature-pipeline"
/>
</div>
</div>
</div>

View File

@@ -36,6 +36,7 @@ import {
PlanningModeSelect,
EnhanceWithAI,
EnhancementHistoryButton,
PipelineExclusionControls,
type EnhancementMode,
} from '../shared';
import type { WorkMode } from '../shared';
@@ -67,6 +68,7 @@ interface EditFeatureDialogProps {
requirePlanApproval: boolean;
dependencies?: string[];
childDependencies?: string[]; // Feature IDs that should depend on this feature
excludedPipelineSteps?: string[]; // Pipeline step IDs to skip for this feature
},
descriptionHistorySource?: 'enhance' | 'edit',
enhancementMode?: EnhancementMode,
@@ -78,6 +80,7 @@ interface EditFeatureDialogProps {
currentBranch?: string;
isMaximized: boolean;
allFeatures: Feature[];
projectPath?: string;
}
export function EditFeatureDialog({
@@ -90,6 +93,7 @@ export function EditFeatureDialog({
currentBranch,
isMaximized,
allFeatures,
projectPath,
}: EditFeatureDialogProps) {
const navigate = useNavigate();
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);
});
// Pipeline exclusion state
const [excludedPipelineSteps, setExcludedPipelineSteps] = useState<string[]>(
feature?.excludedPipelineSteps ?? []
);
useEffect(() => {
setEditingFeature(feature);
if (feature) {
@@ -171,6 +180,8 @@ export function EditFeatureDialog({
.map((f) => f.id);
setChildDependencies(childDeps);
setOriginalChildDependencies(childDeps);
// Reset pipeline exclusion state
setExcludedPipelineSteps(feature.excludedPipelineSteps ?? []);
} else {
setEditFeaturePreviewMap(new Map());
setDescriptionChangeSource(null);
@@ -179,6 +190,7 @@ export function EditFeatureDialog({
setParentDependencies([]);
setChildDependencies([]);
setOriginalChildDependencies([]);
setExcludedPipelineSteps([]);
}
}, [feature, allFeatures]);
@@ -232,6 +244,7 @@ export function EditFeatureDialog({
workMode,
dependencies: parentDependencies,
childDependencies: childDepsChanged ? childDependencies : undefined,
excludedPipelineSteps: excludedPipelineSteps.length > 0 ? excludedPipelineSteps : undefined,
};
// Determine if description changed and what source to use
@@ -618,6 +631,16 @@ export function EditFeatureDialog({
</div>
</div>
)}
{/* Pipeline Exclusion Controls */}
<div className="pt-2">
<PipelineExclusionControls
projectPath={projectPath}
excludedPipelineSteps={excludedPipelineSteps}
onExcludedStepsChange={setExcludedPipelineSteps}
testIdPrefix="edit-feature-pipeline"
/>
</div>
</div>
</div>

View File

@@ -13,7 +13,13 @@ import { Label } from '@/components/ui/label';
import { AlertCircle } from 'lucide-react';
import { modelSupportsThinking } from '@/lib/utils';
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 { PhaseModelSelector } from '@/components/views/settings-view/model-defaults/phase-model-selector';
import { isCursorModel, isClaudeModel, type PhaseModelEntry } from '@automaker/types';
@@ -28,6 +34,7 @@ interface MassEditDialogProps {
branchSuggestions: string[];
branchCardCounts?: Record<string, number>;
currentBranch?: string;
projectPath?: string;
}
interface ApplyState {
@@ -38,11 +45,13 @@ interface ApplyState {
priority: boolean;
skipTests: boolean;
branchName: boolean;
excludedPipelineSteps: boolean;
}
function getMixedValues(features: Feature[]): Record<string, boolean> {
if (features.length === 0) return {};
const first = features[0];
const firstExcludedSteps = JSON.stringify(first.excludedPipelineSteps || []);
return {
model: !features.every((f) => f.model === first.model),
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),
skipTests: !features.every((f) => f.skipTests === first.skipTests),
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,
branchCardCounts,
currentBranch,
projectPath,
}: MassEditDialogProps) {
const [isApplying, setIsApplying] = useState(false);
@@ -123,6 +136,7 @@ export function MassEditDialog({
priority: false,
skipTests: false,
branchName: false,
excludedPipelineSteps: false,
});
// Field values
@@ -145,6 +159,11 @@ export function MassEditDialog({
return getInitialValue(selectedFeatures, 'branchName', '') as string;
});
// Pipeline exclusion state
const [excludedPipelineSteps, setExcludedPipelineSteps] = useState<string[]>(() => {
return getInitialValue(selectedFeatures, 'excludedPipelineSteps', []) as string[];
});
// Calculate mixed values
const mixedValues = useMemo(() => getMixedValues(selectedFeatures), [selectedFeatures]);
@@ -159,6 +178,7 @@ export function MassEditDialog({
priority: false,
skipTests: false,
branchName: false,
excludedPipelineSteps: false,
});
setModel(getInitialValue(selectedFeatures, 'model', 'claude-sonnet') as ModelAlias);
setThinkingLevel(getInitialValue(selectedFeatures, 'thinkingLevel', 'none') as ThinkingLevel);
@@ -170,6 +190,10 @@ export function MassEditDialog({
const initialBranchName = getInitialValue(selectedFeatures, 'branchName', '') as string;
setBranchName(initialBranchName);
setWorkMode(initialBranchName ? 'custom' : 'current');
// Reset pipeline exclusions
setExcludedPipelineSteps(
getInitialValue(selectedFeatures, 'excludedPipelineSteps', []) as string[]
);
}
}, [open, selectedFeatures]);
@@ -188,6 +212,10 @@ export function MassEditDialog({
// For 'custom' mode, use the specified branch name
updates.branchName = workMode === 'custom' ? branchName : '';
}
if (applyState.excludedPipelineSteps) {
updates.excludedPipelineSteps =
excludedPipelineSteps.length > 0 ? excludedPipelineSteps : undefined;
}
if (Object.keys(updates).length === 0) {
onClose();
@@ -350,6 +378,23 @@ export function MassEditDialog({
testIdPrefix="mass-edit-work-mode"
/>
</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>
<DialogFooter>

View File

@@ -11,3 +11,4 @@ export * from './planning-mode-select';
export * from './ancestor-context-section';
export * from './work-mode-selector';
export * from './enhancement';
export * from './pipeline-exclusion-controls';

View File

@@ -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>
);
}

View File

@@ -392,6 +392,7 @@ export function GraphViewPage() {
currentBranch={currentWorktreeBranch || undefined}
isMaximized={false}
allFeatures={hookFeatures}
projectPath={currentProject?.path}
/>
{/* Add Feature Dialog (for spawning) */}
@@ -414,6 +415,7 @@ export function GraphViewPage() {
isMaximized={false}
parentFeature={spawnParentFeature}
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
selectedNonMainWorktreeBranch={
addFeatureUseSelectedWorktreeBranch && currentWorktreePath !== null