feat: add --parent flag to bd update for reparenting issues (bd-cj2e)

Allows reparenting issues to a different epic/parent:
  bd update bd-xyz --parent=bd-epic

Implementation:
- Add Parent field to UpdateArgs in protocol.go
- Handle reparenting in RPC handler (server_issues_epics.go)
- Add --parent flag to updateCmd with both daemon and direct mode support
- Remove old parent-child dependency before adding new one
- Pass empty string to remove parent entirely

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-12-27 22:17:03 -08:00
parent 05d44503de
commit 35f8e197ee
3 changed files with 114 additions and 3 deletions

View File

@@ -635,6 +635,10 @@ var updateCmd = &cobra.Command{
setLabels, _ := cmd.Flags().GetStringSlice("set-labels") setLabels, _ := cmd.Flags().GetStringSlice("set-labels")
updates["set_labels"] = setLabels updates["set_labels"] = setLabels
} }
if cmd.Flags().Changed("parent") {
parent, _ := cmd.Flags().GetString("parent")
updates["parent"] = parent
}
if cmd.Flags().Changed("type") { if cmd.Flags().Changed("type") {
issueType, _ := cmd.Flags().GetString("type") issueType, _ := cmd.Flags().GetString("type")
// Validate issue type // Validate issue type
@@ -726,6 +730,9 @@ var updateCmd = &cobra.Command{
if issueType, ok := updates["issue_type"].(string); ok { if issueType, ok := updates["issue_type"].(string); ok {
updateArgs.IssueType = &issueType updateArgs.IssueType = &issueType
} }
if parent, ok := updates["parent"].(string); ok {
updateArgs.Parent = &parent
}
resp, err := daemonClient.Update(updateArgs) resp, err := daemonClient.Update(updateArgs)
if err != nil { if err != nil {
@@ -771,7 +778,7 @@ var updateCmd = &cobra.Command{
// Apply regular field updates if any // Apply regular field updates if any
regularUpdates := make(map[string]interface{}) regularUpdates := make(map[string]interface{})
for k, v := range updates { for k, v := range updates {
if k != "add_labels" && k != "remove_labels" && k != "set_labels" { if k != "add_labels" && k != "remove_labels" && k != "set_labels" && k != "parent" {
regularUpdates[k] = v regularUpdates[k] = v
} }
} }
@@ -800,6 +807,50 @@ var updateCmd = &cobra.Command{
} }
} }
// Handle parent reparenting (bd-cj2e)
if newParent, ok := updates["parent"].(string); ok {
// Validate new parent exists (unless empty string to remove parent)
if newParent != "" {
parentIssue, err := store.GetIssue(ctx, newParent)
if err != nil {
fmt.Fprintf(os.Stderr, "Error getting parent %s: %v\n", newParent, err)
continue
}
if parentIssue == nil {
fmt.Fprintf(os.Stderr, "Error: parent issue %s not found\n", newParent)
continue
}
}
// Find and remove existing parent-child dependency
deps, err := store.GetDependencyRecords(ctx, id)
if err != nil {
fmt.Fprintf(os.Stderr, "Error getting dependencies for %s: %v\n", id, err)
continue
}
for _, dep := range deps {
if dep.Type == types.DepParentChild {
if err := store.RemoveDependency(ctx, id, dep.DependsOnID, actor); err != nil {
fmt.Fprintf(os.Stderr, "Error removing old parent dependency: %v\n", err)
}
break
}
}
// Add new parent-child dependency (if not removing parent)
if newParent != "" {
newDep := &types.Dependency{
IssueID: id,
DependsOnID: newParent,
Type: types.DepParentChild,
}
if err := store.AddDependency(ctx, newDep, actor); err != nil {
fmt.Fprintf(os.Stderr, "Error adding parent dependency: %v\n", err)
continue
}
}
}
// Run update hook (bd-kwro.8) // Run update hook (bd-kwro.8)
updatedIssue, _ := store.GetIssue(ctx, id) updatedIssue, _ := store.GetIssue(ctx, id)
if updatedIssue != nil && hookRunner != nil { if updatedIssue != nil && hookRunner != nil {
@@ -1496,6 +1547,7 @@ func init() {
updateCmd.Flags().StringSlice("add-label", nil, "Add labels (repeatable)") updateCmd.Flags().StringSlice("add-label", nil, "Add labels (repeatable)")
updateCmd.Flags().StringSlice("remove-label", nil, "Remove labels (repeatable)") updateCmd.Flags().StringSlice("remove-label", nil, "Remove labels (repeatable)")
updateCmd.Flags().StringSlice("set-labels", nil, "Set labels, replacing all existing (repeatable)") updateCmd.Flags().StringSlice("set-labels", nil, "Set labels, replacing all existing (repeatable)")
updateCmd.Flags().String("parent", "", "New parent issue ID (reparents the issue, use empty string to remove parent)")
rootCmd.AddCommand(updateCmd) rootCmd.AddCommand(updateCmd)
editCmd.Flags().Bool("title", false, "Edit the title") editCmd.Flags().Bool("title", false, "Edit the title")

View File

@@ -124,6 +124,8 @@ type UpdateArgs struct {
SupersededBy *string `json:"superseded_by,omitempty"` // Replacement issue ID if obsolete SupersededBy *string `json:"superseded_by,omitempty"` // Replacement issue ID if obsolete
// Pinned field (bd-iea) // Pinned field (bd-iea)
Pinned *bool `json:"pinned,omitempty"` // If true, issue is a persistent context marker Pinned *bool `json:"pinned,omitempty"` // If true, issue is a persistent context marker
// Reparenting field (bd-cj2e)
Parent *string `json:"parent,omitempty"` // New parent issue ID (reparents the issue)
} }
// CloseArgs represents arguments for the close operation // CloseArgs represents arguments for the close operation

View File

@@ -466,8 +466,65 @@ func (s *Server) handleUpdate(req *Request) Response {
} }
} }
// Emit mutation event for event-driven daemon (only if any updates or label operations were performed) // Handle reparenting (bd-cj2e)
if len(updates) > 0 || len(updateArgs.SetLabels) > 0 || len(updateArgs.AddLabels) > 0 || len(updateArgs.RemoveLabels) > 0 { if updateArgs.Parent != nil {
newParentID := *updateArgs.Parent
// Validate new parent exists (unless empty string to remove parent)
if newParentID != "" {
newParent, err := store.GetIssue(ctx, newParentID)
if err != nil {
return Response{
Success: false,
Error: fmt.Sprintf("failed to get new parent: %v", err),
}
}
if newParent == nil {
return Response{
Success: false,
Error: fmt.Sprintf("parent issue %s not found", newParentID),
}
}
}
// Find and remove existing parent-child dependency
deps, err := store.GetDependencyRecords(ctx, updateArgs.ID)
if err != nil {
return Response{
Success: false,
Error: fmt.Sprintf("failed to get dependencies: %v", err),
}
}
for _, dep := range deps {
if dep.Type == types.DepParentChild {
if err := store.RemoveDependency(ctx, updateArgs.ID, dep.DependsOnID, actor); err != nil {
return Response{
Success: false,
Error: fmt.Sprintf("failed to remove old parent dependency: %v", err),
}
}
break // Only one parent-child dependency expected
}
}
// Add new parent-child dependency (if not removing parent)
if newParentID != "" {
newDep := &types.Dependency{
IssueID: updateArgs.ID,
DependsOnID: newParentID,
Type: types.DepParentChild,
}
if err := store.AddDependency(ctx, newDep, actor); err != nil {
return Response{
Success: false,
Error: fmt.Sprintf("failed to add parent dependency: %v", err),
}
}
}
}
// Emit mutation event for event-driven daemon (only if any updates or label/parent operations were performed)
if len(updates) > 0 || len(updateArgs.SetLabels) > 0 || len(updateArgs.AddLabels) > 0 || len(updateArgs.RemoveLabels) > 0 || updateArgs.Parent != nil {
// Check if this was a status change - emit rich MutationStatus event // Check if this was a status change - emit rich MutationStatus event
if updateArgs.Status != nil && *updateArgs.Status != string(issue.Status) { if updateArgs.Status != nil && *updateArgs.Status != string(issue.Status) {
s.emitRichMutation(MutationEvent{ s.emitRichMutation(MutationEvent{