package main import ( "context" "database/sql" "fmt" "os" "strings" "time" "github.com/spf13/cobra" "github.com/steveyegge/beads/internal/storage/sqlite" ) var migrateIssuesCmd = &cobra.Command{ Use: "migrate-issues", Short: "Move issues between repositories", Long: `Move issues from one source repository to another with filtering and dependency preservation. This command updates the source_repo field for selected issues, allowing you to: - Move contributor planning issues to upstream repository - Reorganize issues across multi-phase repositories - Consolidate issues from multiple repos Examples: # Preview migration from planning repo to current repo bd migrate-issues --from ~/.beads-planning --to . --dry-run # Move all open P1 bugs bd migrate-issues --from ~/repo1 --to ~/repo2 --priority 1 --type bug --status open # Move specific issues with their dependencies bd migrate-issues --from . --to ~/archive --id bd-abc --id bd-xyz --include closure # Move issues with label filter bd migrate-issues --from . --to ~/feature-work --label frontend --label urgent`, Run: func(cmd *cobra.Command, args []string) { ctx := context.Background() // Parse flags from, _ := cmd.Flags().GetString("from") to, _ := cmd.Flags().GetString("to") statusStr, _ := cmd.Flags().GetString("status") priorityInt, _ := cmd.Flags().GetInt("priority") typeStr, _ := cmd.Flags().GetString("type") labels, _ := cmd.Flags().GetStringSlice("label") ids, _ := cmd.Flags().GetStringSlice("id") idsFile, _ := cmd.Flags().GetString("ids-file") include, _ := cmd.Flags().GetString("include") withinFromOnly, _ := cmd.Flags().GetBool("within-from-only") dryRun, _ := cmd.Flags().GetBool("dry-run") strict, _ := cmd.Flags().GetBool("strict") yes, _ := cmd.Flags().GetBool("yes") // Validate required flags if from == "" || to == "" { if jsonOutput { outputJSON(map[string]interface{}{ "error": "missing_required_flags", "message": "Both --from and --to are required", }) } else { fmt.Fprintln(os.Stderr, "Error: both --from and --to flags are required") } os.Exit(1) } if from == to { if jsonOutput { outputJSON(map[string]interface{}{ "error": "same_source_and_dest", "message": "Source and destination repositories must be different", }) } else { fmt.Fprintln(os.Stderr, "Error: --from and --to must be different repositories") } os.Exit(1) } // Load IDs from file if specified if idsFile != "" { fileIDs, err := loadIDsFromFile(idsFile) if err != nil { if jsonOutput { outputJSON(map[string]interface{}{ "error": "ids_file_read_failed", "message": err.Error(), }) } else { fmt.Fprintf(os.Stderr, "Error reading IDs file: %v\n", err) } os.Exit(1) } ids = append(ids, fileIDs...) } // Execute migration if err := executeMigrateIssues(ctx, migrateIssuesParams{ from: from, to: to, status: statusStr, priority: priorityInt, issueType: typeStr, labels: labels, ids: ids, include: include, withinFromOnly: withinFromOnly, dryRun: dryRun, strict: strict, yes: yes, }); err != nil { if jsonOutput { outputJSON(map[string]interface{}{ "error": "migration_failed", "message": err.Error(), }) } else { fmt.Fprintf(os.Stderr, "Error: %v\n", err) } os.Exit(1) } }, } type migrateIssuesParams struct { from string to string status string priority int issueType string labels []string ids []string include string withinFromOnly bool dryRun bool strict bool yes bool } type migrationPlan struct { TotalSelected int `json:"total_selected"` AddedByDependency int `json:"added_by_dependency"` IncomingEdges int `json:"incoming_edges"` OutgoingEdges int `json:"outgoing_edges"` Orphans int `json:"orphans"` OrphanSamples []string `json:"orphan_samples,omitempty"` IssueIDs []string `json:"issue_ids"` From string `json:"from"` To string `json:"to"` } func executeMigrateIssues(ctx context.Context, p migrateIssuesParams) error { // Get database connection (use global store) sqlStore, ok := store.(*sqlite.SQLiteStorage) if !ok { return fmt.Errorf("migrate-issues requires SQLite storage") } db := sqlStore.UnderlyingDB() // Step 1: Validate repositories exist if err := validateRepos(ctx, db, p.from, p.to, p.strict); err != nil { return err } // Step 2: Build initial candidate set C using filters candidates, err := findCandidateIssues(ctx, db, p) if err != nil { return fmt.Errorf("failed to find candidate issues: %w", err) } if len(candidates) == 0 { if jsonOutput { outputJSON(map[string]interface{}{ "message": "No issues match the specified filters", }) } else { fmt.Println("Nothing to do: no issues match the specified filters") } return nil } // Step 3: Expand set to M (migration set) based on --include migrationSet, dependencyStats, err := expandMigrationSet(ctx, db, candidates, p) if err != nil { return fmt.Errorf("failed to compute migration set: %w", err) } // Step 4: Check for orphaned dependencies orphans, err := checkOrphanedDependencies(ctx, db, migrationSet) if err != nil { return fmt.Errorf("failed to check dependencies: %w", err) } if len(orphans) > 0 && p.strict { return fmt.Errorf("strict mode: found %d orphaned dependencies", len(orphans)) } // Step 5: Build migration plan plan := buildMigrationPlan(candidates, migrationSet, dependencyStats, orphans, p.from, p.to) // Step 6: Display plan if err := displayMigrationPlan(plan, p.dryRun); err != nil { return err } // Step 7: Execute migration if not dry-run if !p.dryRun { if !p.yes && !jsonOutput { if !confirmMigration(plan) { fmt.Println("Migration cancelled") return nil } } if err := executeMigration(ctx, db, migrationSet, p.to); err != nil { return fmt.Errorf("migration failed: %w", err) } if jsonOutput { outputJSON(map[string]interface{}{ "success": true, "message": fmt.Sprintf("Migrated %d issues from %s to %s", len(migrationSet), p.from, p.to), "plan": plan, }) } else { fmt.Printf("\n✓ Successfully migrated %d issues from %s to %s\n", len(migrationSet), p.from, p.to) } } return nil } func validateRepos(ctx context.Context, db *sql.DB, from, to string, strict bool) error { // Check if source repo exists var fromCount int err := db.QueryRowContext(ctx, "SELECT COUNT(*) FROM issues WHERE source_repo = ?", from).Scan(&fromCount) if err != nil { return fmt.Errorf("failed to check source repository: %w", err) } if fromCount == 0 { msg := fmt.Sprintf("source repository '%s' has no issues", from) if strict { return fmt.Errorf("%s", msg) } if !jsonOutput { fmt.Fprintf(os.Stderr, "Warning: %s\n", msg) } } // Check if destination repo exists (just a warning) var toCount int err = db.QueryRowContext(ctx, "SELECT COUNT(*) FROM issues WHERE source_repo = ?", to).Scan(&toCount) if err != nil { return fmt.Errorf("failed to check destination repository: %w", err) } if toCount == 0 && !jsonOutput { fmt.Fprintf(os.Stderr, "Info: destination repository '%s' will be created\n", to) } return nil } func findCandidateIssues(ctx context.Context, db *sql.DB, p migrateIssuesParams) ([]string, error) { // Build WHERE clause var conditions []string var args []interface{} // Always filter by source_repo conditions = append(conditions, "source_repo = ?") args = append(args, p.from) // Filter by status if p.status != "" && p.status != "all" { conditions = append(conditions, "status = ?") args = append(args, p.status) } // Filter by priority if p.priority >= 0 { conditions = append(conditions, "priority = ?") args = append(args, p.priority) } // Filter by type if p.issueType != "" && p.issueType != "all" { conditions = append(conditions, "issue_type = ?") args = append(args, p.issueType) } // Filter by labels if len(p.labels) > 0 { // Issues must have ALL specified labels (AND logic) for _, label := range p.labels { conditions = append(conditions, `id IN (SELECT issue_id FROM issue_labels WHERE label = ?)`) args = append(args, label) } } // Build query query := "SELECT id FROM issues WHERE " + strings.Join(conditions, " AND ") rows, err := db.QueryContext(ctx, query, args...) if err != nil { return nil, err } defer rows.Close() var candidates []string for rows.Next() { var id string if err := rows.Scan(&id); err != nil { return nil, err } candidates = append(candidates, id) } // Filter by explicit ID list if provided if len(p.ids) > 0 { idSet := make(map[string]bool) for _, id := range p.ids { idSet[id] = true } var filtered []string for _, id := range candidates { if idSet[id] { filtered = append(filtered, id) } } candidates = filtered } return candidates, nil } type dependencyStats struct { incomingEdges int outgoingEdges int } func expandMigrationSet(ctx context.Context, db *sql.DB, candidates []string, p migrateIssuesParams) ([]string, dependencyStats, error) { if p.include == "none" || p.include == "" { return candidates, dependencyStats{}, nil } // Build initial set migrationSet := make(map[string]bool) for _, id := range candidates { migrationSet[id] = true } // BFS traversal for dependency closure visited := make(map[string]bool) queue := make([]string, len(candidates)) copy(queue, candidates) for len(queue) > 0 { current := queue[0] queue = queue[1:] if visited[current] { continue } visited[current] = true // Traverse based on include mode var deps []string var err error switch p.include { case "upstream": deps, err = getUpstreamDependencies(ctx, db, current, p.from, p.withinFromOnly) case "downstream": deps, err = getDownstreamDependencies(ctx, db, current, p.from, p.withinFromOnly) case "closure": upDeps, err1 := getUpstreamDependencies(ctx, db, current, p.from, p.withinFromOnly) downDeps, err2 := getDownstreamDependencies(ctx, db, current, p.from, p.withinFromOnly) if err1 != nil { err = err1 } else if err2 != nil { err = err2 } else { deps = append(upDeps, downDeps...) } } if err != nil { return nil, dependencyStats{}, err } for _, dep := range deps { if !visited[dep] { migrationSet[dep] = true queue = append(queue, dep) } } } // Convert map to slice result := make([]string, 0, len(migrationSet)) for id := range migrationSet { result = append(result, id) } // Count cross-repo edges stats, err := countCrossRepoEdges(ctx, db, result) if err != nil { return nil, dependencyStats{}, err } return result, stats, nil } func getUpstreamDependencies(ctx context.Context, db *sql.DB, issueID, fromRepo string, withinFromOnly bool) ([]string, error) { query := `SELECT depends_on_id FROM dependencies WHERE issue_id = ?` if withinFromOnly { query = `SELECT d.depends_on_id FROM dependencies d JOIN issues i ON d.depends_on_id = i.id WHERE d.issue_id = ? AND i.source_repo = ?` } var rows *sql.Rows var err error if withinFromOnly { rows, err = db.QueryContext(ctx, query, issueID, fromRepo) } else { rows, err = db.QueryContext(ctx, query, issueID) } if err != nil { return nil, err } defer rows.Close() var deps []string for rows.Next() { var dep string if err := rows.Scan(&dep); err != nil { return nil, err } deps = append(deps, dep) } return deps, nil } func getDownstreamDependencies(ctx context.Context, db *sql.DB, issueID, fromRepo string, withinFromOnly bool) ([]string, error) { query := `SELECT issue_id FROM dependencies WHERE depends_on_id = ?` if withinFromOnly { query = `SELECT d.issue_id FROM dependencies d JOIN issues i ON d.issue_id = i.id WHERE d.depends_on_id = ? AND i.source_repo = ?` } var rows *sql.Rows var err error if withinFromOnly { rows, err = db.QueryContext(ctx, query, issueID, fromRepo) } else { rows, err = db.QueryContext(ctx, query, issueID) } if err != nil { return nil, err } defer rows.Close() var deps []string for rows.Next() { var dep string if err := rows.Scan(&dep); err != nil { return nil, err } deps = append(deps, dep) } return deps, nil } func countCrossRepoEdges(ctx context.Context, db *sql.DB, migrationSet []string) (dependencyStats, error) { if len(migrationSet) == 0 { return dependencyStats{}, nil } // Build placeholders for IN clause placeholders := make([]string, len(migrationSet)) args := make([]interface{}, len(migrationSet)) for i, id := range migrationSet { placeholders[i] = "?" args[i] = id } inClause := strings.Join(placeholders, ",") // Count incoming edges (external issues depend on migrated issues) incomingQuery := fmt.Sprintf(` SELECT COUNT(*) FROM dependencies WHERE depends_on_id IN (%s) AND issue_id NOT IN (%s)`, inClause, inClause) var incoming int if err := db.QueryRowContext(ctx, incomingQuery, append(args, args...)...).Scan(&incoming); err != nil { return dependencyStats{}, err } // Count outgoing edges (migrated issues depend on external issues) outgoingQuery := fmt.Sprintf(` SELECT COUNT(*) FROM dependencies WHERE issue_id IN (%s) AND depends_on_id NOT IN (%s)`, inClause, inClause) var outgoing int if err := db.QueryRowContext(ctx, outgoingQuery, append(args, args...)...).Scan(&outgoing); err != nil { return dependencyStats{}, err } return dependencyStats{ incomingEdges: incoming, outgoingEdges: outgoing, }, nil } func checkOrphanedDependencies(ctx context.Context, db *sql.DB, migrationSet []string) ([]string, error) { // Check for dependencies referencing non-existent issues query := ` SELECT DISTINCT d.depends_on_id FROM dependencies d LEFT JOIN issues i ON d.depends_on_id = i.id WHERE i.id IS NULL UNION SELECT DISTINCT d.issue_id FROM dependencies d LEFT JOIN issues i ON d.issue_id = i.id WHERE i.id IS NULL ` rows, err := db.QueryContext(ctx, query) if err != nil { return nil, err } defer rows.Close() var orphans []string for rows.Next() { var orphan string if err := rows.Scan(&orphan); err != nil { return nil, err } orphans = append(orphans, orphan) } return orphans, nil } func buildMigrationPlan(candidates, migrationSet []string, stats dependencyStats, orphans []string, from, to string) migrationPlan { orphanSamples := orphans if len(orphanSamples) > 10 { orphanSamples = orphanSamples[:10] } return migrationPlan{ TotalSelected: len(candidates), AddedByDependency: len(migrationSet) - len(candidates), IncomingEdges: stats.incomingEdges, OutgoingEdges: stats.outgoingEdges, Orphans: len(orphans), OrphanSamples: orphanSamples, IssueIDs: migrationSet, From: from, To: to, } } func displayMigrationPlan(plan migrationPlan, dryRun bool) error { if jsonOutput { output := map[string]interface{}{ "plan": plan, "dry_run": dryRun, } outputJSON(output); return nil } // Human-readable output fmt.Println("\n=== Migration Plan ===") fmt.Printf("From: %s\n", plan.From) fmt.Printf("To: %s\n", plan.To) fmt.Println() fmt.Printf("Total selected: %d issues\n", plan.TotalSelected) if plan.AddedByDependency > 0 { fmt.Printf("Added by dependencies: %d issues\n", plan.AddedByDependency) } fmt.Printf("Total to migrate: %d issues\n", len(plan.IssueIDs)) fmt.Println() fmt.Printf("Cross-repo edges preserved:\n") fmt.Printf(" Incoming: %d\n", plan.IncomingEdges) fmt.Printf(" Outgoing: %d\n", plan.OutgoingEdges) if plan.Orphans > 0 { fmt.Println() fmt.Printf("⚠️ Warning: Found %d orphaned dependencies\n", plan.Orphans) if len(plan.OrphanSamples) > 0 { fmt.Println("Sample orphaned IDs:") for _, id := range plan.OrphanSamples { fmt.Printf(" - %s\n", id) } } } if dryRun { fmt.Println("\n[DRY RUN] No changes made") if len(plan.IssueIDs) <= 20 { fmt.Println("\nIssues to migrate:") for _, id := range plan.IssueIDs { fmt.Printf(" - %s\n", id) } } else { fmt.Printf("\n(%d issues would be migrated, showing first 20)\n", len(plan.IssueIDs)) for i := 0; i < 20 && i < len(plan.IssueIDs); i++ { fmt.Printf(" - %s\n", plan.IssueIDs[i]) } } } return nil } func confirmMigration(plan migrationPlan) bool { fmt.Printf("\nMigrate %d issues from %s to %s? [y/N] ", len(plan.IssueIDs), plan.From, plan.To) var response string fmt.Scanln(&response) return strings.ToLower(strings.TrimSpace(response)) == "y" } func executeMigration(ctx context.Context, db *sql.DB, migrationSet []string, to string) error { tx, err := db.BeginTx(ctx, nil) if err != nil { return fmt.Errorf("failed to begin transaction: %w", err) } defer tx.Rollback() now := time.Now() // Update source_repo for all issues in migration set for _, id := range migrationSet { _, err := tx.ExecContext(ctx, "UPDATE issues SET source_repo = ?, updated_at = ? WHERE id = ?", to, now, id) if err != nil { return fmt.Errorf("failed to update issue %s: %w", id, err) } // Mark as dirty for export _, err = tx.ExecContext(ctx, "INSERT OR IGNORE INTO dirty_issues(issue_id) VALUES (?)", id) if err != nil { return fmt.Errorf("failed to mark issue %s as dirty: %w", id, err) } } return tx.Commit() } func loadIDsFromFile(path string) ([]string, error) { data, err := os.ReadFile(path) if err != nil { return nil, err } lines := strings.Split(string(data), "\n") var ids []string for _, line := range lines { line = strings.TrimSpace(line) if line != "" && !strings.HasPrefix(line, "#") { ids = append(ids, line) } } return ids, nil } func init() { rootCmd.AddCommand(migrateIssuesCmd) migrateIssuesCmd.Flags().String("from", "", "Source repository (required)") migrateIssuesCmd.Flags().String("to", "", "Destination repository (required)") migrateIssuesCmd.Flags().String("status", "", "Filter by status (open/closed/all)") migrateIssuesCmd.Flags().Int("priority", -1, "Filter by priority (0-4)") migrateIssuesCmd.Flags().String("type", "", "Filter by issue type (bug/feature/task/epic/chore)") migrateIssuesCmd.Flags().StringSlice("label", nil, "Filter by labels (can specify multiple)") migrateIssuesCmd.Flags().StringSlice("id", nil, "Specific issue IDs to migrate (can specify multiple)") migrateIssuesCmd.Flags().String("ids-file", "", "File containing issue IDs (one per line)") migrateIssuesCmd.Flags().String("include", "none", "Include dependencies: none/upstream/downstream/closure") migrateIssuesCmd.Flags().Bool("within-from-only", true, "Only include dependencies from source repo") migrateIssuesCmd.Flags().Bool("dry-run", false, "Show plan without making changes") migrateIssuesCmd.Flags().Bool("strict", false, "Fail on orphaned dependencies or missing repos") migrateIssuesCmd.Flags().Bool("yes", false, "Skip confirmation prompt") migrateIssuesCmd.MarkFlagRequired("from") migrateIssuesCmd.MarkFlagRequired("to") }