feat(dep): add --blocks shorthand flag for natural dependency syntax (GH#884)
Adds a --blocks (-b) shorthand flag to bd dep command for natural dependency syntax: bd dep bd-xyz --blocks bd-abc # bd-xyz blocks bd-abc Equivalent to: bd dep add bd-abc bd-xyz - Full daemon and direct mode support - Cycle detection and child-parent anti-pattern checks - JSON output support Contributed by: kraitsura
This commit is contained in:
153
cmd/bd/dep.go
153
cmd/bd/dep.go
@@ -43,9 +43,153 @@ func isChildOf(childID, parentID string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var depCmd = &cobra.Command{
|
var depCmd = &cobra.Command{
|
||||||
Use: "dep",
|
Use: "dep [issue-id]",
|
||||||
GroupID: "deps",
|
GroupID: "deps",
|
||||||
Short: "Manage dependencies",
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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{
|
var depAddCmd = &cobra.Command{
|
||||||
@@ -1017,10 +1161,12 @@ func ParseExternalRef(ref string) (project, capability string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
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().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("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)")
|
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().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)")
|
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("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().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)")
|
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().String("direction", "down", "Direction: 'down' (dependencies), 'up' (dependents)")
|
||||||
depListCmd.Flags().StringP("type", "t", "", "Filter by dependency type (e.g., tracks, blocks, parent-child)")
|
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")
|
t.Fatal("depCmd should be initialized")
|
||||||
}
|
}
|
||||||
|
|
||||||
if depCmd.Use != "dep" {
|
if depCmd.Use != "dep [issue-id]" {
|
||||||
t.Errorf("Expected Use='dep', got %q", depCmd.Use)
|
t.Errorf("Expected Use='dep [issue-id]', got %q", depCmd.Use)
|
||||||
}
|
}
|
||||||
|
|
||||||
if depAddCmd == nil {
|
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) {
|
func TestDepTreeFormatFlag(t *testing.T) {
|
||||||
// Test that the --format flag exists on depTreeCmd
|
// Test that the --format flag exists on depTreeCmd
|
||||||
flag := depTreeCmd.Flags().Lookup("format")
|
flag := depTreeCmd.Flags().Lookup("format")
|
||||||
|
|||||||
Reference in New Issue
Block a user