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:
Aarya Reddy
2025-12-25 21:11:58 -08:00
committed by GitHub
parent 4ffbab5311
commit bd5fc214c3
18 changed files with 380 additions and 31 deletions

View File

@@ -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 // 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 { if err != nil {
return nil, fmt.Errorf("querying blocked issues: %w", err) return nil, fmt.Errorf("querying blocked issues: %w", err)
} }

View File

@@ -39,6 +39,7 @@ This is useful for agents executing molecules to see which steps can run next.`,
labels, _ := cmd.Flags().GetStringSlice("label") labels, _ := cmd.Flags().GetStringSlice("label")
labelsAny, _ := cmd.Flags().GetStringSlice("label-any") labelsAny, _ := cmd.Flags().GetStringSlice("label-any")
issueType, _ := cmd.Flags().GetString("type") issueType, _ := cmd.Flags().GetString("type")
parentID, _ := cmd.Flags().GetString("parent")
// Use global jsonOutput set by PersistentPreRun (respects config.yaml + env vars) // Use global jsonOutput set by PersistentPreRun (respects config.yaml + env vars)
// Normalize labels: trim, dedupe, remove empty // 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 { if assignee != "" && !unassigned {
filter.Assignee = &assignee filter.Assignee = &assignee
} }
if parentID != "" {
filter.ParentID = &parentID
}
// Validate sort policy // Validate sort policy
if !filter.SortPolicy.IsValid() { if !filter.SortPolicy.IsValid() {
fmt.Fprintf(os.Stderr, "Error: invalid sort policy '%s'. Valid values: hybrid, priority, oldest\n", sortPolicy) 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, SortPolicy: sortPolicy,
Labels: labels, Labels: labels,
LabelsAny: labelsAny, LabelsAny: labelsAny,
ParentID: parentID,
} }
if cmd.Flags().Changed("priority") { if cmd.Flags().Changed("priority") {
priority, _ := cmd.Flags().GetInt("priority") priority, _ := cmd.Flags().GetInt("priority")
@@ -229,12 +234,17 @@ var blockedCmd = &cobra.Command{
var err error var err error
store, err = sqlite.New(ctx, dbPath) store, err = sqlite.New(ctx, dbPath)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to open database: %v\n", err) fmt.Fprintf(os.Stderr, "Error: failed to open database: %v\n", err)
os.Exit(1) os.Exit(1)
} }
defer func() { _ = store.Close() }() 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 { if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err) fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1) 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().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().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("mol", "", "Filter to steps within a specific molecule")
readyCmd.Flags().String("parent", "", "Filter to descendants of this bead/epic")
rootCmd.AddCommand(readyCmd) rootCmd.AddCommand(readyCmd)
blockedCmd.Flags().String("parent", "", "Filter to descendants of this bead/epic")
rootCmd.AddCommand(blockedCmd) rootCmd.AddCommand(blockedCmd)
} }

View File

@@ -204,7 +204,7 @@ func TestRelateCommand(t *testing.T) {
} }
// Issue1 should NOT be blocked (relates-to doesn't block) // 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 { if err != nil {
t.Fatalf("GetBlockedIssues failed: %v", err) t.Fatalf("GetBlockedIssues failed: %v", err)
} }

View File

@@ -12,6 +12,7 @@ from .config import load_config
from .models import ( from .models import (
AddDependencyParams, AddDependencyParams,
BlockedIssue, BlockedIssue,
BlockedParams,
CloseIssueParams, CloseIssueParams,
CreateIssueParams, CreateIssueParams,
InitParams, InitParams,
@@ -126,7 +127,7 @@ class BdClientBase(ABC):
pass pass
@abstractmethod @abstractmethod
async def blocked(self) -> List[BlockedIssue]: async def blocked(self, params: Optional[BlockedParams] = None) -> List[BlockedIssue]:
"""Get blocked issues.""" """Get blocked issues."""
pass pass
@@ -395,6 +396,8 @@ class BdCliClient(BdClientBase):
args.append("--unassigned") args.append("--unassigned")
if params.sort_policy: if params.sort_policy:
args.extend(["--sort", 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) data = await self._run_command(*args)
if not isinstance(data, list): if not isinstance(data, list):
@@ -664,13 +667,21 @@ class BdCliClient(BdClientBase):
return Stats.model_validate(data) return Stats.model_validate(data)
async def blocked(self) -> list[BlockedIssue]: async def blocked(self, params: BlockedParams | None = None) -> list[BlockedIssue]:
"""Get blocked issues. """Get blocked issues.
Args:
params: Query parameters
Returns: Returns:
List of blocked issues with blocking information 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): if not isinstance(data, list):
return [] return []

View File

@@ -11,6 +11,7 @@ from .bd_client import BdClientBase, BdError
from .models import ( from .models import (
AddDependencyParams, AddDependencyParams,
BlockedIssue, BlockedIssue,
BlockedParams,
CloseIssueParams, CloseIssueParams,
CreateIssueParams, CreateIssueParams,
InitParams, InitParams,
@@ -432,6 +433,9 @@ class BdDaemonClient(BdClientBase):
args["sort_policy"] = params.sort_policy args["sort_policy"] = params.sort_policy
if params.limit: if params.limit:
args["limit"] = 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) data = await self._send_request("ready", args)
issues_data = json.loads(data) if isinstance(data, str) else data issues_data = json.loads(data) if isinstance(data, str) else data
@@ -451,18 +455,25 @@ class BdDaemonClient(BdClientBase):
stats_data = {} stats_data = {}
return Stats(**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. """Get blocked issues.
Args:
params: Query parameters (optional)
Returns: Returns:
List of blocked issues with their blockers 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 params = params or BlockedParams()
# This is a placeholder for when it's added args: Dict[str, Any] = {}
raise NotImplementedError("Blocked operation not yet supported via daemon") 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]: async def inspect_migration(self) -> dict[str, Any]:
"""Get migration plan and database state for agent analysis. """Get migration plan and database state for agent analysis.

View File

@@ -209,6 +209,13 @@ class ReadyWorkParams(BaseModel):
labels_any: list[str] | None = None # OR: must have at least one labels_any: list[str] | None = None # OR: must have at least one
unassigned: bool = False # Filter to only unassigned issues unassigned: bool = False # Filter to only unassigned issues
sort_policy: str | None = None # hybrid, priority, oldest 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): class ListIssuesParams(BaseModel):

View File

@@ -18,6 +18,7 @@ if TYPE_CHECKING:
from .models import ( from .models import (
AddDependencyParams, AddDependencyParams,
BlockedIssue, BlockedIssue,
BlockedParams,
CloseIssueParams, CloseIssueParams,
CreateIssueParams, CreateIssueParams,
DependencyType, 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, 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, unassigned: Annotated[bool, "Filter to only unassigned issues"] = False,
sort_policy: Annotated[str | None, "Sort policy: hybrid (default), priority, oldest"] = None, 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]: ) -> list[Issue]:
"""Find issues with no blocking dependencies that are ready to work on. """Find issues with no blocking dependencies that are ready to work on.
Ready work = status is 'open' AND no blocking dependencies. Ready work = status is 'open' AND no blocking dependencies.
Perfect for agents to claim next work! Perfect for agents to claim next work!
Use 'parent' to filter to all descendants of an epic/bead.
""" """
client = await _get_client() client = await _get_client()
params = ReadyWorkParams( params = ReadyWorkParams(
@@ -324,6 +328,7 @@ async def beads_ready_work(
labels_any=labels_any, labels_any=labels_any,
unassigned=unassigned, unassigned=unassigned,
sort_policy=sort_policy, sort_policy=sort_policy,
parent_id=parent,
) )
return await client.ready(params) return await client.ready(params)
@@ -535,13 +540,18 @@ async def beads_stats() -> Stats:
return await client.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. """Get blocked issues.
Returns issues that have blocking dependencies, showing what blocks them. 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() 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]: async def beads_inspect_migration() -> dict[str, Any]:

View File

@@ -333,6 +333,11 @@ func (c *Client) Ready(args *ReadyArgs) (*Response, error) {
return c.Execute(OpReady, args) 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 // Stale gets stale issues via the daemon
func (c *Client) Stale(args *StaleArgs) (*Response, error) { func (c *Client) Stale(args *StaleArgs) (*Response, error) {
return c.Execute(OpStale, args) return c.Execute(OpStale, args)

View File

@@ -20,6 +20,7 @@ const (
OpCount = "count" OpCount = "count"
OpShow = "show" OpShow = "show"
OpReady = "ready" OpReady = "ready"
OpBlocked = "blocked"
OpStale = "stale" OpStale = "stale"
OpStats = "stats" OpStats = "stats"
OpDepAdd = "dep_add" OpDepAdd = "dep_add"
@@ -253,6 +254,12 @@ type ReadyArgs struct {
SortPolicy string `json:"sort_policy,omitempty"` SortPolicy string `json:"sort_policy,omitempty"`
Labels []string `json:"labels,omitempty"` Labels []string `json:"labels,omitempty"`
LabelsAny []string `json:"labels_any,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 // StaleArgs represents arguments for the stale command

