Optimize CheckExternalDeps to group refs by project and open each external DB only once, checking all capabilities in a single query. Before: If 10 issues depend on external:gastown:cap1, we opened gastown's DB 10 times. After: We open each external project's DB once, query for all capabilities needed from that project, then close. Also added deduplication in filterByExternalDeps to collect all unique refs before checking, avoiding redundant checks for the same ref across multiple issues. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1858 lines
60 KiB
Go
1858 lines
60 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestCheckExternalDepsBatching verifies that CheckExternalDeps correctly
|
|
// batches multiple refs to the same project and deduplicates refs (bd-687v).
|
|
func TestCheckExternalDepsBatching(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
// Initialize config (required for config.Set to work)
|
|
if err := config.Initialize(); err != nil {
|
|
t.Fatalf("failed to initialize config: %v", err)
|
|
}
|
|
|
|
// Create external project directory with beads database
|
|
externalDir, err := os.MkdirTemp("", "beads-batch-test-*")
|
|
if err != nil {
|
|
t.Fatalf("failed to create external temp dir: %v", err)
|
|
}
|
|
defer os.RemoveAll(externalDir)
|
|
|
|
// Create .beads directory and config
|
|
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 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)
|
|
}
|
|
|
|
if err := externalStore.SetConfig(ctx, "issue_prefix", "ext"); err != nil {
|
|
t.Fatalf("failed to set issue_prefix: %v", err)
|
|
}
|
|
|
|
// Ship capability "cap1" (closed issue with provides:cap1 label)
|
|
cap1Issue := &types.Issue{
|
|
ID: "ext-cap1",
|
|
Title: "Capability 1",
|
|
Status: types.StatusOpen,
|
|
Priority: 1,
|
|
IssueType: types.TypeFeature,
|
|
}
|
|
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:cap1 label: %v", err)
|
|
}
|
|
if err := externalStore.CloseIssue(ctx, cap1Issue.ID, "Shipped", "test-user", ""); err != nil {
|
|
t.Fatalf("failed to close cap1 issue: %v", err)
|
|
}
|
|
|
|
// Ship capability "cap2"
|
|
cap2Issue := &types.Issue{
|
|
ID: "ext-cap2",
|
|
Title: "Capability 2",
|
|
Status: types.StatusOpen,
|
|
Priority: 1,
|
|
IssueType: types.TypeFeature,
|
|
}
|
|
if err := externalStore.CreateIssue(ctx, cap2Issue, "test-user"); err != nil {
|
|
t.Fatalf("failed to create cap2 issue: %v", err)
|
|
}
|
|
if err := externalStore.AddLabel(ctx, cap2Issue.ID, "provides:cap2", "test-user"); err != nil {
|
|
t.Fatalf("failed to add provides:cap2 label: %v", err)
|
|
}
|
|
if err := externalStore.CloseIssue(ctx, cap2Issue.ID, "Shipped", "test-user", ""); err != nil {
|
|
t.Fatalf("failed to close cap2 issue: %v", err)
|
|
}
|
|
|
|
// Close to checkpoint WAL before read-only access
|
|
externalStore.Close()
|
|
|
|
// Configure external_projects
|
|
oldProjects := config.GetExternalProjects()
|
|
t.Cleanup(func() {
|
|
if oldProjects != nil {
|
|
config.Set("external_projects", oldProjects)
|
|
} else {
|
|
config.Set("external_projects", map[string]string{})
|
|
}
|
|
})
|
|
config.Set("external_projects", map[string]string{
|
|
"batch-test": externalDir,
|
|
})
|
|
|
|
// Test: Check multiple refs including duplicates and mixed satisfied/unsatisfied
|
|
refs := []string{
|
|
"external:batch-test:cap1", // satisfied
|
|
"external:batch-test:cap2", // satisfied
|
|
"external:batch-test:cap3", // NOT satisfied
|
|
"external:batch-test:cap1", // duplicate - should still work
|
|
"external:unconfigured-project:cap1", // unconfigured project
|
|
"invalid-ref", // invalid format
|
|
}
|
|
|
|
statuses := CheckExternalDeps(ctx, refs)
|
|
|
|
// Verify we got results for all unique refs (5 unique, since cap1 appears twice)
|
|
expectedUnique := 5
|
|
if len(statuses) != expectedUnique {
|
|
t.Errorf("Expected %d unique statuses, got %d", expectedUnique, len(statuses))
|
|
}
|
|
|
|
// cap1 should be satisfied
|
|
if s := statuses["external:batch-test:cap1"]; s == nil || !s.Satisfied {
|
|
t.Error("Expected external:batch-test:cap1 to be satisfied")
|
|
}
|
|
|
|
// cap2 should be satisfied
|
|
if s := statuses["external:batch-test:cap2"]; s == nil || !s.Satisfied {
|
|
t.Error("Expected external:batch-test:cap2 to be satisfied")
|
|
}
|
|
|
|
// cap3 should NOT be satisfied
|
|
if s := statuses["external:batch-test:cap3"]; s == nil || s.Satisfied {
|
|
t.Error("Expected external:batch-test:cap3 to be unsatisfied")
|
|
}
|
|
|
|
// unconfigured project should NOT be satisfied
|
|
if s := statuses["external:unconfigured-project:cap1"]; s == nil || s.Satisfied {
|
|
t.Error("Expected external:unconfigured-project:cap1 to be unsatisfied")
|
|
}
|
|
|
|
// invalid ref should NOT be satisfied
|
|
if s := statuses["invalid-ref"]; s == nil || s.Satisfied {
|
|
t.Error("Expected invalid-ref to be unsatisfied")
|
|
}
|
|
}
|
|
|
|
// 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))
|
|
}
|
|
}
|