package main import ( "context" "encoding/json" "fmt" "os" "regexp" "sort" "strconv" "strings" "github.com/fatih/color" "github.com/google/uuid" "github.com/spf13/cobra" "github.com/steveyegge/beads/internal/storage/sqlite" "github.com/steveyegge/beads/internal/types" ) var renumberCmd = &cobra.Command{ Use: "renumber", Short: "Renumber all issues to compact the ID space", Long: `Renumber all issues sequentially to eliminate gaps in the ID space. This command will: - Renumber all issues starting from 1 (keeping chronological order) - Update all dependency links (blocks, related, parent-child, discovered-from) - Update all text references in descriptions, notes, acceptance criteria - Show a mapping report of old ID -> new ID - Export the updated database to JSONL Example: bd renumber --dry-run # Preview changes bd renumber --force # Actually renumber Risks: - May break external references in GitHub issues, docs, commits - Git history may become confusing - Operation cannot be undone (backup recommended)`, Run: func(cmd *cobra.Command, args []string) { dryRun, _ := cmd.Flags().GetBool("dry-run") force, _ := cmd.Flags().GetBool("force") if !dryRun && !force { fmt.Fprintf(os.Stderr, "Error: must specify --dry-run or --force\n") os.Exit(1) } // Renumber command needs direct access to storage // Ensure we have a direct store connection if store == nil { var err error if dbPath == "" { fmt.Fprintf(os.Stderr, "Error: no database path found\n") os.Exit(1) } store, err = sqlite.New(dbPath) if err != nil { fmt.Fprintf(os.Stderr, "Error: failed to open database: %v\n", err) os.Exit(1) } defer store.Close() } ctx := context.Background() // Get prefix from config, or derive from first issue if not set prefix, err := store.GetConfig(ctx, "issue_prefix") if err != nil || prefix == "" { // Get any issue to derive prefix issues, err := store.SearchIssues(ctx, "", types.IssueFilter{}) if err != nil || len(issues) == 0 { fmt.Fprintf(os.Stderr, "Error: failed to determine issue prefix\n") os.Exit(1) } // Extract prefix from first issue (e.g., "bd-123" -> "bd") parts := strings.Split(issues[0].ID, "-") if len(parts) < 2 { fmt.Fprintf(os.Stderr, "Error: invalid issue ID format: %s\n", issues[0].ID) os.Exit(1) } prefix = parts[0] } // Get all issues sorted by creation time issues, err := store.SearchIssues(ctx, "", types.IssueFilter{}) if err != nil { fmt.Fprintf(os.Stderr, "Error: failed to list issues: %v\n", err) os.Exit(1) } if len(issues) == 0 { fmt.Println("No issues to renumber") return } // Sort by creation time to preserve chronological order sort.Slice(issues, func(i, j int) bool { return issues[i].CreatedAt.Before(issues[j].CreatedAt) }) // Build mapping from old ID to new ID idMapping := make(map[string]string) for i, issue := range issues { newNum := i + 1 newID := fmt.Sprintf("%s-%d", prefix, newNum) idMapping[issue.ID] = newID } if dryRun { cyan := color.New(color.FgCyan).SprintFunc() fmt.Printf("DRY RUN: Would renumber %d issues\n\n", len(issues)) fmt.Printf("Sample changes:\n") changesShown := 0 for _, issue := range issues { oldID := issue.ID newID := idMapping[oldID] if oldID != newID { fmt.Printf(" %s -> %s (%s)\n", cyan(oldID), cyan(newID), issue.Title) changesShown++ if changesShown >= 10 { skipped := 0 for _, iss := range issues { if iss.ID != idMapping[iss.ID] { skipped++ } } skipped -= changesShown if skipped > 0 { fmt.Printf("... and %d more changes\n", skipped) } break } } } return } green := color.New(color.FgGreen).SprintFunc() fmt.Printf("Renumbering %d issues...\n", len(issues)) if err := renumberIssuesInDB(ctx, prefix, idMapping, issues); err != nil { fmt.Fprintf(os.Stderr, "Error: failed to renumber issues: %v\n", err) os.Exit(1) } // Schedule full export (IDs changed, incremental won't work) markDirtyAndScheduleFullExport() fmt.Printf("%s Successfully renumbered %d issues\n", green("✓"), len(issues)) // Count actual changes changed := 0 for oldID, newID := range idMapping { if oldID != newID { changed++ } } fmt.Printf(" %d issues renumbered, %d unchanged\n", changed, len(issues)-changed) if jsonOutput { result := map[string]interface{}{ "total_issues": len(issues), "changed": changed, "unchanged": len(issues) - changed, } enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") _ = enc.Encode(result) } }, } func renumberIssuesInDB(ctx context.Context, prefix string, idMapping map[string]string, issues []*types.Issue) error { // Step 0: Get all dependencies BEFORE renaming (while IDs still match) allDepsByIssue, err := store.GetAllDependencyRecords(ctx) if err != nil { return fmt.Errorf("failed to get dependency records: %w", err) } // Step 1: Rename all issues to temporary UUIDs to avoid collisions tempMapping := make(map[string]string) for _, issue := range issues { oldID := issue.ID // Use UUID to guarantee uniqueness (no collision possible) tempID := fmt.Sprintf("temp-%s", uuid.New().String()) tempMapping[oldID] = tempID // Rename to temp ID (don't update text yet) issue.ID = tempID if err := store.UpdateIssueID(ctx, oldID, tempID, issue, actor); err != nil { return fmt.Errorf("failed to rename %s to temp ID: %w", oldID, err) } } // Step 2: Rename from temp IDs to final IDs (still don't update text) for _, issue := range issues { tempID := issue.ID // Currently has temp ID // Find original ID var oldOriginalID string for origID, tID := range tempMapping { if tID == tempID { oldOriginalID = origID break } } finalID := idMapping[oldOriginalID] // Just update the ID, not text yet issue.ID = finalID if err := store.UpdateIssueID(ctx, tempID, finalID, issue, actor); err != nil { return fmt.Errorf("failed to update issue %s: %w", tempID, err) } } // Step 3: Now update all text references using the original old->new mapping // Build regex to match any OLD issue ID (before renumbering) oldIDs := make([]string, 0, len(idMapping)) for oldID := range idMapping { oldIDs = append(oldIDs, regexp.QuoteMeta(oldID)) } oldPattern := regexp.MustCompile(`\b(` + strings.Join(oldIDs, "|") + `)\b`) replaceFunc := func(match string) string { if newID, ok := idMapping[match]; ok { return newID } return match } // Update text references in all issues for _, issue := range issues { changed := false newTitle := oldPattern.ReplaceAllStringFunc(issue.Title, replaceFunc) if newTitle != issue.Title { issue.Title = newTitle changed = true } newDesc := oldPattern.ReplaceAllStringFunc(issue.Description, replaceFunc) if newDesc != issue.Description { issue.Description = newDesc changed = true } if issue.Design != "" { newDesign := oldPattern.ReplaceAllStringFunc(issue.Design, replaceFunc) if newDesign != issue.Design { issue.Design = newDesign changed = true } } if issue.AcceptanceCriteria != "" { newAC := oldPattern.ReplaceAllStringFunc(issue.AcceptanceCriteria, replaceFunc) if newAC != issue.AcceptanceCriteria { issue.AcceptanceCriteria = newAC changed = true } } if issue.Notes != "" { newNotes := oldPattern.ReplaceAllStringFunc(issue.Notes, replaceFunc) if newNotes != issue.Notes { issue.Notes = newNotes changed = true } } // Only update if text changed if changed { if err := store.UpdateIssue(ctx, issue.ID, map[string]interface{}{ "title": issue.Title, "description": issue.Description, "design": issue.Design, "acceptance_criteria": issue.AcceptanceCriteria, "notes": issue.Notes, }, actor); err != nil { return fmt.Errorf("failed to update text references in %s: %w", issue.ID, err) } } } // Update all dependency links (use the deps we fetched before renaming) if err := renumberDependencies(ctx, idMapping, allDepsByIssue); err != nil { return fmt.Errorf("failed to update dependencies: %w", err) } // Update the counter to the highest renumbered ID so next issue gets correct number // After renumbering to bd-1..bd-N, set counter to N so next issue is bd-(N+1) // We need to FORCE set it (not MAX) because counter may be higher from deleted issues // Strategy: Reset (delete) the counter row, then SyncAllCounters recreates it from actual max ID sqliteStore := store.(*sqlite.SQLiteStorage) if err := sqliteStore.ResetCounter(ctx, prefix); err != nil { return fmt.Errorf("failed to reset counter: %w", err) } // Now sync will recreate it from the actual max ID in the database if err := sqliteStore.SyncAllCounters(ctx); err != nil { return fmt.Errorf("failed to sync counter: %w", err) } return nil } func renumberDependencies(ctx context.Context, idMapping map[string]string, allDepsByIssue map[string][]*types.Dependency) error { // Collect all dependencies to update oldDeps := make([]*types.Dependency, 0) newDeps := make([]*types.Dependency, 0) for issueID, deps := range allDepsByIssue { newIssueID, issueRenamed := idMapping[issueID] if !issueRenamed { newIssueID = issueID } for _, dep := range deps { newDependsOnID, depRenamed := idMapping[dep.DependsOnID] if !depRenamed { newDependsOnID = dep.DependsOnID } // If either ID changed, we need to update if issueRenamed || depRenamed { oldDeps = append(oldDeps, dep) newDep := &types.Dependency{ IssueID: newIssueID, DependsOnID: newDependsOnID, Type: dep.Type, } newDeps = append(newDeps, newDep) } } } // First remove all old dependencies for _, oldDep := range oldDeps { // Remove old dependency (may not exist if IDs already updated) _ = store.RemoveDependency(ctx, oldDep.IssueID, oldDep.DependsOnID, "renumber") } // Then add all new dependencies for _, newDep := range newDeps { // Add new dependency if err := store.AddDependency(ctx, newDep, "renumber"); err != nil { // Ignore duplicate and validation errors (parent-child direction might be swapped) if !strings.Contains(err.Error(), "UNIQUE constraint failed") && !strings.Contains(err.Error(), "duplicate") && !strings.Contains(err.Error(), "invalid parent-child") { return fmt.Errorf("failed to add dependency %s -> %s: %w", newDep.IssueID, newDep.DependsOnID, err) } } } return nil } // Helper to extract numeric part from issue ID func extractNumber(issueID, prefix string) (int, error) { numStr := strings.TrimPrefix(issueID, prefix+"-") return strconv.Atoi(numStr) } func init() { renumberCmd.Flags().Bool("dry-run", false, "Preview changes without applying them") renumberCmd.Flags().Bool("force", false, "Actually perform the renumbering") rootCmd.AddCommand(renumberCmd) }