package sqlite import ( "context" "os" "path/filepath" "strings" "testing" "github.com/steveyegge/beads/internal/config" "github.com/steveyegge/beads/internal/configfile" "github.com/steveyegge/beads/internal/types" ) func TestGetReadyWork(t *testing.T) { // Create issues: // issue1: open, no dependencies → READY // issue2: open, depends on issue1 (open) → BLOCKED // issue3: open, no dependencies → READY // issue4: closed, no dependencies → NOT READY (closed) // issue5: open, depends on issue4 (closed) → READY (blocker is closed) env := newTestEnv(t) issue1 := env.CreateIssueWith("Ready 1", types.StatusOpen, 1, types.TypeTask) issue2 := env.CreateIssueWith("Blocked", types.StatusOpen, 1, types.TypeTask) issue3 := env.CreateIssueWith("Ready 2", types.StatusOpen, 2, types.TypeTask) issue4 := env.CreateIssueWith("Closed", types.StatusOpen, 1, types.TypeTask) // create as open first env.Close(issue4, "Done") issue5 := env.CreateIssueWith("Ready 3", types.StatusOpen, 0, types.TypeTask) env.AddDep(issue2, issue1) // issue2 depends on issue1 env.AddDep(issue5, issue4) // issue5 depends on issue4 (which is closed) // Verify ready issues: issue1, issue3, issue5 ready := env.GetReadyWork(types.WorkFilter{Status: types.StatusOpen}) if len(ready) != 3 { t.Fatalf("Expected 3 ready issues, got %d", len(ready)) } env.AssertReady(issue1) env.AssertReady(issue3) env.AssertReady(issue5) // blocker (issue4) is closed env.AssertBlocked(issue2) } func TestGetReadyWorkPriorityOrder(t *testing.T) { env := newTestEnv(t) // Create issues with different priorities (out of order) env.CreateIssueWith("Medium", types.StatusOpen, 2, types.TypeTask) env.CreateIssueWith("Highest", types.StatusOpen, 0, types.TypeTask) env.CreateIssueWith("High", types.StatusOpen, 1, types.TypeTask) ready := env.GetReadyWork(types.WorkFilter{Status: types.StatusOpen}) if len(ready) != 3 { t.Fatalf("Expected 3 ready issues, got %d", len(ready)) } // Verify priority ordering (P0 first, then P1, then P2) if ready[0].Priority != 0 { t.Errorf("Expected first issue to be P0, got P%d", ready[0].Priority) } if ready[1].Priority != 1 { t.Errorf("Expected second issue to be P1, got P%d", ready[1].Priority) } if ready[2].Priority != 2 { t.Errorf("Expected third issue to be P2, got P%d", ready[2].Priority) } } func TestGetReadyWorkWithPriorityFilter(t *testing.T) { env := newTestEnv(t) // Create issues with different priorities env.CreateIssueWith("P0", types.StatusOpen, 0, types.TypeTask) env.CreateIssueWith("P1", types.StatusOpen, 1, types.TypeTask) env.CreateIssueWith("P2", types.StatusOpen, 2, types.TypeTask) // Filter for P0 only priority0 := 0 ready := env.GetReadyWork(types.WorkFilter{Status: types.StatusOpen, Priority: &priority0}) if len(ready) != 1 { t.Fatalf("Expected 1 P0 issue, got %d", len(ready)) } if ready[0].Priority != 0 { t.Errorf("Expected P0 issue, got P%d", ready[0].Priority) } } func TestGetReadyWorkWithAssigneeFilter(t *testing.T) { env := newTestEnv(t) // Create issues with different assignees env.CreateIssueWithAssignee("Alice's task", "alice") env.CreateIssueWithAssignee("Bob's task", "bob") env.CreateIssue("Unassigned") // Filter for alice assignee := "alice" ready := env.GetReadyWork(types.WorkFilter{Status: types.StatusOpen, Assignee: &assignee}) if len(ready) != 1 { t.Fatalf("Expected 1 issue for alice, got %d", len(ready)) } if ready[0].Assignee != "alice" { t.Errorf("Expected alice's issue, got %s", ready[0].Assignee) } } func TestGetReadyWorkWithUnassignedFilter(t *testing.T) { env := newTestEnv(t) // Create issues with different assignees env.CreateIssueWithAssignee("Alice's task", "alice") env.CreateIssueWithAssignee("Bob's task", "bob") unassigned := env.CreateIssue("Unassigned") // Filter for unassigned issues ready := env.GetReadyWork(types.WorkFilter{Status: types.StatusOpen, Unassigned: true}) if len(ready) != 1 { t.Fatalf("Expected 1 unassigned issue, got %d", len(ready)) } if ready[0].Assignee != "" { t.Errorf("Expected unassigned issue, got assignee %q", ready[0].Assignee) } if ready[0].ID != unassigned.ID { t.Errorf("Expected issue %s, got %s", unassigned.ID, ready[0].ID) } } func TestGetReadyWorkWithLimit(t *testing.T) { env := newTestEnv(t) // Create 5 ready issues for i := 0; i < 5; i++ { env.CreateIssue("Task") } // Limit to 3 ready := env.GetReadyWork(types.WorkFilter{Status: types.StatusOpen, Limit: 3}) if len(ready) != 3 { t.Errorf("Expected 3 issues (limit), got %d", len(ready)) } } func TestGetReadyWorkIgnoresRelatedDeps(t *testing.T) { env := newTestEnv(t) // Create two issues with "related" dependency (should not block) issue1 := env.CreateIssue("First") issue2 := env.CreateIssue("Second") env.AddDepType(issue2, issue1, types.DepRelated) // Both should be ready (related deps don't block) ready := env.GetReadyWork(types.WorkFilter{Status: types.StatusOpen}) if len(ready) != 2 { t.Fatalf("Expected 2 ready issues (related deps don't block), got %d", len(ready)) } } func TestGetBlockedIssues(t *testing.T) { store, cleanup := setupTestDB(t) defer cleanup() ctx := context.Background() // Create issues: // bd-1: open, no dependencies → not blocked // bd-2: open, depends on bd-1 (open) → blocked by bd-1 // bd-3: open, depends on bd-1 and bd-2 (both open) → blocked by 2 issues issue1 := &types.Issue{Title: "Foundation", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} issue2 := &types.Issue{Title: "Blocked by 1", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} issue3 := &types.Issue{Title: "Blocked by 2", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} store.CreateIssue(ctx, issue1, "test-user") store.CreateIssue(ctx, issue2, "test-user") store.CreateIssue(ctx, issue3, "test-user") store.AddDependency(ctx, &types.Dependency{IssueID: issue2.ID, DependsOnID: issue1.ID, Type: types.DepBlocks}, "test-user") store.AddDependency(ctx, &types.Dependency{IssueID: issue3.ID, DependsOnID: issue1.ID, Type: types.DepBlocks}, "test-user") store.AddDependency(ctx, &types.Dependency{IssueID: issue3.ID, DependsOnID: issue2.ID, Type: types.DepBlocks}, "test-user") // Get blocked issues blocked, err := store.GetBlockedIssues(ctx, types.WorkFilter{}) if err != nil { t.Fatalf("GetBlockedIssues failed: %v", err) } if len(blocked) != 2 { t.Fatalf("Expected 2 blocked issues, got %d", len(blocked)) } // Find issue3 in blocked list var issue3Blocked *types.BlockedIssue for i := range blocked { if blocked[i].ID == issue3.ID { issue3Blocked = blocked[i] break } } if issue3Blocked == nil { t.Fatal("Expected issue3 to be in blocked list") } if issue3Blocked.BlockedByCount != 2 { t.Errorf("Expected issue3 to be blocked by 2 issues, got %d", issue3Blocked.BlockedByCount) } // Verify the blockers are correct if len(issue3Blocked.BlockedBy) != 2 { t.Errorf("Expected 2 blocker IDs, got %d", len(issue3Blocked.BlockedBy)) } } // TestParentBlockerBlocksChildren tests that children inherit blockage from parents func TestParentBlockerBlocksChildren(t *testing.T) { // Create: // blocker: open // epic1: open, blocked by 'blocker' // task1: open, child of epic1 (via parent-child) // // Expected: task1 should NOT be ready (parent is blocked) env := newTestEnv(t) blocker := env.CreateIssue("Blocker") epic1 := env.CreateEpic("Epic 1") task1 := env.CreateIssue("Task 1") env.AddDep(epic1, blocker) // epic1 blocked by blocker env.AddParentChild(task1, epic1) // task1 is child of epic1 env.AssertBlocked(epic1) env.AssertBlocked(task1) env.AssertReady(blocker) } // TestGrandparentBlockerBlocksGrandchildren tests multi-level propagation func TestGrandparentBlockerBlocksGrandchildren(t *testing.T) { // Create: // blocker: open // epic1: open, blocked by 'blocker' // epic2: open, child of epic1 // task1: open, child of epic2 // // Expected: task1 should NOT be ready (grandparent is blocked) env := newTestEnv(t) blocker := env.CreateIssue("Blocker") epic1 := env.CreateEpic("Epic 1") epic2 := env.CreateEpic("Epic 2") task1 := env.CreateIssue("Task 1") env.AddDep(epic1, blocker) // epic1 blocked by blocker env.AddParentChild(epic2, epic1) // epic2 is child of epic1 env.AddParentChild(task1, epic2) // task1 is child of epic2 env.AssertBlocked(epic1) env.AssertBlocked(epic2) env.AssertBlocked(task1) env.AssertReady(blocker) } // TestMultipleParentsOneBlocked tests that a child is blocked if ANY parent is blocked func TestMultipleParentsOneBlocked(t *testing.T) { // Create: // blocker: open // epic1: open, blocked by 'blocker' // epic2: open, no blockers // task1: open, child of BOTH epic1 and epic2 // // Expected: task1 should NOT be ready (one parent is blocked) env := newTestEnv(t) blocker := env.CreateIssue("Blocker") epic1 := env.CreateEpic("Epic 1 (blocked)") epic2 := env.CreateEpic("Epic 2 (ready)") task1 := env.CreateIssue("Task 1") env.AddDep(epic1, blocker) // epic1 blocked by blocker env.AddParentChild(task1, epic1) // task1 is child of both epic1 and epic2 env.AddParentChild(task1, epic2) env.AssertBlocked(epic1) env.AssertBlocked(task1) // blocked because one parent (epic1) is blocked env.AssertReady(blocker) env.AssertReady(epic2) } // TestBlockerClosedUnblocksChildren tests that closing a blocker unblocks descendants func TestBlockerClosedUnblocksChildren(t *testing.T) { // Create: // blocker: initially open, then closed // epic1: open, blocked by 'blocker' // task1: open, child of epic1 // // After closing blocker: both epic1 and task1 should be ready env := newTestEnv(t) blocker := env.CreateIssue("Blocker") epic1 := env.CreateEpic("Epic 1") task1 := env.CreateIssue("Task 1") env.AddDep(epic1, blocker) // epic1 blocked by blocker env.AddParentChild(task1, epic1) // task1 is child of epic1 // Initially, epic1 and task1 should be blocked env.AssertBlocked(epic1) env.AssertBlocked(task1) // Close the blocker env.Close(blocker, "Done") // Now epic1 and task1 should be ready env.AssertReady(epic1) env.AssertReady(task1) } // TestRelatedDoesNotPropagate tests that 'related' deps don't cause blocking propagation func TestRelatedDoesNotPropagate(t *testing.T) { // Create: // blocker: open // epic1: open, blocked by 'blocker' // task1: open, related to epic1 (NOT parent-child) // // Expected: task1 SHOULD be ready (related doesn't propagate blocking) env := newTestEnv(t) blocker := env.CreateIssue("Blocker") epic1 := env.CreateEpic("Epic 1") task1 := env.CreateIssue("Task 1") env.AddDep(epic1, blocker) // epic1 blocked by blocker env.AddDepType(task1, epic1, types.DepRelated) // task1 is related to epic1 (NOT parent-child) env.AssertBlocked(epic1) env.AssertReady(task1) // related deps don't propagate blocking env.AssertReady(blocker) } // TestCompositeIndexExists verifies the composite index is created func TestCompositeIndexExists(t *testing.T) { store, cleanup := setupTestDB(t) defer cleanup() ctx := context.Background() // Query sqlite_master to check if the index exists var indexName string err := store.db.QueryRowContext(ctx, ` SELECT name FROM sqlite_master WHERE type='index' AND name='idx_dependencies_depends_on_type' `).Scan(&indexName) if err != nil { t.Fatalf("Composite index idx_dependencies_depends_on_type not found: %v", err) } if indexName != "idx_dependencies_depends_on_type" { t.Errorf("Expected index name 'idx_dependencies_depends_on_type', got '%s'", indexName) } } // TestReadyIssuesViewMatchesGetReadyWork verifies the ready_issues VIEW produces same results as GetReadyWork func TestReadyIssuesViewMatchesGetReadyWork(t *testing.T) { store, cleanup := setupTestDB(t) defer cleanup() ctx := context.Background() // Create hierarchy: blocker → epic1 → task1 blocker := &types.Issue{Title: "Blocker", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} epic1 := &types.Issue{Title: "Epic 1", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeEpic} task1 := &types.Issue{Title: "Task 1", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} task2 := &types.Issue{Title: "Task 2", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} store.CreateIssue(ctx, blocker, "test-user") store.CreateIssue(ctx, epic1, "test-user") store.CreateIssue(ctx, task1, "test-user") store.CreateIssue(ctx, task2, "test-user") // epic1 blocked by blocker store.AddDependency(ctx, &types.Dependency{IssueID: epic1.ID, DependsOnID: blocker.ID, Type: types.DepBlocks}, "test-user") // task1 is child of epic1 (should be blocked) store.AddDependency(ctx, &types.Dependency{IssueID: task1.ID, DependsOnID: epic1.ID, Type: types.DepParentChild}, "test-user") // task2 has no dependencies (should be ready) // Get ready work via GetReadyWork function ready, err := store.GetReadyWork(ctx, types.WorkFilter{Status: types.StatusOpen}) if err != nil { t.Fatalf("GetReadyWork failed: %v", err) } readyIDsFromFunc := make(map[string]bool) for _, issue := range ready { readyIDsFromFunc[issue.ID] = true } // Get ready work via VIEW rows, err := store.db.QueryContext(ctx, `SELECT id FROM ready_issues ORDER BY id`) if err != nil { t.Fatalf("Query ready_issues VIEW failed: %v", err) } defer rows.Close() readyIDsFromView := make(map[string]bool) for rows.Next() { var id string if err := rows.Scan(&id); err != nil { t.Fatalf("Scan failed: %v", err) } readyIDsFromView[id] = true } // Verify they match if len(readyIDsFromFunc) != len(readyIDsFromView) { t.Errorf("Mismatch: GetReadyWork returned %d issues, VIEW returned %d", len(readyIDsFromFunc), len(readyIDsFromView)) } for id := range readyIDsFromFunc { if !readyIDsFromView[id] { t.Errorf("Issue %s in GetReadyWork but NOT in VIEW", id) } } for id := range readyIDsFromView { if !readyIDsFromFunc[id] { t.Errorf("Issue %s in VIEW but NOT in GetReadyWork", id) } } // Verify specific expectations if !readyIDsFromView[blocker.ID] { t.Errorf("Expected blocker to be ready in VIEW") } if !readyIDsFromView[task2.ID] { t.Errorf("Expected task2 to be ready in VIEW") } if readyIDsFromView[epic1.ID] { t.Errorf("Expected epic1 to be blocked in VIEW (has blocker)") } if readyIDsFromView[task1.ID] { t.Errorf("Expected task1 to be blocked in VIEW (parent is blocked)") } } // TestDeepHierarchyBlocking tests blocking propagation through 50-level deep hierarchy func TestDeepHierarchyBlocking(t *testing.T) { store, cleanup := setupTestDB(t) defer cleanup() ctx := context.Background() // Create a blocker at the root blocker := &types.Issue{Title: "Root Blocker", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} store.CreateIssue(ctx, blocker, "test-user") // Create 50-level hierarchy: root → level1 → level2 → ... → level50 var issues []*types.Issue for i := 0; i < 50; i++ { issue := &types.Issue{ Title: "Level " + string(rune(i)), Status: types.StatusOpen, Priority: 1, IssueType: types.TypeEpic, } store.CreateIssue(ctx, issue, "test-user") issues = append(issues, issue) if i == 0 { // First level: blocked by blocker store.AddDependency(ctx, &types.Dependency{ IssueID: issue.ID, DependsOnID: blocker.ID, Type: types.DepBlocks, }, "test-user") } else { // Each subsequent level: child of previous level store.AddDependency(ctx, &types.Dependency{ IssueID: issue.ID, DependsOnID: issues[i-1].ID, Type: types.DepParentChild, }, "test-user") } } // Get ready work ready, err := store.GetReadyWork(ctx, types.WorkFilter{Status: types.StatusOpen}) if err != nil { t.Fatalf("GetReadyWork failed: %v", err) } // Build set of ready IDs readyIDs := make(map[string]bool) for _, issue := range ready { readyIDs[issue.ID] = true } // Only the blocker should be ready if len(ready) != 1 { t.Errorf("Expected exactly 1 ready issue (the blocker), got %d", len(ready)) } if !readyIDs[blocker.ID] { t.Errorf("Expected blocker to be ready") } // All 50 levels should be blocked for i, issue := range issues { if readyIDs[issue.ID] { t.Errorf("Expected level %d (issue %s) to be blocked, but it was ready", i, issue.ID) } } // Now close the blocker and verify all levels become ready store.CloseIssue(ctx, blocker.ID, "Done", "test-user", "") ready, err = store.GetReadyWork(ctx, types.WorkFilter{Status: types.StatusOpen}) if err != nil { t.Fatalf("GetReadyWork failed after closing blocker: %v", err) } // All 50 levels should now be ready if len(ready) != 50 { t.Errorf("Expected 50 ready issues after closing blocker, got %d", len(ready)) } readyIDs = make(map[string]bool) for _, issue := range ready { readyIDs[issue.ID] = true } for i, issue := range issues { if !readyIDs[issue.ID] { t.Errorf("Expected level %d (issue %s) to be ready after blocker closed, but it was blocked", i, issue.ID) } } } func TestGetReadyWorkIncludesInProgress(t *testing.T) { store, cleanup := setupTestDB(t) defer cleanup() ctx := context.Background() // Create issues: // bd-1: open, no dependencies → READY // bd-2: in_progress, no dependencies → READY (bd-165) // bd-3: in_progress, depends on open issue → BLOCKED // bd-4: closed, no dependencies → NOT READY (closed) issue1 := &types.Issue{Title: "Open Ready", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} issue2 := &types.Issue{Title: "In Progress Ready", Status: types.StatusInProgress, Priority: 2, IssueType: types.TypeEpic} issue3 := &types.Issue{Title: "In Progress Blocked", Status: types.StatusInProgress, Priority: 1, IssueType: types.TypeTask} issue4 := &types.Issue{Title: "Blocker", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} issue5 := &types.Issue{Title: "Closed", Status: types.StatusClosed, Priority: 1, IssueType: types.TypeTask} store.CreateIssue(ctx, issue1, "test-user") store.CreateIssue(ctx, issue2, "test-user") store.UpdateIssue(ctx, issue2.ID, map[string]interface{}{"status": types.StatusInProgress}, "test-user") store.CreateIssue(ctx, issue3, "test-user") store.UpdateIssue(ctx, issue3.ID, map[string]interface{}{"status": types.StatusInProgress}, "test-user") store.CreateIssue(ctx, issue4, "test-user") store.CreateIssue(ctx, issue5, "test-user") store.CloseIssue(ctx, issue5.ID, "Done", "test-user", "") // Add dependency: issue3 blocks on issue4 store.AddDependency(ctx, &types.Dependency{IssueID: issue3.ID, DependsOnID: issue4.ID, Type: types.DepBlocks}, "test-user") // Get ready work (default filter - no status specified) ready, err := store.GetReadyWork(ctx, types.WorkFilter{}) if err != nil { t.Fatalf("GetReadyWork failed: %v", err) } // Should have 3 ready issues: // - issue1 (open, no blockers) // - issue2 (in_progress, no blockers) ← this is the key test case for bd-165 // - issue4 (open blocker, but itself has no blockers so it's ready to work on) if len(ready) != 3 { t.Logf("Ready issues:") for _, r := range ready { t.Logf(" - %s: %s (status: %s)", r.ID, r.Title, r.Status) } t.Fatalf("Expected 3 ready issues, got %d", len(ready)) } // Verify ready issues readyIDs := make(map[string]bool) for _, issue := range ready { readyIDs[issue.ID] = true } if !readyIDs[issue1.ID] { t.Errorf("Expected %s (open, no blockers) to be ready", issue1.ID) } if !readyIDs[issue2.ID] { t.Errorf("Expected %s (in_progress, no blockers) to be ready - this is bd-165!", issue2.ID) } if !readyIDs[issue4.ID] { t.Errorf("Expected %s (open blocker, but itself unblocked) to be ready", issue4.ID) } if readyIDs[issue3.ID] { t.Errorf("Expected %s (in_progress, blocked) to NOT be ready", issue3.ID) } if readyIDs[issue5.ID] { t.Errorf("Expected %s (closed) to NOT be ready", issue5.ID) } } func TestExplainQueryPlanReadyWork(t *testing.T) { store, cleanup := setupTestDB(t) defer cleanup() ctx := context.Background() blocker := &types.Issue{Title: "Blocker", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} epic1 := &types.Issue{Title: "Epic", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeEpic} task1 := &types.Issue{Title: "Task", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} task2 := &types.Issue{Title: "Ready Task", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask} store.CreateIssue(ctx, blocker, "test-user") store.CreateIssue(ctx, epic1, "test-user") store.CreateIssue(ctx, task1, "test-user") store.CreateIssue(ctx, task2, "test-user") store.AddDependency(ctx, &types.Dependency{IssueID: epic1.ID, DependsOnID: blocker.ID, Type: types.DepBlocks}, "test-user") store.AddDependency(ctx, &types.Dependency{IssueID: task1.ID, DependsOnID: epic1.ID, Type: types.DepParentChild}, "test-user") query := ` EXPLAIN QUERY PLAN WITH RECURSIVE blocked_directly AS ( SELECT DISTINCT d.issue_id FROM dependencies d JOIN issues blocker ON d.depends_on_id = blocker.id WHERE d.type = 'blocks' AND blocker.status IN ('open', 'in_progress', 'blocked') ), blocked_transitively AS ( SELECT issue_id, 0 as depth FROM blocked_directly UNION ALL SELECT d.issue_id, bt.depth + 1 FROM blocked_transitively bt JOIN dependencies d ON d.depends_on_id = bt.issue_id WHERE d.type = 'parent-child' AND bt.depth < 50 ) SELECT i.id, i.content_hash, i.title, i.description, i.design, i.acceptance_criteria, i.notes, i.status, i.priority, i.issue_type, i.assignee, i.estimated_minutes, i.created_at, i.updated_at, i.closed_at, i.external_ref FROM issues i WHERE i.status IN ('open', 'in_progress') AND NOT EXISTS ( SELECT 1 FROM blocked_transitively WHERE issue_id = i.id ) ORDER BY CASE WHEN datetime(i.created_at) >= datetime('now', '-48 hours') THEN 0 ELSE 1 END ASC, CASE WHEN datetime(i.created_at) >= datetime('now', '-48 hours') THEN i.priority ELSE NULL END ASC, CASE WHEN datetime(i.created_at) < datetime('now', '-48 hours') THEN i.created_at ELSE NULL END ASC, i.created_at ASC ` rows, err := store.db.QueryContext(ctx, query) if err != nil { t.Fatalf("EXPLAIN QUERY PLAN failed: %v", err) } defer rows.Close() var planLines []string for rows.Next() { var id, parent, notused int var detail string if err := rows.Scan(&id, &parent, ¬used, &detail); err != nil { t.Fatalf("Failed to scan EXPLAIN output: %v", err) } planLines = append(planLines, detail) } if len(planLines) == 0 { t.Fatal("No query plan output received") } t.Logf("Query plan:") for i, line := range planLines { t.Logf(" %d: %s", i, line) } foundTableScan := false for _, line := range planLines { if strings.Contains(line, "SCAN TABLE issues") || strings.Contains(line, "SCAN TABLE dependencies") { foundTableScan = true t.Errorf("Found table scan in query plan: %s", line) } } if foundTableScan { t.Error("Query plan contains table scans - indexes may not be used efficiently") } } // TestSortPolicyPriority tests strict priority-first sorting func TestSortPolicyPriority(t *testing.T) { store, cleanup := setupTestDB(t) defer cleanup() ctx := context.Background() // Create issues with mixed ages and priorities // Old issues (72 hours ago) issueP0Old := &types.Issue{Title: "old-P0", Status: types.StatusOpen, Priority: 0, IssueType: types.TypeTask} issueP2Old := &types.Issue{Title: "old-P2", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask} issueP1Old := &types.Issue{Title: "old-P1", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} // Recent issues (12 hours ago) issueP3New := &types.Issue{Title: "new-P3", Status: types.StatusOpen, Priority: 3, IssueType: types.TypeTask} issueP1New := &types.Issue{Title: "new-P1", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} // Create old issues first (to have older created_at) store.CreateIssue(ctx, issueP0Old, "test-user") store.CreateIssue(ctx, issueP2Old, "test-user") store.CreateIssue(ctx, issueP1Old, "test-user") // Create new issues store.CreateIssue(ctx, issueP3New, "test-user") store.CreateIssue(ctx, issueP1New, "test-user") // Use priority sort policy ready, err := store.GetReadyWork(ctx, types.WorkFilter{ Status: types.StatusOpen, SortPolicy: types.SortPolicyPriority, }) if err != nil { t.Fatalf("GetReadyWork failed: %v", err) } if len(ready) != 5 { t.Fatalf("Expected 5 ready issues, got %d", len(ready)) } // Verify strict priority ordering: P0, P1, P1, P2, P3 // Within same priority, older created_at comes first expectedOrder := []struct { title string priority int }{ {"old-P0", 0}, {"old-P1", 1}, {"new-P1", 1}, {"old-P2", 2}, {"new-P3", 3}, } for i, expected := range expectedOrder { if ready[i].Title != expected.title { t.Errorf("Position %d: expected %s, got %s", i, expected.title, ready[i].Title) } if ready[i].Priority != expected.priority { t.Errorf("Position %d: expected P%d, got P%d", i, expected.priority, ready[i].Priority) } } } // TestSortPolicyOldest tests oldest-first sorting (ignoring priority) func TestSortPolicyOldest(t *testing.T) { store, cleanup := setupTestDB(t) defer cleanup() ctx := context.Background() // Create issues in order: P2, P0, P1 (mixed priority, chronological creation) issueP2 := &types.Issue{Title: "first-P2", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask} issueP0 := &types.Issue{Title: "second-P0", Status: types.StatusOpen, Priority: 0, IssueType: types.TypeTask} issueP1 := &types.Issue{Title: "third-P1", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} store.CreateIssue(ctx, issueP2, "test-user") store.CreateIssue(ctx, issueP0, "test-user") store.CreateIssue(ctx, issueP1, "test-user") // Use oldest sort policy ready, err := store.GetReadyWork(ctx, types.WorkFilter{ Status: types.StatusOpen, SortPolicy: types.SortPolicyOldest, }) if err != nil { t.Fatalf("GetReadyWork failed: %v", err) } if len(ready) != 3 { t.Fatalf("Expected 3 ready issues, got %d", len(ready)) } // Should be sorted by creation time only (oldest first) expectedTitles := []string{"first-P2", "second-P0", "third-P1"} for i, expected := range expectedTitles { if ready[i].Title != expected { t.Errorf("Position %d: expected %s, got %s", i, expected, ready[i].Title) } } } // TestSortPolicyHybrid tests hybrid sort (default behavior) func TestSortPolicyHybrid(t *testing.T) { store, cleanup := setupTestDB(t) defer cleanup() ctx := context.Background() // Create issues with different priorities // All created recently (within 48 hours in test), so should sort by priority issueP0 := &types.Issue{Title: "issue-P0", Status: types.StatusOpen, Priority: 0, IssueType: types.TypeTask} issueP2 := &types.Issue{Title: "issue-P2", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask} issueP1 := &types.Issue{Title: "issue-P1", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} issueP3 := &types.Issue{Title: "issue-P3", Status: types.StatusOpen, Priority: 3, IssueType: types.TypeTask} store.CreateIssue(ctx, issueP2, "test-user") store.CreateIssue(ctx, issueP0, "test-user") store.CreateIssue(ctx, issueP3, "test-user") store.CreateIssue(ctx, issueP1, "test-user") // Use hybrid sort policy (explicit) ready, err := store.GetReadyWork(ctx, types.WorkFilter{ Status: types.StatusOpen, SortPolicy: types.SortPolicyHybrid, }) if err != nil { t.Fatalf("GetReadyWork failed: %v", err) } if len(ready) != 4 { t.Fatalf("Expected 4 ready issues, got %d", len(ready)) } // Since all issues are created recently (< 48 hours in test context), // hybrid sort should order by priority: P0, P1, P2, P3 expectedPriorities := []int{0, 1, 2, 3} for i, expected := range expectedPriorities { if ready[i].Priority != expected { t.Errorf("Position %d: expected P%d, got P%d", i, expected, ready[i].Priority) } } } // TestSortPolicyDefault tests that empty sort policy defaults to hybrid func TestSortPolicyDefault(t *testing.T) { store, cleanup := setupTestDB(t) defer cleanup() ctx := context.Background() // Create test issues with different priorities issueP1 := &types.Issue{Title: "issue-P1", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask} issueP2 := &types.Issue{Title: "issue-P2", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask} store.CreateIssue(ctx, issueP2, "test-user") store.CreateIssue(ctx, issueP1, "test-user") // Use default (empty) sort policy ready, err := store.GetReadyWork(ctx, types.WorkFilter{ Status: types.StatusOpen, // SortPolicy not specified - should default to hybrid }) if err != nil { t.Fatalf("GetReadyWork failed: %v", err) } if len(ready) != 2 { t.Fatalf("Expected 2 ready issues, got %d", len(ready)) } // Should behave like hybrid: since both are recent, sort by priority (P1 first) if ready[0].Priority != 1 { t.Errorf("Expected P1 first (hybrid default, recent by priority), got P%d", ready[0].Priority) } if ready[1].Priority != 2 { t.Errorf("Expected P2 second, got P%d", ready[1].Priority) } } // TestGetReadyWorkExternalDeps tests that GetReadyWork filters out issues // with unsatisfied external dependencies (bd-zmmy) func TestGetReadyWorkExternalDeps(t *testing.T) { // Create main test database mainStore, mainCleanup := setupTestDB(t) defer mainCleanup() ctx := context.Background() // Create external project directory with beads database externalDir, err := os.MkdirTemp("", "beads-external-test-*") if err != nil { t.Fatalf("failed to create external temp dir: %v", err) } defer os.RemoveAll(externalDir) // Create .beads directory and config in external project beadsDir := filepath.Join(externalDir, ".beads") if err := os.MkdirAll(beadsDir, 0755); err != nil { t.Fatalf("failed to create .beads dir: %v", err) } // Create config file for external project cfg := configfile.DefaultConfig() if err := cfg.Save(beadsDir); err != nil { t.Fatalf("failed to save external config: %v", err) } // Create external database (must match configfile.DefaultConfig().Database) externalDBPath := filepath.Join(beadsDir, "beads.db") externalStore, err := New(ctx, externalDBPath) if err != nil { t.Fatalf("failed to create external store: %v", err) } defer externalStore.Close() // Set issue_prefix in external store if err := externalStore.SetConfig(ctx, "issue_prefix", "ext"); err != nil { t.Fatalf("failed to set external issue_prefix: %v", err) } // Initialize config if not already done (required for Set to work) if err := config.Initialize(); err != nil { t.Fatalf("failed to initialize config: %v", err) } // Configure external_projects to point to our temp external project // Save current value to restore later oldProjects := config.GetExternalProjects() defer func() { if oldProjects != nil { config.Set("external_projects", oldProjects) } else { config.Set("external_projects", map[string]string{}) } }() config.Set("external_projects", map[string]string{ "external-test": externalDir, }) // Create an issue in main DB with external dependency issueWithExtDep := &types.Issue{ Title: "Has external dep", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask, } if err := mainStore.CreateIssue(ctx, issueWithExtDep, "test-user"); err != nil { t.Fatalf("failed to create issue: %v", err) } // Add external dependency extDep := &types.Dependency{ IssueID: issueWithExtDep.ID, DependsOnID: "external:external-test:test-capability", Type: types.DepBlocks, } if err := mainStore.AddDependency(ctx, extDep, "test-user"); err != nil { t.Fatalf("failed to add external dependency: %v", err) } // Create a regular issue without external dep regularIssue := &types.Issue{ Title: "Regular issue", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask, } if err := mainStore.CreateIssue(ctx, regularIssue, "test-user"); err != nil { t.Fatalf("failed to create regular issue: %v", err) } // Debug: check config projects := config.GetExternalProjects() t.Logf("External projects config: %v", projects) resolvedPath := config.ResolveExternalProjectPath("external-test") t.Logf("Resolved path for 'external-test': %s", resolvedPath) // Test 1: External dep is not satisfied - issue should be blocked ready, err := mainStore.GetReadyWork(ctx, types.WorkFilter{}) if err != nil { t.Fatalf("GetReadyWork failed: %v", err) } // Debug: log what we got for _, issue := range ready { t.Logf("Ready issue: %s - %s", issue.ID, issue.Title) } // Should only have the regular issue (external dep not satisfied) if len(ready) != 1 { t.Errorf("Expected 1 ready issue (external dep not satisfied), got %d", len(ready)) } if len(ready) > 0 && ready[0].ID != regularIssue.ID { t.Errorf("Expected regular issue %s to be ready, got %s", regularIssue.ID, ready[0].ID) } // Test 2: Ship the capability in external project // Create an issue with provides:test-capability label and close it capabilityIssue := &types.Issue{ Title: "Ship test-capability", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask, } if err := externalStore.CreateIssue(ctx, capabilityIssue, "test-user"); err != nil { t.Fatalf("failed to create capability issue: %v", err) } // Add the provides: label if err := externalStore.AddLabel(ctx, capabilityIssue.ID, "provides:test-capability", "test-user"); err != nil { t.Fatalf("failed to add provides label: %v", err) } // Close the capability issue if err := externalStore.CloseIssue(ctx, capabilityIssue.ID, "Shipped", "test-user", ""); err != nil { t.Fatalf("failed to close capability issue: %v", err) } // Debug: verify the capability issue was properly set up capIssue, err := externalStore.GetIssue(ctx, capabilityIssue.ID) if err != nil { t.Fatalf("failed to get capability issue: %v", err) } t.Logf("Capability issue status: %s", capIssue.Status) labels, _ := externalStore.GetLabels(ctx, capabilityIssue.ID) t.Logf("Capability issue labels: %v", labels) // Close external store to checkpoint WAL before read-only access externalStore.Close() // Debug: check what path configfile.Load returns testCfg, _ := configfile.Load(beadsDir) if testCfg != nil { t.Logf("Config database path: %s", testCfg.DatabasePath(beadsDir)) t.Logf("External DB path we created: %s", externalDBPath) } // Re-verify: manually check the external dep status := CheckExternalDep(ctx, "external:external-test:test-capability") t.Logf("External dep check: satisfied=%v, reason=%s", status.Satisfied, status.Reason) // Now the external dep should be satisfied ready, err = mainStore.GetReadyWork(ctx, types.WorkFilter{}) if err != nil { t.Fatalf("GetReadyWork failed after shipping: %v", err) } // Should now have both issues if len(ready) != 2 { t.Errorf("Expected 2 ready issues (external dep now satisfied), got %d", len(ready)) for _, issue := range ready { t.Logf("Ready issue after shipping: %s - %s", issue.ID, issue.Title) } } // Verify both issues are present foundExtDep := false foundRegular := false for _, issue := range ready { if issue.ID == issueWithExtDep.ID { foundExtDep = true } if issue.ID == regularIssue.ID { foundRegular = true } } if !foundExtDep { t.Error("Issue with external dep should now be ready") } if !foundRegular { t.Error("Regular issue should still be ready") } } // TestGetReadyWorkNoExternalProjectsConfigured tests that GetReadyWork // works normally when no external_projects are configured (bd-zmmy) func TestGetReadyWorkNoExternalProjectsConfigured(t *testing.T) { store, cleanup := setupTestDB(t) defer cleanup() ctx := context.Background() // Initialize config if not already done if err := config.Initialize(); err != nil { t.Fatalf("failed to initialize config: %v", err) } // Ensure no external_projects configured oldProjects := config.GetExternalProjects() defer func() { if oldProjects != nil { config.Set("external_projects", oldProjects) } }() config.Set("external_projects", map[string]string{}) // Create an issue with an external dependency (shouldn't matter since no config) issue := &types.Issue{ Title: "Has external dep but no config", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask, } if err := store.CreateIssue(ctx, issue, "test-user"); err != nil { t.Fatalf("failed to create issue: %v", err) } // Add external dependency (will be ignored since no external_projects configured) extDep := &types.Dependency{ IssueID: issue.ID, DependsOnID: "external:unconfigured-project:some-capability", Type: types.DepBlocks, } if err := store.AddDependency(ctx, extDep, "test-user"); err != nil { t.Fatalf("failed to add external dependency: %v", err) } // Should skip external dep checking since no external_projects configured ready, err := store.GetReadyWork(ctx, types.WorkFilter{}) if err != nil { t.Fatalf("GetReadyWork failed: %v", err) } // Issue should be ready (external deps skipped when no config) if len(ready) != 1 { t.Errorf("Expected 1 ready issue (external deps skipped), got %d", len(ready)) } } // TestGetBlockedIssuesFiltersExternalDeps tests that GetBlockedIssues filters // satisfied external dependencies from BlockedBy lists (bd-396j) func TestGetBlockedIssuesFiltersExternalDeps(t *testing.T) { // Create main test database mainStore, mainCleanup := setupTestDB(t) defer mainCleanup() ctx := context.Background() // Create external project directory with beads database externalDir, err := os.MkdirTemp("", "beads-blocked-external-test-*") if err != nil { t.Fatalf("failed to create external temp dir: %v", err) } defer os.RemoveAll(externalDir) // Create .beads directory and config in external project beadsDir := filepath.Join(externalDir, ".beads") if err := os.MkdirAll(beadsDir, 0755); err != nil { t.Fatalf("failed to create .beads dir: %v", err) } // Create config file for external project cfg := configfile.DefaultConfig() if err := cfg.Save(beadsDir); err != nil { t.Fatalf("failed to save external config: %v", err) } // Create external database externalDBPath := filepath.Join(beadsDir, "beads.db") externalStore, err := New(ctx, externalDBPath) if err != nil { t.Fatalf("failed to create external store: %v", err) } defer externalStore.Close() // Set issue_prefix in external store if err := externalStore.SetConfig(ctx, "issue_prefix", "ext"); err != nil { t.Fatalf("failed to set external issue_prefix: %v", err) } // Initialize config if not already done if err := config.Initialize(); err != nil { t.Fatalf("failed to initialize config: %v", err) } // Configure external_projects oldProjects := config.GetExternalProjects() defer func() { if oldProjects != nil { config.Set("external_projects", oldProjects) } else { config.Set("external_projects", map[string]string{}) } }() config.Set("external_projects", map[string]string{ "external-test": externalDir, }) // Create an issue with external dependency issueWithExtDep := &types.Issue{ Title: "Blocked by external", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask, } if err := mainStore.CreateIssue(ctx, issueWithExtDep, "test-user"); err != nil { t.Fatalf("failed to create issue: %v", err) } // Add external dependency extDep := &types.Dependency{ IssueID: issueWithExtDep.ID, DependsOnID: "external:external-test:test-capability", Type: types.DepBlocks, } if err := mainStore.AddDependency(ctx, extDep, "test-user"); err != nil { t.Fatalf("failed to add external dependency: %v", err) } // Test 1: External dep not satisfied - issue should appear as blocked blocked, err := mainStore.GetBlockedIssues(ctx, types.WorkFilter{}) if err != nil { t.Fatalf("GetBlockedIssues failed: %v", err) } if len(blocked) != 1 { t.Errorf("Expected 1 blocked issue (external dep not satisfied), got %d", len(blocked)) } if len(blocked) > 0 { if len(blocked[0].BlockedBy) != 1 || blocked[0].BlockedBy[0] != "external:external-test:test-capability" { t.Errorf("Expected BlockedBy to contain external ref, got %v", blocked[0].BlockedBy) } } // Test 2: Ship the capability in external project capabilityIssue := &types.Issue{ Title: "Ship test-capability", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask, } if err := externalStore.CreateIssue(ctx, capabilityIssue, "test-user"); err != nil { t.Fatalf("failed to create capability issue: %v", err) } // Add the provides: label if err := externalStore.AddLabel(ctx, capabilityIssue.ID, "provides:test-capability", "test-user"); err != nil { t.Fatalf("failed to add provides label: %v", err) } // Close the capability issue if err := externalStore.CloseIssue(ctx, capabilityIssue.ID, "Shipped", "test-user", ""); err != nil { t.Fatalf("failed to close capability issue: %v", err) } // Close external store to checkpoint WAL before read-only access externalStore.Close() // Verify external dep is now satisfied status := CheckExternalDep(ctx, "external:external-test:test-capability") if !status.Satisfied { t.Fatalf("Expected external dep to be satisfied, got: %s", status.Reason) } // Now GetBlockedIssues should NOT show the issue (external dep satisfied) blocked, err = mainStore.GetBlockedIssues(ctx, types.WorkFilter{}) if err != nil { t.Fatalf("GetBlockedIssues failed after shipping: %v", err) } // Issue should no longer be blocked if len(blocked) != 0 { t.Errorf("Expected 0 blocked issues (external dep now satisfied), got %d", len(blocked)) for _, b := range blocked { t.Logf("Still blocked: %s - %s, blockers: %v", b.ID, b.Title, b.BlockedBy) } } } // TestGetBlockedIssuesPartialExternalDeps tests that GetBlockedIssues keeps // issues blocked when only SOME external deps are satisfied (bd-396j) func TestGetBlockedIssuesPartialExternalDeps(t *testing.T) { // Create main test database mainStore, mainCleanup := setupTestDB(t) defer mainCleanup() ctx := context.Background() // Create external project directory externalDir, err := os.MkdirTemp("", "beads-blocked-partial-test-*") if err != nil { t.Fatalf("failed to create external temp dir: %v", err) } defer os.RemoveAll(externalDir) // Create .beads directory and config in external project beadsDir := filepath.Join(externalDir, ".beads") if err := os.MkdirAll(beadsDir, 0755); err != nil { t.Fatalf("failed to create .beads dir: %v", err) } cfg := configfile.DefaultConfig() if err := cfg.Save(beadsDir); err != nil { t.Fatalf("failed to save external config: %v", err) } externalDBPath := filepath.Join(beadsDir, "beads.db") externalStore, err := New(ctx, externalDBPath) if err != nil { t.Fatalf("failed to create external store: %v", err) } defer externalStore.Close() if err := externalStore.SetConfig(ctx, "issue_prefix", "ext"); err != nil { t.Fatalf("failed to set external issue_prefix: %v", err) } if err := config.Initialize(); err != nil { t.Fatalf("failed to initialize config: %v", err) } oldProjects := config.GetExternalProjects() defer func() { if oldProjects != nil { config.Set("external_projects", oldProjects) } else { config.Set("external_projects", map[string]string{}) } }() config.Set("external_projects", map[string]string{ "external-test": externalDir, }) // Create an issue with TWO external dependencies issueWithExtDeps := &types.Issue{ Title: "Blocked by two external deps", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask, } if err := mainStore.CreateIssue(ctx, issueWithExtDeps, "test-user"); err != nil { t.Fatalf("failed to create issue: %v", err) } // Add first external dependency if err := mainStore.AddDependency(ctx, &types.Dependency{ IssueID: issueWithExtDeps.ID, DependsOnID: "external:external-test:cap1", Type: types.DepBlocks, }, "test-user"); err != nil { t.Fatalf("failed to add first external dependency: %v", err) } // Add second external dependency if err := mainStore.AddDependency(ctx, &types.Dependency{ IssueID: issueWithExtDeps.ID, DependsOnID: "external:external-test:cap2", Type: types.DepBlocks, }, "test-user"); err != nil { t.Fatalf("failed to add second external dependency: %v", err) } // Ship only the first capability cap1Issue := &types.Issue{ Title: "Ship cap1", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask, } if err := externalStore.CreateIssue(ctx, cap1Issue, "test-user"); err != nil { t.Fatalf("failed to create cap1 issue: %v", err) } if err := externalStore.AddLabel(ctx, cap1Issue.ID, "provides:cap1", "test-user"); err != nil { t.Fatalf("failed to add provides label: %v", err) } if err := externalStore.CloseIssue(ctx, cap1Issue.ID, "Shipped", "test-user", ""); err != nil { t.Fatalf("failed to close cap1 issue: %v", err) } // Close external store to checkpoint WAL externalStore.Close() // Issue should still be blocked (cap2 not satisfied) blocked, err := mainStore.GetBlockedIssues(ctx, types.WorkFilter{}) if err != nil { t.Fatalf("GetBlockedIssues failed: %v", err) } if len(blocked) != 1 { t.Errorf("Expected 1 blocked issue (cap2 still not satisfied), got %d", len(blocked)) } if len(blocked) > 0 { // Should only show cap2 in BlockedBy (cap1 is satisfied and filtered out) if len(blocked[0].BlockedBy) != 1 { t.Errorf("Expected 1 blocker (cap2), got %d: %v", len(blocked[0].BlockedBy), blocked[0].BlockedBy) } if len(blocked[0].BlockedBy) == 1 && blocked[0].BlockedBy[0] != "external:external-test:cap2" { t.Errorf("Expected BlockedBy to be cap2, got %v", blocked[0].BlockedBy) } if blocked[0].BlockedByCount != 1 { t.Errorf("Expected BlockedByCount to be 1, got %d", blocked[0].BlockedByCount) } } } // TestCheckExternalDepNoBeadsDirectory verifies that CheckExternalDep // correctly reports "no beads database" when the target project exists // but has no .beads directory (bd-mv6h). func TestCheckExternalDepNoBeadsDirectory(t *testing.T) { ctx := context.Background() // Create a project directory WITHOUT .beads projectDir, err := os.MkdirTemp("", "beads-no-beads-test-*") if err != nil { t.Fatalf("failed to create temp dir: %v", err) } defer os.RemoveAll(projectDir) // Initialize config if not already done if err := config.Initialize(); err != nil { t.Fatalf("failed to initialize config: %v", err) } // Configure external_projects to point to the directory oldProjects := config.GetExternalProjects() defer func() { if oldProjects != nil { config.Set("external_projects", oldProjects) } else { config.Set("external_projects", map[string]string{}) } }() config.Set("external_projects", map[string]string{ "no-beads-project": projectDir, }) // Check the external dep - should report "no beads database" status := CheckExternalDep(ctx, "external:no-beads-project:some-capability") if status.Satisfied { t.Error("Expected external dep to be unsatisfied when target has no .beads directory") } if status.Reason != "project has no beads database" { t.Errorf("Expected reason 'project has no beads database', got: %s", status.Reason) } } // TestCheckExternalDepInvalidFormats verifies that CheckExternalDep // correctly handles various invalid external ref formats (bd-mv6h). func TestCheckExternalDepInvalidFormats(t *testing.T) { ctx := context.Background() tests := []struct { name string ref string wantReason string }{ { name: "not external prefix", ref: "bd-xyz", wantReason: "not an external reference", }, { name: "missing capability", ref: "external:project", wantReason: "invalid format (expected external:project:capability)", }, { name: "empty project", ref: "external::capability", wantReason: "missing project or capability", }, { name: "empty capability", ref: "external:project:", wantReason: "missing project or capability", }, { name: "only external prefix", ref: "external:", wantReason: "invalid format (expected external:project:capability)", }, { name: "unconfigured project", ref: "external:unconfigured-project:capability", wantReason: "project not configured in external_projects", }, } // Initialize config if not already done if err := config.Initialize(); err != nil { t.Fatalf("failed to initialize config: %v", err) } // Ensure no external projects are configured for some tests oldProjects := config.GetExternalProjects() defer func() { if oldProjects != nil { config.Set("external_projects", oldProjects) } }() config.Set("external_projects", map[string]string{}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { status := CheckExternalDep(ctx, tt.ref) if status.Satisfied { t.Errorf("Expected unsatisfied for %q", tt.ref) } if status.Reason != tt.wantReason { t.Errorf("Expected reason %q, got %q", tt.wantReason, status.Reason) } }) } } // TestGetNewlyUnblockedByClose tests the --suggest-next functionality (GH#679) func TestGetNewlyUnblockedByClose(t *testing.T) { env := newTestEnv(t) // Create a blocker issue blocker := env.CreateIssueWith("Blocker", types.StatusOpen, 1, types.TypeTask) // Create two issues blocked by the blocker blocked1 := env.CreateIssueWith("Blocked 1", types.StatusOpen, 2, types.TypeTask) blocked2 := env.CreateIssueWith("Blocked 2", types.StatusOpen, 3, types.TypeTask) // Create one issue blocked by multiple issues (blocker + another) otherBlocker := env.CreateIssueWith("Other Blocker", types.StatusOpen, 1, types.TypeTask) multiBlocked := env.CreateIssueWith("Multi Blocked", types.StatusOpen, 2, types.TypeTask) // Add dependencies (issue depends on blocker) env.AddDep(blocked1, blocker) env.AddDep(blocked2, blocker) env.AddDep(multiBlocked, blocker) env.AddDep(multiBlocked, otherBlocker) // Close the blocker env.Close(blocker, "Done") // Get newly unblocked issues ctx := context.Background() unblocked, err := env.Store.GetNewlyUnblockedByClose(ctx, blocker.ID) if err != nil { t.Fatalf("GetNewlyUnblockedByClose failed: %v", err) } // Should return blocked1 and blocked2 (but not multiBlocked, which is still blocked by otherBlocker) if len(unblocked) != 2 { t.Errorf("Expected 2 unblocked issues, got %d", len(unblocked)) } // Check that the right issues are unblocked unblockedIDs := make(map[string]bool) for _, issue := range unblocked { unblockedIDs[issue.ID] = true } if !unblockedIDs[blocked1.ID] { t.Errorf("Expected %s to be unblocked", blocked1.ID) } if !unblockedIDs[blocked2.ID] { t.Errorf("Expected %s to be unblocked", blocked2.ID) } if unblockedIDs[multiBlocked.ID] { t.Errorf("Expected %s to still be blocked (has another blocker)", multiBlocked.ID) } } // TestParentIDFilterDescendants tests that ParentID filter returns all descendants of an epic func TestParentIDFilterDescendants(t *testing.T) { env := newTestEnv(t) // Create hierarchy: // epic1 (root) // ├── task1 (child of epic1) // ├── task2 (child of epic1) // └── epic2 (child of epic1) // └── task3 (grandchild of epic1) // task4 (unrelated, should not appear in results) epic1 := env.CreateEpic("Epic 1") task1 := env.CreateIssue("Task 1") task2 := env.CreateIssue("Task 2") epic2 := env.CreateEpic("Epic 2") task3 := env.CreateIssue("Task 3") task4 := env.CreateIssue("Task 4 - unrelated") env.AddParentChild(task1, epic1) env.AddParentChild(task2, epic1) env.AddParentChild(epic2, epic1) env.AddParentChild(task3, epic2) // Query with ParentID = epic1 parentID := epic1.ID ready := env.GetReadyWork(types.WorkFilter{ParentID: &parentID}) // Should include task1, task2, epic2, task3 (all descendants of epic1) // Should NOT include epic1 itself or task4 if len(ready) != 4 { t.Fatalf("Expected 4 ready issues in parent scope, got %d", len(ready)) } // Verify the returned issues are the expected ones readyIDs := make(map[string]bool) for _, issue := range ready { readyIDs[issue.ID] = true } if !readyIDs[task1.ID] { t.Errorf("Expected task1 to be in results") } if !readyIDs[task2.ID] { t.Errorf("Expected task2 to be in results") } if !readyIDs[epic2.ID] { t.Errorf("Expected epic2 to be in results") } if !readyIDs[task3.ID] { t.Errorf("Expected task3 to be in results") } if readyIDs[epic1.ID] { t.Errorf("Expected epic1 (root) to NOT be in results") } if readyIDs[task4.ID] { t.Errorf("Expected task4 (unrelated) to NOT be in results") } } // TestParentIDWithOtherFilters tests that ParentID can be combined with other filters func TestParentIDWithOtherFilters(t *testing.T) { env := newTestEnv(t) // Create hierarchy: // epic1 (root) // ├── task1 (priority 0) // ├── task2 (priority 1) // └── task3 (priority 2) epic1 := env.CreateEpic("Epic 1") task1 := env.CreateIssueWith("Task 1 - P0", types.StatusOpen, 0, types.TypeTask) task2 := env.CreateIssueWith("Task 2 - P1", types.StatusOpen, 1, types.TypeTask) task3 := env.CreateIssueWith("Task 3 - P2", types.StatusOpen, 2, types.TypeTask) env.AddParentChild(task1, epic1) env.AddParentChild(task2, epic1) env.AddParentChild(task3, epic1) // Query with ParentID = epic1 AND priority = 1 parentID := epic1.ID priority := 1 ready := env.GetReadyWork(types.WorkFilter{ParentID: &parentID, Priority: &priority}) // Should only include task2 (parent + priority 1) if len(ready) != 1 { t.Fatalf("Expected 1 issue with parent + priority filter, got %d", len(ready)) } if ready[0].ID != task2.ID { t.Errorf("Expected task2, got %s", ready[0].ID) } } // TestParentIDWithBlockedDescendants tests that blocked descendants are excluded func TestParentIDWithBlockedDescendants(t *testing.T) { env := newTestEnv(t) // Create hierarchy: // epic1 (root) // ├── task1 (ready) // ├── task2 (blocked by blocker) // └── task3 (ready) // blocker (unrelated) epic1 := env.CreateEpic("Epic 1") task1 := env.CreateIssue("Task 1 - ready") task2 := env.CreateIssue("Task 2 - blocked") task3 := env.CreateIssue("Task 3 - ready") blocker := env.CreateIssue("Blocker") env.AddParentChild(task1, epic1) env.AddParentChild(task2, epic1) env.AddParentChild(task3, epic1) env.AddDep(task2, blocker) // task2 is blocked // Query with ParentID = epic1 parentID := epic1.ID ready := env.GetReadyWork(types.WorkFilter{ParentID: &parentID}) // Should include task1, task3 (ready descendants) // Should NOT include task2 (blocked) if len(ready) != 2 { t.Fatalf("Expected 2 ready descendants, got %d", len(ready)) } readyIDs := make(map[string]bool) for _, issue := range ready { readyIDs[issue.ID] = true } if !readyIDs[task1.ID] { t.Errorf("Expected task1 to be ready") } if !readyIDs[task3.ID] { t.Errorf("Expected task3 to be ready") } if readyIDs[task2.ID] { t.Errorf("Expected task2 to be blocked") } } // TestParentIDEmptyParent tests that empty parent returns nothing func TestParentIDEmptyParent(t *testing.T) { env := newTestEnv(t) // Create an epic with no children epic1 := env.CreateEpic("Epic 1 - no children") env.CreateIssue("Unrelated task") // Query with ParentID = epic1 (which has no children) parentID := epic1.ID ready := env.GetReadyWork(types.WorkFilter{ParentID: &parentID}) // Should return empty since epic1 has no descendants if len(ready) != 0 { t.Fatalf("Expected 0 ready issues for empty parent, got %d", len(ready)) } }