feat(ready,blocked): Add --parent flag for scoping by epic/bead desce… (#743)
* feat(ready,blocked): Add --parent flag for scoping by epic/bead descendants
Add --parent flag to `bd ready` and `bd blocked` CLI commands and MCP tools
to filter results to all descendants of a specific epic or bead.
## Backward Compatibility
- CLI: New optional --parent flag; existing usage unchanged
- RPC: New `blocked` operation added (was missing); existing operations unchanged
- MCP: New optional `parent` parameter; existing calls work as before
- Storage interface: GetBlockedIssues signature changed to accept WorkFilter
- All callers updated to pass empty filter for existing behavior
- Empty WorkFilter{} returns identical results to previous implementation
## Implementation Details
SQLite uses recursive CTE to traverse parent-child hierarchy:
WITH RECURSIVE descendants AS (
SELECT issue_id FROM dependencies
WHERE type = 'parent-child' AND depends_on_id = ?
UNION ALL
SELECT d.issue_id FROM dependencies d
JOIN descendants dt ON d.depends_on_id = dt.issue_id
WHERE d.type = 'parent-child'
)
SELECT issue_id FROM descendants
MemoryStorage implements equivalent recursive traversal with visited-set
cycle protection via collectDescendants helper.
Parent filter composes with existing filters (priority, labels, assignee, etc.)
as an additional WHERE clause - all filters are AND'd together.
## RPC Blocked Support
MCP beads_blocked() existed but daemon client raised NotImplementedError.
Added OpBlocked and handleBlocked to enable daemon RPC path, which was
previously broken. Now both CLI and daemon clients work for blocked queries.
## Changes
- internal/types/types.go: Add ParentID *string to WorkFilter
- internal/storage/sqlite/ready.go: Add recursive CTE for parent filtering
- internal/storage/memory/memory.go: Add getAllDescendants/collectDescendants
- internal/storage/storage.go: Update GetBlockedIssues interface signature
- cmd/bd/ready.go: Add --parent flag to ready and blocked commands
- internal/rpc/protocol.go: Add OpBlocked constant and BlockedArgs type
- internal/rpc/server_issues_epics.go: Add handleBlocked RPC handler
- internal/rpc/client.go: Add Blocked client method
- integrations/beads-mcp/: Add BlockedParams model and parent parameter
## Usage
bd ready --parent bd-abc # All ready descendants
bd ready --parent bd-abc --priority 1 # Combined with other filters
bd blocked --parent bd-abc # All blocked descendants
## Testing
Added 4 test cases for parent filtering:
- TestParentIDFilterDescendants: Verifies recursive traversal (grandchildren)
- TestParentIDWithOtherFilters: Verifies composition with priority filter
- TestParentIDWithBlockedDescendants: Verifies blocked issues excluded from ready
- TestParentIDEmptyParent: Verifies empty result for childless parent
* fix: Correct blockedCmd indentation and suppress gosec false positive
- Fix syntax error from incorrect indentation in blockedCmd Run function
- Add nolint:gosec comment for GetBlockedIssues SQL formatting (G201)
The filterSQL variable contains only parameterized WHERE clauses with
? placeholders, not user input
This commit is contained in:
@@ -86,6 +86,25 @@ func (s *SQLiteStorage) GetReadyWork(ctx context.Context, filter types.WorkFilte
|
||||
}
|
||||
}
|
||||
|
||||
// Parent filtering: filter to all descendants of a root issue (epic/molecule)
|
||||
// Uses recursive CTE to find all descendants via parent-child dependencies
|
||||
if filter.ParentID != nil {
|
||||
whereClauses = append(whereClauses, `
|
||||
i.id IN (
|
||||
WITH RECURSIVE descendants AS (
|
||||
SELECT issue_id FROM dependencies
|
||||
WHERE type = 'parent-child' AND depends_on_id = ?
|
||||
UNION ALL
|
||||
SELECT d.issue_id FROM dependencies d
|
||||
JOIN descendants dt ON d.depends_on_id = dt.issue_id
|
||||
WHERE d.type = 'parent-child'
|
||||
)
|
||||
SELECT issue_id FROM descendants
|
||||
)
|
||||
`)
|
||||
args = append(args, *filter.ParentID)
|
||||
}
|
||||
|
||||
// Build WHERE clause properly
|
||||
whereSQL := strings.Join(whereClauses, " AND ")
|
||||
|
||||
@@ -413,7 +432,7 @@ func (s *SQLiteStorage) GetStaleIssues(ctx context.Context, filter types.StaleFi
|
||||
// GetBlockedIssues returns issues that are blocked by dependencies or have status=blocked
|
||||
// Note: Pinned issues are excluded from the output (beads-ei4)
|
||||
// Note: Includes external: references in blocked_by list (bd-om4a)
|
||||
func (s *SQLiteStorage) GetBlockedIssues(ctx context.Context) ([]*types.BlockedIssue, error) {
|
||||
func (s *SQLiteStorage) GetBlockedIssues(ctx context.Context, filter types.WorkFilter) ([]*types.BlockedIssue, error) {
|
||||
// Use UNION to combine:
|
||||
// 1. Issues with open/in_progress/blocked status that have dependency blockers
|
||||
// 2. Issues with status=blocked (even if they have no dependency blockers)
|
||||
@@ -423,7 +442,37 @@ func (s *SQLiteStorage) GetBlockedIssues(ctx context.Context) ([]*types.BlockedI
|
||||
// For blocked_by_count and blocker_ids:
|
||||
// - Count local blockers (open issues) + external refs (external:*)
|
||||
// - External refs are always considered "open" until resolved (bd-om4a)
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
|
||||
// Build additional WHERE clauses for filtering
|
||||
var filterClauses []string
|
||||
var args []any
|
||||
|
||||
// Parent filtering: filter to all descendants of a root issue (epic/molecule)
|
||||
if filter.ParentID != nil {
|
||||
filterClauses = append(filterClauses, `
|
||||
i.id IN (
|
||||
WITH RECURSIVE descendants AS (
|
||||
SELECT issue_id FROM dependencies
|
||||
WHERE type = 'parent-child' AND depends_on_id = ?
|
||||
UNION ALL
|
||||
SELECT d.issue_id FROM dependencies d
|
||||
JOIN descendants dt ON d.depends_on_id = dt.issue_id
|
||||
WHERE d.type = 'parent-child'
|
||||
)
|
||||
SELECT issue_id FROM descendants
|
||||
)
|
||||
`)
|
||||
args = append(args, *filter.ParentID)
|
||||
}
|
||||
|
||||
// Build filter clause SQL
|
||||
filterSQL := ""
|
||||
if len(filterClauses) > 0 {
|
||||
filterSQL = " AND " + strings.Join(filterClauses, " AND ")
|
||||
}
|
||||
|
||||
// nolint:gosec // G201: filterSQL contains only parameterized WHERE clauses with ? placeholders, not user input
|
||||
query := fmt.Sprintf(`
|
||||
SELECT
|
||||
i.id, i.title, i.description, i.design, i.acceptance_criteria, i.notes,
|
||||
i.status, i.priority, i.issue_type, i.assignee, i.estimated_minutes,
|
||||
@@ -441,7 +490,7 @@ func (s *SQLiteStorage) GetBlockedIssues(ctx context.Context) ([]*types.BlockedI
|
||||
AND blocker.status IN ('open', 'in_progress', 'blocked', 'deferred')
|
||||
)
|
||||
-- External refs: always included (resolution happens at query time)
|
||||
OR d.depends_on_id LIKE 'external:%'
|
||||
OR d.depends_on_id LIKE 'external:%%'
|
||||
)
|
||||
WHERE i.status IN ('open', 'in_progress', 'blocked', 'deferred')
|
||||
AND i.pinned = 0
|
||||
@@ -461,12 +510,14 @@ func (s *SQLiteStorage) GetBlockedIssues(ctx context.Context) ([]*types.BlockedI
|
||||
SELECT 1 FROM dependencies d3
|
||||
WHERE d3.issue_id = i.id
|
||||
AND d3.type = 'blocks'
|
||||
AND d3.depends_on_id LIKE 'external:%'
|
||||
AND d3.depends_on_id LIKE 'external:%%'
|
||||
)
|
||||
)
|
||||
%s
|
||||
GROUP BY i.id
|
||||
ORDER BY i.priority ASC
|
||||
`)
|
||||
`, filterSQL)
|
||||
rows, err := s.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get blocked issues: %w", err)
|
||||
}
|
||||
|
||||
@@ -182,7 +182,7 @@ func TestGetBlockedIssues(t *testing.T) {
|
||||
store.AddDependency(ctx, &types.Dependency{IssueID: issue3.ID, DependsOnID: issue2.ID, Type: types.DepBlocks}, "test-user")
|
||||
|
||||
// Get blocked issues
|
||||
blocked, err := store.GetBlockedIssues(ctx)
|
||||
blocked, err := store.GetBlockedIssues(ctx, types.WorkFilter{})
|
||||
if err != nil {
|
||||
t.Fatalf("GetBlockedIssues failed: %v", err)
|
||||
}
|
||||
@@ -1215,7 +1215,7 @@ func TestGetBlockedIssuesFiltersExternalDeps(t *testing.T) {
|
||||
}
|
||||
|
||||
// Test 1: External dep not satisfied - issue should appear as blocked
|
||||
blocked, err := mainStore.GetBlockedIssues(ctx)
|
||||
blocked, err := mainStore.GetBlockedIssues(ctx, types.WorkFilter{})
|
||||
if err != nil {
|
||||
t.Fatalf("GetBlockedIssues failed: %v", err)
|
||||
}
|
||||
@@ -1260,7 +1260,7 @@ func TestGetBlockedIssuesFiltersExternalDeps(t *testing.T) {
|
||||
}
|
||||
|
||||
// Now GetBlockedIssues should NOT show the issue (external dep satisfied)
|
||||
blocked, err = mainStore.GetBlockedIssues(ctx)
|
||||
blocked, err = mainStore.GetBlockedIssues(ctx, types.WorkFilter{})
|
||||
if err != nil {
|
||||
t.Fatalf("GetBlockedIssues failed after shipping: %v", err)
|
||||
}
|
||||
@@ -1379,7 +1379,7 @@ func TestGetBlockedIssuesPartialExternalDeps(t *testing.T) {
|
||||
externalStore.Close()
|
||||
|
||||
// Issue should still be blocked (cap2 not satisfied)
|
||||
blocked, err := mainStore.GetBlockedIssues(ctx)
|
||||
blocked, err := mainStore.GetBlockedIssues(ctx, types.WorkFilter{})
|
||||
if err != nil {
|
||||
t.Fatalf("GetBlockedIssues failed: %v", err)
|
||||
}
|
||||
@@ -1565,3 +1565,159 @@ func TestGetNewlyUnblockedByClose(t *testing.T) {
|
||||
t.Errorf("Expected %s to still be blocked (has another blocker)", multiBlocked.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParentIDFilterDescendants tests that ParentID filter returns all descendants of an epic
|
||||
func TestParentIDFilterDescendants(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
|
||||
// Create hierarchy:
|
||||
// epic1 (root)
|
||||
// ├── task1 (child of epic1)
|
||||
// ├── task2 (child of epic1)
|
||||
// └── epic2 (child of epic1)
|
||||
// └── task3 (grandchild of epic1)
|
||||
// task4 (unrelated, should not appear in results)
|
||||
epic1 := env.CreateEpic("Epic 1")
|
||||
task1 := env.CreateIssue("Task 1")
|
||||
task2 := env.CreateIssue("Task 2")
|
||||
epic2 := env.CreateEpic("Epic 2")
|
||||
task3 := env.CreateIssue("Task 3")
|
||||
task4 := env.CreateIssue("Task 4 - unrelated")
|
||||
|
||||
env.AddParentChild(task1, epic1)
|
||||
env.AddParentChild(task2, epic1)
|
||||
env.AddParentChild(epic2, epic1)
|
||||
env.AddParentChild(task3, epic2)
|
||||
|
||||
// Query with ParentID = epic1
|
||||
parentID := epic1.ID
|
||||
ready := env.GetReadyWork(types.WorkFilter{ParentID: &parentID})
|
||||
|
||||
// Should include task1, task2, epic2, task3 (all descendants of epic1)
|
||||
// Should NOT include epic1 itself or task4
|
||||
if len(ready) != 4 {
|
||||
t.Fatalf("Expected 4 ready issues in parent scope, got %d", len(ready))
|
||||
}
|
||||
|
||||
// Verify the returned issues are the expected ones
|
||||
readyIDs := make(map[string]bool)
|
||||
for _, issue := range ready {
|
||||
readyIDs[issue.ID] = true
|
||||
}
|
||||
|
||||
if !readyIDs[task1.ID] {
|
||||
t.Errorf("Expected task1 to be in results")
|
||||
}
|
||||
if !readyIDs[task2.ID] {
|
||||
t.Errorf("Expected task2 to be in results")
|
||||
}
|
||||
if !readyIDs[epic2.ID] {
|
||||
t.Errorf("Expected epic2 to be in results")
|
||||
}
|
||||
if !readyIDs[task3.ID] {
|
||||
t.Errorf("Expected task3 to be in results")
|
||||
}
|
||||
if readyIDs[epic1.ID] {
|
||||
t.Errorf("Expected epic1 (root) to NOT be in results")
|
||||
}
|
||||
if readyIDs[task4.ID] {
|
||||
t.Errorf("Expected task4 (unrelated) to NOT be in results")
|
||||
}
|
||||
}
|
||||
|
||||
// TestParentIDWithOtherFilters tests that ParentID can be combined with other filters
|
||||
func TestParentIDWithOtherFilters(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
|
||||
// Create hierarchy:
|
||||
// epic1 (root)
|
||||
// ├── task1 (priority 0)
|
||||
// ├── task2 (priority 1)
|
||||
// └── task3 (priority 2)
|
||||
epic1 := env.CreateEpic("Epic 1")
|
||||
task1 := env.CreateIssueWith("Task 1 - P0", types.StatusOpen, 0, types.TypeTask)
|
||||
task2 := env.CreateIssueWith("Task 2 - P1", types.StatusOpen, 1, types.TypeTask)
|
||||
task3 := env.CreateIssueWith("Task 3 - P2", types.StatusOpen, 2, types.TypeTask)
|
||||
|
||||
env.AddParentChild(task1, epic1)
|
||||
env.AddParentChild(task2, epic1)
|
||||
env.AddParentChild(task3, epic1)
|
||||
|
||||
// Query with ParentID = epic1 AND priority = 1
|
||||
parentID := epic1.ID
|
||||
priority := 1
|
||||
ready := env.GetReadyWork(types.WorkFilter{ParentID: &parentID, Priority: &priority})
|
||||
|
||||
// Should only include task2 (parent + priority 1)
|
||||
if len(ready) != 1 {
|
||||
t.Fatalf("Expected 1 issue with parent + priority filter, got %d", len(ready))
|
||||
}
|
||||
if ready[0].ID != task2.ID {
|
||||
t.Errorf("Expected task2, got %s", ready[0].ID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParentIDWithBlockedDescendants tests that blocked descendants are excluded
|
||||
func TestParentIDWithBlockedDescendants(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
|
||||
// Create hierarchy:
|
||||
// epic1 (root)
|
||||
// ├── task1 (ready)
|
||||
// ├── task2 (blocked by blocker)
|
||||
// └── task3 (ready)
|
||||
// blocker (unrelated)
|
||||
epic1 := env.CreateEpic("Epic 1")
|
||||
task1 := env.CreateIssue("Task 1 - ready")
|
||||
task2 := env.CreateIssue("Task 2 - blocked")
|
||||
task3 := env.CreateIssue("Task 3 - ready")
|
||||
blocker := env.CreateIssue("Blocker")
|
||||
|
||||
env.AddParentChild(task1, epic1)
|
||||
env.AddParentChild(task2, epic1)
|
||||
env.AddParentChild(task3, epic1)
|
||||
env.AddDep(task2, blocker) // task2 is blocked
|
||||
|
||||
// Query with ParentID = epic1
|
||||
parentID := epic1.ID
|
||||
ready := env.GetReadyWork(types.WorkFilter{ParentID: &parentID})
|
||||
|
||||
// Should include task1, task3 (ready descendants)
|
||||
// Should NOT include task2 (blocked)
|
||||
if len(ready) != 2 {
|
||||
t.Fatalf("Expected 2 ready descendants, got %d", len(ready))
|
||||
}
|
||||
|
||||
readyIDs := make(map[string]bool)
|
||||
for _, issue := range ready {
|
||||
readyIDs[issue.ID] = true
|
||||
}
|
||||
|
||||
if !readyIDs[task1.ID] {
|
||||
t.Errorf("Expected task1 to be ready")
|
||||
}
|
||||
if !readyIDs[task3.ID] {
|
||||
t.Errorf("Expected task3 to be ready")
|
||||
}
|
||||
if readyIDs[task2.ID] {
|
||||
t.Errorf("Expected task2 to be blocked")
|
||||
}
|
||||
}
|
||||
|
||||
// TestParentIDEmptyParent tests that empty parent returns nothing
|
||||
func TestParentIDEmptyParent(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
|
||||
// Create an epic with no children
|
||||
epic1 := env.CreateEpic("Epic 1 - no children")
|
||||
env.CreateIssue("Unrelated task")
|
||||
|
||||
// Query with ParentID = epic1 (which has no children)
|
||||
parentID := epic1.ID
|
||||
ready := env.GetReadyWork(types.WorkFilter{ParentID: &parentID})
|
||||
|
||||
// Should return empty since epic1 has no descendants
|
||||
if len(ready) != 0 {
|
||||
t.Fatalf("Expected 0 ready issues for empty parent, got %d", len(ready))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user