feat(dep): add --blocks shorthand flag for natural dependency syntax
Agents naturally try to use 'bd dep <blocker> --blocks <blocked>' when establishing blocking relationships - a desire path revealing intuitive mental model for how dependencies should work. When AI agents set up dependency chains, they consistently attempt: bd dep conduit-abc --blocks conduit-xyz This reveals a desire path - the syntax users naturally reach for before reading documentation. Instead of fighting this intuition, we embrace it. - Add --blocks (-b) flag to the bd dep command - Support syntax: bd dep <blocker-id> --blocks <blocked-id> - Equivalent to: bd dep add <blocked-id> <blocker-id> - Full daemon and direct mode support - Cycle detection and child-parent anti-pattern checks - JSON output support for programmatic use This is purely additive. The existing command structure remains: - 'bd dep add' subcommand works exactly as before - All other dep subcommands (remove, list, tree, cycles) unchanged - No breaking changes to existing workflows bd dep bd-xyz --blocks bd-abc # bd-xyz blocks bd-abc bd dep bd-xyz -b bd-abc # Same, using shorthand bd dep add bd-abc bd-xyz # Original syntax still works - Added TestDepBlocksFlag for flag initialization - Added TestDepBlocksFlagFunctionality for semantic correctness - All existing tests pass
This commit is contained in:
162
cmd/bd/dep.go
162
cmd/bd/dep.go
@@ -43,9 +43,162 @@ func isChildOf(childID, parentID string) bool {
|
||||
}
|
||||
|
||||
var depCmd = &cobra.Command{
|
||||
Use: "dep",
|
||||
Use: "dep [issue-id]",
|
||||
GroupID: "deps",
|
||||
Short: "Manage dependencies",
|
||||
Long: `Manage dependencies between issues.
|
||||
|
||||
When called with an issue ID and --blocks flag, creates a blocking dependency:
|
||||
bd dep <blocker-id> --blocks <blocked-id>
|
||||
|
||||
This is equivalent to:
|
||||
bd dep add <blocked-id> <blocker-id>
|
||||
|
||||
Examples:
|
||||
bd dep bd-xyz --blocks bd-abc # bd-xyz blocks bd-abc
|
||||
bd dep add bd-abc bd-xyz # Same as above (bd-abc depends on bd-xyz)`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
blocksID, _ := cmd.Flags().GetString("blocks")
|
||||
|
||||
// If no args and no flags, show help
|
||||
if len(args) == 0 && blocksID == "" {
|
||||
_ = cmd.Help()
|
||||
return
|
||||
}
|
||||
|
||||
// If --blocks flag is provided, create a blocking dependency
|
||||
if blocksID != "" {
|
||||
if len(args) != 1 {
|
||||
FatalErrorRespectJSON("--blocks requires exactly one issue ID argument")
|
||||
}
|
||||
blockerID := args[0]
|
||||
|
||||
CheckReadonly("dep --blocks")
|
||||
|
||||
ctx := rootCtx
|
||||
depType := "blocks"
|
||||
|
||||
// Resolve partial IDs first
|
||||
var fromID, toID string
|
||||
|
||||
if daemonClient != nil {
|
||||
// Resolve the blocked issue ID (the one that will depend on the blocker)
|
||||
resolveArgs := &rpc.ResolveIDArgs{ID: blocksID}
|
||||
resp, err := daemonClient.ResolveID(resolveArgs)
|
||||
if err != nil {
|
||||
FatalErrorRespectJSON("resolving issue ID %s: %v", blocksID, err)
|
||||
}
|
||||
if err := json.Unmarshal(resp.Data, &fromID); err != nil {
|
||||
FatalErrorRespectJSON("unmarshaling resolved ID: %v", err)
|
||||
}
|
||||
|
||||
// Resolve the blocker issue ID
|
||||
resolveArgs = &rpc.ResolveIDArgs{ID: blockerID}
|
||||
resp, err = daemonClient.ResolveID(resolveArgs)
|
||||
if err != nil {
|
||||
FatalErrorRespectJSON("resolving issue ID %s: %v", blockerID, err)
|
||||
}
|
||||
if err := json.Unmarshal(resp.Data, &toID); err != nil {
|
||||
FatalErrorRespectJSON("unmarshaling resolved ID: %v", err)
|
||||
}
|
||||
} else {
|
||||
var err error
|
||||
fromID, err = utils.ResolvePartialID(ctx, store, blocksID)
|
||||
if err != nil {
|
||||
FatalErrorRespectJSON("resolving issue ID %s: %v", blocksID, err)
|
||||
}
|
||||
|
||||
toID, err = utils.ResolvePartialID(ctx, store, blockerID)
|
||||
if err != nil {
|
||||
FatalErrorRespectJSON("resolving issue ID %s: %v", blockerID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Check for child→parent dependency anti-pattern
|
||||
if isChildOf(fromID, toID) {
|
||||
FatalErrorRespectJSON("cannot add dependency: %s is already a child of %s. Children inherit dependency on parent completion via hierarchy. Adding an explicit dependency would create a deadlock", fromID, toID)
|
||||
}
|
||||
|
||||
// If daemon is running, use RPC
|
||||
if daemonClient != nil {
|
||||
depArgs := &rpc.DepAddArgs{
|
||||
FromID: fromID,
|
||||
ToID: toID,
|
||||
DepType: depType,
|
||||
}
|
||||
|
||||
resp, err := daemonClient.AddDependency(depArgs)
|
||||
if err != nil {
|
||||
FatalErrorRespectJSON("%v", err)
|
||||
}
|
||||
|
||||
if jsonOutput {
|
||||
fmt.Println(string(resp.Data))
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("%s Added dependency: %s blocks %s\n",
|
||||
ui.RenderPass("✓"), blockerID, blocksID)
|
||||
return
|
||||
}
|
||||
|
||||
// Direct mode
|
||||
dep := &types.Dependency{
|
||||
IssueID: fromID,
|
||||
DependsOnID: toID,
|
||||
Type: types.DependencyType(depType),
|
||||
}
|
||||
|
||||
if err := store.AddDependency(ctx, dep, actor); err != nil {
|
||||
FatalErrorRespectJSON("%v", err)
|
||||
}
|
||||
|
||||
// Schedule auto-flush
|
||||
markDirtyAndScheduleFlush()
|
||||
|
||||
// Check for cycles after adding dependency
|
||||
cycles, err := store.DetectCycles(ctx)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: Failed to check for cycles: %v\n", err)
|
||||
} else if len(cycles) > 0 {
|
||||
fmt.Fprintf(os.Stderr, "\n%s Warning: Dependency cycle detected!\n", ui.RenderWarn("⚠"))
|
||||
fmt.Fprintf(os.Stderr, "This can hide issues from the ready work list and cause confusion.\n\n")
|
||||
fmt.Fprintf(os.Stderr, "Cycle path:\n")
|
||||
for _, cycle := range cycles {
|
||||
for j, issue := range cycle {
|
||||
if j == 0 {
|
||||
fmt.Fprintf(os.Stderr, " %s", issue.ID)
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, " → %s", issue.ID)
|
||||
}
|
||||
}
|
||||
if len(cycle) > 0 {
|
||||
fmt.Fprintf(os.Stderr, " → %s", cycle[0].ID)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "\n")
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "\nRun 'bd dep cycles' for detailed analysis.\n\n")
|
||||
}
|
||||
|
||||
if jsonOutput {
|
||||
outputJSON(map[string]interface{}{
|
||||
"status": "added",
|
||||
"blocker_id": toID,
|
||||
"blocked_id": fromID,
|
||||
"type": depType,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("%s Added dependency: %s blocks %s\n",
|
||||
ui.RenderPass("✓"), toID, fromID)
|
||||
return
|
||||
}
|
||||
|
||||
// If we have an arg but no --blocks flag, show help
|
||||
_ = cmd.Help()
|
||||
},
|
||||
}
|
||||
|
||||
var depAddCmd = &cobra.Command{
|
||||
@@ -1017,10 +1170,12 @@ func ParseExternalRef(ref string) (project, capability string) {
|
||||
}
|
||||
|
||||
func init() {
|
||||
// dep command shorthand flag
|
||||
depCmd.Flags().StringP("blocks", "b", "", "Issue ID that this issue blocks (shorthand for: bd dep add <blocked> <blocker>)")
|
||||
|
||||
depAddCmd.Flags().StringP("type", "t", "blocks", "Dependency type (blocks|tracks|related|parent-child|discovered-from|until|caused-by|validates|relates-to|supersedes)")
|
||||
depAddCmd.Flags().String("blocked-by", "", "Issue ID that blocks the first issue (alternative to positional arg)")
|
||||
depAddCmd.Flags().String("depends-on", "", "Issue ID that the first issue depends on (alias for --blocked-by)")
|
||||
// Note: --json flag is defined as a persistent flag in main.go, not here
|
||||
|
||||
depTreeCmd.Flags().Bool("show-all-paths", false, "Show all paths to nodes (no deduplication for diamond dependencies)")
|
||||
depTreeCmd.Flags().IntP("max-depth", "d", 50, "Maximum tree depth to display (safety limit)")
|
||||
@@ -1029,9 +1184,6 @@ func init() {
|
||||
depTreeCmd.Flags().String("status", "", "Filter to only show issues with this status (open, in_progress, blocked, deferred, closed)")
|
||||
depTreeCmd.Flags().String("format", "", "Output format: 'mermaid' for Mermaid.js flowchart")
|
||||
depTreeCmd.Flags().StringP("type", "t", "", "Filter to only show dependencies of this type (e.g., tracks, blocks, parent-child)")
|
||||
// Note: --json flag is defined as a persistent flag in main.go, not here
|
||||
|
||||
// Note: --json flag is defined as a persistent flag in main.go, not here
|
||||
|
||||
depListCmd.Flags().String("direction", "down", "Direction: 'down' (dependencies), 'up' (dependents)")
|
||||
depListCmd.Flags().StringP("type", "t", "", "Filter by dependency type (e.g., tracks, blocks, parent-child)")
|
||||
|
||||
@@ -237,8 +237,8 @@ func TestDepCommandsInit(t *testing.T) {
|
||||
t.Fatal("depCmd should be initialized")
|
||||
}
|
||||
|
||||
if depCmd.Use != "dep" {
|
||||
t.Errorf("Expected Use='dep', got %q", depCmd.Use)
|
||||
if depCmd.Use != "dep [issue-id]" {
|
||||
t.Errorf("Expected Use='dep [issue-id]', got %q", depCmd.Use)
|
||||
}
|
||||
|
||||
if depAddCmd == nil {
|
||||
@@ -279,6 +279,103 @@ func TestDepAddFlagAliases(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDepBlocksFlag(t *testing.T) {
|
||||
// Test that the --blocks flag exists on depCmd
|
||||
flag := depCmd.Flags().Lookup("blocks")
|
||||
if flag == nil {
|
||||
t.Fatal("depCmd should have --blocks flag")
|
||||
}
|
||||
|
||||
// Test shorthand is -b
|
||||
if flag.Shorthand != "b" {
|
||||
t.Errorf("Expected shorthand='b', got %q", flag.Shorthand)
|
||||
}
|
||||
|
||||
// Test default value is empty string
|
||||
if flag.DefValue != "" {
|
||||
t.Errorf("Expected default blocks='', got %q", flag.DefValue)
|
||||
}
|
||||
|
||||
// Test usage text
|
||||
if !strings.Contains(flag.Usage, "blocks") {
|
||||
t.Errorf("Expected flag usage to mention 'blocks', got %q", flag.Usage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDepBlocksFlagFunctionality(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
testDB := filepath.Join(tmpDir, ".beads", "beads.db")
|
||||
s := newTestStore(t, testDB)
|
||||
ctx := context.Background()
|
||||
|
||||
// Create test issues
|
||||
issues := []*types.Issue{
|
||||
{
|
||||
ID: "test-blocks-1",
|
||||
Title: "Blocker Issue",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: types.TypeTask,
|
||||
CreatedAt: time.Now(),
|
||||
},
|
||||
{
|
||||
ID: "test-blocks-2",
|
||||
Title: "Blocked Issue",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: types.TypeTask,
|
||||
CreatedAt: time.Now(),
|
||||
},
|
||||
}
|
||||
|
||||
for _, issue := range issues {
|
||||
if err := s.CreateIssue(ctx, issue, "test"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Add dependency using the same logic as --blocks flag would:
|
||||
// "blocker --blocks blocked" means blocked depends on blocker
|
||||
dep := &types.Dependency{
|
||||
IssueID: "test-blocks-2", // blocked issue
|
||||
DependsOnID: "test-blocks-1", // blocker issue
|
||||
Type: types.DepBlocks,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
if err := s.AddDependency(ctx, dep, "test"); err != nil {
|
||||
t.Fatalf("AddDependency failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify the blocked issue now depends on the blocker
|
||||
deps, err := s.GetDependencies(ctx, "test-blocks-2")
|
||||
if err != nil {
|
||||
t.Fatalf("GetDependencies failed: %v", err)
|
||||
}
|
||||
|
||||
if len(deps) != 1 {
|
||||
t.Fatalf("Expected 1 dependency, got %d", len(deps))
|
||||
}
|
||||
|
||||
if deps[0].ID != "test-blocks-1" {
|
||||
t.Errorf("Expected blocked issue to depend on test-blocks-1, got %s", deps[0].ID)
|
||||
}
|
||||
|
||||
// Verify the blocker has a dependent
|
||||
dependents, err := s.GetDependents(ctx, "test-blocks-1")
|
||||
if err != nil {
|
||||
t.Fatalf("GetDependents failed: %v", err)
|
||||
}
|
||||
|
||||
if len(dependents) != 1 {
|
||||
t.Fatalf("Expected 1 dependent, got %d", len(dependents))
|
||||
}
|
||||
|
||||
if dependents[0].ID != "test-blocks-2" {
|
||||
t.Errorf("Expected test-blocks-1 to have dependent test-blocks-2, got %s", dependents[0].ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDepTreeFormatFlag(t *testing.T) {
|
||||
// Test that the --format flag exists on depTreeCmd
|
||||
flag := depTreeCmd.Flags().Lookup("format")
|
||||
|
||||
Reference in New Issue
Block a user