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:
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 []
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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]:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user