Files
beads/cmd/bd/duplicate.go
Steve Yegge 46bfb43b8d feat: add graph links and hooks system (bd-kwro.2-5, bd-kwro.8)
- bd mail reply: reply to messages with thread linking via replies_to
- bd show --thread: display full conversation threads
- bd relate/unrelate: bidirectional relates_to links for knowledge graph
- bd duplicate --of: mark issues as duplicates with auto-close
- bd supersede --with: mark issues as superseded with auto-close
- Hooks system: on_create, on_update, on_close, on_message in .beads/hooks/
- RPC protocol: added Sender, Ephemeral, RepliesTo fields to CreateArgs/UpdateArgs

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 18:34:48 -08:00

231 lines
6.5 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
updates := map[string]interface{}{
"duplicate_of": canonicalID,
"status": string(types.StatusClosed),
}
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
updates := map[string]interface{}{
"superseded_by": newID,
"status": string(types.StatusClosed),
}
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
}