mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-03 21:03:08 +00:00
Merge pull request #135 from AutoMaker-Org/feature-dependency-improvements
Feature Dependency Rework & Options Setting
This commit is contained in:
@@ -57,9 +57,11 @@ import {
|
|||||||
Brain,
|
Brain,
|
||||||
Wand2,
|
Wand2,
|
||||||
Archive,
|
Archive,
|
||||||
|
Lock,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { CountUpTimer } from "@/components/ui/count-up-timer";
|
import { CountUpTimer } from "@/components/ui/count-up-timer";
|
||||||
import { getElectronAPI } from "@/lib/electron";
|
import { getElectronAPI } from "@/lib/electron";
|
||||||
|
import { getBlockingDependencies } from "@/lib/dependency-resolver";
|
||||||
import {
|
import {
|
||||||
parseAgentContext,
|
parseAgentContext,
|
||||||
AgentTaskInfo,
|
AgentTaskInfo,
|
||||||
@@ -143,7 +145,15 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
const [agentInfo, setAgentInfo] = useState<AgentTaskInfo | null>(null);
|
const [agentInfo, setAgentInfo] = useState<AgentTaskInfo | null>(null);
|
||||||
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false);
|
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false);
|
||||||
const [currentTime, setCurrentTime] = useState(() => Date.now());
|
const [currentTime, setCurrentTime] = useState(() => Date.now());
|
||||||
const { kanbanCardDetailLevel, useWorktrees } = useAppStore();
|
const { kanbanCardDetailLevel, enableDependencyBlocking, features, useWorktrees } = useAppStore();
|
||||||
|
|
||||||
|
// Calculate blocking dependencies (if feature is in backlog and has incomplete dependencies)
|
||||||
|
const blockingDependencies = useMemo(() => {
|
||||||
|
if (!enableDependencyBlocking || feature.status !== "backlog") {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return getBlockingDependencies(feature, features);
|
||||||
|
}, [enableDependencyBlocking, feature, features]);
|
||||||
|
|
||||||
const showSteps =
|
const showSteps =
|
||||||
kanbanCardDetailLevel === "standard" ||
|
kanbanCardDetailLevel === "standard" ||
|
||||||
@@ -328,7 +338,7 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute px-2 py-1 text-sm font-bold rounded-md flex items-center justify-center z-10",
|
"absolute px-2 py-1 h-8 text-sm font-bold rounded-md flex items-center justify-center z-10",
|
||||||
"top-2 left-2 min-w-[36px]",
|
"top-2 left-2 min-w-[36px]",
|
||||||
feature.priority === 1 &&
|
feature.priority === 1 &&
|
||||||
"bg-red-500/20 text-red-500 border-2 border-red-500/50",
|
"bg-red-500/20 text-red-500 border-2 border-red-500/50",
|
||||||
@@ -339,7 +349,7 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
)}
|
)}
|
||||||
data-testid={`priority-badge-${feature.id}`}
|
data-testid={`priority-badge-${feature.id}`}
|
||||||
>
|
>
|
||||||
P{feature.priority}
|
{feature.priority === 1 ? "H" : feature.priority === 2 ? "M" : "L"}
|
||||||
</div>
|
</div>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="right" className="text-xs">
|
<TooltipContent side="right" className="text-xs">
|
||||||
@@ -364,27 +374,24 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Status badges row */}
|
{/* Skip Tests (Manual) indicator badge - positioned at top right */}
|
||||||
{(feature.skipTests || feature.error || isJustFinished) && (
|
{feature.skipTests && !feature.error && feature.status === "backlog" && (
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"absolute left-2 z-10 flex items-center gap-1",
|
|
||||||
feature.priority ? "top-11" : "top-2"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{/* Skip Tests (Manual) indicator badge */}
|
|
||||||
{feature.skipTests && !feature.error && (
|
|
||||||
<TooltipProvider delayDuration={200}>
|
<TooltipProvider delayDuration={200}>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<div
|
<div
|
||||||
className="px-1.5 py-0.5 text-[10px] font-medium rounded-md flex items-center gap-1 bg-[var(--status-warning-bg)] border border-[var(--status-warning)]/40 text-[var(--status-warning)]"
|
className={cn(
|
||||||
|
"absolute px-2 py-1 h-8 text-sm font-bold rounded-md flex items-center justify-center z-10",
|
||||||
|
"min-w-[36px]",
|
||||||
|
"top-2 right-2",
|
||||||
|
"bg-[var(--status-warning-bg)] border-2 border-[var(--status-warning)]/50 text-[var(--status-warning)]"
|
||||||
|
)}
|
||||||
data-testid={`skip-tests-badge-${feature.id}`}
|
data-testid={`skip-tests-badge-${feature.id}`}
|
||||||
>
|
>
|
||||||
<Hand className="w-3 h-3" />
|
<Hand className="w-4 h-4" />
|
||||||
</div>
|
</div>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="right" className="text-xs">
|
<TooltipContent side="left" className="text-xs">
|
||||||
<p>Manual verification required</p>
|
<p>Manual verification required</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -397,10 +404,15 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<div
|
<div
|
||||||
className="px-1.5 py-0.5 text-[10px] font-medium rounded-md flex items-center gap-1 bg-[var(--status-error-bg)] border border-[var(--status-error)]/40 text-[var(--status-error)]"
|
className={cn(
|
||||||
|
"absolute px-2 py-1 text-[11px] font-medium rounded-md flex items-center justify-center z-10",
|
||||||
|
"min-w-[36px]",
|
||||||
|
feature.priority ? "top-11 left-2" : "top-2 left-2",
|
||||||
|
"bg-[var(--status-error-bg)] border border-[var(--status-error)]/40 text-[var(--status-error)]"
|
||||||
|
)}
|
||||||
data-testid={`error-badge-${feature.id}`}
|
data-testid={`error-badge-${feature.id}`}
|
||||||
>
|
>
|
||||||
<AlertCircle className="w-3 h-3" />
|
<AlertCircle className="w-3.5 h-3.5" />
|
||||||
</div>
|
</div>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="right" className="text-xs max-w-[250px]">
|
<TooltipContent side="right" className="text-xs max-w-[250px]">
|
||||||
@@ -410,18 +422,51 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Blocked by dependencies badge - positioned at top right */}
|
||||||
|
{blockingDependencies.length > 0 && !feature.error && !feature.skipTests && feature.status === "backlog" && (
|
||||||
|
<TooltipProvider delayDuration={200}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"absolute px-2 py-1 h-8 text-sm font-bold rounded-md flex items-center justify-center z-10",
|
||||||
|
"min-w-[36px]",
|
||||||
|
"top-2 right-2",
|
||||||
|
"bg-orange-500/20 border-2 border-orange-500/50 text-orange-500"
|
||||||
|
)}
|
||||||
|
data-testid={`blocked-badge-${feature.id}`}
|
||||||
|
>
|
||||||
|
<Lock className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="left" className="text-xs max-w-[250px]">
|
||||||
|
<p className="font-medium mb-1">Blocked by {blockingDependencies.length} incomplete {blockingDependencies.length === 1 ? 'dependency' : 'dependencies'}</p>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{blockingDependencies.map(depId => {
|
||||||
|
const dep = features.find(f => f.id === depId);
|
||||||
|
return dep?.description || depId;
|
||||||
|
}).join(', ')}
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Just Finished indicator badge */}
|
{/* Just Finished indicator badge */}
|
||||||
{isJustFinished && (
|
{isJustFinished && (
|
||||||
<div
|
<div
|
||||||
className="px-1.5 py-0.5 text-[10px] font-medium rounded-md flex items-center gap-1 bg-[var(--status-success-bg)] border border-[var(--status-success)]/40 text-[var(--status-success)] animate-pulse"
|
className={cn(
|
||||||
|
"absolute px-1.5 py-0.5 text-[10px] font-medium rounded-md flex items-center gap-1 z-10",
|
||||||
|
feature.priority ? "top-11 left-2" : "top-2 left-2",
|
||||||
|
"bg-[var(--status-success-bg)] border border-[var(--status-success)]/40 text-[var(--status-success)]",
|
||||||
|
"animate-pulse"
|
||||||
|
)}
|
||||||
data-testid={`just-finished-badge-${feature.id}`}
|
data-testid={`just-finished-badge-${feature.id}`}
|
||||||
title="Agent just finished working on this feature"
|
title="Agent just finished working on this feature"
|
||||||
>
|
>
|
||||||
<Sparkles className="w-3 h-3" />
|
<Sparkles className="w-3 h-3" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<CardHeader
|
<CardHeader
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -447,7 +492,7 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!isCurrentAutoTask && feature.status === "backlog" && (
|
{!isCurrentAutoTask && feature.status === "backlog" && (
|
||||||
<div className="absolute top-2 right-2">
|
<div className="absolute bottom-1 right-2">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -466,6 +511,7 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
{!isCurrentAutoTask &&
|
{!isCurrentAutoTask &&
|
||||||
(feature.status === "waiting_approval" ||
|
(feature.status === "waiting_approval" ||
|
||||||
feature.status === "verified") && (
|
feature.status === "verified") && (
|
||||||
|
<>
|
||||||
<div className="absolute top-2 right-2 flex items-center gap-1">
|
<div className="absolute top-2 right-2 flex items-center gap-1">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -503,6 +549,8 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
<FileText className="w-4 h-4" />
|
<FileText className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="absolute bottom-1 right-2">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -520,8 +568,10 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
<Trash2 className="w-4 h-4" />
|
<Trash2 className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{!isCurrentAutoTask && feature.status === "in_progress" && (
|
{!isCurrentAutoTask && feature.status === "in_progress" && (
|
||||||
|
<>
|
||||||
<div className="absolute top-2 right-2">
|
<div className="absolute top-2 right-2">
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
@@ -561,20 +611,26 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
View Logs
|
View Logs
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
<DropdownMenuItem
|
|
||||||
className="text-xs text-destructive focus:text-destructive"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleDeleteClick(e as unknown as React.MouseEvent);
|
|
||||||
}}
|
|
||||||
data-testid={`delete-feature-${feature.id}`}
|
|
||||||
>
|
|
||||||
<Trash2 className="w-3 h-3 mr-2" />
|
|
||||||
Delete
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="absolute bottom-1 right-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 w-6 p-0 hover:bg-white/10 text-muted-foreground hover:text-destructive"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleDeleteClick(e);
|
||||||
|
}}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
data-testid={`delete-feature-${feature.id}`}
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
{isDraggable && (
|
{isDraggable && (
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { getElectronAPI } from "@/lib/electron";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useAutoMode } from "@/hooks/use-auto-mode";
|
import { useAutoMode } from "@/hooks/use-auto-mode";
|
||||||
import { truncateDescription } from "@/lib/utils";
|
import { truncateDescription } from "@/lib/utils";
|
||||||
|
import { getBlockingDependencies } from "@/lib/dependency-resolver";
|
||||||
|
|
||||||
interface UseBoardActionsProps {
|
interface UseBoardActionsProps {
|
||||||
currentProject: { path: string; id: string } | null;
|
currentProject: { path: string; id: string } | null;
|
||||||
@@ -74,6 +75,7 @@ export function useBoardActions({
|
|||||||
removeFeature,
|
removeFeature,
|
||||||
moveFeature,
|
moveFeature,
|
||||||
useWorktrees,
|
useWorktrees,
|
||||||
|
enableDependencyBlocking,
|
||||||
} = useAppStore();
|
} = useAppStore();
|
||||||
const autoMode = useAutoMode();
|
const autoMode = useAutoMode();
|
||||||
|
|
||||||
@@ -344,6 +346,21 @@ export function useBoardActions({
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check for blocking dependencies and show warning if enabled
|
||||||
|
if (enableDependencyBlocking) {
|
||||||
|
const blockingDeps = getBlockingDependencies(feature, features);
|
||||||
|
if (blockingDeps.length > 0) {
|
||||||
|
const depDescriptions = blockingDeps.map(depId => {
|
||||||
|
const dep = features.find(f => f.id === depId);
|
||||||
|
return dep ? truncateDescription(dep.description, 40) : depId;
|
||||||
|
}).join(", ");
|
||||||
|
|
||||||
|
toast.warning("Starting feature with incomplete dependencies", {
|
||||||
|
description: `This feature depends on: ${depDescriptions}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const updates = {
|
const updates = {
|
||||||
status: "in_progress" as const,
|
status: "in_progress" as const,
|
||||||
startedAt: new Date().toISOString(),
|
startedAt: new Date().toISOString(),
|
||||||
@@ -355,7 +372,7 @@ export function useBoardActions({
|
|||||||
await handleRunFeature(feature);
|
await handleRunFeature(feature);
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
[autoMode, updateFeature, persistFeatureUpdate, handleRunFeature]
|
[autoMode, enableDependencyBlocking, features, updateFeature, persistFeatureUpdate, handleRunFeature]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleVerifyFeature = useCallback(
|
const handleVerifyFeature = useCallback(
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useMemo, useCallback } from "react";
|
import { useMemo, useCallback } from "react";
|
||||||
import { Feature } from "@/store/app-store";
|
import { Feature } from "@/store/app-store";
|
||||||
|
import { resolveDependencies } from "@/lib/dependency-resolver";
|
||||||
import { pathsEqual } from "@/lib/utils";
|
import { pathsEqual } from "@/lib/utils";
|
||||||
|
|
||||||
type ColumnId = Feature["status"];
|
type ColumnId = Feature["status"];
|
||||||
@@ -105,12 +106,13 @@ export function useBoardColumnFeatures({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Sort backlog by priority: 1 (high) -> 2 (medium) -> 3 (low) -> no priority
|
// Apply dependency-aware sorting to backlog
|
||||||
map.backlog.sort((a, b) => {
|
// This ensures features appear in dependency order (dependencies before dependents)
|
||||||
const aPriority = a.priority ?? 999; // Features without priority go last
|
// Within the same dependency level, features are sorted by priority
|
||||||
const bPriority = b.priority ?? 999;
|
if (map.backlog.length > 0) {
|
||||||
return aPriority - bPriority;
|
const { orderedFeatures } = resolveDependencies(map.backlog);
|
||||||
});
|
map.backlog = orderedFeatures;
|
||||||
|
}
|
||||||
|
|
||||||
return map;
|
return map;
|
||||||
}, [features, runningAutoTasks, searchQuery, currentWorktreePath, currentWorktreeBranch, projectPath]);
|
}, [features, runningAutoTasks, searchQuery, currentWorktreePath, currentWorktreeBranch, projectPath]);
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ export function SettingsView() {
|
|||||||
setProjectTheme,
|
setProjectTheme,
|
||||||
defaultSkipTests,
|
defaultSkipTests,
|
||||||
setDefaultSkipTests,
|
setDefaultSkipTests,
|
||||||
|
enableDependencyBlocking,
|
||||||
|
setEnableDependencyBlocking,
|
||||||
useWorktrees,
|
useWorktrees,
|
||||||
setUseWorktrees,
|
setUseWorktrees,
|
||||||
showProfilesOnly,
|
showProfilesOnly,
|
||||||
@@ -118,9 +120,11 @@ export function SettingsView() {
|
|||||||
<FeatureDefaultsSection
|
<FeatureDefaultsSection
|
||||||
showProfilesOnly={showProfilesOnly}
|
showProfilesOnly={showProfilesOnly}
|
||||||
defaultSkipTests={defaultSkipTests}
|
defaultSkipTests={defaultSkipTests}
|
||||||
|
enableDependencyBlocking={enableDependencyBlocking}
|
||||||
useWorktrees={useWorktrees}
|
useWorktrees={useWorktrees}
|
||||||
onShowProfilesOnlyChange={setShowProfilesOnly}
|
onShowProfilesOnlyChange={setShowProfilesOnly}
|
||||||
onDefaultSkipTestsChange={setDefaultSkipTests}
|
onDefaultSkipTestsChange={setDefaultSkipTests}
|
||||||
|
onEnableDependencyBlockingChange={setEnableDependencyBlocking}
|
||||||
onUseWorktreesChange={setUseWorktrees}
|
onUseWorktreesChange={setUseWorktrees}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,23 +1,27 @@
|
|||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { FlaskConical, Settings2, TestTube, GitBranch } from "lucide-react";
|
import { FlaskConical, Settings2, TestTube, GitBranch, AlertCircle } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface FeatureDefaultsSectionProps {
|
interface FeatureDefaultsSectionProps {
|
||||||
showProfilesOnly: boolean;
|
showProfilesOnly: boolean;
|
||||||
defaultSkipTests: boolean;
|
defaultSkipTests: boolean;
|
||||||
|
enableDependencyBlocking: boolean;
|
||||||
useWorktrees: boolean;
|
useWorktrees: boolean;
|
||||||
onShowProfilesOnlyChange: (value: boolean) => void;
|
onShowProfilesOnlyChange: (value: boolean) => void;
|
||||||
onDefaultSkipTestsChange: (value: boolean) => void;
|
onDefaultSkipTestsChange: (value: boolean) => void;
|
||||||
|
onEnableDependencyBlockingChange: (value: boolean) => void;
|
||||||
onUseWorktreesChange: (value: boolean) => void;
|
onUseWorktreesChange: (value: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FeatureDefaultsSection({
|
export function FeatureDefaultsSection({
|
||||||
showProfilesOnly,
|
showProfilesOnly,
|
||||||
defaultSkipTests,
|
defaultSkipTests,
|
||||||
|
enableDependencyBlocking,
|
||||||
useWorktrees,
|
useWorktrees,
|
||||||
onShowProfilesOnlyChange,
|
onShowProfilesOnlyChange,
|
||||||
onDefaultSkipTestsChange,
|
onDefaultSkipTestsChange,
|
||||||
|
onEnableDependencyBlockingChange,
|
||||||
onUseWorktreesChange,
|
onUseWorktreesChange,
|
||||||
}: FeatureDefaultsSectionProps) {
|
}: FeatureDefaultsSectionProps) {
|
||||||
return (
|
return (
|
||||||
@@ -102,6 +106,36 @@ export function FeatureDefaultsSection({
|
|||||||
{/* Separator */}
|
{/* Separator */}
|
||||||
<div className="border-t border-border/30" />
|
<div className="border-t border-border/30" />
|
||||||
|
|
||||||
|
{/* Dependency Blocking Setting */}
|
||||||
|
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
|
||||||
|
<Checkbox
|
||||||
|
id="enable-dependency-blocking"
|
||||||
|
checked={enableDependencyBlocking}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
onEnableDependencyBlockingChange(checked === true)
|
||||||
|
}
|
||||||
|
className="mt-1"
|
||||||
|
data-testid="enable-dependency-blocking-checkbox"
|
||||||
|
/>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label
|
||||||
|
htmlFor="enable-dependency-blocking"
|
||||||
|
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<AlertCircle className="w-4 h-4 text-brand-500" />
|
||||||
|
Enable Dependency Blocking
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs text-muted-foreground/80 leading-relaxed">
|
||||||
|
When enabled, features with incomplete dependencies will show blocked badges
|
||||||
|
and warnings. Auto mode and backlog ordering always respect dependencies
|
||||||
|
regardless of this setting.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Separator */}
|
||||||
|
<div className="border-t border-border/30" />
|
||||||
|
|
||||||
{/* Worktree Isolation Setting */}
|
{/* Worktree Isolation Setting */}
|
||||||
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
|
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
|
|||||||
221
apps/app/src/lib/dependency-resolver.ts
Normal file
221
apps/app/src/lib/dependency-resolver.ts
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
/**
|
||||||
|
* Dependency Resolution Utility
|
||||||
|
*
|
||||||
|
* Provides topological sorting and dependency analysis for features.
|
||||||
|
* Uses a modified Kahn's algorithm that respects both dependencies and priorities.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Feature } from "@/store/app-store";
|
||||||
|
|
||||||
|
export interface DependencyResolutionResult {
|
||||||
|
orderedFeatures: Feature[]; // Features in dependency-aware order
|
||||||
|
circularDependencies: string[][]; // Groups of IDs forming cycles
|
||||||
|
missingDependencies: Map<string, string[]>; // featureId -> missing dep IDs
|
||||||
|
blockedFeatures: Map<string, string[]>; // featureId -> blocking dep IDs (incomplete dependencies)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves feature dependencies using topological sort with priority-aware ordering.
|
||||||
|
*
|
||||||
|
* Algorithm:
|
||||||
|
* 1. Build dependency graph and detect missing/blocked dependencies
|
||||||
|
* 2. Apply Kahn's algorithm for topological sort
|
||||||
|
* 3. Within same dependency level, sort by priority (1=high, 2=medium, 3=low)
|
||||||
|
* 4. Detect circular dependencies for features that can't be ordered
|
||||||
|
*
|
||||||
|
* @param features - Array of features to order
|
||||||
|
* @returns Resolution result with ordered features and dependency metadata
|
||||||
|
*/
|
||||||
|
export function resolveDependencies(features: Feature[]): DependencyResolutionResult {
|
||||||
|
const featureMap = new Map<string, Feature>(features.map(f => [f.id, f]));
|
||||||
|
const inDegree = new Map<string, number>();
|
||||||
|
const adjacencyList = new Map<string, string[]>(); // dependencyId -> [dependentIds]
|
||||||
|
const missingDependencies = new Map<string, string[]>();
|
||||||
|
const blockedFeatures = new Map<string, string[]>();
|
||||||
|
|
||||||
|
// Initialize graph structures
|
||||||
|
for (const feature of features) {
|
||||||
|
inDegree.set(feature.id, 0);
|
||||||
|
adjacencyList.set(feature.id, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build dependency graph and detect missing/blocked dependencies
|
||||||
|
for (const feature of features) {
|
||||||
|
const deps = feature.dependencies || [];
|
||||||
|
for (const depId of deps) {
|
||||||
|
if (!featureMap.has(depId)) {
|
||||||
|
// Missing dependency - track it
|
||||||
|
if (!missingDependencies.has(feature.id)) {
|
||||||
|
missingDependencies.set(feature.id, []);
|
||||||
|
}
|
||||||
|
missingDependencies.get(feature.id)!.push(depId);
|
||||||
|
} else {
|
||||||
|
// Valid dependency - add edge to graph
|
||||||
|
adjacencyList.get(depId)!.push(feature.id);
|
||||||
|
inDegree.set(feature.id, (inDegree.get(feature.id) || 0) + 1);
|
||||||
|
|
||||||
|
// Check if dependency is incomplete (blocking)
|
||||||
|
const depFeature = featureMap.get(depId)!;
|
||||||
|
if (depFeature.status !== 'completed' && depFeature.status !== 'verified') {
|
||||||
|
if (!blockedFeatures.has(feature.id)) {
|
||||||
|
blockedFeatures.set(feature.id, []);
|
||||||
|
}
|
||||||
|
blockedFeatures.get(feature.id)!.push(depId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kahn's algorithm with priority-aware selection
|
||||||
|
const queue: Feature[] = [];
|
||||||
|
const orderedFeatures: Feature[] = [];
|
||||||
|
|
||||||
|
// Helper to sort features by priority (lower number = higher priority)
|
||||||
|
const sortByPriority = (a: Feature, b: Feature) =>
|
||||||
|
(a.priority ?? 2) - (b.priority ?? 2);
|
||||||
|
|
||||||
|
// Start with features that have no dependencies (in-degree 0)
|
||||||
|
for (const [id, degree] of inDegree) {
|
||||||
|
if (degree === 0) {
|
||||||
|
queue.push(featureMap.get(id)!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort initial queue by priority
|
||||||
|
queue.sort(sortByPriority);
|
||||||
|
|
||||||
|
// Process features in topological order
|
||||||
|
while (queue.length > 0) {
|
||||||
|
// Take highest priority feature from queue
|
||||||
|
const current = queue.shift()!;
|
||||||
|
orderedFeatures.push(current);
|
||||||
|
|
||||||
|
// Process features that depend on this one
|
||||||
|
for (const dependentId of adjacencyList.get(current.id) || []) {
|
||||||
|
const currentDegree = inDegree.get(dependentId);
|
||||||
|
if (currentDegree === undefined) {
|
||||||
|
throw new Error(`In-degree not initialized for feature ${dependentId}`);
|
||||||
|
}
|
||||||
|
const newDegree = currentDegree - 1;
|
||||||
|
inDegree.set(dependentId, newDegree);
|
||||||
|
|
||||||
|
if (newDegree === 0) {
|
||||||
|
queue.push(featureMap.get(dependentId)!);
|
||||||
|
// Re-sort queue to maintain priority order
|
||||||
|
queue.sort(sortByPriority);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect circular dependencies (features not in output = part of cycle)
|
||||||
|
const circularDependencies: string[][] = [];
|
||||||
|
const processedIds = new Set(orderedFeatures.map(f => f.id));
|
||||||
|
|
||||||
|
if (orderedFeatures.length < features.length) {
|
||||||
|
// Find cycles using DFS
|
||||||
|
const remaining = features.filter(f => !processedIds.has(f.id));
|
||||||
|
const cycles = detectCycles(remaining, featureMap);
|
||||||
|
circularDependencies.push(...cycles);
|
||||||
|
|
||||||
|
// Add remaining features at end (part of cycles)
|
||||||
|
orderedFeatures.push(...remaining);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
orderedFeatures,
|
||||||
|
circularDependencies,
|
||||||
|
missingDependencies,
|
||||||
|
blockedFeatures
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detects circular dependencies using depth-first search
|
||||||
|
*
|
||||||
|
* @param features - Features that couldn't be topologically sorted (potential cycles)
|
||||||
|
* @param featureMap - Map of all features by ID
|
||||||
|
* @returns Array of cycles, where each cycle is an array of feature IDs
|
||||||
|
*/
|
||||||
|
function detectCycles(
|
||||||
|
features: Feature[],
|
||||||
|
featureMap: Map<string, Feature>
|
||||||
|
): string[][] {
|
||||||
|
const cycles: string[][] = [];
|
||||||
|
const visited = new Set<string>();
|
||||||
|
const recursionStack = new Set<string>();
|
||||||
|
const currentPath: string[] = [];
|
||||||
|
|
||||||
|
function dfs(featureId: string): boolean {
|
||||||
|
visited.add(featureId);
|
||||||
|
recursionStack.add(featureId);
|
||||||
|
currentPath.push(featureId);
|
||||||
|
|
||||||
|
const feature = featureMap.get(featureId);
|
||||||
|
if (feature) {
|
||||||
|
for (const depId of feature.dependencies || []) {
|
||||||
|
if (!visited.has(depId)) {
|
||||||
|
if (dfs(depId)) return true;
|
||||||
|
} else if (recursionStack.has(depId)) {
|
||||||
|
// Found cycle - extract it
|
||||||
|
const cycleStart = currentPath.indexOf(depId);
|
||||||
|
cycles.push(currentPath.slice(cycleStart));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
currentPath.pop();
|
||||||
|
recursionStack.delete(featureId);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const feature of features) {
|
||||||
|
if (!visited.has(feature.id)) {
|
||||||
|
dfs(feature.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return cycles;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a feature's dependencies are satisfied (all complete or verified)
|
||||||
|
*
|
||||||
|
* @param feature - Feature to check
|
||||||
|
* @param allFeatures - All features in the project
|
||||||
|
* @returns true if all dependencies are satisfied, false otherwise
|
||||||
|
*/
|
||||||
|
export function areDependenciesSatisfied(
|
||||||
|
feature: Feature,
|
||||||
|
allFeatures: Feature[]
|
||||||
|
): boolean {
|
||||||
|
if (!feature.dependencies || feature.dependencies.length === 0) {
|
||||||
|
return true; // No dependencies = always ready
|
||||||
|
}
|
||||||
|
|
||||||
|
return feature.dependencies.every(depId => {
|
||||||
|
const dep = allFeatures.find(f => f.id === depId);
|
||||||
|
return dep && (dep.status === 'completed' || dep.status === 'verified');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the blocking dependencies for a feature (dependencies that are incomplete)
|
||||||
|
*
|
||||||
|
* @param feature - Feature to check
|
||||||
|
* @param allFeatures - All features in the project
|
||||||
|
* @returns Array of feature IDs that are blocking this feature
|
||||||
|
*/
|
||||||
|
export function getBlockingDependencies(
|
||||||
|
feature: Feature,
|
||||||
|
allFeatures: Feature[]
|
||||||
|
): string[] {
|
||||||
|
if (!feature.dependencies || feature.dependencies.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return feature.dependencies.filter(depId => {
|
||||||
|
const dep = allFeatures.find(f => f.id === depId);
|
||||||
|
return dep && dep.status !== 'completed' && dep.status !== 'verified';
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -394,6 +394,7 @@ export interface AppState {
|
|||||||
|
|
||||||
// Feature Default Settings
|
// Feature Default Settings
|
||||||
defaultSkipTests: boolean; // Default value for skip tests when creating new features
|
defaultSkipTests: boolean; // Default value for skip tests when creating new features
|
||||||
|
enableDependencyBlocking: boolean; // When true, show blocked badges and warnings for features with incomplete dependencies (default: true)
|
||||||
|
|
||||||
// Worktree Settings
|
// Worktree Settings
|
||||||
useWorktrees: boolean; // Whether to use git worktree isolation for features (default: false)
|
useWorktrees: boolean; // Whether to use git worktree isolation for features (default: false)
|
||||||
@@ -580,6 +581,7 @@ export interface AppActions {
|
|||||||
|
|
||||||
// Feature Default Settings actions
|
// Feature Default Settings actions
|
||||||
setDefaultSkipTests: (skip: boolean) => void;
|
setDefaultSkipTests: (skip: boolean) => void;
|
||||||
|
setEnableDependencyBlocking: (enabled: boolean) => void;
|
||||||
|
|
||||||
// Worktree Settings actions
|
// Worktree Settings actions
|
||||||
setUseWorktrees: (enabled: boolean) => void;
|
setUseWorktrees: (enabled: boolean) => void;
|
||||||
@@ -750,6 +752,7 @@ const initialState: AppState = {
|
|||||||
maxConcurrency: 3, // Default to 3 concurrent agents
|
maxConcurrency: 3, // Default to 3 concurrent agents
|
||||||
kanbanCardDetailLevel: "standard", // Default to standard detail level
|
kanbanCardDetailLevel: "standard", // Default to standard detail level
|
||||||
defaultSkipTests: true, // Default to manual verification (tests disabled)
|
defaultSkipTests: true, // Default to manual verification (tests disabled)
|
||||||
|
enableDependencyBlocking: true, // Default to enabled (show dependency blocking UI)
|
||||||
useWorktrees: false, // Default to disabled (worktree feature is experimental)
|
useWorktrees: false, // Default to disabled (worktree feature is experimental)
|
||||||
currentWorktreeByProject: {},
|
currentWorktreeByProject: {},
|
||||||
worktreesByProject: {},
|
worktreesByProject: {},
|
||||||
@@ -1341,6 +1344,7 @@ export const useAppStore = create<AppState & AppActions>()(
|
|||||||
|
|
||||||
// Feature Default Settings actions
|
// Feature Default Settings actions
|
||||||
setDefaultSkipTests: (skip) => set({ defaultSkipTests: skip }),
|
setDefaultSkipTests: (skip) => set({ defaultSkipTests: skip }),
|
||||||
|
setEnableDependencyBlocking: (enabled) => set({ enableDependencyBlocking: enabled }),
|
||||||
|
|
||||||
// Worktree Settings actions
|
// Worktree Settings actions
|
||||||
setUseWorktrees: (enabled) => set({ useWorktrees: enabled }),
|
setUseWorktrees: (enabled) => set({ useWorktrees: enabled }),
|
||||||
@@ -2232,6 +2236,7 @@ export const useAppStore = create<AppState & AppActions>()(
|
|||||||
maxConcurrency: state.maxConcurrency,
|
maxConcurrency: state.maxConcurrency,
|
||||||
autoModeByProject: state.autoModeByProject,
|
autoModeByProject: state.autoModeByProject,
|
||||||
defaultSkipTests: state.defaultSkipTests,
|
defaultSkipTests: state.defaultSkipTests,
|
||||||
|
enableDependencyBlocking: state.enableDependencyBlocking,
|
||||||
useWorktrees: state.useWorktrees,
|
useWorktrees: state.useWorktrees,
|
||||||
currentWorktreeByProject: state.currentWorktreeByProject,
|
currentWorktreeByProject: state.currentWorktreeByProject,
|
||||||
worktreesByProject: state.worktreesByProject,
|
worktreesByProject: state.worktreesByProject,
|
||||||
|
|||||||
221
apps/server/src/lib/dependency-resolver.ts
Normal file
221
apps/server/src/lib/dependency-resolver.ts
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
/**
|
||||||
|
* Dependency Resolution Utility (Server-side)
|
||||||
|
*
|
||||||
|
* Provides topological sorting and dependency analysis for features.
|
||||||
|
* Uses a modified Kahn's algorithm that respects both dependencies and priorities.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Feature } from "../services/feature-loader.js";
|
||||||
|
|
||||||
|
export interface DependencyResolutionResult {
|
||||||
|
orderedFeatures: Feature[]; // Features in dependency-aware order
|
||||||
|
circularDependencies: string[][]; // Groups of IDs forming cycles
|
||||||
|
missingDependencies: Map<string, string[]>; // featureId -> missing dep IDs
|
||||||
|
blockedFeatures: Map<string, string[]>; // featureId -> blocking dep IDs (incomplete dependencies)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves feature dependencies using topological sort with priority-aware ordering.
|
||||||
|
*
|
||||||
|
* Algorithm:
|
||||||
|
* 1. Build dependency graph and detect missing/blocked dependencies
|
||||||
|
* 2. Apply Kahn's algorithm for topological sort
|
||||||
|
* 3. Within same dependency level, sort by priority (1=high, 2=medium, 3=low)
|
||||||
|
* 4. Detect circular dependencies for features that can't be ordered
|
||||||
|
*
|
||||||
|
* @param features - Array of features to order
|
||||||
|
* @returns Resolution result with ordered features and dependency metadata
|
||||||
|
*/
|
||||||
|
export function resolveDependencies(features: Feature[]): DependencyResolutionResult {
|
||||||
|
const featureMap = new Map<string, Feature>(features.map(f => [f.id, f]));
|
||||||
|
const inDegree = new Map<string, number>();
|
||||||
|
const adjacencyList = new Map<string, string[]>(); // dependencyId -> [dependentIds]
|
||||||
|
const missingDependencies = new Map<string, string[]>();
|
||||||
|
const blockedFeatures = new Map<string, string[]>();
|
||||||
|
|
||||||
|
// Initialize graph structures
|
||||||
|
for (const feature of features) {
|
||||||
|
inDegree.set(feature.id, 0);
|
||||||
|
adjacencyList.set(feature.id, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build dependency graph and detect missing/blocked dependencies
|
||||||
|
for (const feature of features) {
|
||||||
|
const deps = feature.dependencies || [];
|
||||||
|
for (const depId of deps) {
|
||||||
|
if (!featureMap.has(depId)) {
|
||||||
|
// Missing dependency - track it
|
||||||
|
if (!missingDependencies.has(feature.id)) {
|
||||||
|
missingDependencies.set(feature.id, []);
|
||||||
|
}
|
||||||
|
missingDependencies.get(feature.id)!.push(depId);
|
||||||
|
} else {
|
||||||
|
// Valid dependency - add edge to graph
|
||||||
|
adjacencyList.get(depId)!.push(feature.id);
|
||||||
|
inDegree.set(feature.id, (inDegree.get(feature.id) || 0) + 1);
|
||||||
|
|
||||||
|
// Check if dependency is incomplete (blocking)
|
||||||
|
const depFeature = featureMap.get(depId)!;
|
||||||
|
if (depFeature.status !== 'completed' && depFeature.status !== 'verified') {
|
||||||
|
if (!blockedFeatures.has(feature.id)) {
|
||||||
|
blockedFeatures.set(feature.id, []);
|
||||||
|
}
|
||||||
|
blockedFeatures.get(feature.id)!.push(depId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kahn's algorithm with priority-aware selection
|
||||||
|
const queue: Feature[] = [];
|
||||||
|
const orderedFeatures: Feature[] = [];
|
||||||
|
|
||||||
|
// Helper to sort features by priority (lower number = higher priority)
|
||||||
|
const sortByPriority = (a: Feature, b: Feature) =>
|
||||||
|
(a.priority ?? 2) - (b.priority ?? 2);
|
||||||
|
|
||||||
|
// Start with features that have no dependencies (in-degree 0)
|
||||||
|
for (const [id, degree] of inDegree) {
|
||||||
|
if (degree === 0) {
|
||||||
|
queue.push(featureMap.get(id)!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort initial queue by priority
|
||||||
|
queue.sort(sortByPriority);
|
||||||
|
|
||||||
|
// Process features in topological order
|
||||||
|
while (queue.length > 0) {
|
||||||
|
// Take highest priority feature from queue
|
||||||
|
const current = queue.shift()!;
|
||||||
|
orderedFeatures.push(current);
|
||||||
|
|
||||||
|
// Process features that depend on this one
|
||||||
|
for (const dependentId of adjacencyList.get(current.id) || []) {
|
||||||
|
const currentDegree = inDegree.get(dependentId);
|
||||||
|
if (currentDegree === undefined) {
|
||||||
|
throw new Error(`In-degree not initialized for feature ${dependentId}`);
|
||||||
|
}
|
||||||
|
const newDegree = currentDegree - 1;
|
||||||
|
inDegree.set(dependentId, newDegree);
|
||||||
|
|
||||||
|
if (newDegree === 0) {
|
||||||
|
queue.push(featureMap.get(dependentId)!);
|
||||||
|
// Re-sort queue to maintain priority order
|
||||||
|
queue.sort(sortByPriority);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect circular dependencies (features not in output = part of cycle)
|
||||||
|
const circularDependencies: string[][] = [];
|
||||||
|
const processedIds = new Set(orderedFeatures.map(f => f.id));
|
||||||
|
|
||||||
|
if (orderedFeatures.length < features.length) {
|
||||||
|
// Find cycles using DFS
|
||||||
|
const remaining = features.filter(f => !processedIds.has(f.id));
|
||||||
|
const cycles = detectCycles(remaining, featureMap);
|
||||||
|
circularDependencies.push(...cycles);
|
||||||
|
|
||||||
|
// Add remaining features at end (part of cycles)
|
||||||
|
orderedFeatures.push(...remaining);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
orderedFeatures,
|
||||||
|
circularDependencies,
|
||||||
|
missingDependencies,
|
||||||
|
blockedFeatures
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detects circular dependencies using depth-first search
|
||||||
|
*
|
||||||
|
* @param features - Features that couldn't be topologically sorted (potential cycles)
|
||||||
|
* @param featureMap - Map of all features by ID
|
||||||
|
* @returns Array of cycles, where each cycle is an array of feature IDs
|
||||||
|
*/
|
||||||
|
function detectCycles(
|
||||||
|
features: Feature[],
|
||||||
|
featureMap: Map<string, Feature>
|
||||||
|
): string[][] {
|
||||||
|
const cycles: string[][] = [];
|
||||||
|
const visited = new Set<string>();
|
||||||
|
const recursionStack = new Set<string>();
|
||||||
|
const currentPath: string[] = [];
|
||||||
|
|
||||||
|
function dfs(featureId: string): boolean {
|
||||||
|
visited.add(featureId);
|
||||||
|
recursionStack.add(featureId);
|
||||||
|
currentPath.push(featureId);
|
||||||
|
|
||||||
|
const feature = featureMap.get(featureId);
|
||||||
|
if (feature) {
|
||||||
|
for (const depId of feature.dependencies || []) {
|
||||||
|
if (!visited.has(depId)) {
|
||||||
|
if (dfs(depId)) return true;
|
||||||
|
} else if (recursionStack.has(depId)) {
|
||||||
|
// Found cycle - extract it
|
||||||
|
const cycleStart = currentPath.indexOf(depId);
|
||||||
|
cycles.push(currentPath.slice(cycleStart));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
currentPath.pop();
|
||||||
|
recursionStack.delete(featureId);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const feature of features) {
|
||||||
|
if (!visited.has(feature.id)) {
|
||||||
|
dfs(feature.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return cycles;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a feature's dependencies are satisfied (all complete or verified)
|
||||||
|
*
|
||||||
|
* @param feature - Feature to check
|
||||||
|
* @param allFeatures - All features in the project
|
||||||
|
* @returns true if all dependencies are satisfied, false otherwise
|
||||||
|
*/
|
||||||
|
export function areDependenciesSatisfied(
|
||||||
|
feature: Feature,
|
||||||
|
allFeatures: Feature[]
|
||||||
|
): boolean {
|
||||||
|
if (!feature.dependencies || feature.dependencies.length === 0) {
|
||||||
|
return true; // No dependencies = always ready
|
||||||
|
}
|
||||||
|
|
||||||
|
return feature.dependencies.every((depId: string) => {
|
||||||
|
const dep = allFeatures.find(f => f.id === depId);
|
||||||
|
return dep && (dep.status === 'completed' || dep.status === 'verified');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the blocking dependencies for a feature (dependencies that are incomplete)
|
||||||
|
*
|
||||||
|
* @param feature - Feature to check
|
||||||
|
* @param allFeatures - All features in the project
|
||||||
|
* @returns Array of feature IDs that are blocking this feature
|
||||||
|
*/
|
||||||
|
export function getBlockingDependencies(
|
||||||
|
feature: Feature,
|
||||||
|
allFeatures: Feature[]
|
||||||
|
): string[] {
|
||||||
|
if (!feature.dependencies || feature.dependencies.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return feature.dependencies.filter((depId: string) => {
|
||||||
|
const dep = allFeatures.find(f => f.id === depId);
|
||||||
|
return dep && dep.status !== 'completed' && dep.status !== 'verified';
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -20,6 +20,8 @@ import { buildPromptWithImages } from "../lib/prompt-builder.js";
|
|||||||
import { resolveModelString, DEFAULT_MODELS } from "../lib/model-resolver.js";
|
import { resolveModelString, DEFAULT_MODELS } from "../lib/model-resolver.js";
|
||||||
import { createAutoModeOptions } from "../lib/sdk-options.js";
|
import { createAutoModeOptions } from "../lib/sdk-options.js";
|
||||||
import { isAbortError, classifyError } from "../lib/error-handler.js";
|
import { isAbortError, classifyError } from "../lib/error-handler.js";
|
||||||
|
import { resolveDependencies, areDependenciesSatisfied } from "../lib/dependency-resolver.js";
|
||||||
|
import type { Feature } from "./feature-loader.js";
|
||||||
import {
|
import {
|
||||||
getFeatureDir,
|
getFeatureDir,
|
||||||
getFeaturesDir,
|
getFeaturesDir,
|
||||||
@@ -29,26 +31,6 @@ import {
|
|||||||
|
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
interface Feature {
|
|
||||||
id: string;
|
|
||||||
category: string;
|
|
||||||
description: string;
|
|
||||||
steps?: string[];
|
|
||||||
status: string;
|
|
||||||
priority?: number;
|
|
||||||
spec?: string;
|
|
||||||
model?: string; // Model to use for this feature
|
|
||||||
imagePaths?: Array<
|
|
||||||
| string
|
|
||||||
| {
|
|
||||||
path: string;
|
|
||||||
filename?: string;
|
|
||||||
mimeType?: string;
|
|
||||||
[key: string]: unknown;
|
|
||||||
}
|
|
||||||
>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RunningFeature {
|
interface RunningFeature {
|
||||||
featureId: string;
|
featureId: string;
|
||||||
projectPath: string;
|
projectPath: string;
|
||||||
@@ -1014,8 +996,10 @@ Format your response as a structured markdown document.`;
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const entries = await fs.readdir(featuresDir, { withFileTypes: true });
|
const entries = await fs.readdir(featuresDir, { withFileTypes: true });
|
||||||
const features: Feature[] = [];
|
const allFeatures: Feature[] = [];
|
||||||
|
const pendingFeatures: Feature[] = [];
|
||||||
|
|
||||||
|
// Load all features (for dependency checking)
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
if (entry.isDirectory()) {
|
if (entry.isDirectory()) {
|
||||||
const featurePath = path.join(
|
const featurePath = path.join(
|
||||||
@@ -1026,12 +1010,15 @@ Format your response as a structured markdown document.`;
|
|||||||
try {
|
try {
|
||||||
const data = await fs.readFile(featurePath, "utf-8");
|
const data = await fs.readFile(featurePath, "utf-8");
|
||||||
const feature = JSON.parse(data);
|
const feature = JSON.parse(data);
|
||||||
|
allFeatures.push(feature);
|
||||||
|
|
||||||
|
// Track pending features separately
|
||||||
if (
|
if (
|
||||||
feature.status === "pending" ||
|
feature.status === "pending" ||
|
||||||
feature.status === "ready" ||
|
feature.status === "ready" ||
|
||||||
feature.status === "backlog"
|
feature.status === "backlog"
|
||||||
) {
|
) {
|
||||||
features.push(feature);
|
pendingFeatures.push(feature);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Skip invalid features
|
// Skip invalid features
|
||||||
@@ -1039,8 +1026,15 @@ Format your response as a structured markdown document.`;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort by priority
|
// Apply dependency-aware ordering
|
||||||
return features.sort((a, b) => (a.priority || 999) - (b.priority || 999));
|
const { orderedFeatures } = resolveDependencies(pendingFeatures);
|
||||||
|
|
||||||
|
// Filter to only features with satisfied dependencies
|
||||||
|
const readyFeatures = orderedFeatures.filter(feature =>
|
||||||
|
areDependenciesSatisfied(feature, allFeatures)
|
||||||
|
);
|
||||||
|
|
||||||
|
return readyFeatures;
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ export interface Feature {
|
|||||||
steps?: string[];
|
steps?: string[];
|
||||||
passes?: boolean;
|
passes?: boolean;
|
||||||
priority?: number;
|
priority?: number;
|
||||||
|
status?: string;
|
||||||
|
dependencies?: string[];
|
||||||
|
spec?: string;
|
||||||
|
model?: string;
|
||||||
imagePaths?: Array<string | { path: string; [key: string]: unknown }>;
|
imagePaths?: Array<string | { path: string; [key: string]: unknown }>;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|||||||
438
apps/server/tests/unit/lib/dependency-resolver.test.ts
Normal file
438
apps/server/tests/unit/lib/dependency-resolver.test.ts
Normal file
@@ -0,0 +1,438 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
resolveDependencies,
|
||||||
|
areDependenciesSatisfied,
|
||||||
|
getBlockingDependencies,
|
||||||
|
type DependencyResolutionResult,
|
||||||
|
} from "@/lib/dependency-resolver.js";
|
||||||
|
import type { Feature } from "@/services/feature-loader.js";
|
||||||
|
|
||||||
|
// Helper to create test features
|
||||||
|
function createFeature(
|
||||||
|
id: string,
|
||||||
|
options: {
|
||||||
|
status?: string;
|
||||||
|
priority?: number;
|
||||||
|
dependencies?: string[];
|
||||||
|
category?: string;
|
||||||
|
description?: string;
|
||||||
|
} = {}
|
||||||
|
): Feature {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
category: options.category || "test",
|
||||||
|
description: options.description || `Feature ${id}`,
|
||||||
|
status: options.status || "backlog",
|
||||||
|
priority: options.priority,
|
||||||
|
dependencies: options.dependencies,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("dependency-resolver.ts", () => {
|
||||||
|
describe("resolveDependencies", () => {
|
||||||
|
it("should handle empty feature list", () => {
|
||||||
|
const result = resolveDependencies([]);
|
||||||
|
|
||||||
|
expect(result.orderedFeatures).toEqual([]);
|
||||||
|
expect(result.circularDependencies).toEqual([]);
|
||||||
|
expect(result.missingDependencies.size).toBe(0);
|
||||||
|
expect(result.blockedFeatures.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle features with no dependencies", () => {
|
||||||
|
const features = [
|
||||||
|
createFeature("f1", { priority: 1 }),
|
||||||
|
createFeature("f2", { priority: 2 }),
|
||||||
|
createFeature("f3", { priority: 3 }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = resolveDependencies(features);
|
||||||
|
|
||||||
|
expect(result.orderedFeatures).toHaveLength(3);
|
||||||
|
expect(result.orderedFeatures[0].id).toBe("f1"); // Highest priority first
|
||||||
|
expect(result.orderedFeatures[1].id).toBe("f2");
|
||||||
|
expect(result.orderedFeatures[2].id).toBe("f3");
|
||||||
|
expect(result.circularDependencies).toEqual([]);
|
||||||
|
expect(result.missingDependencies.size).toBe(0);
|
||||||
|
expect(result.blockedFeatures.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should order features by dependencies (simple chain)", () => {
|
||||||
|
const features = [
|
||||||
|
createFeature("f3", { dependencies: ["f2"] }),
|
||||||
|
createFeature("f1"),
|
||||||
|
createFeature("f2", { dependencies: ["f1"] }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = resolveDependencies(features);
|
||||||
|
|
||||||
|
expect(result.orderedFeatures).toHaveLength(3);
|
||||||
|
expect(result.orderedFeatures[0].id).toBe("f1");
|
||||||
|
expect(result.orderedFeatures[1].id).toBe("f2");
|
||||||
|
expect(result.orderedFeatures[2].id).toBe("f3");
|
||||||
|
expect(result.circularDependencies).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should respect priority within same dependency level", () => {
|
||||||
|
const features = [
|
||||||
|
createFeature("f1", { priority: 3, dependencies: ["base"] }),
|
||||||
|
createFeature("f2", { priority: 1, dependencies: ["base"] }),
|
||||||
|
createFeature("f3", { priority: 2, dependencies: ["base"] }),
|
||||||
|
createFeature("base"),
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = resolveDependencies(features);
|
||||||
|
|
||||||
|
expect(result.orderedFeatures[0].id).toBe("base");
|
||||||
|
expect(result.orderedFeatures[1].id).toBe("f2"); // Priority 1
|
||||||
|
expect(result.orderedFeatures[2].id).toBe("f3"); // Priority 2
|
||||||
|
expect(result.orderedFeatures[3].id).toBe("f1"); // Priority 3
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should use default priority of 2 when not specified", () => {
|
||||||
|
const features = [
|
||||||
|
createFeature("f1", { priority: 1 }),
|
||||||
|
createFeature("f2"), // No priority = default 2
|
||||||
|
createFeature("f3", { priority: 3 }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = resolveDependencies(features);
|
||||||
|
|
||||||
|
expect(result.orderedFeatures[0].id).toBe("f1");
|
||||||
|
expect(result.orderedFeatures[1].id).toBe("f2");
|
||||||
|
expect(result.orderedFeatures[2].id).toBe("f3");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should detect missing dependencies", () => {
|
||||||
|
const features = [
|
||||||
|
createFeature("f1", { dependencies: ["missing1", "missing2"] }),
|
||||||
|
createFeature("f2", { dependencies: ["f1", "missing3"] }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = resolveDependencies(features);
|
||||||
|
|
||||||
|
expect(result.missingDependencies.size).toBe(2);
|
||||||
|
expect(result.missingDependencies.get("f1")).toEqual(["missing1", "missing2"]);
|
||||||
|
expect(result.missingDependencies.get("f2")).toEqual(["missing3"]);
|
||||||
|
expect(result.orderedFeatures).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should detect blocked features (incomplete dependencies)", () => {
|
||||||
|
const features = [
|
||||||
|
createFeature("f1", { status: "in_progress" }),
|
||||||
|
createFeature("f2", { status: "backlog", dependencies: ["f1"] }),
|
||||||
|
createFeature("f3", { status: "completed" }),
|
||||||
|
createFeature("f4", { status: "backlog", dependencies: ["f3"] }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = resolveDependencies(features);
|
||||||
|
|
||||||
|
expect(result.blockedFeatures.size).toBe(1);
|
||||||
|
expect(result.blockedFeatures.get("f2")).toEqual(["f1"]);
|
||||||
|
expect(result.blockedFeatures.has("f4")).toBe(false); // f3 is completed
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not block features whose dependencies are verified", () => {
|
||||||
|
const features = [
|
||||||
|
createFeature("f1", { status: "verified" }),
|
||||||
|
createFeature("f2", { status: "backlog", dependencies: ["f1"] }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = resolveDependencies(features);
|
||||||
|
|
||||||
|
expect(result.blockedFeatures.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should detect circular dependencies (simple cycle)", () => {
|
||||||
|
const features = [
|
||||||
|
createFeature("f1", { dependencies: ["f2"] }),
|
||||||
|
createFeature("f2", { dependencies: ["f1"] }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = resolveDependencies(features);
|
||||||
|
|
||||||
|
expect(result.circularDependencies).toHaveLength(1);
|
||||||
|
expect(result.circularDependencies[0]).toContain("f1");
|
||||||
|
expect(result.circularDependencies[0]).toContain("f2");
|
||||||
|
expect(result.orderedFeatures).toHaveLength(2); // Features still included
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should detect circular dependencies (multi-node cycle)", () => {
|
||||||
|
const features = [
|
||||||
|
createFeature("f1", { dependencies: ["f3"] }),
|
||||||
|
createFeature("f2", { dependencies: ["f1"] }),
|
||||||
|
createFeature("f3", { dependencies: ["f2"] }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = resolveDependencies(features);
|
||||||
|
|
||||||
|
expect(result.circularDependencies.length).toBeGreaterThan(0);
|
||||||
|
expect(result.orderedFeatures).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle mixed valid and circular dependencies", () => {
|
||||||
|
const features = [
|
||||||
|
createFeature("base"),
|
||||||
|
createFeature("f1", { dependencies: ["base", "f2"] }),
|
||||||
|
createFeature("f2", { dependencies: ["f1"] }), // Circular with f1
|
||||||
|
createFeature("f3", { dependencies: ["base"] }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = resolveDependencies(features);
|
||||||
|
|
||||||
|
expect(result.circularDependencies.length).toBeGreaterThan(0);
|
||||||
|
expect(result.orderedFeatures[0].id).toBe("base");
|
||||||
|
expect(result.orderedFeatures).toHaveLength(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle complex dependency graph", () => {
|
||||||
|
const features = [
|
||||||
|
createFeature("ui", { dependencies: ["api", "auth"], priority: 1 }),
|
||||||
|
createFeature("api", { dependencies: ["db"], priority: 2 }),
|
||||||
|
createFeature("auth", { dependencies: ["db"], priority: 1 }),
|
||||||
|
createFeature("db", { priority: 1 }),
|
||||||
|
createFeature("tests", { dependencies: ["ui"], priority: 3 }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = resolveDependencies(features);
|
||||||
|
|
||||||
|
const order = result.orderedFeatures.map(f => f.id);
|
||||||
|
|
||||||
|
expect(order[0]).toBe("db");
|
||||||
|
expect(order.indexOf("db")).toBeLessThan(order.indexOf("api"));
|
||||||
|
expect(order.indexOf("db")).toBeLessThan(order.indexOf("auth"));
|
||||||
|
expect(order.indexOf("api")).toBeLessThan(order.indexOf("ui"));
|
||||||
|
expect(order.indexOf("auth")).toBeLessThan(order.indexOf("ui"));
|
||||||
|
expect(order.indexOf("ui")).toBeLessThan(order.indexOf("tests"));
|
||||||
|
expect(result.circularDependencies).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle features with empty dependencies array", () => {
|
||||||
|
const features = [
|
||||||
|
createFeature("f1", { dependencies: [] }),
|
||||||
|
createFeature("f2", { dependencies: [] }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = resolveDependencies(features);
|
||||||
|
|
||||||
|
expect(result.orderedFeatures).toHaveLength(2);
|
||||||
|
expect(result.circularDependencies).toEqual([]);
|
||||||
|
expect(result.blockedFeatures.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should track multiple blocking dependencies", () => {
|
||||||
|
const features = [
|
||||||
|
createFeature("f1", { status: "in_progress" }),
|
||||||
|
createFeature("f2", { status: "backlog" }),
|
||||||
|
createFeature("f3", { status: "backlog", dependencies: ["f1", "f2"] }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = resolveDependencies(features);
|
||||||
|
|
||||||
|
expect(result.blockedFeatures.get("f3")).toEqual(["f1", "f2"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle self-referencing dependency", () => {
|
||||||
|
const features = [
|
||||||
|
createFeature("f1", { dependencies: ["f1"] }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = resolveDependencies(features);
|
||||||
|
|
||||||
|
expect(result.circularDependencies.length).toBeGreaterThan(0);
|
||||||
|
expect(result.orderedFeatures).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("areDependenciesSatisfied", () => {
|
||||||
|
it("should return true for feature with no dependencies", () => {
|
||||||
|
const feature = createFeature("f1");
|
||||||
|
const allFeatures = [feature];
|
||||||
|
|
||||||
|
expect(areDependenciesSatisfied(feature, allFeatures)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return true for feature with empty dependencies array", () => {
|
||||||
|
const feature = createFeature("f1", { dependencies: [] });
|
||||||
|
const allFeatures = [feature];
|
||||||
|
|
||||||
|
expect(areDependenciesSatisfied(feature, allFeatures)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return true when all dependencies are completed", () => {
|
||||||
|
const allFeatures = [
|
||||||
|
createFeature("f1", { status: "completed" }),
|
||||||
|
createFeature("f2", { status: "completed" }),
|
||||||
|
createFeature("f3", { status: "backlog", dependencies: ["f1", "f2"] }),
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(areDependenciesSatisfied(allFeatures[2], allFeatures)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return true when all dependencies are verified", () => {
|
||||||
|
const allFeatures = [
|
||||||
|
createFeature("f1", { status: "verified" }),
|
||||||
|
createFeature("f2", { status: "verified" }),
|
||||||
|
createFeature("f3", { status: "backlog", dependencies: ["f1", "f2"] }),
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(areDependenciesSatisfied(allFeatures[2], allFeatures)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return true when dependencies are mix of completed and verified", () => {
|
||||||
|
const allFeatures = [
|
||||||
|
createFeature("f1", { status: "completed" }),
|
||||||
|
createFeature("f2", { status: "verified" }),
|
||||||
|
createFeature("f3", { status: "backlog", dependencies: ["f1", "f2"] }),
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(areDependenciesSatisfied(allFeatures[2], allFeatures)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return false when any dependency is in_progress", () => {
|
||||||
|
const allFeatures = [
|
||||||
|
createFeature("f1", { status: "completed" }),
|
||||||
|
createFeature("f2", { status: "in_progress" }),
|
||||||
|
createFeature("f3", { status: "backlog", dependencies: ["f1", "f2"] }),
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(areDependenciesSatisfied(allFeatures[2], allFeatures)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return false when any dependency is in backlog", () => {
|
||||||
|
const allFeatures = [
|
||||||
|
createFeature("f1", { status: "completed" }),
|
||||||
|
createFeature("f2", { status: "backlog" }),
|
||||||
|
createFeature("f3", { status: "backlog", dependencies: ["f1", "f2"] }),
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(areDependenciesSatisfied(allFeatures[2], allFeatures)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return false when dependency is missing", () => {
|
||||||
|
const allFeatures = [
|
||||||
|
createFeature("f1", { status: "backlog", dependencies: ["missing"] }),
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(areDependenciesSatisfied(allFeatures[0], allFeatures)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return false when multiple dependencies are incomplete", () => {
|
||||||
|
const allFeatures = [
|
||||||
|
createFeature("f1", { status: "backlog" }),
|
||||||
|
createFeature("f2", { status: "in_progress" }),
|
||||||
|
createFeature("f3", { status: "waiting_approval" }),
|
||||||
|
createFeature("f4", { status: "backlog", dependencies: ["f1", "f2", "f3"] }),
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(areDependenciesSatisfied(allFeatures[3], allFeatures)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getBlockingDependencies", () => {
|
||||||
|
it("should return empty array for feature with no dependencies", () => {
|
||||||
|
const feature = createFeature("f1");
|
||||||
|
const allFeatures = [feature];
|
||||||
|
|
||||||
|
expect(getBlockingDependencies(feature, allFeatures)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return empty array for feature with empty dependencies array", () => {
|
||||||
|
const feature = createFeature("f1", { dependencies: [] });
|
||||||
|
const allFeatures = [feature];
|
||||||
|
|
||||||
|
expect(getBlockingDependencies(feature, allFeatures)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return empty array when all dependencies are completed", () => {
|
||||||
|
const allFeatures = [
|
||||||
|
createFeature("f1", { status: "completed" }),
|
||||||
|
createFeature("f2", { status: "completed" }),
|
||||||
|
createFeature("f3", { status: "backlog", dependencies: ["f1", "f2"] }),
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(getBlockingDependencies(allFeatures[2], allFeatures)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return empty array when all dependencies are verified", () => {
|
||||||
|
const allFeatures = [
|
||||||
|
createFeature("f1", { status: "verified" }),
|
||||||
|
createFeature("f2", { status: "verified" }),
|
||||||
|
createFeature("f3", { status: "backlog", dependencies: ["f1", "f2"] }),
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(getBlockingDependencies(allFeatures[2], allFeatures)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return blocking dependencies in backlog status", () => {
|
||||||
|
const allFeatures = [
|
||||||
|
createFeature("f1", { status: "backlog" }),
|
||||||
|
createFeature("f2", { status: "completed" }),
|
||||||
|
createFeature("f3", { status: "backlog", dependencies: ["f1", "f2"] }),
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(getBlockingDependencies(allFeatures[2], allFeatures)).toEqual(["f1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return blocking dependencies in in_progress status", () => {
|
||||||
|
const allFeatures = [
|
||||||
|
createFeature("f1", { status: "in_progress" }),
|
||||||
|
createFeature("f2", { status: "verified" }),
|
||||||
|
createFeature("f3", { status: "backlog", dependencies: ["f1", "f2"] }),
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(getBlockingDependencies(allFeatures[2], allFeatures)).toEqual(["f1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return blocking dependencies in waiting_approval status", () => {
|
||||||
|
const allFeatures = [
|
||||||
|
createFeature("f1", { status: "waiting_approval" }),
|
||||||
|
createFeature("f2", { status: "completed" }),
|
||||||
|
createFeature("f3", { status: "backlog", dependencies: ["f1", "f2"] }),
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(getBlockingDependencies(allFeatures[2], allFeatures)).toEqual(["f1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return all blocking dependencies", () => {
|
||||||
|
const allFeatures = [
|
||||||
|
createFeature("f1", { status: "backlog" }),
|
||||||
|
createFeature("f2", { status: "in_progress" }),
|
||||||
|
createFeature("f3", { status: "waiting_approval" }),
|
||||||
|
createFeature("f4", { status: "completed" }),
|
||||||
|
createFeature("f5", { status: "backlog", dependencies: ["f1", "f2", "f3", "f4"] }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const blocking = getBlockingDependencies(allFeatures[4], allFeatures);
|
||||||
|
expect(blocking).toHaveLength(3);
|
||||||
|
expect(blocking).toContain("f1");
|
||||||
|
expect(blocking).toContain("f2");
|
||||||
|
expect(blocking).toContain("f3");
|
||||||
|
expect(blocking).not.toContain("f4");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle missing dependencies", () => {
|
||||||
|
const allFeatures = [
|
||||||
|
createFeature("f1", { status: "backlog", dependencies: ["missing"] }),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Missing dependencies won't be in the blocking list since they don't exist
|
||||||
|
expect(getBlockingDependencies(allFeatures[0], allFeatures)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle mix of completed, verified, and incomplete dependencies", () => {
|
||||||
|
const allFeatures = [
|
||||||
|
createFeature("f1", { status: "completed" }),
|
||||||
|
createFeature("f2", { status: "verified" }),
|
||||||
|
createFeature("f3", { status: "in_progress" }),
|
||||||
|
createFeature("f4", { status: "backlog" }),
|
||||||
|
createFeature("f5", { status: "backlog", dependencies: ["f1", "f2", "f3", "f4"] }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const blocking = getBlockingDependencies(allFeatures[4], allFeatures);
|
||||||
|
expect(blocking).toHaveLength(2);
|
||||||
|
expect(blocking).toContain("f3");
|
||||||
|
expect(blocking).toContain("f4");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user