fix: Update ready_issues VIEW to use hierarchical blocking
The ready_issues VIEW was using old logic that didn't propagate blocking through parent-child hierarchies. This caused inconsistency with the GetReadyWork() function for users querying via sqlite3 CLI. Changes: - Updated VIEW to use same recursive CTE as GetReadyWork() - Added test to verify VIEW and function produce identical results - No migration needed (CREATE VIEW IF NOT EXISTS handles recreation) The VIEW is documented in WORKFLOW.md for direct SQL queries and is now consistent with the function-based API. Resolves: bd-60 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user