refactor: extract shared getEpicChildren helper for swarm commands
- Add EpicChildren struct and getEpicChildren() helper function - Define SwarmStore interface for dependency injection - Refactor analyzeEpicForSwarm to use shared helper - Refactor getSwarmStatus to use shared helper - Eliminates duplicate code for fetching epic children and building dependency maps across both functions 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
201
cmd/bd/swarm.go
201
cmd/bd/swarm.go
@@ -58,6 +58,75 @@ type IssueNode struct {
|
|||||||
Wave int `json:"wave"` // Which ready front this belongs to (-1 if blocked by cycle)
|
Wave int `json:"wave"` // Which ready front this belongs to (-1 if blocked by cycle)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EpicChildren holds the result of fetching an epic's children and their dependencies.
|
||||||
|
type EpicChildren struct {
|
||||||
|
Children []*types.Issue // Child issues of the epic
|
||||||
|
ChildIDSet map[string]bool // Set of child IDs for fast lookup
|
||||||
|
DependsOn map[string][]string // Map of issue ID -> IDs it depends on (within epic)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SwarmStore defines the interface needed for swarm operations.
|
||||||
|
type SwarmStore interface {
|
||||||
|
GetIssue(context.Context, string) (*types.Issue, error)
|
||||||
|
GetDependents(context.Context, string) ([]*types.Issue, error)
|
||||||
|
GetDependencyRecords(context.Context, string) ([]*types.Dependency, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getEpicChildren fetches all children of an epic and builds dependency maps.
|
||||||
|
// It filters to only parent-child relationships and only tracks blocking dependencies
|
||||||
|
// within the epic's children.
|
||||||
|
func getEpicChildren(ctx context.Context, s SwarmStore, epicID string) (*EpicChildren, error) {
|
||||||
|
result := &EpicChildren{
|
||||||
|
Children: []*types.Issue{},
|
||||||
|
ChildIDSet: make(map[string]bool),
|
||||||
|
DependsOn: make(map[string][]string),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all issues that depend on the epic
|
||||||
|
allDependents, err := s.GetDependents(ctx, epicID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get epic dependents: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter to only parent-child relationships by checking each dependent's dependency records
|
||||||
|
for _, dependent := range allDependents {
|
||||||
|
deps, err := s.GetDependencyRecords(ctx, dependent.ID)
|
||||||
|
if err != nil {
|
||||||
|
continue // Skip issues we can't query
|
||||||
|
}
|
||||||
|
for _, dep := range deps {
|
||||||
|
if dep.DependsOnID == epicID && dep.Type == types.DepParentChild {
|
||||||
|
result.Children = append(result.Children, dependent)
|
||||||
|
result.ChildIDSet[dependent.ID] = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build dependency map (blocking dependencies within epic children only)
|
||||||
|
for _, issue := range result.Children {
|
||||||
|
deps, err := s.GetDependencyRecords(ctx, issue.ID)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, dep := range deps {
|
||||||
|
// Skip parent-child to epic itself
|
||||||
|
if dep.DependsOnID == epicID && dep.Type == types.DepParentChild {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Only track blocking dependencies within children
|
||||||
|
if !dep.Type.AffectsReadyWork() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if result.ChildIDSet[dep.DependsOnID] {
|
||||||
|
result.DependsOn[issue.ID] = append(result.DependsOn[issue.ID], dep.DependsOnID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
var swarmValidateCmd = &cobra.Command{
|
var swarmValidateCmd = &cobra.Command{
|
||||||
Use: "validate [epic-id]",
|
Use: "validate [epic-id]",
|
||||||
Short: "Validate epic structure for swarming",
|
Short: "Validate epic structure for swarming",
|
||||||
@@ -147,11 +216,7 @@ Examples:
|
|||||||
}
|
}
|
||||||
|
|
||||||
// analyzeEpicForSwarm performs structural analysis of an epic for swarm execution.
|
// analyzeEpicForSwarm performs structural analysis of an epic for swarm execution.
|
||||||
func analyzeEpicForSwarm(ctx context.Context, s interface{
|
func analyzeEpicForSwarm(ctx context.Context, s SwarmStore, epic *types.Issue) (*SwarmAnalysis, error) {
|
||||||
GetIssue(context.Context, string) (*types.Issue, error)
|
|
||||||
GetDependents(context.Context, string) ([]*types.Issue, error)
|
|
||||||
GetDependencyRecords(context.Context, string) ([]*types.Dependency, error)
|
|
||||||
}, epic *types.Issue) (*SwarmAnalysis, error) {
|
|
||||||
analysis := &SwarmAnalysis{
|
analysis := &SwarmAnalysis{
|
||||||
EpicID: epic.ID,
|
EpicID: epic.ID,
|
||||||
EpicTitle: epic.Title,
|
EpicTitle: epic.Title,
|
||||||
@@ -159,45 +224,33 @@ func analyzeEpicForSwarm(ctx context.Context, s interface{
|
|||||||
Issues: make(map[string]*IssueNode),
|
Issues: make(map[string]*IssueNode),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get all issues that depend on the epic
|
// Get children and dependency map using shared helper
|
||||||
allDependents, err := s.GetDependents(ctx, epic.ID)
|
epicChildren, err := getEpicChildren(ctx, s, epic.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to get epic dependents: %w", err)
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter to only parent-child relationships by checking each dependent's dependency records
|
if len(epicChildren.Children) == 0 {
|
||||||
var childIssues []*types.Issue
|
|
||||||
for _, dependent := range allDependents {
|
|
||||||
deps, err := s.GetDependencyRecords(ctx, dependent.ID)
|
|
||||||
if err != nil {
|
|
||||||
continue // Skip issues we can't query
|
|
||||||
}
|
|
||||||
for _, dep := range deps {
|
|
||||||
if dep.DependsOnID == epic.ID && dep.Type == types.DepParentChild {
|
|
||||||
childIssues = append(childIssues, dependent)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(childIssues) == 0 {
|
|
||||||
analysis.Warnings = append(analysis.Warnings, "Epic has no children")
|
analysis.Warnings = append(analysis.Warnings, "Epic has no children")
|
||||||
return analysis, nil
|
return analysis, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
analysis.TotalIssues = len(childIssues)
|
analysis.TotalIssues = len(epicChildren.Children)
|
||||||
|
|
||||||
// Build the issue graph
|
// Build the issue graph with nodes
|
||||||
for _, issue := range childIssues {
|
for _, issue := range epicChildren.Children {
|
||||||
node := &IssueNode{
|
node := &IssueNode{
|
||||||
ID: issue.ID,
|
ID: issue.ID,
|
||||||
Title: issue.Title,
|
Title: issue.Title,
|
||||||
Status: string(issue.Status),
|
Status: string(issue.Status),
|
||||||
Priority: issue.Priority,
|
Priority: issue.Priority,
|
||||||
DependsOn: []string{},
|
DependsOn: epicChildren.DependsOn[issue.ID], // Use pre-computed deps
|
||||||
DependedOnBy: []string{},
|
DependedOnBy: []string{},
|
||||||
Wave: -1, // Will be set later
|
Wave: -1, // Will be set later
|
||||||
}
|
}
|
||||||
|
if node.DependsOn == nil {
|
||||||
|
node.DependsOn = []string{}
|
||||||
|
}
|
||||||
analysis.Issues[issue.ID] = node
|
analysis.Issues[issue.ID] = node
|
||||||
|
|
||||||
if issue.Status == types.StatusClosed {
|
if issue.Status == types.StatusClosed {
|
||||||
@@ -205,38 +258,30 @@ func analyzeEpicForSwarm(ctx context.Context, s interface{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build dependency relationships (only within the epic's children)
|
// Build reverse dependency map (DependedOnBy) and check for external deps
|
||||||
childIDSet := make(map[string]bool)
|
for _, issue := range epicChildren.Children {
|
||||||
for _, issue := range childIssues {
|
|
||||||
childIDSet[issue.ID] = true
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, issue := range childIssues {
|
|
||||||
deps, err := s.GetDependencyRecords(ctx, issue.ID)
|
deps, err := s.GetDependencyRecords(ctx, issue.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to get dependencies for %s: %w", issue.ID, err)
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
node := analysis.Issues[issue.ID]
|
|
||||||
for _, dep := range deps {
|
for _, dep := range deps {
|
||||||
// Only consider dependencies within the epic (not parent-child to epic itself)
|
// Skip parent-child to epic itself
|
||||||
if dep.DependsOnID == epic.ID && dep.Type == types.DepParentChild {
|
if dep.DependsOnID == epic.ID && dep.Type == types.DepParentChild {
|
||||||
continue // Skip the parent relationship to the epic
|
continue
|
||||||
}
|
}
|
||||||
// Only track blocking dependencies
|
// Only track blocking dependencies
|
||||||
if !dep.Type.AffectsReadyWork() {
|
if !dep.Type.AffectsReadyWork() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// Only track dependencies within the epic's children
|
// Build DependedOnBy for internal deps
|
||||||
if childIDSet[dep.DependsOnID] {
|
if epicChildren.ChildIDSet[dep.DependsOnID] {
|
||||||
node.DependsOn = append(node.DependsOn, dep.DependsOnID)
|
|
||||||
if targetNode, ok := analysis.Issues[dep.DependsOnID]; ok {
|
if targetNode, ok := analysis.Issues[dep.DependsOnID]; ok {
|
||||||
targetNode.DependedOnBy = append(targetNode.DependedOnBy, issue.ID)
|
targetNode.DependedOnBy = append(targetNode.DependedOnBy, issue.ID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// External dependencies to issues outside the epic
|
// Warn about external dependencies
|
||||||
if !childIDSet[dep.DependsOnID] && dep.DependsOnID != epic.ID {
|
if !epicChildren.ChildIDSet[dep.DependsOnID] && dep.DependsOnID != epic.ID {
|
||||||
// Check if it's an external ref
|
|
||||||
if strings.HasPrefix(dep.DependsOnID, "external:") {
|
if strings.HasPrefix(dep.DependsOnID, "external:") {
|
||||||
analysis.Warnings = append(analysis.Warnings,
|
analysis.Warnings = append(analysis.Warnings,
|
||||||
fmt.Sprintf("%s has external dependency: %s", issue.ID, dep.DependsOnID))
|
fmt.Sprintf("%s has external dependency: %s", issue.ID, dep.DependsOnID))
|
||||||
@@ -249,7 +294,7 @@ func analyzeEpicForSwarm(ctx context.Context, s interface{
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Detect structural issues
|
// Detect structural issues
|
||||||
detectStructuralIssues(analysis, childIssues)
|
detectStructuralIssues(analysis, epicChildren.Children)
|
||||||
|
|
||||||
// Compute ready fronts (waves of parallel work)
|
// Compute ready fronts (waves of parallel work)
|
||||||
computeReadyFronts(analysis)
|
computeReadyFronts(analysis)
|
||||||
@@ -613,10 +658,7 @@ Examples:
|
|||||||
}
|
}
|
||||||
|
|
||||||
// getSwarmStatus computes current swarm status from beads.
|
// getSwarmStatus computes current swarm status from beads.
|
||||||
func getSwarmStatus(ctx context.Context, s interface {
|
func getSwarmStatus(ctx context.Context, s SwarmStore, epic *types.Issue) (*SwarmStatus, error) {
|
||||||
GetDependents(context.Context, string) ([]*types.Issue, error)
|
|
||||||
GetDependencyRecords(context.Context, string) ([]*types.Dependency, error)
|
|
||||||
}, epic *types.Issue) (*SwarmStatus, error) {
|
|
||||||
status := &SwarmStatus{
|
status := &SwarmStatus{
|
||||||
EpicID: epic.ID,
|
EpicID: epic.ID,
|
||||||
EpicTitle: epic.Title,
|
EpicTitle: epic.Title,
|
||||||
@@ -626,68 +668,25 @@ func getSwarmStatus(ctx context.Context, s interface {
|
|||||||
Blocked: []StatusIssue{},
|
Blocked: []StatusIssue{},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get all issues that depend on the epic (children)
|
// Get children and dependency map using shared helper
|
||||||
allDependents, err := s.GetDependents(ctx, epic.ID)
|
epicChildren, err := getEpicChildren(ctx, s, epic.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to get epic dependents: %w", err)
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter to only parent-child relationships
|
status.TotalIssues = len(epicChildren.Children)
|
||||||
var childIssues []*types.Issue
|
if len(epicChildren.Children) == 0 {
|
||||||
for _, dependent := range allDependents {
|
|
||||||
deps, err := s.GetDependencyRecords(ctx, dependent.ID)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, dep := range deps {
|
|
||||||
if dep.DependsOnID == epic.ID && dep.Type == types.DepParentChild {
|
|
||||||
childIssues = append(childIssues, dependent)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
status.TotalIssues = len(childIssues)
|
|
||||||
if len(childIssues) == 0 {
|
|
||||||
return status, nil
|
return status, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build set of child IDs for filtering
|
|
||||||
childIDSet := make(map[string]bool)
|
|
||||||
for _, issue := range childIssues {
|
|
||||||
childIDSet[issue.ID] = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build status map for efficient blocked checks (avoids N+1 queries)
|
// Build status map for efficient blocked checks (avoids N+1 queries)
|
||||||
statusMap := make(map[string]types.Status)
|
statusMap := make(map[string]types.Status)
|
||||||
for _, issue := range childIssues {
|
for _, issue := range epicChildren.Children {
|
||||||
statusMap[issue.ID] = issue.Status
|
statusMap[issue.ID] = issue.Status
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build dependency map (within epic children only)
|
|
||||||
dependsOn := make(map[string][]string)
|
|
||||||
for _, issue := range childIssues {
|
|
||||||
deps, err := s.GetDependencyRecords(ctx, issue.ID)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, dep := range deps {
|
|
||||||
// Skip parent-child to epic itself
|
|
||||||
if dep.DependsOnID == epic.ID && dep.Type == types.DepParentChild {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// Only track blocking dependencies within children
|
|
||||||
if !dep.Type.AffectsReadyWork() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if childIDSet[dep.DependsOnID] {
|
|
||||||
dependsOn[issue.ID] = append(dependsOn[issue.ID], dep.DependsOnID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Categorize each issue
|
// Categorize each issue
|
||||||
for _, issue := range childIssues {
|
for _, issue := range epicChildren.Children {
|
||||||
si := StatusIssue{
|
si := StatusIssue{
|
||||||
ID: issue.ID,
|
ID: issue.ID,
|
||||||
Title: issue.Title,
|
Title: issue.Title,
|
||||||
@@ -706,7 +705,7 @@ func getSwarmStatus(ctx context.Context, s interface {
|
|||||||
|
|
||||||
default: // open or other
|
default: // open or other
|
||||||
// Check if blocked by open dependencies (uses statusMap, no extra queries)
|
// Check if blocked by open dependencies (uses statusMap, no extra queries)
|
||||||
deps := dependsOn[issue.ID]
|
deps := epicChildren.DependsOn[issue.ID]
|
||||||
var blockers []string
|
var blockers []string
|
||||||
for _, depID := range deps {
|
for _, depID := range deps {
|
||||||
if depStatus, ok := statusMap[depID]; ok && depStatus != types.StatusClosed {
|
if depStatus, ok := statusMap[depID]; ok && depStatus != types.StatusClosed {
|
||||||
|
|||||||
Reference in New Issue
Block a user