fix: mol run loads all hierarchical children + supports title lookup (bd-c8d5, bd-drcx)
Two issues fixed:
1. bd-c8d5: mol run only created partial children from proto
- Root cause: children with missing/wrong dependencies were not loaded
- Fix: loadDescendants now uses two strategies:
- Strategy 1: Check dependency records for parent-child relationships
- Strategy 2: Find hierarchical children by ID pattern (parent.N)
- This catches children that may have broken dependency data
2. bd-drcx: mol run now supports proto lookup by title
- Can use: bd mol run mol-polecat-work --var issue=gt-xxx
- Or by ID: bd mol run gt-lwuu --var issue=gt-xxx
- Title matching is case-insensitive and supports partial matches
- Shows helpful error on ambiguous matches
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/steveyegge/beads/internal/storage/sqlite"
|
||||
@@ -474,3 +475,356 @@ func TestExtractAllVariables(t *testing.T) {
|
||||
t.Error("Missing variable: environment")
|
||||
}
|
||||
}
|
||||
|
||||
// createIssueWithID creates an issue with a specific ID (for testing hierarchical IDs)
|
||||
func (h *templateTestHelper) createIssueWithID(id, title, description string, issueType types.IssueType, priority int) *types.Issue {
|
||||
issue := &types.Issue{
|
||||
ID: id,
|
||||
Title: title,
|
||||
Description: description,
|
||||
Priority: priority,
|
||||
IssueType: issueType,
|
||||
Status: types.StatusOpen,
|
||||
}
|
||||
if err := h.s.CreateIssue(h.ctx, issue, "test-user"); err != nil {
|
||||
h.t.Fatalf("Failed to create issue with ID %s: %v", id, err)
|
||||
}
|
||||
return issue
|
||||
}
|
||||
|
||||
// TestResolveProtoIDOrTitle tests proto lookup by ID or title (bd-drcx)
|
||||
func TestResolveProtoIDOrTitle(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "bd-test-proto-lookup-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
testDB := filepath.Join(tmpDir, "test.db")
|
||||
s := newTestStore(t, testDB)
|
||||
defer s.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
h := &templateTestHelper{s: s, ctx: ctx, t: t}
|
||||
|
||||
// Create some protos with distinct titles
|
||||
proto1 := h.createIssue("mol-polecat-work", "Polecat workflow", types.TypeEpic, 1)
|
||||
h.addLabel(proto1.ID, BeadsTemplateLabel)
|
||||
|
||||
proto2 := h.createIssue("mol-version-bump", "Version bump workflow", types.TypeEpic, 1)
|
||||
h.addLabel(proto2.ID, BeadsTemplateLabel)
|
||||
|
||||
proto3 := h.createIssue("mol-release", "Release workflow", types.TypeEpic, 1)
|
||||
h.addLabel(proto3.ID, BeadsTemplateLabel)
|
||||
|
||||
// Create a non-proto issue with similar title
|
||||
nonProto := h.createIssue("mol-test", "Not a proto", types.TypeTask, 2)
|
||||
_ = nonProto
|
||||
|
||||
t.Run("resolve by exact ID", func(t *testing.T) {
|
||||
resolved, err := resolveProtoIDOrTitle(ctx, s, proto1.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to resolve by ID: %v", err)
|
||||
}
|
||||
if resolved != proto1.ID {
|
||||
t.Errorf("Expected %s, got %s", proto1.ID, resolved)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("resolve by exact title", func(t *testing.T) {
|
||||
resolved, err := resolveProtoIDOrTitle(ctx, s, "mol-polecat-work")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to resolve by title: %v", err)
|
||||
}
|
||||
if resolved != proto1.ID {
|
||||
t.Errorf("Expected %s, got %s", proto1.ID, resolved)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("resolve by title case-insensitive", func(t *testing.T) {
|
||||
resolved, err := resolveProtoIDOrTitle(ctx, s, "MOL-POLECAT-WORK")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to resolve by title (case-insensitive): %v", err)
|
||||
}
|
||||
if resolved != proto1.ID {
|
||||
t.Errorf("Expected %s, got %s", proto1.ID, resolved)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("resolve by unique partial title", func(t *testing.T) {
|
||||
resolved, err := resolveProtoIDOrTitle(ctx, s, "polecat")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to resolve by partial title: %v", err)
|
||||
}
|
||||
if resolved != proto1.ID {
|
||||
t.Errorf("Expected %s, got %s", proto1.ID, resolved)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ambiguous partial title returns error", func(t *testing.T) {
|
||||
// "mol-" matches all three protos
|
||||
_, err := resolveProtoIDOrTitle(ctx, s, "mol-")
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for ambiguous title, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "ambiguous") {
|
||||
t.Errorf("Expected 'ambiguous' in error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-existent returns error", func(t *testing.T) {
|
||||
_, err := resolveProtoIDOrTitle(ctx, s, "nonexistent-proto")
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for non-existent proto, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "no proto found") {
|
||||
t.Errorf("Expected 'no proto found' in error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-proto ID returns error", func(t *testing.T) {
|
||||
// This ID exists but is not a proto (no template label)
|
||||
_, err := resolveProtoIDOrTitle(ctx, s, nonProto.ID)
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for non-proto ID, got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestLoadTemplateSubgraphWithManyChildren tests loading with 4+ children (bd-c8d5)
|
||||
// This reproduces the bug where only 2 of 4 children were loaded.
|
||||
func TestLoadTemplateSubgraphWithManyChildren(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "bd-test-many-children-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
testDB := filepath.Join(tmpDir, "test.db")
|
||||
s := newTestStore(t, testDB)
|
||||
defer s.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
h := &templateTestHelper{s: s, ctx: ctx, t: t}
|
||||
|
||||
t.Run("load epic with 4 children", func(t *testing.T) {
|
||||
epic := h.createIssue("Proto Workflow", "Workflow with 4 steps", types.TypeEpic, 1)
|
||||
h.addLabel(epic.ID, BeadsTemplateLabel)
|
||||
|
||||
// Create 4 children with different titles (like the bug report)
|
||||
child1 := h.createIssue("load-context", "", types.TypeTask, 2)
|
||||
child2 := h.createIssue("implement", "", types.TypeTask, 2)
|
||||
child3 := h.createIssue("self-review", "", types.TypeTask, 2)
|
||||
child4 := h.createIssue("request-shutdown", "", types.TypeTask, 2)
|
||||
|
||||
h.addParentChild(child1.ID, epic.ID)
|
||||
h.addParentChild(child2.ID, epic.ID)
|
||||
h.addParentChild(child3.ID, epic.ID)
|
||||
h.addParentChild(child4.ID, epic.ID)
|
||||
|
||||
subgraph, err := loadTemplateSubgraph(ctx, s, epic.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("loadTemplateSubgraph failed: %v", err)
|
||||
}
|
||||
|
||||
// Should have 5 issues: 1 root + 4 children
|
||||
if len(subgraph.Issues) != 5 {
|
||||
t.Errorf("Issues count = %d, want 5 (epic + 4 children)", len(subgraph.Issues))
|
||||
t.Logf("Found issues:")
|
||||
for _, iss := range subgraph.Issues {
|
||||
t.Logf(" - %s: %s", iss.ID, iss.Title)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify each child is in the subgraph
|
||||
childIDs := []string{child1.ID, child2.ID, child3.ID, child4.ID}
|
||||
for _, childID := range childIDs {
|
||||
if _, ok := subgraph.IssueMap[childID]; !ok {
|
||||
t.Errorf("Child %s not found in subgraph", childID)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("clone epic with 4 children creates all 4", func(t *testing.T) {
|
||||
epic := h.createIssue("Polecat Work", "", types.TypeEpic, 1)
|
||||
h.addLabel(epic.ID, BeadsTemplateLabel)
|
||||
|
||||
child1 := h.createIssue("load-context", "", types.TypeTask, 2)
|
||||
child2 := h.createIssue("implement", "", types.TypeTask, 2)
|
||||
child3 := h.createIssue("self-review", "", types.TypeTask, 2)
|
||||
child4 := h.createIssue("request-shutdown", "", types.TypeTask, 2)
|
||||
|
||||
h.addParentChild(child1.ID, epic.ID)
|
||||
h.addParentChild(child2.ID, epic.ID)
|
||||
h.addParentChild(child3.ID, epic.ID)
|
||||
h.addParentChild(child4.ID, epic.ID)
|
||||
|
||||
subgraph, err := loadTemplateSubgraph(ctx, s, epic.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("loadTemplateSubgraph failed: %v", err)
|
||||
}
|
||||
|
||||
opts := CloneOptions{Actor: "test-user"}
|
||||
result, err := cloneSubgraph(ctx, s, subgraph, opts)
|
||||
if err != nil {
|
||||
t.Fatalf("cloneSubgraph failed: %v", err)
|
||||
}
|
||||
|
||||
// Should create 5 issues (1 root + 4 children)
|
||||
if result.Created != 5 {
|
||||
t.Errorf("Created = %d, want 5", result.Created)
|
||||
}
|
||||
|
||||
// Verify all children were mapped
|
||||
for _, childID := range []string{child1.ID, child2.ID, child3.ID, child4.ID} {
|
||||
if _, ok := result.IDMapping[childID]; !ok {
|
||||
t.Errorf("Child %s not in ID mapping", childID)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("load epic with hierarchical child IDs - bd-c8d5 reproduction", func(t *testing.T) {
|
||||
// This replicates the exact scenario from bd-c8d5:
|
||||
// Proto gt-lwuu has children gt-lwuu.1, gt-lwuu.2, gt-lwuu.3, gt-lwuu.8
|
||||
// Only gt-lwuu.1 and gt-lwuu.2 were being loaded
|
||||
// Using test-xxx prefix to match test database configuration
|
||||
epic := h.createIssueWithID("test-lwuu", "mol-polecat-work", "", types.TypeEpic, 1)
|
||||
h.addLabel(epic.ID, BeadsTemplateLabel)
|
||||
|
||||
// Create children with hierarchical IDs (note the gap: .1, .2, .3, .8)
|
||||
child1 := h.createIssueWithID("test-lwuu.1", "load-context", "", types.TypeTask, 2)
|
||||
child2 := h.createIssueWithID("test-lwuu.2", "implement", "", types.TypeTask, 2)
|
||||
child3 := h.createIssueWithID("test-lwuu.3", "self-review", "", types.TypeTask, 2)
|
||||
child8 := h.createIssueWithID("test-lwuu.8", "request-shutdown", "", types.TypeTask, 2)
|
||||
|
||||
h.addParentChild(child1.ID, epic.ID)
|
||||
h.addParentChild(child2.ID, epic.ID)
|
||||
h.addParentChild(child3.ID, epic.ID)
|
||||
h.addParentChild(child8.ID, epic.ID)
|
||||
|
||||
subgraph, err := loadTemplateSubgraph(ctx, s, epic.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("loadTemplateSubgraph failed: %v", err)
|
||||
}
|
||||
|
||||
// Should have 5 issues: 1 root + 4 children
|
||||
if len(subgraph.Issues) != 5 {
|
||||
t.Errorf("Issues count = %d, want 5", len(subgraph.Issues))
|
||||
t.Logf("Found issues:")
|
||||
for _, iss := range subgraph.Issues {
|
||||
t.Logf(" - %s: %s", iss.ID, iss.Title)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify all 4 children are loaded
|
||||
expectedChildren := []string{"test-lwuu.1", "test-lwuu.2", "test-lwuu.3", "test-lwuu.8"}
|
||||
for _, childID := range expectedChildren {
|
||||
if _, ok := subgraph.IssueMap[childID]; !ok {
|
||||
t.Errorf("Child %s not found in subgraph", childID)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("children with wrong dep type are not loaded - potential bug cause", func(t *testing.T) {
|
||||
// This tests the hypothesis that the bug is caused by children
|
||||
// having the wrong dependency type (e.g., "blocks" instead of "parent-child")
|
||||
epic := h.createIssue("Proto with mixed deps", "", types.TypeEpic, 1)
|
||||
h.addLabel(epic.ID, BeadsTemplateLabel)
|
||||
|
||||
child1 := h.createIssue("load-context", "", types.TypeTask, 2)
|
||||
child2 := h.createIssue("implement", "", types.TypeTask, 2)
|
||||
child3 := h.createIssue("self-review", "", types.TypeTask, 2)
|
||||
child4 := h.createIssue("request-shutdown", "", types.TypeTask, 2)
|
||||
|
||||
// Only child1 and child2 have parent-child dependency
|
||||
h.addParentChild(child1.ID, epic.ID)
|
||||
h.addParentChild(child2.ID, epic.ID)
|
||||
|
||||
// child3 and child4 have "blocks" dependency (wrong type)
|
||||
blocksDep := &types.Dependency{
|
||||
IssueID: child3.ID,
|
||||
DependsOnID: epic.ID,
|
||||
Type: types.DepBlocks,
|
||||
}
|
||||
if err := s.AddDependency(ctx, blocksDep, "test-user"); err != nil {
|
||||
t.Fatalf("Failed to add blocks dependency: %v", err)
|
||||
}
|
||||
blocksDep2 := &types.Dependency{
|
||||
IssueID: child4.ID,
|
||||
DependsOnID: epic.ID,
|
||||
Type: types.DepBlocks,
|
||||
}
|
||||
if err := s.AddDependency(ctx, blocksDep2, "test-user"); err != nil {
|
||||
t.Fatalf("Failed to add blocks dependency: %v", err)
|
||||
}
|
||||
|
||||
subgraph, err := loadTemplateSubgraph(ctx, s, epic.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("loadTemplateSubgraph failed: %v", err)
|
||||
}
|
||||
|
||||
// With non-hierarchical IDs, only parent-child deps are loaded
|
||||
// This is expected - the hierarchical ID fallback doesn't apply
|
||||
t.Logf("Found %d issues (expecting 3 without hierarchical IDs):", len(subgraph.Issues))
|
||||
for _, iss := range subgraph.Issues {
|
||||
t.Logf(" - %s: %s", iss.ID, iss.Title)
|
||||
}
|
||||
|
||||
if len(subgraph.Issues) != 3 {
|
||||
t.Errorf("Expected 3 issues (without hierarchical ID fallback), got %d", len(subgraph.Issues))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("hierarchical children with wrong dep type ARE loaded - bd-c8d5 fix", func(t *testing.T) {
|
||||
// This tests the fix for bd-c8d5:
|
||||
// Hierarchical children (parent.N pattern) are loaded even if they have
|
||||
// wrong dependency types, using the ID pattern fallback.
|
||||
epic := h.createIssueWithID("test-pcat", "Proto with mixed deps", "", types.TypeEpic, 1)
|
||||
h.addLabel(epic.ID, BeadsTemplateLabel)
|
||||
|
||||
// child1 and child2 have correct parent-child dependency
|
||||
child1 := h.createIssueWithID("test-pcat.1", "load-context", "", types.TypeTask, 2)
|
||||
child2 := h.createIssueWithID("test-pcat.2", "implement", "", types.TypeTask, 2)
|
||||
h.addParentChild(child1.ID, epic.ID)
|
||||
h.addParentChild(child2.ID, epic.ID)
|
||||
|
||||
// child3 has NO dependency at all (broken data)
|
||||
_ = h.createIssueWithID("test-pcat.3", "self-review", "", types.TypeTask, 2)
|
||||
// No dependency added for child3!
|
||||
|
||||
// child8 has wrong dependency type (blocks instead of parent-child)
|
||||
child8 := h.createIssueWithID("test-pcat.8", "request-shutdown", "", types.TypeTask, 2)
|
||||
blocksDep := &types.Dependency{
|
||||
IssueID: child8.ID,
|
||||
DependsOnID: epic.ID,
|
||||
Type: types.DepBlocks,
|
||||
}
|
||||
if err := s.AddDependency(ctx, blocksDep, "test-user"); err != nil {
|
||||
t.Fatalf("Failed to add blocks dependency: %v", err)
|
||||
}
|
||||
|
||||
subgraph, err := loadTemplateSubgraph(ctx, s, epic.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("loadTemplateSubgraph failed: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("Found %d issues:", len(subgraph.Issues))
|
||||
for _, iss := range subgraph.Issues {
|
||||
t.Logf(" - %s: %s", iss.ID, iss.Title)
|
||||
}
|
||||
|
||||
// With the bd-c8d5 fix, all 5 issues should be loaded:
|
||||
// 1 root + 4 hierarchical children (found by ID pattern fallback)
|
||||
if len(subgraph.Issues) != 5 {
|
||||
t.Errorf("Expected 5 issues (root + 4 hierarchical children), got %d", len(subgraph.Issues))
|
||||
}
|
||||
|
||||
// Verify all children are in the subgraph
|
||||
expectedChildren := []string{"test-pcat.1", "test-pcat.2", "test-pcat.3", "test-pcat.8"}
|
||||
for _, childID := range expectedChildren {
|
||||
if _, ok := subgraph.IssueMap[childID]; !ok {
|
||||
t.Errorf("Child %s not found in subgraph", childID)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user