package main import ( "context" "fmt" "os" "path/filepath" "github.com/spf13/cobra" "github.com/steveyegge/beads/internal/routing" "github.com/steveyegge/beads/internal/storage" "github.com/steveyegge/beads/internal/storage/sqlite" "github.com/steveyegge/beads/internal/types" "github.com/steveyegge/beads/internal/ui" ) var moveCmd = &cobra.Command{ Use: "move --to ", GroupID: "issues", Short: "Move an issue to a different rig with dependency remapping", Long: `Move an issue from one rig to another, updating dependencies. This command: 1. Creates a new issue in the target rig with the same content 2. Updates dependencies that reference the old ID (see below) 3. Closes the source issue with a redirect note The target rig can be specified as: - A rig name: beads, gastown - A prefix: bd-, gt- - A prefix without hyphen: bd, gt Dependency handling for cross-rig moves: - Issues that depend ON the moved issue: updated to external refs - Issues that the moved issue DEPENDS ON: removed (recreate manually in target) Note: Labels are copied. Comments and event history are not transferred. Examples: bd move hq-c21fj --to beads # Move to beads by rig name bd move hq-q3tki --to gt- # Move to gastown by prefix bd move hq-1h2to --to gt # Move to gastown (prefix without hyphen)`, Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { CheckReadonly("move") sourceID := args[0] targetRig, _ := cmd.Flags().GetString("to") if targetRig == "" { FatalError("--to flag is required. Specify target rig (e.g., --to beads, --to gt-)") } keepOpen, _ := cmd.Flags().GetBool("keep-open") skipDeps, _ := cmd.Flags().GetBool("skip-deps") ctx := rootCtx // Step 1: Get the source issue (via routing if needed) result, err := resolveAndGetIssueWithRouting(ctx, store, sourceID) if err != nil { FatalError("failed to find source issue: %v", err) } if result == nil || result.Issue == nil { FatalError("source issue %s not found", sourceID) } defer result.Close() sourceIssue := result.Issue resolvedSourceID := result.ResolvedID sourceStore := result.Store // Warn if source issue is already closed if sourceIssue.Status == types.StatusClosed { fmt.Fprintf(os.Stderr, "%s Source issue %s is already closed\n", ui.RenderWarn("⚠"), resolvedSourceID) } // Warn if ephemeral if sourceIssue.Ephemeral { fmt.Fprintf(os.Stderr, "%s Source issue %s is ephemeral (wisp). Moving ephemeral issues may not be appropriate.\n", ui.RenderWarn("⚠"), resolvedSourceID) } // Step 2: Find the town-level beads directory townBeadsDir, err := findTownBeadsDir() if err != nil { FatalError("cannot move: %v", err) } // Step 3: Resolve the target rig's beads directory targetBeadsDir, targetPrefix, err := routing.ResolveBeadsDirForRig(targetRig, townBeadsDir) if err != nil { FatalError("%v", err) } // Check we're not moving to the same rig sourcePrefix := routing.ExtractPrefix(resolvedSourceID) if sourcePrefix == targetPrefix { FatalError("source issue %s is already in rig %q", resolvedSourceID, targetRig) } // Step 4: Open storage for the target rig targetDBPath := filepath.Join(targetBeadsDir, "beads.db") targetStore, err := sqlite.New(ctx, targetDBPath) if err != nil { FatalError("failed to open target rig database: %v", err) } defer func() { if err := targetStore.Close(); err != nil { fmt.Fprintf(os.Stderr, "warning: failed to close target rig database: %v\n", err) } }() // Step 5: Create the new issue in target rig (copy all fields) newIssue := &types.Issue{ // Don't copy ID - let target rig generate new one Title: sourceIssue.Title, Description: sourceIssue.Description, Design: sourceIssue.Design, AcceptanceCriteria: sourceIssue.AcceptanceCriteria, Notes: sourceIssue.Notes, Status: types.StatusOpen, // Always start as open Priority: sourceIssue.Priority, IssueType: sourceIssue.IssueType, Assignee: sourceIssue.Assignee, ExternalRef: sourceIssue.ExternalRef, EstimatedMinutes: sourceIssue.EstimatedMinutes, SourceRepo: sourceIssue.SourceRepo, Ephemeral: sourceIssue.Ephemeral, MolType: sourceIssue.MolType, RoleType: sourceIssue.RoleType, Rig: sourceIssue.Rig, DueAt: sourceIssue.DueAt, DeferUntil: sourceIssue.DeferUntil, CreatedBy: actor, } // Append moved note to description if newIssue.Description != "" { newIssue.Description += "\n\n" } newIssue.Description += fmt.Sprintf("(Moved from %s)", resolvedSourceID) if err := targetStore.CreateIssue(ctx, newIssue, actor); err != nil { FatalError("failed to create issue in target rig: %v", err) } newID := newIssue.ID // Step 6: Copy labels if any labels, err := sourceStore.GetLabels(ctx, resolvedSourceID) if err == nil && len(labels) > 0 { for _, label := range labels { if err := targetStore.AddLabel(ctx, newID, label, actor); err != nil { WarnError("failed to copy label %s: %v", label, err) } } } // Step 7: Remap dependencies in the source store // targetRig is used to create external references for cross-rig moves var depsRemapped int if !skipDeps { depsRemapped, err = remapDependencies(ctx, sourceStore, resolvedSourceID, newID, targetRig, actor) if err != nil { WarnError("failed to remap some dependencies: %v", err) } } // Step 8: Close the source issue (unless --keep-open) if !keepOpen { closeReason := fmt.Sprintf("Moved to %s", newID) if err := sourceStore.CloseIssue(ctx, resolvedSourceID, closeReason, actor, ""); err != nil { WarnError("failed to close source issue: %v", err) } // Schedule auto-flush if source was local store if !result.Routed { markDirtyAndScheduleFlush() } } // Output if jsonOutput { outputJSON(map[string]interface{}{ "source": resolvedSourceID, "target": newID, "closed": !keepOpen, "deps_remapped": depsRemapped, }) } else { fmt.Printf("%s Moved %s → %s\n", ui.RenderPass("✓"), resolvedSourceID, newID) if depsRemapped > 0 { fmt.Printf(" Remapped %d dependencies\n", depsRemapped) } if !keepOpen { fmt.Printf(" Source issue closed\n") } } }, } // remapDependencies updates all dependencies in the store that reference oldID to use newID. // For cross-rig moves (which is the only supported case), dependencies TO the old ID are // converted to external references. Dependencies FROM the old ID are removed since they // can't be recreated in the source store. // Returns the number of dependencies remapped. func remapDependencies(ctx context.Context, s storage.Storage, oldID, newID, targetRig, actor string) (int, error) { count := 0 // Get dependencies where oldID is the issue (oldID depends on something) // These must be removed since the new issue is in a different rig's store depsFrom, err := s.GetDependencyRecords(ctx, oldID) if err != nil { return count, fmt.Errorf("getting dependencies from %s: %w", oldID, err) } // Remove deps FROM the old ID (user needs to recreate in target rig) for _, dep := range depsFrom { if err := s.RemoveDependency(ctx, oldID, dep.DependsOnID, actor); err != nil { fmt.Fprintf(os.Stderr, " warning: failed to remove dep %s->%s: %v\n", oldID, dep.DependsOnID, err) } } if len(depsFrom) > 0 { fmt.Fprintf(os.Stderr, " note: %d dependencies FROM %s were removed (recreate in target rig if needed)\n", len(depsFrom), oldID) } // Get dependents (issues that depend on oldID) dependents, err := s.GetDependents(ctx, oldID) if err != nil { return count, fmt.Errorf("getting dependents of %s: %w", oldID, err) } // For each issue that depends on oldID, update to use external ref to newID for _, dependent := range dependents { // Get the dependency record to preserve type/metadata depRecords, err := s.GetDependencyRecords(ctx, dependent.ID) if err != nil { fmt.Fprintf(os.Stderr, " warning: failed to get deps for %s: %v\n", dependent.ID, err) continue } for _, dep := range depRecords { if dep.DependsOnID != oldID { continue } // Remove old dependency if err := s.RemoveDependency(ctx, dependent.ID, oldID, actor); err != nil { fmt.Fprintf(os.Stderr, " warning: failed to remove dep %s->%s: %v\n", dependent.ID, oldID, err) continue } // Point to external reference in target rig externalRef := fmt.Sprintf("external:%s:%s", targetRig, newID) // Add new dependency with external ref newDep := &types.Dependency{ IssueID: dependent.ID, DependsOnID: externalRef, Type: dep.Type, CreatedBy: actor, Metadata: dep.Metadata, ThreadID: dep.ThreadID, } if err := s.AddDependency(ctx, newDep, actor); err != nil { fmt.Fprintf(os.Stderr, " warning: failed to add dep %s->%s: %v\n", dependent.ID, externalRef, err) continue } count++ } } return count, nil } func init() { moveCmd.Flags().String("to", "", "Target rig or prefix (required)") moveCmd.Flags().Bool("keep-open", false, "Keep the source issue open (don't close it)") moveCmd.Flags().Bool("skip-deps", false, "Skip dependency remapping") moveCmd.ValidArgsFunction = issueIDCompletion rootCmd.AddCommand(moveCmd) }