Add collision resolution with automatic ID remapping
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>
This commit is contained in:
189
cmd/bd/import.go
189
cmd/bd/import.go
@@ -6,8 +6,10 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steveyegge/beads/internal/storage/sqlite"
|
||||
"github.com/steveyegge/beads/internal/types"
|
||||
)
|
||||
|
||||
@@ -21,11 +23,15 @@ Reads from stdin by default, or use -i flag for file input.
|
||||
Behavior:
|
||||
- Existing issues (same ID) are updated
|
||||
- New issues are created
|
||||
- Import is atomic (all or nothing)`,
|
||||
- Collisions (same ID, different content) are detected
|
||||
- Use --resolve-collisions to automatically remap colliding issues
|
||||
- Use --dry-run to preview changes without applying them`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
input, _ := cmd.Flags().GetString("input")
|
||||
skipUpdate, _ := cmd.Flags().GetBool("skip-existing")
|
||||
strict, _ := cmd.Flags().GetBool("strict")
|
||||
resolveCollisions, _ := cmd.Flags().GetBool("resolve-collisions")
|
||||
dryRun, _ := cmd.Flags().GetBool("dry-run")
|
||||
|
||||
// Open input
|
||||
in := os.Stdin
|
||||
@@ -43,12 +49,11 @@ Behavior:
|
||||
in = f
|
||||
}
|
||||
|
||||
// Read and parse JSONL
|
||||
// Phase 1: Read and parse all JSONL
|
||||
ctx := context.Background()
|
||||
scanner := bufio.NewScanner(in)
|
||||
|
||||
var created, updated, skipped int
|
||||
var allIssues []*types.Issue // Store all issues for dependency processing
|
||||
var allIssues []*types.Issue
|
||||
lineNum := 0
|
||||
|
||||
for scanner.Scan() {
|
||||
@@ -60,23 +65,106 @@ Behavior:
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse JSON - first into a map to detect which fields are present
|
||||
var rawData map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(line), &rawData); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error parsing line %d: %v\n", lineNum, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Then parse into the Issue struct
|
||||
// Parse JSON
|
||||
var issue types.Issue
|
||||
if err := json.Unmarshal([]byte(line), &issue); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error parsing line %d: %v\n", lineNum, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Store for dependency processing later
|
||||
allIssues = append(allIssues, &issue)
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error reading input: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Phase 2: Detect collisions
|
||||
sqliteStore, ok := store.(*sqlite.SQLiteStorage)
|
||||
if !ok {
|
||||
fmt.Fprintf(os.Stderr, "Error: collision detection requires SQLite storage backend\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
collisionResult, err := sqlite.DetectCollisions(ctx, sqliteStore, allIssues)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error detecting collisions: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
var idMapping map[string]string
|
||||
var created, updated, skipped int
|
||||
|
||||
// Phase 3: Handle collisions
|
||||
if len(collisionResult.Collisions) > 0 {
|
||||
// Print collision report
|
||||
printCollisionReport(collisionResult)
|
||||
|
||||
if dryRun {
|
||||
// In dry-run mode, just print report and exit
|
||||
fmt.Fprintf(os.Stderr, "\nDry-run mode: no changes made\n")
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
if !resolveCollisions {
|
||||
// Default behavior: fail on collision (safe mode)
|
||||
fmt.Fprintf(os.Stderr, "\nCollision detected! Use --resolve-collisions to automatically remap colliding issues.\n")
|
||||
fmt.Fprintf(os.Stderr, "Or use --dry-run to preview without making changes.\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Resolve collisions by scoring and remapping
|
||||
fmt.Fprintf(os.Stderr, "\nResolving collisions...\n")
|
||||
|
||||
// Get all existing issues for scoring
|
||||
allExistingIssues, err := store.SearchIssues(ctx, "", types.IssueFilter{})
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error getting existing issues: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Score collisions
|
||||
if err := sqlite.ScoreCollisions(ctx, sqliteStore, collisionResult.Collisions, allExistingIssues); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error scoring collisions: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Remap collisions
|
||||
idMapping, err = sqlite.RemapCollisions(ctx, sqliteStore, collisionResult.Collisions, allExistingIssues)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error remapping collisions: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Print remapping report
|
||||
printRemappingReport(idMapping, collisionResult.Collisions)
|
||||
|
||||
// Colliding issues were already created with new IDs
|
||||
created = len(collisionResult.Collisions)
|
||||
|
||||
// Remove colliding issues from allIssues (they're already processed)
|
||||
filteredIssues := make([]*types.Issue, 0)
|
||||
collidingIDs := make(map[string]bool)
|
||||
for _, collision := range collisionResult.Collisions {
|
||||
collidingIDs[collision.ID] = true
|
||||
}
|
||||
for _, issue := range allIssues {
|
||||
if !collidingIDs[issue.ID] {
|
||||
filteredIssues = append(filteredIssues, issue)
|
||||
}
|
||||
}
|
||||
allIssues = filteredIssues
|
||||
} else if dryRun {
|
||||
// No collisions in dry-run mode
|
||||
fmt.Fprintf(os.Stderr, "No collisions detected.\n")
|
||||
fmt.Fprintf(os.Stderr, "Would create %d new issues, update %d existing issues\n",
|
||||
len(collisionResult.NewIssues), len(collisionResult.ExactMatches))
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
// Phase 4: Process remaining issues (exact matches and new issues)
|
||||
for _, issue := range allIssues {
|
||||
// Check if issue exists
|
||||
existing, err := store.GetIssue(ctx, issue.ID)
|
||||
if err != nil {
|
||||
@@ -89,7 +177,13 @@ Behavior:
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
// Update existing issue - only update fields that are present in JSON
|
||||
|
||||
// Update existing issue
|
||||
// Parse raw JSON to detect which fields are present
|
||||
var rawData map[string]interface{}
|
||||
jsonBytes, _ := json.Marshal(issue)
|
||||
json.Unmarshal(jsonBytes, &rawData)
|
||||
|
||||
updates := make(map[string]interface{})
|
||||
if _, ok := rawData["title"]; ok {
|
||||
updates["title"] = issue.Title
|
||||
@@ -133,7 +227,7 @@ Behavior:
|
||||
updated++
|
||||
} else {
|
||||
// Create new issue
|
||||
if err := store.CreateIssue(ctx, &issue, "import"); err != nil {
|
||||
if err := store.CreateIssue(ctx, issue, "import"); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error creating issue %s: %v\n", issue.ID, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -141,12 +235,7 @@ Behavior:
|
||||
}
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error reading input: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Second pass: Process dependencies
|
||||
// Phase 5: Process dependencies
|
||||
// Do this after all issues are created to handle forward references
|
||||
var depsCreated, depsSkipped int
|
||||
for _, issue := range allIssues {
|
||||
@@ -206,13 +295,71 @@ Behavior:
|
||||
fmt.Fprintf(os.Stderr, " (%d already existed)", depsSkipped)
|
||||
}
|
||||
}
|
||||
if len(idMapping) > 0 {
|
||||
fmt.Fprintf(os.Stderr, ", %d issues remapped", len(idMapping))
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "\n")
|
||||
},
|
||||
}
|
||||
|
||||
// printCollisionReport prints a detailed report of detected collisions
|
||||
func printCollisionReport(result *sqlite.CollisionResult) {
|
||||
fmt.Fprintf(os.Stderr, "\n=== Collision Detection Report ===\n")
|
||||
fmt.Fprintf(os.Stderr, "Exact matches (idempotent): %d\n", len(result.ExactMatches))
|
||||
fmt.Fprintf(os.Stderr, "New issues: %d\n", len(result.NewIssues))
|
||||
fmt.Fprintf(os.Stderr, "COLLISIONS DETECTED: %d\n\n", len(result.Collisions))
|
||||
|
||||
if len(result.Collisions) > 0 {
|
||||
fmt.Fprintf(os.Stderr, "Colliding issues:\n")
|
||||
for _, collision := range result.Collisions {
|
||||
fmt.Fprintf(os.Stderr, " %s: %s\n", collision.ID, collision.IncomingIssue.Title)
|
||||
fmt.Fprintf(os.Stderr, " Conflicting fields: %v\n", collision.ConflictingFields)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// printRemappingReport prints a report of ID remappings with reference scores
|
||||
func printRemappingReport(idMapping map[string]string, collisions []*sqlite.CollisionDetail) {
|
||||
fmt.Fprintf(os.Stderr, "\n=== Remapping Report ===\n")
|
||||
fmt.Fprintf(os.Stderr, "Issues remapped: %d\n\n", len(idMapping))
|
||||
|
||||
// Sort by old ID for consistent output
|
||||
type mapping struct {
|
||||
oldID string
|
||||
newID string
|
||||
score int
|
||||
}
|
||||
mappings := make([]mapping, 0, len(idMapping))
|
||||
|
||||
scoreMap := make(map[string]int)
|
||||
for _, collision := range collisions {
|
||||
scoreMap[collision.ID] = collision.ReferenceScore
|
||||
}
|
||||
|
||||
for oldID, newID := range idMapping {
|
||||
mappings = append(mappings, mapping{
|
||||
oldID: oldID,
|
||||
newID: newID,
|
||||
score: scoreMap[oldID],
|
||||
})
|
||||
}
|
||||
|
||||
sort.Slice(mappings, func(i, j int) bool {
|
||||
return mappings[i].score < mappings[j].score
|
||||
})
|
||||
|
||||
fmt.Fprintf(os.Stderr, "Remappings (sorted by reference count):\n")
|
||||
for _, m := range mappings {
|
||||
fmt.Fprintf(os.Stderr, " %s → %s (refs: %d)\n", m.oldID, m.newID, m.score)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "\nAll text and dependency references have been updated.\n")
|
||||
}
|
||||
|
||||
func init() {
|
||||
importCmd.Flags().StringP("input", "i", "", "Input file (default: stdin)")
|
||||
importCmd.Flags().BoolP("skip-existing", "s", false, "Skip existing issues instead of updating them")
|
||||
importCmd.Flags().Bool("strict", false, "Fail on dependency errors instead of treating them as warnings")
|
||||
importCmd.Flags().Bool("resolve-collisions", false, "Automatically resolve ID collisions by remapping")
|
||||
importCmd.Flags().Bool("dry-run", false, "Preview collision detection without making changes")
|
||||
rootCmd.AddCommand(importCmd)
|
||||
}
|
||||
|
||||
898
cmd/bd/import_collision_test.go
Normal file
898
cmd/bd/import_collision_test.go
Normal file
@@ -0,0 +1,898 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -229,6 +229,9 @@ var showCmd = &cobra.Command{
|
||||
if issue.Design != "" {
|
||||
fmt.Printf("\nDesign:\n%s\n", issue.Design)
|
||||
}
|
||||
if issue.Notes != "" {
|
||||
fmt.Printf("\nNotes:\n%s\n", issue.Notes)
|
||||
}
|
||||
if issue.AcceptanceCriteria != "" {
|
||||
fmt.Printf("\nAcceptance Criteria:\n%s\n", issue.AcceptanceCriteria)
|
||||
}
|
||||
@@ -350,6 +353,18 @@ var updateCmd = &cobra.Command{
|
||||
assignee, _ := cmd.Flags().GetString("assignee")
|
||||
updates["assignee"] = assignee
|
||||
}
|
||||
if cmd.Flags().Changed("design") {
|
||||
design, _ := cmd.Flags().GetString("design")
|
||||
updates["design"] = design
|
||||
}
|
||||
if cmd.Flags().Changed("notes") {
|
||||
notes, _ := cmd.Flags().GetString("notes")
|
||||
updates["notes"] = notes
|
||||
}
|
||||
if cmd.Flags().Changed("acceptance-criteria") {
|
||||
acceptanceCriteria, _ := cmd.Flags().GetString("acceptance-criteria")
|
||||
updates["acceptance_criteria"] = acceptanceCriteria
|
||||
}
|
||||
|
||||
if len(updates) == 0 {
|
||||
fmt.Println("No updates specified")
|
||||
@@ -378,6 +393,9 @@ func init() {
|
||||
updateCmd.Flags().IntP("priority", "p", 0, "New priority")
|
||||
updateCmd.Flags().String("title", "", "New title")
|
||||
updateCmd.Flags().StringP("assignee", "a", "", "New assignee")
|
||||
updateCmd.Flags().String("design", "", "Design notes")
|
||||
updateCmd.Flags().String("notes", "", "Additional notes")
|
||||
updateCmd.Flags().String("acceptance-criteria", "", "Acceptance criteria")
|
||||
rootCmd.AddCommand(updateCmd)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user