diff --git a/internal/storage/sqlite/ready_test.go b/internal/storage/sqlite/ready_test.go index 04616463..4e15328c 100644 --- a/internal/storage/sqlite/ready_test.go +++ b/internal/storage/sqlite/ready_test.go @@ -574,3 +574,87 @@ func TestCompositeIndexExists(t *testing.T) { 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)") + } +} diff --git a/internal/storage/sqlite/schema.go b/internal/storage/sqlite/schema.go index 43d43ba2..543ef2fe 100644 --- a/internal/storage/sqlite/schema.go +++ b/internal/storage/sqlite/schema.go @@ -95,17 +95,36 @@ CREATE TABLE IF NOT EXISTS issue_counters ( last_id INTEGER NOT NULL DEFAULT 0 ); --- Ready work view +-- Ready work view (with hierarchical blocking) +-- Uses recursive CTE to propagate blocking through parent-child hierarchy CREATE VIEW IF NOT EXISTS ready_issues AS +WITH RECURSIVE + -- Find issues blocked directly by dependencies + 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') + ), + -- Propagate blockage to all descendants via parent-child + blocked_transitively AS ( + -- Base case: directly blocked issues + SELECT issue_id, 0 as depth + FROM blocked_directly + UNION ALL + -- Recursive case: children of blocked issues inherit blockage + 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.* FROM issues i WHERE i.status = 'open' AND NOT EXISTS ( - SELECT 1 FROM dependencies d - JOIN issues blocked ON d.depends_on_id = blocked.id - WHERE d.issue_id = i.id - AND d.type = 'blocks' - AND blocked.status IN ('open', 'in_progress', 'blocked') + SELECT 1 FROM blocked_transitively WHERE issue_id = i.id ); -- Blocked issues view