From 3ec517cc1b2a281ebe3b9d40c1554eb97a7eaa0c Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Thu, 18 Dec 2025 01:34:14 -0800 Subject: [PATCH] Fix daemon/direct mode inconsistency in graph commands (bd-fu83) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- cmd/bd/duplicate.go | 52 ++++++++++++++----- cmd/bd/relate.go | 80 +++++++++++++++++++++++------ internal/rpc/protocol.go | 4 ++ internal/rpc/server_issues_epics.go | 10 ++++ 4 files changed, 116 insertions(+), 30 deletions(-) diff --git a/cmd/bd/duplicate.go b/cmd/bd/duplicate.go index ea4f0d6a..db3542f9 100644 --- a/cmd/bd/duplicate.go +++ b/cmd/bd/duplicate.go @@ -112,13 +112,25 @@ func runDuplicate(cmd *cobra.Command, args []string) error { } // 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) + 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 @@ -199,13 +211,25 @@ func runSupersede(cmd *cobra.Command, args []string) error { } // 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) + 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 diff --git a/cmd/bd/relate.go b/cmd/bd/relate.go index eb71db9b..9eac1e0e 100644 --- a/cmd/bd/relate.go +++ b/cmd/bd/relate.go @@ -122,20 +122,44 @@ func runRelate(cmd *cobra.Command, args []string) error { // Add id2 to issue1's relates_to if not already present if !contains(issue1.RelatesTo, id2) { newRelatesTo1 := append(issue1.RelatesTo, id2) - if err := store.UpdateIssue(ctx, id1, map[string]interface{}{ - "relates_to": formatRelatesTo(newRelatesTo1), - }, actor); err != nil { - return fmt.Errorf("failed to update %s: %w", id1, err) + formattedRelatesTo1 := formatRelatesTo(newRelatesTo1) + if daemonClient != nil { + // Use RPC for daemon mode (bd-fu83) + _, err := daemonClient.Update(&rpc.UpdateArgs{ + ID: id1, + RelatesTo: &formattedRelatesTo1, + }) + if err != nil { + return fmt.Errorf("failed to update %s: %w", id1, err) + } + } else { + if err := store.UpdateIssue(ctx, id1, map[string]interface{}{ + "relates_to": formattedRelatesTo1, + }, actor); err != nil { + return fmt.Errorf("failed to update %s: %w", id1, err) + } } } // Add id1 to issue2's relates_to if not already present if !contains(issue2.RelatesTo, id1) { newRelatesTo2 := append(issue2.RelatesTo, id1) - if err := store.UpdateIssue(ctx, id2, map[string]interface{}{ - "relates_to": formatRelatesTo(newRelatesTo2), - }, actor); err != nil { - return fmt.Errorf("failed to update %s: %w", id2, err) + formattedRelatesTo2 := formatRelatesTo(newRelatesTo2) + if daemonClient != nil { + // Use RPC for daemon mode (bd-fu83) + _, err := daemonClient.Update(&rpc.UpdateArgs{ + ID: id2, + RelatesTo: &formattedRelatesTo2, + }) + if err != nil { + return fmt.Errorf("failed to update %s: %w", id2, err) + } + } else { + if err := store.UpdateIssue(ctx, id2, map[string]interface{}{ + "relates_to": formattedRelatesTo2, + }, actor); err != nil { + return fmt.Errorf("failed to update %s: %w", id2, err) + } } } @@ -232,18 +256,42 @@ func runUnrelate(cmd *cobra.Command, args []string) error { // Remove id2 from issue1's relates_to newRelatesTo1 := remove(issue1.RelatesTo, id2) - if err := store.UpdateIssue(ctx, id1, map[string]interface{}{ - "relates_to": formatRelatesTo(newRelatesTo1), - }, actor); err != nil { - return fmt.Errorf("failed to update %s: %w", id1, err) + formattedRelatesTo1 := formatRelatesTo(newRelatesTo1) + if daemonClient != nil { + // Use RPC for daemon mode (bd-fu83) + _, err := daemonClient.Update(&rpc.UpdateArgs{ + ID: id1, + RelatesTo: &formattedRelatesTo1, + }) + if err != nil { + return fmt.Errorf("failed to update %s: %w", id1, err) + } + } else { + if err := store.UpdateIssue(ctx, id1, map[string]interface{}{ + "relates_to": formattedRelatesTo1, + }, actor); err != nil { + return fmt.Errorf("failed to update %s: %w", id1, err) + } } // Remove id1 from issue2's relates_to newRelatesTo2 := remove(issue2.RelatesTo, id1) - if err := store.UpdateIssue(ctx, id2, map[string]interface{}{ - "relates_to": formatRelatesTo(newRelatesTo2), - }, actor); err != nil { - return fmt.Errorf("failed to update %s: %w", id2, err) + formattedRelatesTo2 := formatRelatesTo(newRelatesTo2) + if daemonClient != nil { + // Use RPC for daemon mode (bd-fu83) + _, err := daemonClient.Update(&rpc.UpdateArgs{ + ID: id2, + RelatesTo: &formattedRelatesTo2, + }) + if err != nil { + return fmt.Errorf("failed to update %s: %w", id2, err) + } + } else { + if err := store.UpdateIssue(ctx, id2, map[string]interface{}{ + "relates_to": formattedRelatesTo2, + }, actor); err != nil { + return fmt.Errorf("failed to update %s: %w", id2, err) + } } // Trigger auto-flush diff --git a/internal/rpc/protocol.go b/internal/rpc/protocol.go index 55591ed2..aae6aed8 100644 --- a/internal/rpc/protocol.go +++ b/internal/rpc/protocol.go @@ -99,6 +99,10 @@ type UpdateArgs struct { Sender *string `json:"sender,omitempty"` // Who sent this (for messages) Ephemeral *bool `json:"ephemeral,omitempty"` // Can be bulk-deleted when closed RepliesTo *string `json:"replies_to,omitempty"` // Issue ID for conversation threading + // Graph link fields (bd-fu83) + RelatesTo *string `json:"relates_to,omitempty"` // JSON array of related issue IDs + DuplicateOf *string `json:"duplicate_of,omitempty"` // Canonical issue ID if duplicate + SupersededBy *string `json:"superseded_by,omitempty"` // Replacement issue ID if obsolete } // CloseArgs represents arguments for the close operation diff --git a/internal/rpc/server_issues_epics.go b/internal/rpc/server_issues_epics.go index 3bcbca3b..17136231 100644 --- a/internal/rpc/server_issues_epics.go +++ b/internal/rpc/server_issues_epics.go @@ -87,6 +87,16 @@ func updatesFromArgs(a UpdateArgs) map[string]interface{} { if a.RepliesTo != nil { u["replies_to"] = *a.RepliesTo } + // Graph link fields (bd-fu83) + if a.RelatesTo != nil { + u["relates_to"] = *a.RelatesTo + } + if a.DuplicateOf != nil { + u["duplicate_of"] = *a.DuplicateOf + } + if a.SupersededBy != nil { + u["superseded_by"] = *a.SupersededBy + } return u }