View File

@@ -1262,6 +1262,7 @@ func (s *Server) handleReady(req *Request) Response {
wf := types.WorkFilter{ wf := types.WorkFilter{
Status: types.StatusOpen, Status: types.StatusOpen,
Type: readyArgs.Type,
Priority: readyArgs.Priority, Priority: readyArgs.Priority,
Unassigned: readyArgs.Unassigned, Unassigned: readyArgs.Unassigned,
Limit: readyArgs.Limit, Limit: readyArgs.Limit,
@@ -1272,6 +1273,9 @@ func (s *Server) handleReady(req *Request) Response {
if readyArgs.Assignee != "" && !readyArgs.Unassigned { if readyArgs.Assignee != "" && !readyArgs.Unassigned {
wf.Assignee = &readyArgs.Assignee wf.Assignee = &readyArgs.Assignee
} }
if readyArgs.ParentID != "" {
wf.ParentID = &readyArgs.ParentID
}
ctx := s.reqCtx(req) ctx := s.reqCtx(req)
issues, err := store.GetReadyWork(ctx, wf) 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 { func (s *Server) handleStale(req *Request) Response {
var staleArgs StaleArgs var staleArgs StaleArgs
if err := json.Unmarshal(req.Args, &staleArgs); err != nil { if err := json.Unmarshal(req.Args, &staleArgs); err != nil {

View File

@@ -188,6 +188,8 @@ func (s *Server) handleRequest(req *Request) Response {
resp = s.handleResolveID(req) resp = s.handleResolveID(req)
case OpReady: case OpReady:
resp = s.handleReady(req) resp = s.handleReady(req)
case OpBlocked:
resp = s.handleBlocked(req)
case OpStale: case OpStale:
resp = s.handleStale(req) resp = s.handleStale(req)
case OpStats: case OpStats:

View File

@@ -1097,10 +1097,16 @@ func (m *MemoryStorage) getOpenBlockers(issueID string) []string {
// GetBlockedIssues returns issues that are blocked by other issues // GetBlockedIssues returns issues that are blocked by other issues
// Note: Pinned issues are excluded from the output (beads-ei4) // 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() m.mu.RLock()
defer m.mu.RUnlock() 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 var results []*types.BlockedIssue
for _, issue := range m.issues { for _, issue := range m.issues {
@@ -1114,6 +1120,11 @@ func (m *MemoryStorage) GetBlockedIssues(ctx context.Context) ([]*types.BlockedI
continue continue
} }
// Parent filtering: only include descendants of specified parent
if descendantIDs != nil && !descendantIDs[issue.ID] {
continue
}
blockers := m.getOpenBlockers(issue.ID) blockers := m.getOpenBlockers(issue.ID)
// Issue is "blocked" if: status is blocked, status is deferred, or has open blockers // 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 { 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 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) { func (m *MemoryStorage) GetEpicsEligibleForClosure(ctx context.Context) ([]*types.EpicStatus, error) {
return nil, nil return nil, nil
} }

View File

@@ -124,7 +124,7 @@ func TestGetBlockedIssues_IncludesExplicitlyBlockedStatus(t *testing.T) {
t.Fatalf("AddDependency failed: %v", err) t.Fatalf("AddDependency failed: %v", err)
} }
blocked, err := store.GetBlockedIssues(ctx) blocked, err := store.GetBlockedIssues(ctx, types.WorkFilter{})
if err != nil { if err != nil {
t.Fatalf("GetBlockedIssues failed: %v", err) t.Fatalf("GetBlockedIssues failed: %v", err)
} }

View File

@@ -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 // Build WHERE clause properly
whereSQL := strings.Join(whereClauses, " AND ") 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 // GetBlockedIssues returns issues that are blocked by dependencies or have status=blocked
// Note: Pinned issues are excluded from the output (beads-ei4) // Note: Pinned issues are excluded from the output (beads-ei4)
// Note: Includes external: references in blocked_by list (bd-om4a) // 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: // Use UNION to combine:
// 1. Issues with open/in_progress/blocked status that have dependency blockers // 1. Issues with open/in_progress/blocked status that have dependency blockers
// 2. Issues with status=blocked (even if they have no 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: // For blocked_by_count and blocker_ids:
// - Count local blockers (open issues) + external refs (external:*) // - Count local blockers (open issues) + external refs (external:*)
// - External refs are always considered "open" until resolved (bd-om4a) // - 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 SELECT
i.id, i.title, i.description, i.design, i.acceptance_criteria, i.notes, 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, 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') AND blocker.status IN ('open', 'in_progress', 'blocked', 'deferred')
) )
-- External refs: always included (resolution happens at query time) -- 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') WHERE i.status IN ('open', 'in_progress', 'blocked', 'deferred')
AND i.pinned = 0 AND i.pinned = 0
@@ -461,12 +510,14 @@ func (s *SQLiteStorage) GetBlockedIssues(ctx context.Context) ([]*types.BlockedI
SELECT 1 FROM dependencies d3 SELECT 1 FROM dependencies d3
WHERE d3.issue_id = i.id WHERE d3.issue_id = i.id
AND d3.type = 'blocks' AND d3.type = 'blocks'
AND d3.depends_on_id LIKE 'external:%' AND d3.depends_on_id LIKE 'external:%%'
) )
) )
%s
GROUP BY i.id GROUP BY i.id
ORDER BY i.priority ASC ORDER BY i.priority ASC
`) `, filterSQL)
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get blocked issues: %w", err) return nil, fmt.Errorf("failed to get blocked issues: %w", err)
} }

