feat: display external deps in bd dep tree (bd-vks2, bd-mv6h, bd-d9mu)

External dependencies (external:project:capability) are now visible in
the dependency tree output. Previously they were invisible because the
recursive CTE only JOINed against the issues table.

Changes:
- GetDependencyTree now fetches external deps and adds them as synthetic
  leaf nodes with resolution status (satisfied/blocked)
- formatTreeNode displays external deps with special formatting
- Added helper parseExternalRefParts for parsing external refs

Test coverage added for:
- External deps appearing in dependency tree
- Cycle detection ignoring external refs
- CheckExternalDep when target has no .beads directory
- Various invalid external ref format variations

Closes: bd-vks2, bd-mv6h, bd-d9mu
This commit is contained in:
Steve Yegge
2025-12-22 22:34:03 -08:00
parent 73b2e14919
commit ad02b80330
4 changed files with 373 additions and 0 deletions

View File

@@ -625,9 +625,97 @@ func (s *SQLiteStorage) GetDependencyTree(ctx context.Context, issueID string, m
nodes = append(nodes, &node)
}
// Fetch external dependencies for all issues in the tree (bd-vks2)
// External deps like "external:project:capability" don't exist in the issues
// table, so the recursive CTE above doesn't find them. We add them as
// synthetic leaf nodes here.
if len(nodes) > 0 && !reverse {
// Collect all issue IDs in the tree
issueIDs := make([]string, len(nodes))
depthByID := make(map[string]int)
for i, n := range nodes {
issueIDs[i] = n.ID
depthByID[n.ID] = n.Depth
}
// Query for external dependencies
externalDeps, err := s.getExternalDepsForIssues(ctx, issueIDs)
if err != nil {
// Non-fatal: just skip external deps if query fails
_ = err
} else {
// Create synthetic TreeNode for each external dep
for parentID, extRefs := range externalDeps {
parentDepth, ok := depthByID[parentID]
if !ok {
continue
}
// Skip if we've exceeded maxDepth
if parentDepth >= maxDepth {
continue
}
for _, ref := range extRefs {
// Parse external ref for display
_, capability := parseExternalRefParts(ref)
if capability == "" {
capability = ref // fallback to full ref
}
// Check resolution status
status := CheckExternalDep(ctx, ref)
var nodeStatus types.Status
var title string
if status.Satisfied {
nodeStatus = types.StatusClosed
title = fmt.Sprintf("✓ %s", capability)
} else {
nodeStatus = types.StatusBlocked
title = fmt.Sprintf("⏳ %s", capability)
}
extNode := &types.TreeNode{
Issue: types.Issue{
ID: ref,
Title: title,
Status: nodeStatus,
Priority: 0, // External deps don't have priority
IssueType: types.TypeTask,
},
Depth: parentDepth + 1,
ParentID: parentID,
}
// Apply deduplication if needed
if !showAllPaths {
if _, exists := seen[ref]; exists {
continue
}
seen[ref] = extNode.Depth
}
nodes = append(nodes, extNode)
}
}
}
}
return nodes, nil
}
// parseExternalRefParts parses "external:project:capability" and returns (project, capability).
// Returns empty strings if the format is invalid.
func parseExternalRefParts(ref string) (project, capability string) {
if !strings.HasPrefix(ref, "external:") {
return "", ""
}
parts := strings.SplitN(ref, ":", 3)
if len(parts) != 3 {
return "", ""
}
return parts[1], parts[2]
}
// DetectCycles finds circular dependencies and returns the actual cycle paths
// Note: relates-to dependencies are excluded because they are intentionally bidirectional
// ("see also" relationships) and do not represent problematic cycles.