When an epic is blocked, all its children should also be considered blocked in the ready work calculation. Previously, only direct blocking dependencies were checked, allowing children of blocked epics to appear as ready work. Implementation: - Use recursive CTE to propagate blocking from parents to descendants - Only 'parent-child' dependencies propagate blocking (not 'related') - Changed NOT IN to NOT EXISTS for better NULL safety and performance - Added depth limit of 50 to prevent pathological cases Test coverage: - TestParentBlockerBlocksChildren: Basic parent→child propagation - TestGrandparentBlockerBlocksGrandchildren: Multi-level depth - TestMultipleParentsOneBlocked: Child blocked if ANY parent blocked - TestBlockerClosedUnblocksChildren: Dynamic unblocking works - TestRelatedDoesNotPropagate: Only parent-child propagates Closes: https://github.com/steveyegge/beads/issues/19 Resolves: bd-58 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
577 lines
20 KiB
Go
577 lines
20 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)
|
|
}
|
|
}
|