View File

@@ -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") store.AddDependency(ctx, &types.Dependency{IssueID: issue3.ID, DependsOnID: issue2.ID, Type: types.DepBlocks}, "test-user")
// Get blocked issues // Get blocked issues
blocked, err := store.GetBlockedIssues(ctx) blocked, err := store.GetBlockedIssues(ctx, types.WorkFilter{})
if err != nil { if err != nil {
t.Fatalf("GetBlockedIssues failed: %v", err) 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 // 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 { if err != nil {
t.Fatalf("GetBlockedIssues failed: %v", err) 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) // 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 { if err != nil {
t.Fatalf("GetBlockedIssues failed after shipping: %v", err) t.Fatalf("GetBlockedIssues failed after shipping: %v", err)
} }
@@ -1379,7 +1379,7 @@ func TestGetBlockedIssuesPartialExternalDeps(t *testing.T) {
externalStore.Close() externalStore.Close()
// Issue should still be blocked (cap2 not satisfied) // Issue should still be blocked (cap2 not satisfied)
blocked, err := mainStore.GetBlockedIssues(ctx) blocked, err := mainStore.GetBlockedIssues(ctx, types.WorkFilter{})
if err != nil { if err != nil {
t.Fatalf("GetBlockedIssues failed: %v", err) 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) 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))
}
}

