Commands relate, unrelate, duplicate, and supersede now properly use RPC Update when daemonClient is available, instead of always calling store.UpdateIssue() directly and bypassing the daemon. Added RelatesTo, DuplicateOf, and SupersededBy fields to UpdateArgs in the RPC protocol, and updated server_issues_epics.go to handle them. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
255 lines
7.1 KiB
Go
255 lines
7.1 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
|
|
"github.com/fatih/color"
|
|
"github.com/spf13/cobra"
|
|
"github.com/steveyegge/beads/internal/rpc"
|
|
"github.com/steveyegge/beads/internal/types"
|
|
"github.com/steveyegge/beads/internal/utils"
|
|
)
|
|
|
|
var duplicateCmd = &cobra.Command{
|
|
Use: "duplicate <id> --of <canonical>",
|
|
Short: "Mark an issue as a duplicate of another",
|
|
Long: `Mark an issue as a duplicate of a canonical issue.
|
|
|
|
The duplicate issue is automatically closed with a reference to the canonical.
|
|
This is essential for large issue databases with many similar reports.
|
|
|
|
Examples:
|
|
bd duplicate bd-abc --of bd-xyz # Mark bd-abc as duplicate of bd-xyz`,
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: runDuplicate,
|
|
}
|
|
|
|
var supersedeCmd = &cobra.Command{
|
|
Use: "supersede <id> --with <new>",
|
|
Short: "Mark an issue as superseded by a newer one",
|
|
Long: `Mark an issue as superseded by a newer version.
|
|
|
|
The superseded issue is automatically closed with a reference to the replacement.
|
|
Useful for design docs, specs, and evolving artifacts.
|
|
|
|
Examples:
|
|
bd supersede bd-old --with bd-new # Mark bd-old as superseded by bd-new`,
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: runSupersede,
|
|
}
|
|
|
|
var (
|
|
duplicateOf string
|
|
supersededWith string
|
|
)
|
|
|
|
func init() {
|
|
duplicateCmd.Flags().StringVar(&duplicateOf, "of", "", "Canonical issue ID (required)")
|
|
_ = duplicateCmd.MarkFlagRequired("of")
|
|
rootCmd.AddCommand(duplicateCmd)
|
|
|
|
supersedeCmd.Flags().StringVar(&supersededWith, "with", "", "Replacement issue ID (required)")
|
|
_ = supersedeCmd.MarkFlagRequired("with")
|
|
rootCmd.AddCommand(supersedeCmd)
|
|
}
|
|
|
|
func runDuplicate(cmd *cobra.Command, args []string) error {
|
|
CheckReadonly("duplicate")
|
|
|
|
ctx := rootCtx
|
|
|
|
// Resolve partial IDs
|
|
var duplicateID, canonicalID string
|
|
if daemonClient != nil {
|
|
resp1, err := daemonClient.ResolveID(&rpc.ResolveIDArgs{ID: args[0]})
|
|
if err != nil {
|
|
return fmt.Errorf("failed to resolve %s: %w", args[0], err)
|
|
}
|
|
if err := json.Unmarshal(resp1.Data, &duplicateID); err != nil {
|
|
return fmt.Errorf("parsing response: %w", err)
|
|
}
|
|
resp2, err := daemonClient.ResolveID(&rpc.ResolveIDArgs{ID: duplicateOf})
|
|
if err != nil {
|
|
return fmt.Errorf("failed to resolve %s: %w", duplicateOf, err)
|
|
}
|
|
if err := json.Unmarshal(resp2.Data, &canonicalID); err != nil {
|
|
return fmt.Errorf("parsing response: %w", err)
|
|
}
|
|
} else {
|
|
var err error
|
|
duplicateID, err = utils.ResolvePartialID(ctx, store, args[0])
|
|
if err != nil {
|
|
return fmt.Errorf("failed to resolve %s: %w", args[0], err)
|
|
}
|
|
canonicalID, err = utils.ResolvePartialID(ctx, store, duplicateOf)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to resolve %s: %w", duplicateOf, err)
|
|
}
|
|
}
|
|
|
|
if duplicateID == canonicalID {
|
|
return fmt.Errorf("cannot mark an issue as duplicate of itself")
|
|
}
|
|
|
|
// Verify canonical issue exists
|
|
var canonical *types.Issue
|
|
if daemonClient != nil {
|
|
resp, err := daemonClient.Show(&rpc.ShowArgs{ID: canonicalID})
|
|
if err != nil {
|
|
return fmt.Errorf("canonical issue not found: %s", canonicalID)
|
|
}
|
|
if err := json.Unmarshal(resp.Data, &canonical); err != nil {
|
|
return fmt.Errorf("parsing response: %w", err)
|
|
}
|
|
} else {
|
|
var err error
|
|
canonical, err = store.GetIssue(ctx, canonicalID)
|
|
if err != nil || canonical == nil {
|
|
return fmt.Errorf("canonical issue not found: %s", canonicalID)
|
|
}
|
|
}
|
|
|
|
// Update the duplicate issue with duplicate_of and close it
|
|
closedStatus := string(types.StatusClosed)
|
|
if daemonClient != nil {
|
|
// Use RPC for daemon mode (bd-fu83)
|
|
_, err := daemonClient.Update(&rpc.UpdateArgs{
|
|
ID: duplicateID,
|
|
DuplicateOf: &canonicalID,
|
|
Status: &closedStatus,
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("failed to mark as duplicate: %w", err)
|
|
}
|
|
} else {
|
|
updates := map[string]interface{}{
|
|
"duplicate_of": canonicalID,
|
|
"status": closedStatus,
|
|
}
|
|
if err := store.UpdateIssue(ctx, duplicateID, updates, actor); err != nil {
|
|
return fmt.Errorf("failed to mark as duplicate: %w", err)
|
|
}
|
|
}
|
|
|
|
// Trigger auto-flush
|
|
if flushManager != nil {
|
|
flushManager.MarkDirty(false)
|
|
}
|
|
|
|
if jsonOutput {
|
|
result := map[string]interface{}{
|
|
"duplicate": duplicateID,
|
|
"canonical": canonicalID,
|
|
"status": "closed",
|
|
}
|
|
encoder := json.NewEncoder(os.Stdout)
|
|
encoder.SetIndent("", " ")
|
|
return encoder.Encode(result)
|
|
}
|
|
|
|
green := color.New(color.FgGreen).SprintFunc()
|
|
fmt.Printf("%s Marked %s as duplicate of %s (closed)\n", green("✓"), duplicateID, canonicalID)
|
|
return nil
|
|
}
|
|
|
|
func runSupersede(cmd *cobra.Command, args []string) error {
|
|
CheckReadonly("supersede")
|
|
|
|
ctx := rootCtx
|
|
|
|
// Resolve partial IDs
|
|
var oldID, newID string
|
|
if daemonClient != nil {
|
|
resp1, err := daemonClient.ResolveID(&rpc.ResolveIDArgs{ID: args[0]})
|
|
if err != nil {
|
|
return fmt.Errorf("failed to resolve %s: %w", args[0], err)
|
|
}
|
|
if err := json.Unmarshal(resp1.Data, &oldID); err != nil {
|
|
return fmt.Errorf("parsing response: %w", err)
|
|
}
|
|
resp2, err := daemonClient.ResolveID(&rpc.ResolveIDArgs{ID: supersededWith})
|
|
if err != nil {
|
|
return fmt.Errorf("failed to resolve %s: %w", supersededWith, err)
|
|
}
|
|
if err := json.Unmarshal(resp2.Data, &newID); err != nil {
|
|
return fmt.Errorf("parsing response: %w", err)
|
|
}
|
|
} else {
|
|
var err error
|
|
oldID, err = utils.ResolvePartialID(ctx, store, args[0])
|
|
if err != nil {
|
|
return fmt.Errorf("failed to resolve %s: %w", args[0], err)
|
|
}
|
|
newID, err = utils.ResolvePartialID(ctx, store, supersededWith)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to resolve %s: %w", supersededWith, err)
|
|
}
|
|
}
|
|
|
|
if oldID == newID {
|
|
return fmt.Errorf("cannot mark an issue as superseded by itself")
|
|
}
|
|
|
|
// Verify new issue exists
|
|
var newIssue *types.Issue
|
|
if daemonClient != nil {
|
|
resp, err := daemonClient.Show(&rpc.ShowArgs{ID: newID})
|
|
if err != nil {
|
|
return fmt.Errorf("replacement issue not found: %s", newID)
|
|
}
|
|
if err := json.Unmarshal(resp.Data, &newIssue); err != nil {
|
|
return fmt.Errorf("parsing response: %w", err)
|
|
}
|
|
} else {
|
|
var err error
|
|
newIssue, err = store.GetIssue(ctx, newID)
|
|
if err != nil || newIssue == nil {
|
|
return fmt.Errorf("replacement issue not found: %s", newID)
|
|
}
|
|
}
|
|
|
|
// Update the old issue with superseded_by and close it
|
|
closedStatus := string(types.StatusClosed)
|
|
if daemonClient != nil {
|
|
// Use RPC for daemon mode (bd-fu83)
|
|
_, err := daemonClient.Update(&rpc.UpdateArgs{
|
|
ID: oldID,
|
|
SupersededBy: &newID,
|
|
Status: &closedStatus,
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("failed to mark as superseded: %w", err)
|
|
}
|
|
} else {
|
|
updates := map[string]interface{}{
|
|
"superseded_by": newID,
|
|
"status": closedStatus,
|
|
}
|
|
if err := store.UpdateIssue(ctx, oldID, updates, actor); err != nil {
|
|
return fmt.Errorf("failed to mark as superseded: %w", err)
|
|
}
|
|
}
|
|
|
|
// Trigger auto-flush
|
|
if flushManager != nil {
|
|
flushManager.MarkDirty(false)
|
|
}
|
|
|
|
if jsonOutput {
|
|
result := map[string]interface{}{
|
|
"superseded": oldID,
|
|
"replacement": newID,
|
|
"status": "closed",
|
|
}
|
|
encoder := json.NewEncoder(os.Stdout)
|
|
encoder.SetIndent("", " ")
|
|
return encoder.Encode(result)
|
|
}
|
|
|
|
green := color.New(color.FgGreen).SprintFunc()
|
|
fmt.Printf("%s Marked %s as superseded by %s (closed)\n", green("✓"), oldID, newID)
|
|
return nil
|
|
}
|