Files
beads/cmd/bd/import.go
Steve Yegge 183ded4096 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>
2025-10-12 17:13:09 -07:00

366 lines
11 KiB
Go

package main
import (
"bufio"
"context"
"encoding/json"
"fmt"
"os"
"sort"
"github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/storage/sqlite"
"github.com/steveyegge/beads/internal/types"
)
var importCmd = &cobra.Command{
Use: "import",
Short: "Import issues from JSONL format",
Long: `Import issues from JSON Lines format (one JSON object per line).
Reads from stdin by default, or use -i flag for file input.
Behavior:
- Existing issues (same ID) are updated
- New issues are created
- 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
if input != "" {
f, err := os.Open(input)
if err != nil {
fmt.Fprintf(os.Stderr, "Error opening input file: %v\n", err)
os.Exit(1)
}
defer func() {
if err := f.Close(); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to close input file: %v\n", err)
}
}()
in = f
}
// Phase 1: Read and parse all JSONL
ctx := context.Background()
scanner := bufio.NewScanner(in)
var allIssues []*types.Issue
lineNum := 0
for scanner.Scan() {
lineNum++
line := scanner.Text()
// Skip empty lines
if line == "" {
continue
}
// 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)
}
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 {
fmt.Fprintf(os.Stderr, "Error checking issue %s: %v\n", issue.ID, err)
os.Exit(1)
}
if existing != nil {
if skipUpdate {
skipped++
continue
}
// 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
}
if _, ok := rawData["description"]; ok {
updates["description"] = issue.Description
}
if _, ok := rawData["design"]; ok {
updates["design"] = issue.Design
}
if _, ok := rawData["acceptance_criteria"]; ok {
updates["acceptance_criteria"] = issue.AcceptanceCriteria
}
if _, ok := rawData["notes"]; ok {
updates["notes"] = issue.Notes
}
if _, ok := rawData["status"]; ok {
updates["status"] = issue.Status
}
if _, ok := rawData["priority"]; ok {
updates["priority"] = issue.Priority
}
if _, ok := rawData["issue_type"]; ok {
updates["issue_type"] = issue.IssueType
}
if _, ok := rawData["assignee"]; ok {
updates["assignee"] = issue.Assignee
}
if _, ok := rawData["estimated_minutes"]; ok {
if issue.EstimatedMinutes != nil {
updates["estimated_minutes"] = *issue.EstimatedMinutes
} else {
updates["estimated_minutes"] = nil
}
}
if err := store.UpdateIssue(ctx, issue.ID, updates, "import"); err != nil {
fmt.Fprintf(os.Stderr, "Error updating issue %s: %v\n", issue.ID, err)
os.Exit(1)
}
updated++
} else {
// Create new issue
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)
}
created++
}
}
// Phase 5: Process dependencies
// Do this after all issues are created to handle forward references
var depsCreated, depsSkipped int
for _, issue := range allIssues {
if len(issue.Dependencies) == 0 {
continue
}
for _, dep := range issue.Dependencies {
// Check if dependency already exists
existingDeps, err := store.GetDependencyRecords(ctx, dep.IssueID)
if err != nil {
fmt.Fprintf(os.Stderr, "Error checking dependencies for %s: %v\n", dep.IssueID, err)
os.Exit(1)
}
// Skip if this exact dependency already exists
exists := false
for _, existing := range existingDeps {
if existing.DependsOnID == dep.DependsOnID && existing.Type == dep.Type {
exists = true
break
}
}
if exists {
depsSkipped++
continue
}
// Add dependency
if err := store.AddDependency(ctx, dep, "import"); err != nil {
if strict {
// In strict mode, fail on any dependency error
fmt.Fprintf(os.Stderr, "Error: could not add dependency %s → %s: %v\n",
dep.IssueID, dep.DependsOnID, err)
fmt.Fprintf(os.Stderr, "Use --strict=false to treat dependency errors as warnings\n")
os.Exit(1)
}
// In non-strict mode, ignore errors for missing target issues or cycles
// This can happen if dependencies reference issues not in the import
fmt.Fprintf(os.Stderr, "Warning: could not add dependency %s → %s: %v\n",
dep.IssueID, dep.DependsOnID, err)
continue
}
depsCreated++
}
}
// Print summary
fmt.Fprintf(os.Stderr, "Import complete: %d created, %d updated", created, updated)
if skipped > 0 {
fmt.Fprintf(os.Stderr, ", %d skipped", skipped)
}
if depsCreated > 0 || depsSkipped > 0 {
fmt.Fprintf(os.Stderr, ", %d dependencies added", depsCreated)
if depsSkipped > 0 {
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)
}