View File

@@ -107,7 +107,7 @@ type Storage interface {
// Ready Work & Blocking // Ready Work & Blocking
GetReadyWork(ctx context.Context, filter types.WorkFilter) ([]*types.Issue, error) 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) GetEpicsEligibleForClosure(ctx context.Context) ([]*types.EpicStatus, error)
GetStaleIssues(ctx context.Context, filter types.StaleFilter) ([]*types.Issue, error) GetStaleIssues(ctx context.Context, filter types.StaleFilter) ([]*types.Issue, error)
GetNewlyUnblockedByClose(ctx context.Context, closedIssueID string) ([]*types.Issue, error) // GH#679 GetNewlyUnblockedByClose(ctx context.Context, closedIssueID string) ([]*types.Issue, error) // GH#679

View File

@@ -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) { func (m *mockStorage) GetReadyWork(ctx context.Context, filter types.WorkFilter) ([]*types.Issue, error) {
return nil, nil 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 return nil, nil
} }
func (m *mockStorage) GetEpicsEligibleForClosure(ctx context.Context) ([]*types.EpicStatus, error) { func (m *mockStorage) GetEpicsEligibleForClosure(ctx context.Context) ([]*types.EpicStatus, error) {

View File

@@ -646,6 +646,9 @@ type WorkFilter struct {
LabelsAny []string // OR semantics: issue must have AT LEAST ONE of these labels LabelsAny []string // OR semantics: issue must have AT LEAST ONE of these labels
Limit int Limit int
SortPolicy SortPolicy 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 // StaleFilter is used to filter stale issue queries