relates-to dependencies are intentionally bidirectional ("see also" links)
and should not be reported as cycles by bd dep cycles. This matches the
existing behavior in AddDependency which already skips cycle prevention
for relates-to.
Changes:
- Filter relates-to edges in DetectCycles SQL query
- Add tests for relates-to exclusion from cycle detection
- Update existing test to reflect correct behavior
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
646 lines
19 KiB
Go
646 lines
19 KiB
Go
package sqlite
|
|
|
|
import (
|
|
"context"
|
|
"strconv"
|
|
"testing"
|
|
|
|
"github.com/steveyegge/beads/internal/types"
|
|
)
|
|
|
|
// TestDetectCyclesSimple tests simple 2-node cycles
|
|
func TestDetectCyclesSimple(t *testing.T) {
|
|
store, cleanup := setupTestDB(t)
|
|
defer cleanup()
|
|
|
|
ctx := context.Background()
|
|
|
|
// Create two issues
|
|
issue1 := &types.Issue{Title: "Issue 1", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
|
|
issue2 := &types.Issue{Title: "Issue 2", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
|
|
|
|
if err := store.CreateIssue(ctx, issue1, "test-user"); err != nil {
|
|
t.Fatalf("CreateIssue failed: %v", err)
|
|
}
|
|
if err := store.CreateIssue(ctx, issue2, "test-user"); err != nil {
|
|
t.Fatalf("CreateIssue failed: %v", err)
|
|
}
|
|
|
|
// Manually create a cycle by inserting directly into dependencies table
|
|
// (bypassing AddDependency's cycle prevention)
|
|
_, err := store.db.ExecContext(ctx, `
|
|
INSERT INTO dependencies (issue_id, depends_on_id, type, created_by, created_at)
|
|
VALUES (?, ?, ?, 'test-user', CURRENT_TIMESTAMP)
|
|
`, issue1.ID, issue2.ID, types.DepBlocks)
|
|
if err != nil {
|
|
t.Fatalf("Insert dependency failed: %v", err)
|
|
}
|
|
|
|
_, err = store.db.ExecContext(ctx, `
|
|
INSERT INTO dependencies (issue_id, depends_on_id, type, created_by, created_at)
|
|
VALUES (?, ?, ?, 'test-user', CURRENT_TIMESTAMP)
|
|
`, issue2.ID, issue1.ID, types.DepBlocks)
|
|
if err != nil {
|
|
t.Fatalf("Insert dependency failed: %v", err)
|
|
}
|
|
|
|
// Detect cycles
|
|
cycles, err := store.DetectCycles(ctx)
|
|
if err != nil {
|
|
t.Fatalf("DetectCycles failed: %v", err)
|
|
}
|
|
|
|
if len(cycles) == 0 {
|
|
t.Fatal("Expected to detect a cycle, but found none")
|
|
}
|
|
|
|
// Verify the cycle contains both issues
|
|
cycle := cycles[0]
|
|
if len(cycle) != 2 {
|
|
t.Logf("Cycle issues: %v", cycle)
|
|
for i, iss := range cycle {
|
|
t.Logf(" [%d] ID=%s Title=%s", i, iss.ID, iss.Title)
|
|
}
|
|
t.Errorf("Expected cycle of length 2, got %d", len(cycle))
|
|
}
|
|
|
|
// Verify both issues are in the cycle
|
|
foundIDs := make(map[string]bool)
|
|
for _, issue := range cycle {
|
|
foundIDs[issue.ID] = true
|
|
}
|
|
|
|
if !foundIDs[issue1.ID] || !foundIDs[issue2.ID] {
|
|
t.Errorf("Cycle missing expected issues. Got: %v", foundIDs)
|
|
}
|
|
}
|
|
|
|
// TestDetectCyclesComplex tests a more complex multi-node cycle
|
|
func TestDetectCyclesComplex(t *testing.T) {
|
|
store, cleanup := setupTestDB(t)
|
|
defer cleanup()
|
|
|
|
ctx := context.Background()
|
|
|
|
// Create a 4-node cycle: A → B → C → D → A
|
|
issues := make([]*types.Issue, 4)
|
|
for i := 0; i < 4; i++ {
|
|
issues[i] = &types.Issue{
|
|
Title: "Issue " + string(rune('A'+i)),
|
|
Status: types.StatusOpen,
|
|
Priority: 1,
|
|
IssueType: types.TypeTask,
|
|
}
|
|
if err := store.CreateIssue(ctx, issues[i], "test-user"); err != nil {
|
|
t.Fatalf("CreateIssue failed: %v", err)
|
|
}
|
|
}
|
|
|
|
// Create cycle: 0→1→2→3→0
|
|
for i := 0; i < 4; i++ {
|
|
nextIdx := (i + 1) % 4
|
|
_, err := store.db.ExecContext(ctx, `
|
|
INSERT INTO dependencies (issue_id, depends_on_id, type, created_by, created_at)
|
|
VALUES (?, ?, ?, 'test-user', CURRENT_TIMESTAMP)
|
|
`, issues[i].ID, issues[nextIdx].ID, types.DepBlocks)
|
|
if err != nil {
|
|
t.Fatalf("Insert dependency failed: %v", err)
|
|
}
|
|
}
|
|
|
|
// Detect cycles
|
|
cycles, err := store.DetectCycles(ctx)
|
|
if err != nil {
|
|
t.Fatalf("DetectCycles failed: %v", err)
|
|
}
|
|
|
|
if len(cycles) == 0 {
|
|
t.Fatal("Expected to detect a cycle, but found none")
|
|
}
|
|
|
|
// Verify the cycle contains all 4 issues
|
|
cycle := cycles[0]
|
|
if len(cycle) != 4 {
|
|
t.Errorf("Expected cycle of length 4, got %d", len(cycle))
|
|
}
|
|
|
|
// Verify all issues are in the cycle
|
|
foundIDs := make(map[string]bool)
|
|
for _, issue := range cycle {
|
|
foundIDs[issue.ID] = true
|
|
}
|
|
|
|
for _, issue := range issues {
|
|
if !foundIDs[issue.ID] {
|
|
t.Errorf("Cycle missing issue %s", issue.ID)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestDetectCyclesSelfLoop tests detection of self-loops (A → A)
|
|
func TestDetectCyclesSelfLoop(t *testing.T) {
|
|
store, cleanup := setupTestDB(t)
|
|
defer cleanup()
|
|
|
|
ctx := context.Background()
|
|
|
|
issue := &types.Issue{Title: "Self Loop", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
|
|
if err := store.CreateIssue(ctx, issue, "test-user"); err != nil {
|
|
t.Fatalf("CreateIssue failed: %v", err)
|
|
}
|
|
|
|
// Create self-loop
|
|
_, err := store.db.ExecContext(ctx, `
|
|
INSERT INTO dependencies (issue_id, depends_on_id, type, created_by, created_at)
|
|
VALUES (?, ?, ?, 'test-user', CURRENT_TIMESTAMP)
|
|
`, issue.ID, issue.ID, types.DepBlocks)
|
|
if err != nil {
|
|
t.Fatalf("Insert dependency failed: %v", err)
|
|
}
|
|
|
|
// Detect cycles
|
|
cycles, err := store.DetectCycles(ctx)
|
|
if err != nil {
|
|
t.Fatalf("DetectCycles failed: %v", err)
|
|
}
|
|
|
|
if len(cycles) == 0 {
|
|
t.Fatal("Expected to detect a self-loop cycle, but found none")
|
|
}
|
|
|
|
// Verify the cycle contains the issue
|
|
cycle := cycles[0]
|
|
if len(cycle) != 1 {
|
|
t.Errorf("Expected self-loop cycle of length 1, got %d", len(cycle))
|
|
}
|
|
|
|
if cycle[0].ID != issue.ID {
|
|
t.Errorf("Expected cycle to contain issue %s, got %s", issue.ID, cycle[0].ID)
|
|
}
|
|
}
|
|
|
|
// TestDetectCyclesMultipleIndependent tests detection of multiple independent cycles
|
|
func TestDetectCyclesMultipleIndependent(t *testing.T) {
|
|
store, cleanup := setupTestDB(t)
|
|
defer cleanup()
|
|
|
|
ctx := context.Background()
|
|
|
|
// Create two independent cycles:
|
|
// Cycle 1: A → B → A
|
|
// Cycle 2: C → D → C
|
|
|
|
cycle1 := make([]*types.Issue, 2)
|
|
cycle2 := make([]*types.Issue, 2)
|
|
|
|
for i := 0; i < 2; i++ {
|
|
cycle1[i] = &types.Issue{
|
|
Title: "Cycle1-" + string(rune('A'+i)),
|
|
Status: types.StatusOpen,
|
|
Priority: 1,
|
|
IssueType: types.TypeTask,
|
|
}
|
|
cycle2[i] = &types.Issue{
|
|
Title: "Cycle2-" + string(rune('A'+i)),
|
|
Status: types.StatusOpen,
|
|
Priority: 1,
|
|
IssueType: types.TypeTask,
|
|
}
|
|
if err := store.CreateIssue(ctx, cycle1[i], "test-user"); err != nil {
|
|
t.Fatalf("CreateIssue failed: %v", err)
|
|
}
|
|
if err := store.CreateIssue(ctx, cycle2[i], "test-user"); err != nil {
|
|
t.Fatalf("CreateIssue failed: %v", err)
|
|
}
|
|
}
|
|
|
|
// Create first cycle: 0→1→0
|
|
_, err := store.db.ExecContext(ctx, `
|
|
INSERT INTO dependencies (issue_id, depends_on_id, type, created_by, created_at)
|
|
VALUES (?, ?, ?, 'test-user', CURRENT_TIMESTAMP)
|
|
`, cycle1[0].ID, cycle1[1].ID, types.DepBlocks)
|
|
if err != nil {
|
|
t.Fatalf("Insert dependency failed: %v", err)
|
|
}
|
|
_, err = store.db.ExecContext(ctx, `
|
|
INSERT INTO dependencies (issue_id, depends_on_id, type, created_by, created_at)
|
|
VALUES (?, ?, ?, 'test-user', CURRENT_TIMESTAMP)
|
|
`, cycle1[1].ID, cycle1[0].ID, types.DepBlocks)
|
|
if err != nil {
|
|
t.Fatalf("Insert dependency failed: %v", err)
|
|
}
|
|
|
|
// Create second cycle: 0→1→0
|
|
_, err = store.db.ExecContext(ctx, `
|
|
INSERT INTO dependencies (issue_id, depends_on_id, type, created_by, created_at)
|
|
VALUES (?, ?, ?, 'test-user', CURRENT_TIMESTAMP)
|
|
`, cycle2[0].ID, cycle2[1].ID, types.DepBlocks)
|
|
if err != nil {
|
|
t.Fatalf("Insert dependency failed: %v", err)
|
|
}
|
|
_, err = store.db.ExecContext(ctx, `
|
|
INSERT INTO dependencies (issue_id, depends_on_id, type, created_by, created_at)
|
|
VALUES (?, ?, ?, 'test-user', CURRENT_TIMESTAMP)
|
|
`, cycle2[1].ID, cycle2[0].ID, types.DepBlocks)
|
|
if err != nil {
|
|
t.Fatalf("Insert dependency failed: %v", err)
|
|
}
|
|
|
|
// Detect cycles
|
|
cycles, err := store.DetectCycles(ctx)
|
|
if err != nil {
|
|
t.Fatalf("DetectCycles failed: %v", err)
|
|
}
|
|
|
|
// The SQL may detect the same cycle from different entry points,
|
|
// so we might get more than 2 cycles reported. Verify we have at least 2.
|
|
if len(cycles) < 2 {
|
|
t.Errorf("Expected to detect at least 2 independent cycles, got %d", len(cycles))
|
|
}
|
|
|
|
// Verify we found cycles involving all 4 issues
|
|
foundIssues := make(map[string]bool)
|
|
for _, cycle := range cycles {
|
|
for _, issue := range cycle {
|
|
foundIssues[issue.ID] = true
|
|
}
|
|
}
|
|
|
|
allCycleIssues := append(cycle1, cycle2...)
|
|
for _, issue := range allCycleIssues {
|
|
if !foundIssues[issue.ID] {
|
|
t.Errorf("Cycle detection missing issue %s", issue.ID)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestDetectCyclesAcyclicGraph tests that no cycles are detected in an acyclic graph
|
|
func TestDetectCyclesAcyclicGraph(t *testing.T) {
|
|
store, cleanup := setupTestDB(t)
|
|
defer cleanup()
|
|
|
|
ctx := context.Background()
|
|
|
|
// Create a DAG: A → B → C → D (no cycles)
|
|
issues := make([]*types.Issue, 4)
|
|
for i := 0; i < 4; i++ {
|
|
issues[i] = &types.Issue{
|
|
Title: "Issue " + string(rune('A'+i)),
|
|
Status: types.StatusOpen,
|
|
Priority: 1,
|
|
IssueType: types.TypeTask,
|
|
}
|
|
if err := store.CreateIssue(ctx, issues[i], "test-user"); err != nil {
|
|
t.Fatalf("CreateIssue failed: %v", err)
|
|
}
|
|
}
|
|
|
|
// Create chain: 0→1→2→3 (no cycle)
|
|
for i := 0; i < 3; i++ {
|
|
_, err := store.db.ExecContext(ctx, `
|
|
INSERT INTO dependencies (issue_id, depends_on_id, type, created_by, created_at)
|
|
VALUES (?, ?, ?, 'test-user', CURRENT_TIMESTAMP)
|
|
`, issues[i].ID, issues[i+1].ID, types.DepBlocks)
|
|
if err != nil {
|
|
t.Fatalf("Insert dependency failed: %v", err)
|
|
}
|
|
}
|
|
|
|
// Detect cycles
|
|
cycles, err := store.DetectCycles(ctx)
|
|
if err != nil {
|
|
t.Fatalf("DetectCycles failed: %v", err)
|
|
}
|
|
|
|
if len(cycles) != 0 {
|
|
t.Errorf("Expected no cycles in acyclic graph, but found %d", len(cycles))
|
|
}
|
|
}
|
|
|
|
// TestDetectCyclesEmptyGraph tests cycle detection on empty graph
|
|
func TestDetectCyclesEmptyGraph(t *testing.T) {
|
|
store, cleanup := setupTestDB(t)
|
|
defer cleanup()
|
|
|
|
ctx := context.Background()
|
|
|
|
// Detect cycles on empty database
|
|
cycles, err := store.DetectCycles(ctx)
|
|
if err != nil {
|
|
t.Fatalf("DetectCycles failed: %v", err)
|
|
}
|
|
|
|
if len(cycles) != 0 {
|
|
t.Errorf("Expected no cycles in empty graph, but found %d", len(cycles))
|
|
}
|
|
}
|
|
|
|
// TestDetectCyclesSingleNode tests cycle detection with a single node (no dependencies)
|
|
func TestDetectCyclesSingleNode(t *testing.T) {
|
|
store, cleanup := setupTestDB(t)
|
|
defer cleanup()
|
|
|
|
ctx := context.Background()
|
|
|
|
// Create a single issue with no dependencies
|
|
issue := &types.Issue{Title: "Lonely Issue", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
|
|
if err := store.CreateIssue(ctx, issue, "test-user"); err != nil {
|
|
t.Fatalf("CreateIssue failed: %v", err)
|
|
}
|
|
|
|
// Detect cycles
|
|
cycles, err := store.DetectCycles(ctx)
|
|
if err != nil {
|
|
t.Fatalf("DetectCycles failed: %v", err)
|
|
}
|
|
|
|
if len(cycles) != 0 {
|
|
t.Errorf("Expected no cycles for single node with no dependencies, but found %d", len(cycles))
|
|
}
|
|
}
|
|
|
|
// TestDetectCyclesDiamond tests cycle detection in a diamond pattern (no cycle)
|
|
func TestDetectCyclesDiamond(t *testing.T) {
|
|
store, cleanup := setupTestDB(t)
|
|
defer cleanup()
|
|
|
|
ctx := context.Background()
|
|
|
|
// Create a diamond pattern: A → B → D, A → C → D (no cycle)
|
|
issues := make([]*types.Issue, 4)
|
|
names := []string{"A", "B", "C", "D"}
|
|
for i := 0; i < 4; i++ {
|
|
issues[i] = &types.Issue{
|
|
Title: "Issue " + names[i],
|
|
Status: types.StatusOpen,
|
|
Priority: 1,
|
|
IssueType: types.TypeTask,
|
|
}
|
|
if err := store.CreateIssue(ctx, issues[i], "test-user"); err != nil {
|
|
t.Fatalf("CreateIssue failed: %v", err)
|
|
}
|
|
}
|
|
|
|
// Create dependencies: A→B, A→C, B→D, C→D
|
|
deps := [][2]int{{0, 1}, {0, 2}, {1, 3}, {2, 3}}
|
|
for _, dep := range deps {
|
|
_, err := store.db.ExecContext(ctx, `
|
|
INSERT INTO dependencies (issue_id, depends_on_id, type, created_by, created_at)
|
|
VALUES (?, ?, ?, 'test-user', CURRENT_TIMESTAMP)
|
|
`, issues[dep[0]].ID, issues[dep[1]].ID, types.DepBlocks)
|
|
if err != nil {
|
|
t.Fatalf("Insert dependency failed: %v", err)
|
|
}
|
|
}
|
|
|
|
// Detect cycles
|
|
cycles, err := store.DetectCycles(ctx)
|
|
if err != nil {
|
|
t.Fatalf("DetectCycles failed: %v", err)
|
|
}
|
|
|
|
if len(cycles) != 0 {
|
|
t.Errorf("Expected no cycles in diamond pattern, but found %d", len(cycles))
|
|
}
|
|
}
|
|
|
|
// TestDetectCyclesLongCycle tests detection of a long cycle (10 nodes)
|
|
func TestDetectCyclesLongCycle(t *testing.T) {
|
|
store, cleanup := setupTestDB(t)
|
|
defer cleanup()
|
|
|
|
ctx := context.Background()
|
|
|
|
// Create a 10-node cycle
|
|
const cycleLength = 10
|
|
issues := make([]*types.Issue, cycleLength)
|
|
for i := 0; i < cycleLength; i++ {
|
|
issues[i] = &types.Issue{
|
|
Title: "Issue " + strconv.Itoa(i),
|
|
Status: types.StatusOpen,
|
|
Priority: 1,
|
|
IssueType: types.TypeTask,
|
|
}
|
|
if err := store.CreateIssue(ctx, issues[i], "test-user"); err != nil {
|
|
t.Fatalf("CreateIssue failed: %v", err)
|
|
}
|
|
}
|
|
|
|
// Create cycle: 0→1→2→...→9→0
|
|
for i := 0; i < cycleLength; i++ {
|
|
nextIdx := (i + 1) % cycleLength
|
|
_, err := store.db.ExecContext(ctx, `
|
|
INSERT INTO dependencies (issue_id, depends_on_id, type, created_by, created_at)
|
|
VALUES (?, ?, ?, 'test-user', CURRENT_TIMESTAMP)
|
|
`, issues[i].ID, issues[nextIdx].ID, types.DepBlocks)
|
|
if err != nil {
|
|
t.Fatalf("Insert dependency failed: %v", err)
|
|
}
|
|
}
|
|
|
|
// Detect cycles
|
|
cycles, err := store.DetectCycles(ctx)
|
|
if err != nil {
|
|
t.Fatalf("DetectCycles failed: %v", err)
|
|
}
|
|
|
|
if len(cycles) == 0 {
|
|
t.Fatal("Expected to detect a cycle, but found none")
|
|
}
|
|
|
|
// Verify the cycle contains all 10 issues
|
|
cycle := cycles[0]
|
|
if len(cycle) != cycleLength {
|
|
t.Errorf("Expected cycle of length %d, got %d", cycleLength, len(cycle))
|
|
}
|
|
}
|
|
|
|
// TestDetectCyclesMixedTypes tests cycle detection with different dependency types
|
|
func TestDetectCyclesMixedTypes(t *testing.T) {
|
|
store, cleanup := setupTestDB(t)
|
|
defer cleanup()
|
|
|
|
ctx := context.Background()
|
|
|
|
// Create a cycle using different dependency types
|
|
issues := make([]*types.Issue, 3)
|
|
for i := 0; i < 3; i++ {
|
|
issues[i] = &types.Issue{
|
|
Title: "Issue " + string(rune('A'+i)),
|
|
Status: types.StatusOpen,
|
|
Priority: 1,
|
|
IssueType: types.TypeTask,
|
|
}
|
|
if err := store.CreateIssue(ctx, issues[i], "test-user"); err != nil {
|
|
t.Fatalf("CreateIssue failed: %v", err)
|
|
}
|
|
}
|
|
|
|
// Create cycle with mixed types: A -blocks-> B -related-> C -parent-child-> A
|
|
depTypes := []types.DependencyType{types.DepBlocks, types.DepRelated, types.DepParentChild}
|
|
for i := 0; i < 3; i++ {
|
|
nextIdx := (i + 1) % 3
|
|
_, err := store.db.ExecContext(ctx, `
|
|
INSERT INTO dependencies (issue_id, depends_on_id, type, created_by, created_at)
|
|
VALUES (?, ?, ?, 'test-user', CURRENT_TIMESTAMP)
|
|
`, issues[i].ID, issues[nextIdx].ID, depTypes[i])
|
|
if err != nil {
|
|
t.Fatalf("Insert dependency failed: %v", err)
|
|
}
|
|
}
|
|
|
|
// Detect cycles
|
|
cycles, err := store.DetectCycles(ctx)
|
|
if err != nil {
|
|
t.Fatalf("DetectCycles failed: %v", err)
|
|
}
|
|
|
|
if len(cycles) == 0 {
|
|
t.Fatal("Expected to detect a cycle with mixed types, but found none")
|
|
}
|
|
|
|
// Verify the cycle contains all 3 issues
|
|
cycle := cycles[0]
|
|
if len(cycle) != 3 {
|
|
t.Errorf("Expected cycle of length 3, got %d", len(cycle))
|
|
}
|
|
}
|
|
|
|
// TestDetectCyclesRelatesToNotACycle tests that bidirectional relates-to links are NOT reported as cycles
|
|
// This is the fix for GitHub issue #661: relates-to relationships should be excluded from cycle detection
|
|
// because they are inherently bidirectional ("see also" links) and don't affect work ordering.
|
|
func TestDetectCyclesRelatesToNotACycle(t *testing.T) {
|
|
store, cleanup := setupTestDB(t)
|
|
defer cleanup()
|
|
|
|
ctx := context.Background()
|
|
|
|
// Create two issues
|
|
issue1 := &types.Issue{Title: "Todo 1", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask}
|
|
issue2 := &types.Issue{Title: "Todo 2", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask}
|
|
|
|
if err := store.CreateIssue(ctx, issue1, "test-user"); err != nil {
|
|
t.Fatalf("CreateIssue failed: %v", err)
|
|
}
|
|
if err := store.CreateIssue(ctx, issue2, "test-user"); err != nil {
|
|
t.Fatalf("CreateIssue failed: %v", err)
|
|
}
|
|
|
|
// Create bidirectional relates_to links (simulating what bd relate does)
|
|
// issue1 relates_to issue2
|
|
_, err := store.db.ExecContext(ctx, `
|
|
INSERT INTO dependencies (issue_id, depends_on_id, type, created_by, created_at)
|
|
VALUES (?, ?, ?, 'test-user', CURRENT_TIMESTAMP)
|
|
`, issue1.ID, issue2.ID, types.DepRelatesTo)
|
|
if err != nil {
|
|
t.Fatalf("Insert relates_to dependency failed: %v", err)
|
|
}
|
|
|
|
// issue2 relates_to issue1 (the reverse link)
|
|
_, err = store.db.ExecContext(ctx, `
|
|
INSERT INTO dependencies (issue_id, depends_on_id, type, created_by, created_at)
|
|
VALUES (?, ?, ?, 'test-user', CURRENT_TIMESTAMP)
|
|
`, issue2.ID, issue1.ID, types.DepRelatesTo)
|
|
if err != nil {
|
|
t.Fatalf("Insert relates_to dependency failed: %v", err)
|
|
}
|
|
|
|
// Detect cycles - should find NONE because relates_to is excluded
|
|
cycles, err := store.DetectCycles(ctx)
|
|
if err != nil {
|
|
t.Fatalf("DetectCycles failed: %v", err)
|
|
}
|
|
|
|
if len(cycles) != 0 {
|
|
t.Errorf("Expected no cycles for relates_to bidirectional links, but found %d cycles", len(cycles))
|
|
for i, cycle := range cycles {
|
|
t.Logf("Cycle %d:", i+1)
|
|
for _, issue := range cycle {
|
|
t.Logf(" - %s: %s", issue.ID, issue.Title)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestDetectCyclesRelatesToWithOtherCycle tests that relates-to is excluded but other cycles are still detected
|
|
func TestDetectCyclesRelatesToWithOtherCycle(t *testing.T) {
|
|
store, cleanup := setupTestDB(t)
|
|
defer cleanup()
|
|
|
|
ctx := context.Background()
|
|
|
|
// Create three issues
|
|
issue1 := &types.Issue{Title: "Issue A", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask}
|
|
issue2 := &types.Issue{Title: "Issue B", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask}
|
|
issue3 := &types.Issue{Title: "Issue C", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask}
|
|
|
|
if err := store.CreateIssue(ctx, issue1, "test-user"); err != nil {
|
|
t.Fatalf("CreateIssue failed: %v", err)
|
|
}
|
|
if err := store.CreateIssue(ctx, issue2, "test-user"); err != nil {
|
|
t.Fatalf("CreateIssue failed: %v", err)
|
|
}
|
|
if err := store.CreateIssue(ctx, issue3, "test-user"); err != nil {
|
|
t.Fatalf("CreateIssue failed: %v", err)
|
|
}
|
|
|
|
// Create bidirectional relates_to between issue1 and issue2 (should NOT trigger cycle)
|
|
_, err := store.db.ExecContext(ctx, `
|
|
INSERT INTO dependencies (issue_id, depends_on_id, type, created_by, created_at)
|
|
VALUES (?, ?, ?, 'test-user', CURRENT_TIMESTAMP)
|
|
`, issue1.ID, issue2.ID, types.DepRelatesTo)
|
|
if err != nil {
|
|
t.Fatalf("Insert relates_to dependency failed: %v", err)
|
|
}
|
|
_, err = store.db.ExecContext(ctx, `
|
|
INSERT INTO dependencies (issue_id, depends_on_id, type, created_by, created_at)
|
|
VALUES (?, ?, ?, 'test-user', CURRENT_TIMESTAMP)
|
|
`, issue2.ID, issue1.ID, types.DepRelatesTo)
|
|
if err != nil {
|
|
t.Fatalf("Insert relates_to dependency failed: %v", err)
|
|
}
|
|
|
|
// Create a real cycle with blocks: issue2 -> issue3 -> issue2
|
|
_, err = store.db.ExecContext(ctx, `
|
|
INSERT INTO dependencies (issue_id, depends_on_id, type, created_by, created_at)
|
|
VALUES (?, ?, ?, 'test-user', CURRENT_TIMESTAMP)
|
|
`, issue2.ID, issue3.ID, types.DepBlocks)
|
|
if err != nil {
|
|
t.Fatalf("Insert blocks dependency failed: %v", err)
|
|
}
|
|
_, err = store.db.ExecContext(ctx, `
|
|
INSERT INTO dependencies (issue_id, depends_on_id, type, created_by, created_at)
|
|
VALUES (?, ?, ?, 'test-user', CURRENT_TIMESTAMP)
|
|
`, issue3.ID, issue2.ID, types.DepBlocks)
|
|
if err != nil {
|
|
t.Fatalf("Insert blocks dependency failed: %v", err)
|
|
}
|
|
|
|
// Detect cycles - should find the blocks cycle but NOT the relates_to "cycle"
|
|
cycles, err := store.DetectCycles(ctx)
|
|
if err != nil {
|
|
t.Fatalf("DetectCycles failed: %v", err)
|
|
}
|
|
|
|
if len(cycles) == 0 {
|
|
t.Fatal("Expected to find the blocks cycle, but found none")
|
|
}
|
|
|
|
// Verify the cycle contains issue2 and issue3, but NOT issue1
|
|
foundIDs := make(map[string]bool)
|
|
for _, cycle := range cycles {
|
|
for _, issue := range cycle {
|
|
foundIDs[issue.ID] = true
|
|
}
|
|
}
|
|
|
|
if !foundIDs[issue2.ID] || !foundIDs[issue3.ID] {
|
|
t.Errorf("Expected cycle to contain issue2 and issue3. Found: %v", foundIDs)
|
|
}
|
|
|
|
// Verify issue1 is NOT in the cycle (it's only connected via relates-to)
|
|
if foundIDs[issue1.ID] {
|
|
t.Errorf("issue1 should NOT be in cycle (only connected via relates-to), but was found")
|
|
}
|
|
}
|