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:
@@ -333,6 +333,11 @@ func (c *Client) Ready(args *ReadyArgs) (*Response, error) {
|
||||
return c.Execute(OpReady, args)
|
||||
}
|
||||
|
||||
// Blocked gets blocked issues via the daemon
|
||||
func (c *Client) Blocked(args *BlockedArgs) (*Response, error) {
|
||||
return c.Execute(OpBlocked, args)
|
||||
}
|
||||
|
||||
// Stale gets stale issues via the daemon
|
||||
func (c *Client) Stale(args *StaleArgs) (*Response, error) {
|
||||
return c.Execute(OpStale, args)
|
||||
|
||||
@@ -20,6 +20,7 @@ const (
|
||||
OpCount = "count"
|
||||
OpShow = "show"
|
||||
OpReady = "ready"
|
||||
OpBlocked = "blocked"
|
||||
OpStale = "stale"
|
||||
OpStats = "stats"
|
||||
OpDepAdd = "dep_add"
|
||||
@@ -253,6 +254,12 @@ type ReadyArgs struct {
|
||||
SortPolicy string `json:"sort_policy,omitempty"`
|
||||
Labels []string `json:"labels,omitempty"`
|
||||
LabelsAny []string `json:"labels_any,omitempty"`
|
||||
ParentID string `json:"parent_id,omitempty"` // Filter to descendants of this bead/epic
|
||||
}
|
||||
|
||||
// BlockedArgs represents arguments for the blocked operation
|
||||
type BlockedArgs struct {
|
||||
ParentID string `json:"parent_id,omitempty"` // Filter to descendants of this bead/epic
|
||||
}
|
||||
|
||||
// StaleArgs represents arguments for the stale command
|
||||
|
||||
@@ -1262,6 +1262,7 @@ func (s *Server) handleReady(req *Request) Response {
|
||||
|
||||
wf := types.WorkFilter{
|
||||
Status: types.StatusOpen,
|
||||
Type: readyArgs.Type,
|
||||
Priority: readyArgs.Priority,
|
||||
Unassigned: readyArgs.Unassigned,
|
||||
Limit: readyArgs.Limit,
|
||||
@@ -1272,6 +1273,9 @@ func (s *Server) handleReady(req *Request) Response {
|
||||
if readyArgs.Assignee != "" && !readyArgs.Unassigned {
|
||||
wf.Assignee = &readyArgs.Assignee
|
||||
}
|
||||
if readyArgs.ParentID != "" {
|
||||
wf.ParentID = &readyArgs.ParentID
|
||||
}
|
||||
|
||||
ctx := s.reqCtx(req)
|
||||
issues, err := store.GetReadyWork(ctx, wf)
|
||||
@@ -1289,6 +1293,44 @@ func (s *Server) handleReady(req *Request) Response {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleBlocked(req *Request) Response {
|
||||
var blockedArgs BlockedArgs
|
||||
if err := json.Unmarshal(req.Args, &blockedArgs); err != nil {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("invalid blocked args: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
store := s.storage
|
||||
if store == nil {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: "storage not available (global daemon deprecated - use local daemon instead with 'bd daemon' in your project)",
|
||||
}
|
||||
}
|
||||
|
||||
var wf types.WorkFilter
|
||||
if blockedArgs.ParentID != "" {
|
||||
wf.ParentID = &blockedArgs.ParentID
|
||||
}
|
||||
|
||||
ctx := s.reqCtx(req)
|
||||
blocked, err := store.GetBlockedIssues(ctx, wf)
|
||||
if err != nil {
|
||||
return Response{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to get blocked issues: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(blocked)
|
||||
return Response{
|
||||
Success: true,
|
||||
Data: data,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleStale(req *Request) Response {
|
||||
var staleArgs StaleArgs
|
||||
if err := json.Unmarshal(req.Args, &staleArgs); err != nil {
|
||||
|
||||
@@ -188,6 +188,8 @@ func (s *Server) handleRequest(req *Request) Response {
|
||||
resp = s.handleResolveID(req)
|
||||
case OpReady:
|
||||
resp = s.handleReady(req)
|
||||
case OpBlocked:
|
||||
resp = s.handleBlocked(req)
|
||||
case OpStale:
|
||||
resp = s.handleStale(req)
|
||||
case OpStats:
|
||||
|
||||
@@ -1097,10 +1097,16 @@ func (m *MemoryStorage) getOpenBlockers(issueID string) []string {
|
||||
|
||||
// GetBlockedIssues returns issues that are blocked by other issues
|
||||
// Note: Pinned issues are excluded from the output (beads-ei4)
|
||||
func (m *MemoryStorage) GetBlockedIssues(ctx context.Context) ([]*types.BlockedIssue, error) {
|
||||
func (m *MemoryStorage) GetBlockedIssues(ctx context.Context, filter types.WorkFilter) ([]*types.BlockedIssue, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
// Build set of descendant IDs if parent filter is specified
|
||||
var descendantIDs map[string]bool
|
||||
if filter.ParentID != nil {
|
||||
descendantIDs = m.getAllDescendants(*filter.ParentID)
|
||||
}
|
||||
|
||||
var results []*types.BlockedIssue
|
||||
|
||||
for _, issue := range m.issues {
|
||||
@@ -1114,6 +1120,11 @@ func (m *MemoryStorage) GetBlockedIssues(ctx context.Context) ([]*types.BlockedI
|
||||
continue
|
||||
}
|
||||
|
||||
// Parent filtering: only include descendants of specified parent
|
||||
if descendantIDs != nil && !descendantIDs[issue.ID] {
|
||||
continue
|
||||
}
|
||||
|
||||
blockers := m.getOpenBlockers(issue.ID)
|
||||
// Issue is "blocked" if: status is blocked, status is deferred, or has open blockers
|
||||
if issue.Status != types.StatusBlocked && issue.Status != types.StatusDeferred && len(blockers) == 0 {
|
||||
@@ -1149,6 +1160,27 @@ func (m *MemoryStorage) GetBlockedIssues(ctx context.Context) ([]*types.BlockedI
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// getAllDescendants returns all descendant IDs of a parent issue recursively
|
||||
func (m *MemoryStorage) getAllDescendants(parentID string) map[string]bool {
|
||||
descendants := make(map[string]bool)
|
||||
m.collectDescendants(parentID, descendants)
|
||||
return descendants
|
||||
}
|
||||
|
||||
// collectDescendants recursively collects all descendants of a parent
|
||||
func (m *MemoryStorage) collectDescendants(parentID string, descendants map[string]bool) {
|
||||
for issueID, deps := range m.dependencies {
|
||||
for _, dep := range deps {
|
||||
if dep.Type == types.DepParentChild && dep.DependsOnID == parentID {
|
||||
if !descendants[issueID] {
|
||||
descendants[issueID] = true
|
||||
m.collectDescendants(issueID, descendants)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *MemoryStorage) GetEpicsEligibleForClosure(ctx context.Context) ([]*types.EpicStatus, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -124,7 +124,7 @@ func TestGetBlockedIssues_IncludesExplicitlyBlockedStatus(t *testing.T) {
|
||||
t.Fatalf("AddDependency failed: %v", err)
|
||||
}
|
||||
|
||||
blocked, err := store.GetBlockedIssues(ctx)
|
||||
blocked, err := store.GetBlockedIssues(ctx, types.WorkFilter{})
|
||||
if err != nil {
|
||||
t.Fatalf("GetBlockedIssues failed: %v", err)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,7 +107,7 @@ type Storage interface {
|
||||
|
||||
// Ready Work & Blocking
|
||||
GetReadyWork(ctx context.Context, filter types.WorkFilter) ([]*types.Issue, error)
|
||||
GetBlockedIssues(ctx context.Context) ([]*types.BlockedIssue, error)
|
||||
GetBlockedIssues(ctx context.Context, filter types.WorkFilter) ([]*types.BlockedIssue, error)
|
||||
GetEpicsEligibleForClosure(ctx context.Context) ([]*types.EpicStatus, error)
|
||||
GetStaleIssues(ctx context.Context, filter types.StaleFilter) ([]*types.Issue, error)
|
||||
GetNewlyUnblockedByClose(ctx context.Context, closedIssueID string) ([]*types.Issue, error) // GH#679
|
||||
|
||||
@@ -89,7 +89,7 @@ func (m *mockStorage) GetIssuesByLabel(ctx context.Context, label string) ([]*ty
|
||||
func (m *mockStorage) GetReadyWork(ctx context.Context, filter types.WorkFilter) ([]*types.Issue, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (m *mockStorage) GetBlockedIssues(ctx context.Context) ([]*types.BlockedIssue, error) {
|
||||
func (m *mockStorage) GetBlockedIssues(ctx context.Context, filter types.WorkFilter) ([]*types.BlockedIssue, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (m *mockStorage) GetEpicsEligibleForClosure(ctx context.Context) ([]*types.EpicStatus, error) {
|
||||
|
||||
@@ -646,6 +646,9 @@ type WorkFilter struct {
|
||||
LabelsAny []string // OR semantics: issue must have AT LEAST ONE of these labels
|
||||
Limit int
|
||||
SortPolicy SortPolicy
|
||||
|
||||
// Parent filtering: filter to descendants of a bead/epic (recursive)
|
||||
ParentID *string // Show all descendants of this issue
|
||||
}
|
||||
|
||||
// StaleFilter is used to filter stale issue queries
|
||||
|
||||
Reference in New Issue
Block a user