diff --git a/cmd/bd/mol_stale.go b/cmd/bd/mol_stale.go index 38b23278..0460f0ee 100644 --- a/cmd/bd/mol_stale.go +++ b/cmd/bd/mol_stale.go @@ -136,7 +136,7 @@ func findStaleMolecules(ctx context.Context, s storage.Storage, blockingOnly, un } // Get blocked issues to find what each stale molecule is blocking - blockedIssues, err := s.GetBlockedIssues(ctx) + blockedIssues, err := s.GetBlockedIssues(ctx, types.WorkFilter{}) if err != nil { return nil, fmt.Errorf("querying blocked issues: %w", err) } diff --git a/cmd/bd/ready.go b/cmd/bd/ready.go index 07cb841e..1f96f1b1 100644 --- a/cmd/bd/ready.go +++ b/cmd/bd/ready.go @@ -39,6 +39,7 @@ This is useful for agents executing molecules to see which steps can run next.`, labels, _ := cmd.Flags().GetStringSlice("label") labelsAny, _ := cmd.Flags().GetStringSlice("label-any") issueType, _ := cmd.Flags().GetString("type") + parentID, _ := cmd.Flags().GetString("parent") // Use global jsonOutput set by PersistentPreRun (respects config.yaml + env vars) // Normalize labels: trim, dedupe, remove empty @@ -69,6 +70,9 @@ This is useful for agents executing molecules to see which steps can run next.`, if assignee != "" && !unassigned { filter.Assignee = &assignee } + if parentID != "" { + filter.ParentID = &parentID + } // Validate sort policy if !filter.SortPolicy.IsValid() { fmt.Fprintf(os.Stderr, "Error: invalid sort policy '%s'. Valid values: hybrid, priority, oldest\n", sortPolicy) @@ -84,6 +88,7 @@ This is useful for agents executing molecules to see which steps can run next.`, SortPolicy: sortPolicy, Labels: labels, LabelsAny: labelsAny, + ParentID: parentID, } if cmd.Flags().Changed("priority") { priority, _ := cmd.Flags().GetInt("priority") @@ -229,12 +234,17 @@ var blockedCmd = &cobra.Command{ var err error store, err = sqlite.New(ctx, dbPath) if err != nil { - fmt.Fprintf(os.Stderr, "Error: failed to open database: %v\n", err) - os.Exit(1) + fmt.Fprintf(os.Stderr, "Error: failed to open database: %v\n", err) + os.Exit(1) } defer func() { _ = store.Close() }() - } - blocked, err := store.GetBlockedIssues(ctx) + } + parentID, _ := cmd.Flags().GetString("parent") + var blockedFilter types.WorkFilter + if parentID != "" { + blockedFilter.ParentID = &parentID + } + blocked, err := store.GetBlockedIssues(ctx, blockedFilter) if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) @@ -410,6 +420,8 @@ func init() { readyCmd.Flags().StringSlice("label-any", []string{}, "Filter by labels (OR: must have AT LEAST ONE). Can combine with --label") readyCmd.Flags().StringP("type", "t", "", "Filter by issue type (task, bug, feature, epic, merge-request)") readyCmd.Flags().String("mol", "", "Filter to steps within a specific molecule") + readyCmd.Flags().String("parent", "", "Filter to descendants of this bead/epic") rootCmd.AddCommand(readyCmd) + blockedCmd.Flags().String("parent", "", "Filter to descendants of this bead/epic") rootCmd.AddCommand(blockedCmd) } diff --git a/cmd/bd/relate_test.go b/cmd/bd/relate_test.go index a9ce5a85..5842f82a 100644 --- a/cmd/bd/relate_test.go +++ b/cmd/bd/relate_test.go @@ -204,7 +204,7 @@ func TestRelateCommand(t *testing.T) { } // Issue1 should NOT be blocked (relates-to doesn't block) - blocked, err := s.GetBlockedIssues(ctx) + blocked, err := s.GetBlockedIssues(ctx, types.WorkFilter{}) if err != nil { t.Fatalf("GetBlockedIssues failed: %v", err) } diff --git a/integrations/beads-mcp/src/beads_mcp/bd_client.py b/integrations/beads-mcp/src/beads_mcp/bd_client.py index 056fa8aa..dae4bc5d 100644 --- a/integrations/beads-mcp/src/beads_mcp/bd_client.py +++ b/integrations/beads-mcp/src/beads_mcp/bd_client.py @@ -12,6 +12,7 @@ from .config import load_config from .models import ( AddDependencyParams, BlockedIssue, + BlockedParams, CloseIssueParams, CreateIssueParams, InitParams, @@ -126,7 +127,7 @@ class BdClientBase(ABC): pass @abstractmethod - async def blocked(self) -> List[BlockedIssue]: + async def blocked(self, params: Optional[BlockedParams] = None) -> List[BlockedIssue]: """Get blocked issues.""" pass @@ -395,6 +396,8 @@ class BdCliClient(BdClientBase): args.append("--unassigned") if params.sort_policy: args.extend(["--sort", params.sort_policy]) + if params.parent_id: + args.extend(["--parent", params.parent_id]) data = await self._run_command(*args) if not isinstance(data, list): @@ -664,13 +667,21 @@ class BdCliClient(BdClientBase): return Stats.model_validate(data) - async def blocked(self) -> list[BlockedIssue]: + async def blocked(self, params: BlockedParams | None = None) -> list[BlockedIssue]: """Get blocked issues. + Args: + params: Query parameters + Returns: List of blocked issues with blocking information """ - data = await self._run_command("blocked") + params = params or BlockedParams() + args = ["blocked"] + if params.parent_id: + args.extend(["--parent", params.parent_id]) + + data = await self._run_command(*args) if not isinstance(data, list): return [] diff --git a/integrations/beads-mcp/src/beads_mcp/bd_daemon_client.py b/integrations/beads-mcp/src/beads_mcp/bd_daemon_client.py index e960ffbb..552a39de 100644 --- a/integrations/beads-mcp/src/beads_mcp/bd_daemon_client.py +++ b/integrations/beads-mcp/src/beads_mcp/bd_daemon_client.py @@ -11,6 +11,7 @@ from .bd_client import BdClientBase, BdError from .models import ( AddDependencyParams, BlockedIssue, + BlockedParams, CloseIssueParams, CreateIssueParams, InitParams, @@ -432,6 +433,9 @@ class BdDaemonClient(BdClientBase): args["sort_policy"] = params.sort_policy if params.limit: args["limit"] = params.limit + # Parent filtering (descendants of a bead/epic) + if params.parent_id: + args["parent_id"] = params.parent_id data = await self._send_request("ready", args) issues_data = json.loads(data) if isinstance(data, str) else data @@ -451,18 +455,25 @@ class BdDaemonClient(BdClientBase): stats_data = {} return Stats(**stats_data) - async def blocked(self) -> List[BlockedIssue]: + async def blocked(self, params: Optional[BlockedParams] = None) -> List[BlockedIssue]: """Get blocked issues. + Args: + params: Query parameters (optional) + Returns: List of blocked issues with their blockers - - Note: - This operation may not be implemented in daemon RPC yet """ - # Note: blocked operation may not be in RPC protocol yet - # This is a placeholder for when it's added - raise NotImplementedError("Blocked operation not yet supported via daemon") + params = params or BlockedParams() + args: Dict[str, Any] = {} + if params.parent_id: + args["parent_id"] = params.parent_id + + data = await self._send_request("blocked", args) + issues_data = json.loads(data) if isinstance(data, str) else data + if issues_data is None: + return [] + return [BlockedIssue(**issue) for issue in issues_data] async def inspect_migration(self) -> dict[str, Any]: """Get migration plan and database state for agent analysis. diff --git a/integrations/beads-mcp/src/beads_mcp/models.py b/integrations/beads-mcp/src/beads_mcp/models.py index 1d095650..92429693 100644 --- a/integrations/beads-mcp/src/beads_mcp/models.py +++ b/integrations/beads-mcp/src/beads_mcp/models.py @@ -209,6 +209,13 @@ class ReadyWorkParams(BaseModel): labels_any: list[str] | None = None # OR: must have at least one unassigned: bool = False # Filter to only unassigned issues sort_policy: str | None = None # hybrid, priority, oldest + parent_id: str | None = None # Filter to descendants of this bead/epic + + +class BlockedParams(BaseModel): + """Parameters for querying blocked issues.""" + + parent_id: str | None = None # Filter to descendants of this bead/epic class ListIssuesParams(BaseModel): diff --git a/integrations/beads-mcp/src/beads_mcp/tools.py b/integrations/beads-mcp/src/beads_mcp/tools.py index e47a70cb..7e907758 100644 --- a/integrations/beads-mcp/src/beads_mcp/tools.py +++ b/integrations/beads-mcp/src/beads_mcp/tools.py @@ -18,6 +18,7 @@ if TYPE_CHECKING: from .models import ( AddDependencyParams, BlockedIssue, + BlockedParams, CloseIssueParams, CreateIssueParams, DependencyType, @@ -309,11 +310,14 @@ async def beads_ready_work( labels_any: Annotated[list[str] | None, "Filter by labels (OR: must have at least one)"] = None, unassigned: Annotated[bool, "Filter to only unassigned issues"] = False, sort_policy: Annotated[str | None, "Sort policy: hybrid (default), priority, oldest"] = None, + parent: Annotated[str | None, "Filter to descendants of this bead/epic"] = None, ) -> list[Issue]: """Find issues with no blocking dependencies that are ready to work on. Ready work = status is 'open' AND no blocking dependencies. Perfect for agents to claim next work! + + Use 'parent' to filter to all descendants of an epic/bead. """ client = await _get_client() params = ReadyWorkParams( @@ -324,6 +328,7 @@ async def beads_ready_work( labels_any=labels_any, unassigned=unassigned, sort_policy=sort_policy, + parent_id=parent, ) return await client.ready(params) @@ -535,13 +540,18 @@ async def beads_stats() -> Stats: return await client.stats() -async def beads_blocked() -> list[BlockedIssue]: +async def beads_blocked( + parent: Annotated[str | None, "Filter to descendants of this bead/epic"] = None, +) -> list[BlockedIssue]: """Get blocked issues. Returns issues that have blocking dependencies, showing what blocks them. + + Use 'parent' to filter to all descendants of an epic/bead. """ client = await _get_client() - return await client.blocked() + params = BlockedParams(parent_id=parent) + return await client.blocked(params) async def beads_inspect_migration() -> dict[str, Any]: diff --git a/internal/rpc/client.go b/internal/rpc/client.go index b6c9156b..747cfdf3 100644 --- a/internal/rpc/client.go +++ b/internal/rpc/client.go @@ -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) diff --git a/internal/rpc/protocol.go b/internal/rpc/protocol.go index d08831ca..00d12907 100644 --- a/internal/rpc/protocol.go +++ b/internal/rpc/protocol.go @@ -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 diff --git a/internal/rpc/server_issues_epics.go b/internal/rpc/server_issues_epics.go index 8193634f..918d19ed 100644 --- a/internal/rpc/server_issues_epics.go +++ b/internal/rpc/server_issues_epics.go @@ -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 { diff --git a/internal/rpc/server_routing_validation_diagnostics.go b/internal/rpc/server_routing_validation_diagnostics.go index fc99b0e4..f7fedc54 100644 --- a/internal/rpc/server_routing_validation_diagnostics.go +++ b/internal/rpc/server_routing_validation_diagnostics.go @@ -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: diff --git a/internal/storage/memory/memory.go b/internal/storage/memory/memory.go index f1d17cea..60f70a0a 100644 --- a/internal/storage/memory/memory.go +++ b/internal/storage/memory/memory.go @@ -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 } diff --git a/internal/storage/memory/ready_blocked_nodb_test.go b/internal/storage/memory/ready_blocked_nodb_test.go index 73c13120..2edf771d 100644 --- a/internal/storage/memory/ready_blocked_nodb_test.go +++ b/internal/storage/memory/ready_blocked_nodb_test.go @@ -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) } diff --git a/internal/storage/sqlite/ready.go b/internal/storage/sqlite/ready.go index 04f5dded..23e22a38 100644 --- a/internal/storage/sqlite/ready.go +++ b/internal/storage/sqlite/ready.go @@ -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) } diff --git a/internal/storage/sqlite/ready_test.go b/internal/storage/sqlite/ready_test.go index f9c85327..f2e2013f 100644 --- a/internal/storage/sqlite/ready_test.go +++ b/internal/storage/sqlite/ready_test.go @@ -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)) + } +} diff --git a/internal/storage/storage.go b/internal/storage/storage.go index 96209f46..8ac122d7 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -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 diff --git a/internal/storage/storage_test.go b/internal/storage/storage_test.go index 6152ae0a..7e7450d0 100644 --- a/internal/storage/storage_test.go +++ b/internal/storage/storage_test.go @@ -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) { diff --git a/internal/types/types.go b/internal/types/types.go index facad91e..4c7d57af 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -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