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>
661 lines
22 KiB
Go
661 lines
22 KiB
Go
package sqlite
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
|
|
"github.com/steveyegge/beads/internal/types"
|
|
)
|
|
|
|
func TestGetReadyWork(t *testing.T) {
|
|
store, cleanup := setupTestDB(t)
|
|
defer cleanup()
|
|
|
|
ctx := context.Background()
|
|
|
|
// Create issues:
|
|
// bd-1: open, no dependencies → READY
|
|
// bd-2: open, depends on bd-1 (open) → BLOCKED
|
|
// bd-3: open, no dependencies → READY
|
|
// bd-4: closed, no dependencies → NOT READY (closed)
|
|
// bd-5: open, depends on bd-4 (closed) → READY (blocker is closed)
|
|
|
|
issue1 := &types.Issue{Title: "Ready 1", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
|
|
issue2 := &types.Issue{Title: "Blocked", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
|
|
issue3 := &types.Issue{Title: "Ready 2", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask}
|
|
issue4 := &types.Issue{Title: "Closed", Status: types.StatusClosed, Priority: 1, IssueType: types.TypeTask}
|
|
issue5 := &types.Issue{Title: "Ready 3", Status: types.StatusOpen, Priority: 0, IssueType: types.TypeTask}
|
|
|
|
store.CreateIssue(ctx, issue1, "test-user")
|
|
store.CreateIssue(ctx, issue2, "test-user")
|
|
store.CreateIssue(ctx, issue3, "test-user")
|
|
store.CreateIssue(ctx, issue4, "test-user")
|
|
store.CloseIssue(ctx, issue4.ID, "Done", "test-user")
|
|
store.CreateIssue(ctx, issue5, "test-user")
|
|
|
|
// Add dependencies
|
|
store.AddDependency(ctx, &types.Dependency{IssueID: issue2.ID, DependsOnID: issue1.ID, Type: types.DepBlocks}, "test-user")
|
|
store.AddDependency(ctx, &types.Dependency{IssueID: issue5.ID, DependsOnID: issue4.ID, Type: types.DepBlocks}, "test-user")
|
|
|
|
// Get ready work
|
|
ready, err := store.GetReadyWork(ctx, types.WorkFilter{Status: types.StatusOpen})
|
|
if err != nil {
|
|
t.Fatalf("GetReadyWork failed: %v", err)
|
|
}
|
|
|
|
// Should have 3 ready issues: bd-1, bd-3, bd-5
|
|
if len(ready) != 3 {
|
|
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 to be ready", issue1.ID)
|
|
}
|
|
if !readyIDs[issue3.ID] {
|
|
t.Errorf("Expected %s to be ready", issue3.ID)
|
|
}
|
|
if !readyIDs[issue5.ID] {
|
|
t.Errorf("Expected %s to be ready", issue5.ID)
|
|
}
|
|
if readyIDs[issue2.ID] {
|
|
t.Errorf("Expected %s to be blocked, but it was ready", issue2.ID)
|
|
}
|
|
}
|
|
|
|
func TestGetReadyWorkPriorityOrder(t *testing.T) {
|
|
store, cleanup := setupTestDB(t)
|
|
defer cleanup()
|
|
|
|
ctx := context.Background()
|
|
|
|
// Create issues with different priorities
|
|
issueP0 := &types.Issue{Title: "Highest", Status: types.StatusOpen, Priority: 0, IssueType: types.TypeTask}
|
|
issueP2 := &types.Issue{Title: "Medium", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask}
|
|
issueP1 := &types.Issue{Title: "High", 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")
|
|
|
|
// Get ready work
|
|
ready, err := store.GetReadyWork(ctx, types.WorkFilter{Status: types.StatusOpen})
|
|
if err != nil {
|
|
t.Fatalf("GetReadyWork failed: %v", err)
|
|
}
|
|
|
|
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) {
|
|
store, cleanup := setupTestDB(t)
|
|
defer cleanup()
|
|
|
|
ctx := context.Background()
|
|
|
|
// Create issues with different priorities
|
|
issueP0 := &types.Issue{Title: "P0", Status: types.StatusOpen, Priority: 0, IssueType: types.TypeTask}
|
|
issueP1 := &types.Issue{Title: "P1", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
|
|
issueP2 := &types.Issue{Title: "P2", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask}
|
|
|
|
store.CreateIssue(ctx, issueP0, "test-user")
|
|
store.CreateIssue(ctx, issueP1, "test-user")
|
|
store.CreateIssue(ctx, issueP2, "test-user")
|
|
|
|
// Filter for P0 only
|
|
priority0 := 0
|
|
ready, err := store.GetReadyWork(ctx, types.WorkFilter{Status: types.StatusOpen, Priority: &priority0})
|
|
if err != nil {
|
|
t.Fatalf("GetReadyWork failed: %v", err)
|
|
}
|
|
|
|
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) {
|
|
store, cleanup := setupTestDB(t)
|
|
defer cleanup()
|
|
|
|
ctx := context.Background()
|
|
|
|
// Create issues with different assignees
|
|
issueAlice := &types.Issue{Title: "Alice's task", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask, Assignee: "alice"}
|
|
issueBob := &types.Issue{Title: "Bob's task", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask, Assignee: "bob"}
|
|
issueUnassigned := &types.Issue{Title: "Unassigned", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
|
|
|
|
store.CreateIssue(ctx, issueAlice, "test-user")
|
|
store.CreateIssue(ctx, issueBob, "test-user")
|
|
store.CreateIssue(ctx, issueUnassigned, "test-user")
|
|
|
|
// Filter for alice
|
|
assignee := "alice"
|
|
ready, err := store.GetReadyWork(ctx, types.WorkFilter{Status: types.StatusOpen, Assignee: &assignee})
|
|
if err != nil {
|
|
t.Fatalf("GetReadyWork failed: %v", err)
|
|
}
|
|
|
|
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 TestGetReadyWorkWithLimit(t *testing.T) {
|
|
store, cleanup := setupTestDB(t)
|
|
defer cleanup()
|
|
|
|
ctx := context.Background()
|
|
|
|
// Create 5 ready issues
|
|
for i := 0; i < 5; i++ {
|
|
issue := &types.Issue{Title: "Task", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask}
|
|
store.CreateIssue(ctx, issue, "test-user")
|
|
}
|
|
|
|
// Limit to 3
|
|
ready, err := store.GetReadyWork(ctx, types.WorkFilter{Status: types.StatusOpen, Limit: 3})
|
|
if err != nil {
|
|
t.Fatalf("GetReadyWork failed: %v", err)
|
|
}
|
|
|
|
if len(ready) != 3 {
|
|
t.Errorf("Expected 3 issues (limit), got %d", len(ready))
|
|
}
|
|
}
|
|
|
|
func TestGetReadyWorkIgnoresRelatedDeps(t *testing.T) {
|
|
store, cleanup := setupTestDB(t)
|
|
defer cleanup()
|
|
|
|
ctx := context.Background()
|
|
|
|
// Create two issues with "related" dependency (should not block)
|
|
issue1 := &types.Issue{Title: "First", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
|
|
issue2 := &types.Issue{Title: "Second", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
|
|
|
|
store.CreateIssue(ctx, issue1, "test-user")
|
|
store.CreateIssue(ctx, issue2, "test-user")
|
|
|
|
// Add "related" dependency (not blocking)
|
|
store.AddDependency(ctx, &types.Dependency{IssueID: issue2.ID, DependsOnID: issue1.ID, Type: types.DepRelated}, "test-user")
|
|
|
|
// Both should be ready (related deps don't block)
|
|
ready, err := store.GetReadyWork(ctx, types.WorkFilter{Status: types.StatusOpen})
|
|
if err != nil {
|
|
t.Fatalf("GetReadyWork failed: %v", err)
|
|
}
|
|
|
|
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)
|
|
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) {
|
|
store, cleanup := setupTestDB(t)
|
|
defer cleanup()
|
|
|
|
ctx := context.Background()
|
|
|
|
// 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)
|
|
|
|
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}
|
|
|
|
store.CreateIssue(ctx, blocker, "test-user")
|
|
store.CreateIssue(ctx, epic1, "test-user")
|
|
store.CreateIssue(ctx, task1, "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
|
|
store.AddDependency(ctx, &types.Dependency{IssueID: task1.ID, DependsOnID: epic1.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)
|
|
}
|
|
|
|
// Should have only blocker ready
|
|
readyIDs := make(map[string]bool)
|
|
for _, issue := range ready {
|
|
readyIDs[issue.ID] = true
|
|
}
|
|
|
|
if readyIDs[epic1.ID] {
|
|
t.Errorf("Expected epic1 to be blocked, but it was ready")
|
|
}
|
|
if readyIDs[task1.ID] {
|
|
t.Errorf("Expected task1 to be blocked (parent is blocked), but it was ready")
|
|
}
|
|
if !readyIDs[blocker.ID] {
|
|
t.Errorf("Expected blocker to be ready")
|
|
}
|
|
}
|
|
|
|
// TestGrandparentBlockerBlocksGrandchildren tests multi-level propagation
|
|
func TestGrandparentBlockerBlocksGrandchildren(t *testing.T) {
|
|
store, cleanup := setupTestDB(t)
|
|
defer cleanup()
|
|
|
|
ctx := context.Background()
|
|
|
|
// 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)
|
|
|
|
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}
|
|
epic2 := &types.Issue{Title: "Epic 2", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeEpic}
|
|
task1 := &types.Issue{Title: "Task 1", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
|
|
|
|
store.CreateIssue(ctx, blocker, "test-user")
|
|
store.CreateIssue(ctx, epic1, "test-user")
|
|
store.CreateIssue(ctx, epic2, "test-user")
|
|
store.CreateIssue(ctx, task1, "test-user")
|
|
|
|
// epic1 blocked by blocker
|
|
store.AddDependency(ctx, &types.Dependency{IssueID: epic1.ID, DependsOnID: blocker.ID, Type: types.DepBlocks}, "test-user")
|
|
// epic2 is child of epic1
|
|
store.AddDependency(ctx, &types.Dependency{IssueID: epic2.ID, DependsOnID: epic1.ID, Type: types.DepParentChild}, "test-user")
|
|
// task1 is child of epic2
|
|
store.AddDependency(ctx, &types.Dependency{IssueID: task1.ID, DependsOnID: epic2.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)
|
|
}
|
|
|
|
// Should have only blocker ready
|
|
readyIDs := make(map[string]bool)
|
|
for _, issue := range ready {
|
|
readyIDs[issue.ID] = true
|
|
}
|
|
|
|
if readyIDs[epic1.ID] {
|
|
t.Errorf("Expected epic1 to be blocked, but it was ready")
|
|
}
|
|
if readyIDs[epic2.ID] {
|
|
t.Errorf("Expected epic2 to be blocked (parent is blocked), but it was ready")
|
|
}
|
|
if readyIDs[task1.ID] {
|
|
t.Errorf("Expected task1 to be blocked (grandparent is blocked), but it was ready")
|
|
}
|
|
if !readyIDs[blocker.ID] {
|
|
t.Errorf("Expected blocker to be ready")
|
|
}
|
|
}
|
|
|
|
// TestMultipleParentsOneBlocked tests that a child is blocked if ANY parent is blocked
|
|
func TestMultipleParentsOneBlocked(t *testing.T) {
|
|
store, cleanup := setupTestDB(t)
|
|
defer cleanup()
|
|
|
|
ctx := context.Background()
|
|
|
|
// 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)
|
|
|
|
blocker := &types.Issue{Title: "Blocker", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
|
|
epic1 := &types.Issue{Title: "Epic 1 (blocked)", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeEpic}
|
|
epic2 := &types.Issue{Title: "Epic 2 (ready)", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeEpic}
|
|
task1 := &types.Issue{Title: "Task 1", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
|
|
|
|
store.CreateIssue(ctx, blocker, "test-user")
|
|
store.CreateIssue(ctx, epic1, "test-user")
|
|
store.CreateIssue(ctx, epic2, "test-user")
|
|
store.CreateIssue(ctx, task1, "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 both epic1 and epic2
|
|
store.AddDependency(ctx, &types.Dependency{IssueID: task1.ID, DependsOnID: epic1.ID, Type: types.DepParentChild}, "test-user")
|
|
store.AddDependency(ctx, &types.Dependency{IssueID: task1.ID, DependsOnID: epic2.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)
|
|
}
|
|
|
|
// Should have blocker and epic2 ready, but NOT epic1 or task1
|
|
readyIDs := make(map[string]bool)
|
|
for _, issue := range ready {
|
|
readyIDs[issue.ID] = true
|
|
}
|
|
|
|
if readyIDs[epic1.ID] {
|
|
t.Errorf("Expected epic1 to be blocked, but it was ready")
|
|
}
|
|
if readyIDs[task1.ID] {
|
|
t.Errorf("Expected task1 to be blocked (one parent is blocked), but it was ready")
|
|
}
|
|
if !readyIDs[blocker.ID] {
|
|
t.Errorf("Expected blocker to be ready")
|
|
}
|
|
if !readyIDs[epic2.ID] {
|
|
t.Errorf("Expected epic2 to be ready")
|
|
}
|
|
}
|
|
|
|
// TestBlockerClosedUnblocksChildren tests that closing a blocker unblocks descendants
|
|
func TestBlockerClosedUnblocksChildren(t *testing.T) {
|
|
store, cleanup := setupTestDB(t)
|
|
defer cleanup()
|
|
|
|
ctx := context.Background()
|
|
|
|
// 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
|
|
|
|
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}
|
|
|
|
store.CreateIssue(ctx, blocker, "test-user")
|
|
store.CreateIssue(ctx, epic1, "test-user")
|
|
store.CreateIssue(ctx, task1, "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
|
|
store.AddDependency(ctx, &types.Dependency{IssueID: task1.ID, DependsOnID: epic1.ID, Type: types.DepParentChild}, "test-user")
|
|
|
|
// Initially, epic1 and task1 should be blocked
|
|
ready, err := store.GetReadyWork(ctx, types.WorkFilter{Status: types.StatusOpen})
|
|
if err != nil {
|
|
t.Fatalf("GetReadyWork failed: %v", err)
|
|
}
|
|
|
|
readyIDs := make(map[string]bool)
|
|
for _, issue := range ready {
|
|
readyIDs[issue.ID] = true
|
|
}
|
|
|
|
if readyIDs[epic1.ID] || readyIDs[task1.ID] {
|
|
t.Errorf("Expected epic1 and task1 to be blocked initially")
|
|
}
|
|
|
|
// Close the blocker
|
|
store.CloseIssue(ctx, blocker.ID, "Done", "test-user")
|
|
|
|
// Now epic1 and task1 should be ready
|
|
ready, err = store.GetReadyWork(ctx, types.WorkFilter{Status: types.StatusOpen})
|
|
if err != nil {
|
|
t.Fatalf("GetReadyWork failed after closing blocker: %v", err)
|
|
}
|
|
|
|
readyIDs = make(map[string]bool)
|
|
for _, issue := range ready {
|
|
readyIDs[issue.ID] = true
|
|
}
|
|
|
|
if !readyIDs[epic1.ID] {
|
|
t.Errorf("Expected epic1 to be ready after blocker closed")
|
|
}
|
|
if !readyIDs[task1.ID] {
|
|
t.Errorf("Expected task1 to be ready after blocker closed")
|
|
}
|
|
}
|
|
|
|
// TestRelatedDoesNotPropagate tests that 'related' deps don't cause blocking propagation
|
|
func TestRelatedDoesNotPropagate(t *testing.T) {
|
|
store, cleanup := setupTestDB(t)
|
|
defer cleanup()
|
|
|
|
ctx := context.Background()
|
|
|
|
// 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)
|
|
|
|
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}
|
|
|
|
store.CreateIssue(ctx, blocker, "test-user")
|
|
store.CreateIssue(ctx, epic1, "test-user")
|
|
store.CreateIssue(ctx, task1, "test-user")
|
|
|
|
// epic1 blocked by blocker
|
|
store.AddDependency(ctx, &types.Dependency{IssueID: epic1.ID, DependsOnID: blocker.ID, Type: types.DepBlocks}, "test-user")
|
|
// task1 is related to epic1 (NOT parent-child)
|
|
store.AddDependency(ctx, &types.Dependency{IssueID: task1.ID, DependsOnID: epic1.ID, Type: types.DepRelated}, "test-user")
|
|
|
|
// Get ready work
|
|
ready, err := store.GetReadyWork(ctx, types.WorkFilter{Status: types.StatusOpen})
|
|
if err != nil {
|
|
t.Fatalf("GetReadyWork failed: %v", err)
|
|
}
|
|
|
|
// Should have blocker AND task1 ready (related doesn't propagate)
|
|
readyIDs := make(map[string]bool)
|
|
for _, issue := range ready {
|
|
readyIDs[issue.ID] = true
|
|
}
|
|
|
|
if readyIDs[epic1.ID] {
|
|
t.Errorf("Expected epic1 to be blocked, but it was ready")
|
|
}
|
|
if !readyIDs[task1.ID] {
|
|
t.Errorf("Expected task1 to be ready (related deps don't propagate blocking), but it was blocked")
|
|
}
|
|
if !readyIDs[blocker.ID] {
|
|
t.Errorf("Expected blocker to be ready")
|
|
}
|
|
}
|
|
|
|
// 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)")
|
|
}
|
|
}
|