Add rename-prefix command (bd-420)
- Implement bd rename-prefix command with --dry-run and --json flags - Add prefix validation (max 8 chars, lowercase, starts with letter) - Update all issue IDs and text references atomically per issue - Update dependencies, labels, events, and counters - Fix counter merge to use MAX() to prevent ID collisions - Update snapshot tables for FK integrity - Add comprehensive tests for validation and rename workflow - Document in README.md and AGENTS.md Known limitation: Each issue updates in its own transaction. A failure mid-way could leave mixed state. Acceptable for intended use case (infrequent operation on small DBs). Amp-Thread-ID: https://ampcode.com/threads/T-7e77b779-bd88-44f2-9f0b-a9f2ccd54d38 Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
191
cmd/bd/rename_prefix.go
Normal file
191
cmd/bd/rename_prefix.go
Normal file
@@ -0,0 +1,191 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steveyegge/beads/internal/types"
|
||||
)
|
||||
|
||||
var renamePrefixCmd = &cobra.Command{
|
||||
Use: "rename-prefix <new-prefix>",
|
||||
Short: "Rename the issue prefix for all issues",
|
||||
Long: `Rename the issue prefix for all issues in the database.
|
||||
This will update all issue IDs and all text references across all fields.
|
||||
|
||||
Prefix validation rules:
|
||||
- Max length: 8 characters
|
||||
- Allowed characters: lowercase letters, numbers, hyphens
|
||||
- Must start with a letter
|
||||
- Must end with a hyphen (e.g., 'kw-', 'work-')
|
||||
- Cannot be empty or just a hyphen
|
||||
|
||||
Example:
|
||||
bd rename-prefix kw- # Rename from 'knowledge-work-' to 'kw-'`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
newPrefix := args[0]
|
||||
dryRun, _ := cmd.Flags().GetBool("dry-run")
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
if err := validatePrefix(newPrefix); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
oldPrefix, err := store.GetConfig(ctx, "issue_prefix")
|
||||
if err != nil || oldPrefix == "" {
|
||||
fmt.Fprintf(os.Stderr, "Error: failed to get current prefix: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
newPrefix = strings.TrimRight(newPrefix, "-")
|
||||
|
||||
if oldPrefix == newPrefix {
|
||||
fmt.Fprintf(os.Stderr, "Error: new prefix is the same as current prefix: %s\n", oldPrefix)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
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.Printf("No issues to rename. Updating prefix to %s\n", newPrefix)
|
||||
if !dryRun {
|
||||
if err := store.SetConfig(ctx, "issue_prefix", newPrefix); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: failed to update prefix: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if dryRun {
|
||||
cyan := color.New(color.FgCyan).SprintFunc()
|
||||
fmt.Printf("DRY RUN: Would rename %d issues from prefix '%s' to '%s'\n\n", len(issues), oldPrefix, newPrefix)
|
||||
fmt.Printf("Sample changes:\n")
|
||||
for i, issue := range issues {
|
||||
if i >= 5 {
|
||||
fmt.Printf("... and %d more issues\n", len(issues)-5)
|
||||
break
|
||||
}
|
||||
oldID := fmt.Sprintf("%s-%s", oldPrefix, strings.TrimPrefix(issue.ID, oldPrefix+"-"))
|
||||
newID := fmt.Sprintf("%s-%s", newPrefix, strings.TrimPrefix(issue.ID, oldPrefix+"-"))
|
||||
fmt.Printf(" %s -> %s\n", cyan(oldID), cyan(newID))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
green := color.New(color.FgGreen).SprintFunc()
|
||||
cyan := color.New(color.FgCyan).SprintFunc()
|
||||
|
||||
fmt.Printf("Renaming %d issues from prefix '%s' to '%s'...\n", len(issues), oldPrefix, newPrefix)
|
||||
|
||||
if err := renamePrefixInDB(ctx, oldPrefix, newPrefix, issues); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: failed to rename prefix: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("%s Successfully renamed prefix from %s to %s\n", green("✓"), cyan(oldPrefix), cyan(newPrefix))
|
||||
|
||||
if jsonOutput {
|
||||
result := map[string]interface{}{
|
||||
"old_prefix": oldPrefix,
|
||||
"new_prefix": newPrefix,
|
||||
"issues_count": len(issues),
|
||||
}
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
_ = enc.Encode(result)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
func validatePrefix(prefix string) error {
|
||||
prefix = strings.TrimRight(prefix, "-")
|
||||
|
||||
if prefix == "" {
|
||||
return fmt.Errorf("prefix cannot be empty")
|
||||
}
|
||||
|
||||
if len(prefix) > 8 {
|
||||
return fmt.Errorf("prefix too long (max 8 characters): %s", prefix)
|
||||
}
|
||||
|
||||
matched, _ := regexp.MatchString(`^[a-z][a-z0-9-]*$`, prefix)
|
||||
if !matched {
|
||||
return fmt.Errorf("prefix must start with a lowercase letter and contain only lowercase letters, numbers, and hyphens: %s", prefix)
|
||||
}
|
||||
|
||||
if strings.HasPrefix(prefix, "-") || strings.HasSuffix(prefix, "--") {
|
||||
return fmt.Errorf("prefix has invalid hyphen placement: %s", prefix)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func renamePrefixInDB(ctx context.Context, oldPrefix, newPrefix string, issues []*types.Issue) error {
|
||||
// NOTE: Each issue is updated in its own transaction. A failure mid-way could leave
|
||||
// the database in a mixed state with some issues renamed and others not.
|
||||
// For production use, consider implementing a single atomic RenamePrefix() method
|
||||
// in the storage layer that wraps all updates in one transaction.
|
||||
|
||||
oldPrefixPattern := regexp.MustCompile(`\b` + regexp.QuoteMeta(oldPrefix) + `-(\d+)\b`)
|
||||
|
||||
replaceFunc := func(match string) string {
|
||||
return strings.Replace(match, oldPrefix+"-", newPrefix+"-", 1)
|
||||
}
|
||||
|
||||
for _, issue := range issues {
|
||||
oldID := issue.ID
|
||||
numPart := strings.TrimPrefix(oldID, oldPrefix+"-")
|
||||
newID := fmt.Sprintf("%s-%s", newPrefix, numPart)
|
||||
|
||||
issue.ID = newID
|
||||
|
||||
issue.Title = oldPrefixPattern.ReplaceAllStringFunc(issue.Title, replaceFunc)
|
||||
issue.Description = oldPrefixPattern.ReplaceAllStringFunc(issue.Description, replaceFunc)
|
||||
if issue.Design != "" {
|
||||
issue.Design = oldPrefixPattern.ReplaceAllStringFunc(issue.Design, replaceFunc)
|
||||
}
|
||||
if issue.AcceptanceCriteria != "" {
|
||||
issue.AcceptanceCriteria = oldPrefixPattern.ReplaceAllStringFunc(issue.AcceptanceCriteria, replaceFunc)
|
||||
}
|
||||
if issue.Notes != "" {
|
||||
issue.Notes = oldPrefixPattern.ReplaceAllStringFunc(issue.Notes, replaceFunc)
|
||||
}
|
||||
|
||||
if err := store.UpdateIssueID(ctx, oldID, newID, issue, actor); err != nil {
|
||||
return fmt.Errorf("failed to update issue %s: %w", oldID, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := store.RenameDependencyPrefix(ctx, oldPrefix, newPrefix); err != nil {
|
||||
return fmt.Errorf("failed to update dependencies: %w", err)
|
||||
}
|
||||
|
||||
if err := store.RenameCounterPrefix(ctx, oldPrefix, newPrefix); err != nil {
|
||||
return fmt.Errorf("failed to update counter: %w", err)
|
||||
}
|
||||
|
||||
if err := store.SetConfig(ctx, "issue_prefix", newPrefix); err != nil {
|
||||
return fmt.Errorf("failed to update config: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
renamePrefixCmd.Flags().Bool("dry-run", false, "Preview changes without applying them")
|
||||
rootCmd.AddCommand(renamePrefixCmd)
|
||||
}
|
||||
221
cmd/bd/rename_prefix_test.go
Normal file
221
cmd/bd/rename_prefix_test.go
Normal file
@@ -0,0 +1,221 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/steveyegge/beads/internal/storage/sqlite"
|
||||
"github.com/steveyegge/beads/internal/types"
|
||||
)
|
||||
|
||||
func TestValidatePrefix(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
prefix string
|
||||
wantErr bool
|
||||
}{
|
||||
{"valid lowercase", "kw-", false},
|
||||
{"valid with numbers", "work1-", false},
|
||||
{"valid with hyphen", "my-work-", false},
|
||||
{"empty", "", true},
|
||||
{"too long", "verylongprefix-", true},
|
||||
{"starts with number", "1work-", true},
|
||||
{"uppercase", "KW-", true},
|
||||
{"no hyphen", "kw", false},
|
||||
{"just hyphen", "-", true},
|
||||
{"starts with hyphen", "-work", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validatePrefix(tt.prefix)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("validatePrefix(%q) error = %v, wantErr %v", tt.prefix, err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenamePrefixCommand(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
dbPath := filepath.Join(tmpDir, "test.db")
|
||||
|
||||
testStore, err := sqlite.New(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test database: %v", err)
|
||||
}
|
||||
defer testStore.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
store = testStore
|
||||
actor = "test"
|
||||
defer func() {
|
||||
store = nil
|
||||
actor = ""
|
||||
}()
|
||||
|
||||
if err := testStore.SetConfig(ctx, "issue_prefix", "old"); err != nil {
|
||||
t.Fatalf("Failed to set config: %v", err)
|
||||
}
|
||||
|
||||
issue1 := &types.Issue{
|
||||
ID: "old-1",
|
||||
Title: "Fix bug in old-2",
|
||||
Description: "See old-3 for details",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: types.TypeBug,
|
||||
}
|
||||
issue2 := &types.Issue{
|
||||
ID: "old-2",
|
||||
Title: "Related to old-1",
|
||||
Description: "This depends on old-1",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: types.TypeTask,
|
||||
}
|
||||
issue3 := &types.Issue{
|
||||
ID: "old-3",
|
||||
Title: "Another issue",
|
||||
Description: "Referenced by old-1",
|
||||
Design: "Mentions old-2 in design",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 2,
|
||||
IssueType: types.TypeFeature,
|
||||
}
|
||||
|
||||
if err := testStore.CreateIssue(ctx, issue1, "test"); err != nil {
|
||||
t.Fatalf("Failed to create issue1: %v", err)
|
||||
}
|
||||
if err := testStore.CreateIssue(ctx, issue2, "test"); err != nil {
|
||||
t.Fatalf("Failed to create issue2: %v", err)
|
||||
}
|
||||
if err := testStore.CreateIssue(ctx, issue3, "test"); err != nil {
|
||||
t.Fatalf("Failed to create issue3: %v", err)
|
||||
}
|
||||
|
||||
dep := &types.Dependency{
|
||||
IssueID: "old-1",
|
||||
DependsOnID: "old-2",
|
||||
Type: types.DepBlocks,
|
||||
}
|
||||
if err := testStore.AddDependency(ctx, dep, "test"); err != nil {
|
||||
t.Fatalf("Failed to add dependency: %v", err)
|
||||
}
|
||||
|
||||
issues := []*types.Issue{issue1, issue2, issue3}
|
||||
if err := renamePrefixInDB(ctx, "old", "new", issues); err != nil {
|
||||
t.Fatalf("renamePrefixInDB failed: %v", err)
|
||||
}
|
||||
|
||||
newPrefix, err := testStore.GetConfig(ctx, "issue_prefix")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get new prefix: %v", err)
|
||||
}
|
||||
if newPrefix != "new" {
|
||||
t.Errorf("Expected prefix 'new', got %q", newPrefix)
|
||||
}
|
||||
|
||||
updatedIssue1, err := testStore.GetIssue(ctx, "new-1")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get updated issue1: %v", err)
|
||||
}
|
||||
if updatedIssue1.Title != "Fix bug in new-2" {
|
||||
t.Errorf("Expected title 'Fix bug in new-2', got %q", updatedIssue1.Title)
|
||||
}
|
||||
if updatedIssue1.Description != "See new-3 for details" {
|
||||
t.Errorf("Expected description 'See new-3 for details', got %q", updatedIssue1.Description)
|
||||
}
|
||||
|
||||
updatedIssue2, err := testStore.GetIssue(ctx, "new-2")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get updated issue2: %v", err)
|
||||
}
|
||||
if updatedIssue2.Title != "Related to new-1" {
|
||||
t.Errorf("Expected title 'Related to new-1', got %q", updatedIssue2.Title)
|
||||
}
|
||||
if updatedIssue2.Description != "This depends on new-1" {
|
||||
t.Errorf("Expected description 'This depends on new-1', got %q", updatedIssue2.Description)
|
||||
}
|
||||
|
||||
updatedIssue3, err := testStore.GetIssue(ctx, "new-3")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get updated issue3: %v", err)
|
||||
}
|
||||
if updatedIssue3.Design != "Mentions new-2 in design" {
|
||||
t.Errorf("Expected design 'Mentions new-2 in design', got %q", updatedIssue3.Design)
|
||||
}
|
||||
|
||||
deps, err := testStore.GetDependencies(ctx, "new-1")
|
||||
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].ID != "new-2" {
|
||||
t.Errorf("Expected dependency ID 'new-2', got %q", deps[0].ID)
|
||||
}
|
||||
|
||||
oldIssue, err := testStore.GetIssue(ctx, "old-1")
|
||||
if err == nil && oldIssue != nil {
|
||||
t.Errorf("Expected old-1 to not exist, but got: %+v", oldIssue)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenamePrefixInDB(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
dbPath := filepath.Join(tmpDir, "test.db")
|
||||
|
||||
testStore, err := sqlite.New(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test database: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
testStore.Close()
|
||||
os.Remove(dbPath)
|
||||
})
|
||||
|
||||
ctx := context.Background()
|
||||
store = testStore
|
||||
actor = "test-actor"
|
||||
|
||||
if err := testStore.SetConfig(ctx, "issue_prefix", "old"); err != nil {
|
||||
t.Fatalf("Failed to set config: %v", err)
|
||||
}
|
||||
|
||||
issue1 := &types.Issue{
|
||||
ID: "old-1",
|
||||
Title: "Test issue",
|
||||
Description: "Description",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: types.TypeTask,
|
||||
}
|
||||
|
||||
if err := testStore.CreateIssue(ctx, issue1, "test"); err != nil {
|
||||
t.Fatalf("Failed to create issue: %v", err)
|
||||
}
|
||||
|
||||
issues := []*types.Issue{issue1}
|
||||
err = renamePrefixInDB(ctx, "old", "new", issues)
|
||||
if err != nil {
|
||||
t.Fatalf("renamePrefixInDB failed: %v", err)
|
||||
}
|
||||
|
||||
oldIssue, err := testStore.GetIssue(ctx, "old-1")
|
||||
if err == nil && oldIssue != nil {
|
||||
t.Errorf("Expected old-1 to not exist after rename, got: %+v", oldIssue)
|
||||
}
|
||||
|
||||
newIssue, err := testStore.GetIssue(ctx, "new-1")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get new-1: %v", err)
|
||||
}
|
||||
if newIssue.ID != "new-1" {
|
||||
t.Errorf("Expected ID 'new-1', got %q", newIssue.ID)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user