Implements --resolve-collisions flag for import command to safely handle ID collisions during branch merges. When enabled, colliding issues are remapped to new IDs and all text references and dependencies are automatically updated. Also adds comprehensive tests, branch-merge example, and documentation. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
899 lines
24 KiB
Go
899 lines
24 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/steveyegge/beads/internal/storage/sqlite"
|
|
"github.com/steveyegge/beads/internal/types"
|
|
)
|
|
|
|
// TestImportSimpleCollision tests the basic collision detection and resolution
|
|
func TestImportSimpleCollision(t *testing.T) {
|
|
tmpDir, err := os.MkdirTemp("", "bd-collision-test-*")
|
|
if err != nil {
|
|
t.Fatalf("Failed to create temp dir: %v", err)
|
|
}
|
|
defer os.RemoveAll(tmpDir)
|
|
|
|
dbPath := filepath.Join(tmpDir, "test.db")
|
|
testStore, err := sqlite.New(dbPath)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create storage: %v", err)
|
|
}
|
|
defer testStore.Close()
|
|
|
|
ctx := context.Background()
|
|
|
|
// Create existing issue with a higher ID to avoid conflicts with auto-generated IDs
|
|
existing := &types.Issue{
|
|
ID: "bd-10",
|
|
Title: "Existing issue",
|
|
Description: "Original description",
|
|
Status: types.StatusOpen,
|
|
Priority: 1,
|
|
IssueType: types.TypeTask,
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
|
|
if err := testStore.CreateIssue(ctx, existing, "test"); err != nil {
|
|
t.Fatalf("Failed to create existing issue: %v", err)
|
|
}
|
|
|
|
// Prepare import with collision
|
|
incoming := &types.Issue{
|
|
ID: "bd-10",
|
|
Title: "MODIFIED issue",
|
|
Description: "Different description",
|
|
Status: types.StatusInProgress,
|
|
Priority: 2,
|
|
IssueType: types.TypeBug,
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
|
|
incomingIssues := []*types.Issue{incoming}
|
|
|
|
// Test collision detection
|
|
result, err := sqlite.DetectCollisions(ctx, testStore, incomingIssues)
|
|
if err != nil {
|
|
t.Fatalf("DetectCollisions failed: %v", err)
|
|
}
|
|
|
|
if len(result.Collisions) != 1 {
|
|
t.Fatalf("Expected 1 collision, got %d", len(result.Collisions))
|
|
}
|
|
|
|
if result.Collisions[0].ID != "bd-10" {
|
|
t.Errorf("Expected collision ID bd-10, got %s", result.Collisions[0].ID)
|
|
}
|
|
|
|
// Test resolution
|
|
allExisting, _ := testStore.SearchIssues(ctx, "", types.IssueFilter{})
|
|
|
|
if err := sqlite.ScoreCollisions(ctx, testStore, result.Collisions, allExisting); err != nil {
|
|
t.Fatalf("ScoreCollisions failed: %v", err)
|
|
}
|
|
|
|
idMapping, err := sqlite.RemapCollisions(ctx, testStore, result.Collisions, allExisting)
|
|
if err != nil {
|
|
t.Fatalf("RemapCollisions failed: %v", err)
|
|
}
|
|
|
|
if len(idMapping) != 1 {
|
|
t.Fatalf("Expected 1 remapping, got %d", len(idMapping))
|
|
}
|
|
|
|
newID := idMapping["bd-10"]
|
|
if newID == "" {
|
|
t.Fatal("Expected bd-10 to be remapped")
|
|
}
|
|
|
|
// Verify remapped issue exists
|
|
remapped, err := testStore.GetIssue(ctx, newID)
|
|
if err != nil {
|
|
t.Fatalf("Failed to get remapped issue: %v", err)
|
|
}
|
|
if remapped == nil {
|
|
t.Fatal("Remapped issue not found")
|
|
}
|
|
if remapped.Title != "MODIFIED issue" {
|
|
t.Errorf("Remapped issue title = %s, want 'MODIFIED issue'", remapped.Title)
|
|
}
|
|
|
|
// Verify original issue unchanged
|
|
original, err := testStore.GetIssue(ctx, "bd-10")
|
|
if err != nil {
|
|
t.Fatalf("Failed to get original issue: %v", err)
|
|
}
|
|
if original.Title != "Existing issue" {
|
|
t.Errorf("Original issue modified: %s", original.Title)
|
|
}
|
|
}
|
|
|
|
// TestImportMultipleCollisions tests handling of multiple colliding issues
|
|
func TestImportMultipleCollisions(t *testing.T) {
|
|
tmpDir, err := os.MkdirTemp("", "bd-collision-test-*")
|
|
if err != nil {
|
|
t.Fatalf("Failed to create temp dir: %v", err)
|
|
}
|
|
defer os.RemoveAll(tmpDir)
|
|
|
|
dbPath := filepath.Join(tmpDir, "test.db")
|
|
testStore, err := sqlite.New(dbPath)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create storage: %v", err)
|
|
}
|
|
defer testStore.Close()
|
|
|
|
ctx := context.Background()
|
|
|
|
// Create existing issues with high IDs to avoid conflicts with auto-generated sequence
|
|
for i := 100; i <= 102; i++ {
|
|
issue := &types.Issue{
|
|
ID: fmt.Sprintf("bd-%d", i),
|
|
Title: fmt.Sprintf("Existing issue %d", i),
|
|
Description: "Original",
|
|
Status: types.StatusOpen,
|
|
Priority: 1,
|
|
IssueType: types.TypeTask,
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
if err := testStore.CreateIssue(ctx, issue, "test"); err != nil {
|
|
t.Fatalf("Failed to create issue %d: %v", i, err)
|
|
}
|
|
}
|
|
|
|
// Prepare import with multiple collisions
|
|
incomingIssues := []*types.Issue{
|
|
{
|
|
ID: "bd-100",
|
|
Title: "Modified 1",
|
|
Description: "Changed",
|
|
Status: types.StatusOpen,
|
|
Priority: 1,
|
|
IssueType: types.TypeTask,
|
|
},
|
|
{
|
|
ID: "bd-101",
|
|
Title: "Modified 2",
|
|
Description: "Changed",
|
|
Status: types.StatusOpen,
|
|
Priority: 1,
|
|
IssueType: types.TypeTask,
|
|
},
|
|
{
|
|
ID: "bd-102",
|
|
Title: "Modified 3",
|
|
Description: "Changed",
|
|
Status: types.StatusOpen,
|
|
Priority: 1,
|
|
IssueType: types.TypeTask,
|
|
},
|
|
}
|
|
|
|
result, err := sqlite.DetectCollisions(ctx, testStore, incomingIssues)
|
|
if err != nil {
|
|
t.Fatalf("DetectCollisions failed: %v", err)
|
|
}
|
|
|
|
if len(result.Collisions) != 3 {
|
|
t.Fatalf("Expected 3 collisions, got %d", len(result.Collisions))
|
|
}
|
|
|
|
// Resolve collisions
|
|
allExisting, _ := testStore.SearchIssues(ctx, "", types.IssueFilter{})
|
|
if err := sqlite.ScoreCollisions(ctx, testStore, result.Collisions, allExisting); err != nil {
|
|
t.Fatalf("ScoreCollisions failed: %v", err)
|
|
}
|
|
|
|
idMapping, err := sqlite.RemapCollisions(ctx, testStore, result.Collisions, allExisting)
|
|
if err != nil {
|
|
t.Fatalf("RemapCollisions failed: %v", err)
|
|
}
|
|
|
|
if len(idMapping) != 3 {
|
|
t.Fatalf("Expected 3 remappings, got %d", len(idMapping))
|
|
}
|
|
|
|
// Verify all remappings
|
|
for oldID, newID := range idMapping {
|
|
remapped, err := testStore.GetIssue(ctx, newID)
|
|
if err != nil {
|
|
t.Fatalf("Failed to get remapped issue %s: %v", newID, err)
|
|
}
|
|
if remapped == nil {
|
|
t.Fatalf("Remapped issue %s not found", newID)
|
|
}
|
|
if !strings.Contains(remapped.Title, "Modified") {
|
|
t.Errorf("Remapped issue %s has wrong title: %s", oldID, remapped.Title)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestImportDependencyUpdates tests that dependencies are updated during remapping
|
|
func TestImportDependencyUpdates(t *testing.T) {
|
|
tmpDir, err := os.MkdirTemp("", "bd-collision-test-*")
|
|
if err != nil {
|
|
t.Fatalf("Failed to create temp dir: %v", err)
|
|
}
|
|
defer os.RemoveAll(tmpDir)
|
|
|
|
dbPath := filepath.Join(tmpDir, "test.db")
|
|
testStore, err := sqlite.New(dbPath)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create storage: %v", err)
|
|
}
|
|
defer testStore.Close()
|
|
|
|
ctx := context.Background()
|
|
|
|
// Create existing issues with dependencies
|
|
issue1 := &types.Issue{
|
|
ID: "bd-10",
|
|
Title: "Issue 1",
|
|
Status: types.StatusOpen,
|
|
Priority: 1,
|
|
IssueType: types.TypeTask,
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
issue2 := &types.Issue{
|
|
ID: "bd-11",
|
|
Title: "Issue 2",
|
|
Status: types.StatusOpen,
|
|
Priority: 1,
|
|
IssueType: types.TypeTask,
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
issue3 := &types.Issue{
|
|
ID: "bd-12",
|
|
Title: "Issue 3",
|
|
Status: types.StatusOpen,
|
|
Priority: 1,
|
|
IssueType: types.TypeTask,
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
|
|
if err := testStore.CreateIssue(ctx, issue1, "test"); err != nil {
|
|
t.Fatalf("Failed to create issue 1: %v", err)
|
|
}
|
|
if err := testStore.CreateIssue(ctx, issue2, "test"); err != nil {
|
|
t.Fatalf("Failed to create issue 2: %v", err)
|
|
}
|
|
if err := testStore.CreateIssue(ctx, issue3, "test"); err != nil {
|
|
t.Fatalf("Failed to create issue 3: %v", err)
|
|
}
|
|
|
|
// Add dependencies: bd-1 → bd-2, bd-3 → bd-2
|
|
dep1 := &types.Dependency{
|
|
IssueID: "bd-10",
|
|
DependsOnID: "bd-11",
|
|
Type: types.DepBlocks,
|
|
}
|
|
dep2 := &types.Dependency{
|
|
IssueID: "bd-12",
|
|
DependsOnID: "bd-11",
|
|
Type: types.DepBlocks,
|
|
}
|
|
|
|
if err := testStore.AddDependency(ctx, dep1, "test"); err != nil {
|
|
t.Fatalf("Failed to add dep1: %v", err)
|
|
}
|
|
if err := testStore.AddDependency(ctx, dep2, "test"); err != nil {
|
|
t.Fatalf("Failed to add dep2: %v", err)
|
|
}
|
|
|
|
// Import colliding bd-2
|
|
incomingIssues := []*types.Issue{
|
|
{
|
|
ID: "bd-11",
|
|
Title: "Modified Issue 2",
|
|
Description: "Changed",
|
|
Status: types.StatusOpen,
|
|
Priority: 2,
|
|
IssueType: types.TypeBug,
|
|
},
|
|
}
|
|
|
|
result, err := sqlite.DetectCollisions(ctx, testStore, incomingIssues)
|
|
if err != nil {
|
|
t.Fatalf("DetectCollisions failed: %v", err)
|
|
}
|
|
|
|
if len(result.Collisions) != 1 {
|
|
t.Fatalf("Expected 1 collision, got %d", len(result.Collisions))
|
|
}
|
|
|
|
// Resolve collision
|
|
allExisting, _ := testStore.SearchIssues(ctx, "", types.IssueFilter{})
|
|
if err := sqlite.ScoreCollisions(ctx, testStore, result.Collisions, allExisting); err != nil {
|
|
t.Fatalf("ScoreCollisions failed: %v", err)
|
|
}
|
|
|
|
idMapping, err := sqlite.RemapCollisions(ctx, testStore, result.Collisions, allExisting)
|
|
if err != nil {
|
|
t.Fatalf("RemapCollisions failed: %v", err)
|
|
}
|
|
|
|
newID := idMapping["bd-11"]
|
|
if newID == "" {
|
|
t.Fatal("bd-2 not remapped")
|
|
}
|
|
|
|
// Verify dependencies were updated
|
|
// bd-1 should now depend on newID
|
|
deps1, err := testStore.GetDependencyRecords(ctx, "bd-10")
|
|
if err != nil {
|
|
t.Fatalf("Failed to get deps for bd-1: %v", err)
|
|
}
|
|
if len(deps1) != 1 {
|
|
t.Fatalf("Expected 1 dependency for bd-1, got %d", len(deps1))
|
|
}
|
|
if deps1[0].DependsOnID != newID {
|
|
t.Errorf("bd-1 dependency not updated: %s, want %s", deps1[0].DependsOnID, newID)
|
|
}
|
|
|
|
// bd-3 should now depend on newID
|
|
deps3, err := testStore.GetDependencyRecords(ctx, "bd-12")
|
|
if err != nil {
|
|
t.Fatalf("Failed to get deps for bd-3: %v", err)
|
|
}
|
|
if len(deps3) != 1 {
|
|
t.Fatalf("Expected 1 dependency for bd-3, got %d", len(deps3))
|
|
}
|
|
if deps3[0].DependsOnID != newID {
|
|
t.Errorf("bd-3 dependency not updated: %s, want %s", deps3[0].DependsOnID, newID)
|
|
}
|
|
}
|
|
|
|
// TestImportTextReferenceUpdates tests that text references are updated during remapping
|
|
func TestImportTextReferenceUpdates(t *testing.T) {
|
|
tmpDir, err := os.MkdirTemp("", "bd-collision-test-*")
|
|
if err != nil {
|
|
t.Fatalf("Failed to create temp dir: %v", err)
|
|
}
|
|
defer os.RemoveAll(tmpDir)
|
|
|
|
dbPath := filepath.Join(tmpDir, "test.db")
|
|
testStore, err := sqlite.New(dbPath)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create storage: %v", err)
|
|
}
|
|
defer testStore.Close()
|
|
|
|
ctx := context.Background()
|
|
|
|
// Create existing issues with text references
|
|
issue1 := &types.Issue{
|
|
ID: "bd-10",
|
|
Title: "Issue 1",
|
|
Description: "This depends on bd-11 and bd-12",
|
|
Design: "Implementation uses bd-11 approach",
|
|
Notes: "See bd-12 for details",
|
|
AcceptanceCriteria: "Must work with bd-11",
|
|
Status: types.StatusOpen,
|
|
Priority: 1,
|
|
IssueType: types.TypeTask,
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
issue2 := &types.Issue{
|
|
ID: "bd-11",
|
|
Title: "Issue 2",
|
|
Status: types.StatusOpen,
|
|
Priority: 1,
|
|
IssueType: types.TypeTask,
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
issue3 := &types.Issue{
|
|
ID: "bd-12",
|
|
Title: "Issue 3",
|
|
Status: types.StatusOpen,
|
|
Priority: 1,
|
|
IssueType: types.TypeTask,
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
|
|
if err := testStore.CreateIssue(ctx, issue1, "test"); err != nil {
|
|
t.Fatalf("Failed to create issue 1: %v", err)
|
|
}
|
|
if err := testStore.CreateIssue(ctx, issue2, "test"); err != nil {
|
|
t.Fatalf("Failed to create issue 2: %v", err)
|
|
}
|
|
if err := testStore.CreateIssue(ctx, issue3, "test"); err != nil {
|
|
t.Fatalf("Failed to create issue 3: %v", err)
|
|
}
|
|
|
|
// Import colliding issues
|
|
incomingIssues := []*types.Issue{
|
|
{
|
|
ID: "bd-11",
|
|
Title: "Modified Issue 2",
|
|
Status: types.StatusInProgress,
|
|
Priority: 2,
|
|
IssueType: types.TypeBug,
|
|
},
|
|
{
|
|
ID: "bd-12",
|
|
Title: "Modified Issue 3",
|
|
Status: types.StatusInProgress,
|
|
Priority: 2,
|
|
IssueType: types.TypeBug,
|
|
},
|
|
}
|
|
|
|
result, err := sqlite.DetectCollisions(ctx, testStore, incomingIssues)
|
|
if err != nil {
|
|
t.Fatalf("DetectCollisions failed: %v", err)
|
|
}
|
|
|
|
if len(result.Collisions) != 2 {
|
|
t.Fatalf("Expected 2 collisions, got %d", len(result.Collisions))
|
|
}
|
|
|
|
// Resolve collisions
|
|
allExisting, _ := testStore.SearchIssues(ctx, "", types.IssueFilter{})
|
|
if err := sqlite.ScoreCollisions(ctx, testStore, result.Collisions, allExisting); err != nil {
|
|
t.Fatalf("ScoreCollisions failed: %v", err)
|
|
}
|
|
|
|
idMapping, err := sqlite.RemapCollisions(ctx, testStore, result.Collisions, allExisting)
|
|
if err != nil {
|
|
t.Fatalf("RemapCollisions failed: %v", err)
|
|
}
|
|
|
|
if len(idMapping) != 2 {
|
|
t.Fatalf("Expected 2 remappings, got %d", len(idMapping))
|
|
}
|
|
|
|
newID2 := idMapping["bd-11"]
|
|
newID3 := idMapping["bd-12"]
|
|
|
|
// Verify text references were updated in issue 1
|
|
updated, err := testStore.GetIssue(ctx, "bd-10")
|
|
if err != nil {
|
|
t.Fatalf("Failed to get updated issue 1: %v", err)
|
|
}
|
|
|
|
if !strings.Contains(updated.Description, newID2) {
|
|
t.Errorf("Description not updated: %s (should contain %s)", updated.Description, newID2)
|
|
}
|
|
if !strings.Contains(updated.Description, newID3) {
|
|
t.Errorf("Description not updated: %s (should contain %s)", updated.Description, newID3)
|
|
}
|
|
if !strings.Contains(updated.Design, newID2) {
|
|
t.Errorf("Design not updated: %s (should contain %s)", updated.Design, newID2)
|
|
}
|
|
if !strings.Contains(updated.Notes, newID3) {
|
|
t.Errorf("Notes not updated: %s (should contain %s)", updated.Notes, newID3)
|
|
}
|
|
if !strings.Contains(updated.AcceptanceCriteria, newID2) {
|
|
t.Errorf("AcceptanceCriteria not updated: %s (should contain %s)", updated.AcceptanceCriteria, newID2)
|
|
}
|
|
|
|
// Verify old IDs are NOT present
|
|
if strings.Contains(updated.Description, "bd-11") {
|
|
t.Error("Old ID bd-11 still present in Description")
|
|
}
|
|
if strings.Contains(updated.Description, "bd-12") {
|
|
t.Error("Old ID bd-12 still present in Description")
|
|
}
|
|
}
|
|
|
|
// TestImportChainDependencies tests remapping with chained dependencies
|
|
func TestImportChainDependencies(t *testing.T) {
|
|
tmpDir, err := os.MkdirTemp("", "bd-collision-test-*")
|
|
if err != nil {
|
|
t.Fatalf("Failed to create temp dir: %v", err)
|
|
}
|
|
defer os.RemoveAll(tmpDir)
|
|
|
|
dbPath := filepath.Join(tmpDir, "test.db")
|
|
testStore, err := sqlite.New(dbPath)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create storage: %v", err)
|
|
}
|
|
defer testStore.Close()
|
|
|
|
ctx := context.Background()
|
|
|
|
// Create chain: bd-100 → bd-101 → bd-102 → bd-103
|
|
for i := 100; i <= 103; i++ {
|
|
issue := &types.Issue{
|
|
ID: fmt.Sprintf("bd-%d", i),
|
|
Title: fmt.Sprintf("Issue %d", i),
|
|
Status: types.StatusOpen,
|
|
Priority: 1,
|
|
IssueType: types.TypeTask,
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
if err := testStore.CreateIssue(ctx, issue, "test"); err != nil {
|
|
t.Fatalf("Failed to create issue %d: %v", i, err)
|
|
}
|
|
}
|
|
|
|
// Add chain dependencies
|
|
for i := 100; i <= 102; i++ {
|
|
dep := &types.Dependency{
|
|
IssueID: fmt.Sprintf("bd-%d", i),
|
|
DependsOnID: fmt.Sprintf("bd-%d", i+1),
|
|
Type: types.DepBlocks,
|
|
}
|
|
if err := testStore.AddDependency(ctx, dep, "test"); err != nil {
|
|
t.Fatalf("Failed to add dependency %d: %v", i, err)
|
|
}
|
|
}
|
|
|
|
// Import colliding bd-101
|
|
incomingIssues := []*types.Issue{
|
|
{
|
|
ID: "bd-101",
|
|
Title: "Modified Issue 101",
|
|
Status: types.StatusInProgress,
|
|
Priority: 2,
|
|
IssueType: types.TypeBug,
|
|
},
|
|
}
|
|
|
|
result, err := sqlite.DetectCollisions(ctx, testStore, incomingIssues)
|
|
if err != nil {
|
|
t.Fatalf("DetectCollisions failed: %v", err)
|
|
}
|
|
|
|
// Resolve collision
|
|
allExisting, _ := testStore.SearchIssues(ctx, "", types.IssueFilter{})
|
|
if err := sqlite.ScoreCollisions(ctx, testStore, result.Collisions, allExisting); err != nil {
|
|
t.Fatalf("ScoreCollisions failed: %v", err)
|
|
}
|
|
|
|
idMapping, err := sqlite.RemapCollisions(ctx, testStore, result.Collisions, allExisting)
|
|
if err != nil {
|
|
t.Fatalf("RemapCollisions failed: %v", err)
|
|
}
|
|
|
|
newID := idMapping["bd-101"]
|
|
|
|
// Verify chain is maintained
|
|
// bd-100 → newID (was bd-101)
|
|
deps1, _ := testStore.GetDependencyRecords(ctx, "bd-100")
|
|
if len(deps1) != 1 || deps1[0].DependsOnID != newID {
|
|
t.Errorf("bd-100 dependency broken: %v", deps1)
|
|
}
|
|
|
|
// newID → bd-102
|
|
depsNew, _ := testStore.GetDependencyRecords(ctx, newID)
|
|
if len(depsNew) != 1 || depsNew[0].DependsOnID != "bd-102" {
|
|
t.Errorf("newID dependency broken: %v", depsNew)
|
|
}
|
|
|
|
// bd-102 → bd-103 (unchanged)
|
|
deps3, _ := testStore.GetDependencyRecords(ctx, "bd-102")
|
|
if len(deps3) != 1 || deps3[0].DependsOnID != "bd-103" {
|
|
t.Errorf("bd-102 dependency broken: %v", deps3)
|
|
}
|
|
}
|
|
|
|
// TestImportPartialIDMatch tests word boundary matching (bd-10 vs bd-100)
|
|
func TestImportPartialIDMatch(t *testing.T) {
|
|
tmpDir, err := os.MkdirTemp("", "bd-collision-test-*")
|
|
if err != nil {
|
|
t.Fatalf("Failed to create temp dir: %v", err)
|
|
}
|
|
defer os.RemoveAll(tmpDir)
|
|
|
|
dbPath := filepath.Join(tmpDir, "test.db")
|
|
testStore, err := sqlite.New(dbPath)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create storage: %v", err)
|
|
}
|
|
defer testStore.Close()
|
|
|
|
ctx := context.Background()
|
|
|
|
// Create issues with similar IDs (use higher numbers to avoid conflicts)
|
|
issues := []*types.Issue{
|
|
{
|
|
ID: "bd-50",
|
|
Title: "Issue 50",
|
|
Description: "References bd-100 and bd-1000 and bd-10000",
|
|
Status: types.StatusOpen,
|
|
Priority: 1,
|
|
IssueType: types.TypeTask,
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
},
|
|
{
|
|
ID: "bd-100",
|
|
Title: "Issue 100",
|
|
Status: types.StatusOpen,
|
|
Priority: 1,
|
|
IssueType: types.TypeTask,
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
},
|
|
{
|
|
ID: "bd-1000",
|
|
Title: "Issue 1000",
|
|
Status: types.StatusOpen,
|
|
Priority: 1,
|
|
IssueType: types.TypeTask,
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
},
|
|
{
|
|
ID: "bd-10000",
|
|
Title: "Issue 10000",
|
|
Status: types.StatusOpen,
|
|
Priority: 1,
|
|
IssueType: types.TypeTask,
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
},
|
|
}
|
|
|
|
for _, issue := range issues {
|
|
if err := testStore.CreateIssue(ctx, issue, "test"); err != nil {
|
|
t.Fatalf("Failed to create %s: %v", issue.ID, err)
|
|
}
|
|
}
|
|
|
|
// Import colliding bd-100
|
|
incomingIssues := []*types.Issue{
|
|
{
|
|
ID: "bd-100",
|
|
Title: "Modified Issue 100",
|
|
Status: types.StatusInProgress,
|
|
Priority: 2,
|
|
IssueType: types.TypeBug,
|
|
},
|
|
}
|
|
|
|
result, err := sqlite.DetectCollisions(ctx, testStore, incomingIssues)
|
|
if err != nil {
|
|
t.Fatalf("DetectCollisions failed: %v", err)
|
|
}
|
|
|
|
// Resolve collision
|
|
allExisting, _ := testStore.SearchIssues(ctx, "", types.IssueFilter{})
|
|
if err := sqlite.ScoreCollisions(ctx, testStore, result.Collisions, allExisting); err != nil {
|
|
t.Fatalf("ScoreCollisions failed: %v", err)
|
|
}
|
|
|
|
idMapping, err := sqlite.RemapCollisions(ctx, testStore, result.Collisions, allExisting)
|
|
if err != nil {
|
|
t.Fatalf("RemapCollisions failed: %v", err)
|
|
}
|
|
|
|
newID100 := idMapping["bd-100"]
|
|
|
|
// Verify only bd-100 was replaced, not bd-1000 or bd-10000
|
|
updated, err := testStore.GetIssue(ctx, "bd-50")
|
|
if err != nil {
|
|
t.Fatalf("Failed to get updated issue: %v", err)
|
|
}
|
|
|
|
if !strings.Contains(updated.Description, newID100) {
|
|
t.Errorf("bd-100 not replaced: %s", updated.Description)
|
|
}
|
|
if !strings.Contains(updated.Description, "bd-1000") {
|
|
t.Errorf("bd-1000 incorrectly replaced: %s", updated.Description)
|
|
}
|
|
if !strings.Contains(updated.Description, "bd-10000") {
|
|
t.Errorf("bd-10000 incorrectly replaced: %s", updated.Description)
|
|
}
|
|
|
|
// Make sure old bd-100 reference is gone
|
|
if strings.Contains(updated.Description, " bd-100 ") || strings.Contains(updated.Description, " bd-100,") {
|
|
t.Errorf("Old bd-100 reference still present: %s", updated.Description)
|
|
}
|
|
}
|
|
|
|
// TestImportExactMatch tests idempotent import (no collision)
|
|
func TestImportExactMatch(t *testing.T) {
|
|
tmpDir, err := os.MkdirTemp("", "bd-collision-test-*")
|
|
if err != nil {
|
|
t.Fatalf("Failed to create temp dir: %v", err)
|
|
}
|
|
defer os.RemoveAll(tmpDir)
|
|
|
|
dbPath := filepath.Join(tmpDir, "test.db")
|
|
testStore, err := sqlite.New(dbPath)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create storage: %v", err)
|
|
}
|
|
defer testStore.Close()
|
|
|
|
ctx := context.Background()
|
|
|
|
// Create existing issue
|
|
existing := &types.Issue{
|
|
ID: "bd-10",
|
|
Title: "Test issue",
|
|
Description: "Description",
|
|
Status: types.StatusOpen,
|
|
Priority: 1,
|
|
IssueType: types.TypeTask,
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
|
|
if err := testStore.CreateIssue(ctx, existing, "test"); err != nil {
|
|
t.Fatalf("Failed to create issue: %v", err)
|
|
}
|
|
|
|
// Import identical issue
|
|
incoming := &types.Issue{
|
|
ID: "bd-10",
|
|
Title: "Test issue",
|
|
Description: "Description",
|
|
Status: types.StatusOpen,
|
|
Priority: 1,
|
|
IssueType: types.TypeTask,
|
|
}
|
|
|
|
result, err := sqlite.DetectCollisions(ctx, testStore, []*types.Issue{incoming})
|
|
if err != nil {
|
|
t.Fatalf("DetectCollisions failed: %v", err)
|
|
}
|
|
|
|
// Should be exact match, not collision
|
|
if len(result.Collisions) != 0 {
|
|
t.Errorf("Expected 0 collisions for exact match, got %d", len(result.Collisions))
|
|
}
|
|
if len(result.ExactMatches) != 1 {
|
|
t.Errorf("Expected 1 exact match, got %d", len(result.ExactMatches))
|
|
}
|
|
}
|
|
|
|
// TestImportMixedScenario tests import with exact matches, collisions, and new issues
|
|
func TestImportMixedScenario(t *testing.T) {
|
|
tmpDir, err := os.MkdirTemp("", "bd-collision-test-*")
|
|
if err != nil {
|
|
t.Fatalf("Failed to create temp dir: %v", err)
|
|
}
|
|
defer os.RemoveAll(tmpDir)
|
|
|
|
dbPath := filepath.Join(tmpDir, "test.db")
|
|
testStore, err := sqlite.New(dbPath)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create storage: %v", err)
|
|
}
|
|
defer testStore.Close()
|
|
|
|
ctx := context.Background()
|
|
|
|
// Create existing issues with high IDs
|
|
for i := 200; i <= 201; i++ {
|
|
issue := &types.Issue{
|
|
ID: fmt.Sprintf("bd-%d", i),
|
|
Title: fmt.Sprintf("Issue %d", i),
|
|
Description: "Original",
|
|
Status: types.StatusOpen,
|
|
Priority: 1,
|
|
IssueType: types.TypeTask,
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
if err := testStore.CreateIssue(ctx, issue, "test"); err != nil {
|
|
t.Fatalf("Failed to create issue %d: %v", i, err)
|
|
}
|
|
}
|
|
|
|
// Import: exact match (bd-200), collision (bd-201), new (bd-202)
|
|
incomingIssues := []*types.Issue{
|
|
{
|
|
ID: "bd-200",
|
|
Title: "Issue 200",
|
|
Description: "Original",
|
|
Status: types.StatusOpen,
|
|
Priority: 1,
|
|
IssueType: types.TypeTask,
|
|
},
|
|
{
|
|
ID: "bd-201",
|
|
Title: "Modified Issue 201",
|
|
Description: "Changed",
|
|
Status: types.StatusInProgress,
|
|
Priority: 2,
|
|
IssueType: types.TypeBug,
|
|
},
|
|
{
|
|
ID: "bd-202",
|
|
Title: "New Issue",
|
|
Description: "Brand new",
|
|
Status: types.StatusOpen,
|
|
Priority: 1,
|
|
IssueType: types.TypeFeature,
|
|
},
|
|
}
|
|
|
|
result, err := sqlite.DetectCollisions(ctx, testStore, incomingIssues)
|
|
if err != nil {
|
|
t.Fatalf("DetectCollisions failed: %v", err)
|
|
}
|
|
|
|
if len(result.ExactMatches) != 1 {
|
|
t.Errorf("Expected 1 exact match, got %d", len(result.ExactMatches))
|
|
}
|
|
if len(result.Collisions) != 1 {
|
|
t.Errorf("Expected 1 collision, got %d", len(result.Collisions))
|
|
}
|
|
if len(result.NewIssues) != 1 {
|
|
t.Errorf("Expected 1 new issue, got %d", len(result.NewIssues))
|
|
}
|
|
}
|
|
|
|
// TestImportWithDependenciesInJSONL tests importing issues with embedded dependencies
|
|
func TestImportWithDependenciesInJSONL(t *testing.T) {
|
|
tmpDir, err := os.MkdirTemp("", "bd-collision-test-*")
|
|
if err != nil {
|
|
t.Fatalf("Failed to create temp dir: %v", err)
|
|
}
|
|
defer os.RemoveAll(tmpDir)
|
|
|
|
dbPath := filepath.Join(tmpDir, "test.db")
|
|
testStore, err := sqlite.New(dbPath)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create storage: %v", err)
|
|
}
|
|
defer testStore.Close()
|
|
|
|
ctx := context.Background()
|
|
|
|
// Create JSONL with dependencies
|
|
jsonl := `{"id":"bd-10","title":"Issue 1","status":"open","priority":1,"issue_type":"task"}
|
|
{"id":"bd-11","title":"Issue 2","status":"open","priority":1,"issue_type":"task","dependencies":[{"issue_id":"bd-11","depends_on_id":"bd-10","type":"blocks"}]}`
|
|
|
|
// Parse JSONL
|
|
var issues []*types.Issue
|
|
for _, line := range strings.Split(strings.TrimSpace(jsonl), "\n") {
|
|
var issue types.Issue
|
|
if err := json.Unmarshal([]byte(line), &issue); err != nil {
|
|
t.Fatalf("Failed to parse JSONL: %v", err)
|
|
}
|
|
issues = append(issues, &issue)
|
|
}
|
|
|
|
// Create issues
|
|
for _, issue := range issues {
|
|
if err := testStore.CreateIssue(ctx, issue, "test"); err != nil {
|
|
t.Fatalf("Failed to create issue: %v", err)
|
|
}
|
|
}
|
|
|
|
// Add dependencies from JSONL
|
|
for _, issue := range issues {
|
|
for _, dep := range issue.Dependencies {
|
|
if err := testStore.AddDependency(ctx, dep, "test"); err != nil {
|
|
t.Fatalf("Failed to add dependency: %v", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Verify dependency
|
|
deps, err := testStore.GetDependencyRecords(ctx, "bd-11")
|
|
if err != nil {
|
|
t.Fatalf("Failed to get dependencies: %v", err)
|
|
}
|
|
if len(deps) != 1 {
|
|
t.Fatalf("Expected 1 dependency, got %d", len(deps))
|
|
}
|
|
if deps[0].DependsOnID != "bd-10" {
|
|
t.Errorf("Dependency target = %s, want bd-1", deps[0].DependsOnID)
|
|
}
|
|
}
|