Improvements to shell completions from PR #935: 1. Add IDPrefix field to IssueFilter for efficient database-level filtering - Queries are now filtered at SQL level instead of fetching all issues - Updated sqlite, transaction, and memory stores to support IDPrefix 2. Add ValidArgsFunction to additional commands: - dep (add, remove, list, tree) - comments, comment (add) - delete - graph - label (add, remove, list) - duplicate, supersede - audit - move - relate, unrelate - refile - gate (show, resolve, add-waiter) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> Executed-By: beads/crew/dave Rig: beads Role: crew
324 lines
9.0 KiB
Go
324 lines
9.0 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
|
|
"github.com/spf13/cobra"
|
|
"github.com/steveyegge/beads/internal/rpc"
|
|
"github.com/steveyegge/beads/internal/types"
|
|
"github.com/steveyegge/beads/internal/ui"
|
|
"github.com/steveyegge/beads/internal/utils"
|
|
)
|
|
|
|
var relateCmd = &cobra.Command{
|
|
Use: "relate <id1> <id2>",
|
|
Short: "Create a bidirectional relates_to link between issues",
|
|
Long: `Create a loose 'see also' relationship between two issues.
|
|
|
|
The relates_to link is bidirectional - both issues will reference each other.
|
|
This enables knowledge graph connections without blocking or hierarchy.
|
|
|
|
Examples:
|
|
bd relate bd-abc bd-xyz # Link two related issues
|
|
bd relate bd-123 bd-456 # Create see-also connection`,
|
|
Args: cobra.ExactArgs(2),
|
|
RunE: runRelate,
|
|
}
|
|
|
|
var unrelateCmd = &cobra.Command{
|
|
Use: "unrelate <id1> <id2>",
|
|
Short: "Remove a relates_to link between issues",
|
|
Long: `Remove a relates_to relationship between two issues.
|
|
|
|
Removes the link in both directions.
|
|
|
|
Example:
|
|
bd unrelate bd-abc bd-xyz`,
|
|
Args: cobra.ExactArgs(2),
|
|
RunE: runUnrelate,
|
|
}
|
|
|
|
func init() {
|
|
// Issue ID completions
|
|
relateCmd.ValidArgsFunction = issueIDCompletion
|
|
unrelateCmd.ValidArgsFunction = issueIDCompletion
|
|
|
|
// Add as subcommands of dep
|
|
depCmd.AddCommand(relateCmd)
|
|
depCmd.AddCommand(unrelateCmd)
|
|
|
|
// Backwards compatibility aliases at root level (hidden)
|
|
relateAliasCmd := *relateCmd
|
|
relateAliasCmd.Hidden = true
|
|
relateAliasCmd.Deprecated = "use 'bd dep relate' instead (will be removed in v1.0.0)"
|
|
rootCmd.AddCommand(&relateAliasCmd)
|
|
|
|
unrelateAliasCmd := *unrelateCmd
|
|
unrelateAliasCmd.Hidden = true
|
|
unrelateAliasCmd.Deprecated = "use 'bd dep unrelate' instead (will be removed in v1.0.0)"
|
|
rootCmd.AddCommand(&unrelateAliasCmd)
|
|
}
|
|
|
|
func runRelate(cmd *cobra.Command, args []string) error {
|
|
CheckReadonly("relate")
|
|
|
|
ctx := rootCtx
|
|
|
|
// Resolve partial IDs
|
|
var id1, id2 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, &id1); err != nil {
|
|
return fmt.Errorf("parsing response: %w", err)
|
|
}
|
|
resp2, err := daemonClient.ResolveID(&rpc.ResolveIDArgs{ID: args[1]})
|
|
if err != nil {
|
|
return fmt.Errorf("failed to resolve %s: %w", args[1], err)
|
|
}
|
|
if err := json.Unmarshal(resp2.Data, &id2); err != nil {
|
|
return fmt.Errorf("parsing response: %w", err)
|
|
}
|
|
} else {
|
|
var err error
|
|
id1, err = utils.ResolvePartialID(ctx, store, args[0])
|
|
if err != nil {
|
|
return fmt.Errorf("failed to resolve %s: %w", args[0], err)
|
|
}
|
|
id2, err = utils.ResolvePartialID(ctx, store, args[1])
|
|
if err != nil {
|
|
return fmt.Errorf("failed to resolve %s: %w", args[1], err)
|
|
}
|
|
}
|
|
|
|
if id1 == id2 {
|
|
return fmt.Errorf("cannot relate an issue to itself")
|
|
}
|
|
|
|
// Get both issues
|
|
var issue1, issue2 *types.Issue
|
|
if daemonClient != nil {
|
|
resp1, err := daemonClient.Show(&rpc.ShowArgs{ID: id1})
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get issue %s: %w", id1, err)
|
|
}
|
|
if err := json.Unmarshal(resp1.Data, &issue1); err != nil {
|
|
return fmt.Errorf("parsing response: %w", err)
|
|
}
|
|
resp2, err := daemonClient.Show(&rpc.ShowArgs{ID: id2})
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get issue %s: %w", id2, err)
|
|
}
|
|
if err := json.Unmarshal(resp2.Data, &issue2); err != nil {
|
|
return fmt.Errorf("parsing response: %w", err)
|
|
}
|
|
} else {
|
|
var err error
|
|
issue1, err = store.GetIssue(ctx, id1)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get issue %s: %w", id1, err)
|
|
}
|
|
issue2, err = store.GetIssue(ctx, id2)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get issue %s: %w", id2, err)
|
|
}
|
|
}
|
|
|
|
if issue1 == nil {
|
|
return fmt.Errorf("issue not found: %s", id1)
|
|
}
|
|
if issue2 == nil {
|
|
return fmt.Errorf("issue not found: %s", id2)
|
|
}
|
|
|
|
// Add relates-to dependency: id1 -> id2 (bidirectional, so also id2 -> id1)
|
|
// Per Decision 004, relates-to links are now stored in dependencies table
|
|
if daemonClient != nil {
|
|
// Add id1 -> id2
|
|
_, err := daemonClient.AddDependency(&rpc.DepAddArgs{
|
|
FromID: id1,
|
|
ToID: id2,
|
|
DepType: string(types.DepRelatesTo),
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("failed to add relates-to %s -> %s: %w", id1, id2, err)
|
|
}
|
|
// Add id2 -> id1 (bidirectional)
|
|
_, err = daemonClient.AddDependency(&rpc.DepAddArgs{
|
|
FromID: id2,
|
|
ToID: id1,
|
|
DepType: string(types.DepRelatesTo),
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("failed to add relates-to %s -> %s: %w", id2, id1, err)
|
|
}
|
|
} else {
|
|
// Add id1 -> id2
|
|
dep1 := &types.Dependency{
|
|
IssueID: id1,
|
|
DependsOnID: id2,
|
|
Type: types.DepRelatesTo,
|
|
}
|
|
if err := store.AddDependency(ctx, dep1, actor); err != nil {
|
|
return fmt.Errorf("failed to add relates-to %s -> %s: %w", id1, id2, err)
|
|
}
|
|
// Add id2 -> id1 (bidirectional)
|
|
dep2 := &types.Dependency{
|
|
IssueID: id2,
|
|
DependsOnID: id1,
|
|
Type: types.DepRelatesTo,
|
|
}
|
|
if err := store.AddDependency(ctx, dep2, actor); err != nil {
|
|
return fmt.Errorf("failed to add relates-to %s -> %s: %w", id2, id1, err)
|
|
}
|
|
}
|
|
|
|
// Trigger auto-flush
|
|
if flushManager != nil {
|
|
flushManager.MarkDirty(false)
|
|
}
|
|
|
|
if jsonOutput {
|
|
result := map[string]interface{}{
|
|
"id1": id1,
|
|
"id2": id2,
|
|
"related": true,
|
|
}
|
|
encoder := json.NewEncoder(os.Stdout)
|
|
encoder.SetIndent("", " ")
|
|
return encoder.Encode(result)
|
|
}
|
|
|
|
fmt.Printf("%s Linked %s ↔ %s\n", ui.RenderPass("✓"), id1, id2)
|
|
return nil
|
|
}
|
|
|
|
func runUnrelate(cmd *cobra.Command, args []string) error {
|
|
CheckReadonly("unrelate")
|
|
|
|
ctx := rootCtx
|
|
|
|
// Resolve partial IDs
|
|
var id1, id2 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, &id1); err != nil {
|
|
return fmt.Errorf("parsing response: %w", err)
|
|
}
|
|
resp2, err := daemonClient.ResolveID(&rpc.ResolveIDArgs{ID: args[1]})
|
|
if err != nil {
|
|
return fmt.Errorf("failed to resolve %s: %w", args[1], err)
|
|
}
|
|
if err := json.Unmarshal(resp2.Data, &id2); err != nil {
|
|
return fmt.Errorf("parsing response: %w", err)
|
|
}
|
|
} else {
|
|
var err error
|
|
id1, err = utils.ResolvePartialID(ctx, store, args[0])
|
|
if err != nil {
|
|
return fmt.Errorf("failed to resolve %s: %w", args[0], err)
|
|
}
|
|
id2, err = utils.ResolvePartialID(ctx, store, args[1])
|
|
if err != nil {
|
|
return fmt.Errorf("failed to resolve %s: %w", args[1], err)
|
|
}
|
|
}
|
|
|
|
// Get both issues
|
|
var issue1, issue2 *types.Issue
|
|
if daemonClient != nil {
|
|
resp1, err := daemonClient.Show(&rpc.ShowArgs{ID: id1})
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get issue %s: %w", id1, err)
|
|
}
|
|
if err := json.Unmarshal(resp1.Data, &issue1); err != nil {
|
|
return fmt.Errorf("parsing response: %w", err)
|
|
}
|
|
resp2, err := daemonClient.Show(&rpc.ShowArgs{ID: id2})
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get issue %s: %w", id2, err)
|
|
}
|
|
if err := json.Unmarshal(resp2.Data, &issue2); err != nil {
|
|
return fmt.Errorf("parsing response: %w", err)
|
|
}
|
|
} else {
|
|
var err error
|
|
issue1, err = store.GetIssue(ctx, id1)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get issue %s: %w", id1, err)
|
|
}
|
|
issue2, err = store.GetIssue(ctx, id2)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get issue %s: %w", id2, err)
|
|
}
|
|
}
|
|
|
|
if issue1 == nil {
|
|
return fmt.Errorf("issue not found: %s", id1)
|
|
}
|
|
if issue2 == nil {
|
|
return fmt.Errorf("issue not found: %s", id2)
|
|
}
|
|
|
|
// Remove relates-to dependency in both directions
|
|
// Per Decision 004, relates-to links are now stored in dependencies table
|
|
if daemonClient != nil {
|
|
// Remove id1 -> id2
|
|
_, err := daemonClient.RemoveDependency(&rpc.DepRemoveArgs{
|
|
FromID: id1,
|
|
ToID: id2,
|
|
DepType: string(types.DepRelatesTo),
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("failed to remove relates-to %s -> %s: %w", id1, id2, err)
|
|
}
|
|
// Remove id2 -> id1 (bidirectional)
|
|
_, err = daemonClient.RemoveDependency(&rpc.DepRemoveArgs{
|
|
FromID: id2,
|
|
ToID: id1,
|
|
DepType: string(types.DepRelatesTo),
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("failed to remove relates-to %s -> %s: %w", id2, id1, err)
|
|
}
|
|
} else {
|
|
// Remove id1 -> id2
|
|
if err := store.RemoveDependency(ctx, id1, id2, actor); err != nil {
|
|
return fmt.Errorf("failed to remove relates-to %s -> %s: %w", id1, id2, err)
|
|
}
|
|
// Remove id2 -> id1 (bidirectional)
|
|
if err := store.RemoveDependency(ctx, id2, id1, actor); err != nil {
|
|
return fmt.Errorf("failed to remove relates-to %s -> %s: %w", id2, id1, err)
|
|
}
|
|
}
|
|
|
|
// Trigger auto-flush
|
|
if flushManager != nil {
|
|
flushManager.MarkDirty(false)
|
|
}
|
|
|
|
if jsonOutput {
|
|
result := map[string]interface{}{
|
|
"id1": id1,
|
|
"id2": id2,
|
|
"unrelated": true,
|
|
}
|
|
encoder := json.NewEncoder(os.Stdout)
|
|
encoder.SetIndent("", " ")
|
|
return encoder.Encode(result)
|
|
}
|
|
|
|
fmt.Printf("%s Unlinked %s ↔ %s\n", ui.RenderPass("✓"), id1, id2)
|
|
return nil
|
|
}
|
|
|
|
// Note: contains, remove, formatRelatesTo functions removed per Decision 004
|
|
// relates-to links now use dependencies API instead of Issue.RelatesTo field
|