diff --git a/cmd/bd/dep.go b/cmd/bd/dep.go index 1e06904e..959ed2ba 100644 --- a/cmd/bd/dep.go +++ b/cmd/bd/dep.go @@ -43,9 +43,153 @@ 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 --blocks + +This is equivalent to: + bd dep add + +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) + } + + // Add the dependency via daemon or direct mode + if daemonClient != nil { + depArgs := &rpc.DepAddArgs{ + FromID: fromID, + ToID: toID, + DepType: depType, + } + + _, err := daemonClient.AddDependency(depArgs) + if err != nil { + FatalErrorRespectJSON("%v", err) + } + } else { + // 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 (both daemon and direct mode) + 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 +1161,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 )") + 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 +1175,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)") diff --git a/cmd/bd/dep_test.go b/cmd/bd/dep_test.go index 9bcbe354..8b9b4798 100644 --- a/cmd/bd/dep_test.go +++ b/cmd/bd/dep_test.go @@ -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")