Files
beads/cmd/bd/mol_stale.go
Aarya Reddy bd5fc214c3 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
2025-12-25 21:11:58 -08:00

221 lines
6.3 KiB
Go

package main
import (
"context"
"fmt"
"os"
"github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/storage"
"github.com/steveyegge/beads/internal/types"
"github.com/steveyegge/beads/internal/ui"
)
var molStaleCmd = &cobra.Command{
Use: "stale",
Short: "Detect complete-but-unclosed molecules",
Long: `Detect molecules (epics with children) that are complete but still open.
A molecule is considered stale if:
1. All children are closed (Completed == Total)
2. Root issue is still open
3. Not assigned to anyone (optional, use --unassigned)
4. Is blocking other work (optional, use --blocking)
By default, shows all complete-but-unclosed molecules.
Examples:
bd mol stale # List all stale molecules
bd mol stale --json # Machine-readable output
bd mol stale --blocking # Only show those blocking other work
bd mol stale --unassigned # Only show unassigned molecules
bd mol stale --all # Include molecules with 0 children`,
Run: runMolStale,
}
// StaleMolecule holds info about a stale molecule
type StaleMolecule struct {
ID string `json:"id"`
Title string `json:"title"`
TotalChildren int `json:"total_children"`
ClosedChildren int `json:"closed_children"`
Assignee string `json:"assignee,omitempty"`
BlockingIssues []string `json:"blocking_issues,omitempty"`
BlockingCount int `json:"blocking_count"`
}
// StaleResult holds the result of the stale check
type StaleResult struct {
StaleMolecules []*StaleMolecule `json:"stale_molecules"`
TotalCount int `json:"total_count"`
BlockingCount int `json:"blocking_count"`
}
func runMolStale(cmd *cobra.Command, args []string) {
ctx := rootCtx
blockingOnly, _ := cmd.Flags().GetBool("blocking")
unassignedOnly, _ := cmd.Flags().GetBool("unassigned")
showAll, _ := cmd.Flags().GetBool("all")
// Get storage (direct or daemon)
var result *StaleResult
var err error
if daemonClient != nil {
// For now, stale check requires direct store access
// TODO: Add RPC endpoint for stale check
fmt.Fprintf(os.Stderr, "Error: mol stale requires direct database access\n")
fmt.Fprintf(os.Stderr, "Hint: use --no-daemon flag: bd --no-daemon mol stale\n")
os.Exit(1)
}
if store == nil {
fmt.Fprintf(os.Stderr, "Error: no database connection\n")
os.Exit(1)
}
result, err = findStaleMolecules(ctx, store, blockingOnly, unassignedOnly, showAll)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
if jsonOutput {
outputJSON(result)
return
}
if len(result.StaleMolecules) == 0 {
fmt.Println("No stale molecules found.")
return
}
// Print header
if blockingOnly {
fmt.Printf("%s Stale molecules (complete but unclosed, blocking work):\n\n",
ui.RenderWarnIcon())
} else {
fmt.Printf("%s Stale molecules (complete but unclosed):\n\n",
ui.RenderInfoIcon())
}
// Print each stale molecule
for _, mol := range result.StaleMolecules {
progress := fmt.Sprintf("%d/%d", mol.ClosedChildren, mol.TotalChildren)
if mol.BlockingCount > 0 {
fmt.Printf(" %s %s (%s) [blocking %d]\n",
ui.RenderID(mol.ID), mol.Title, progress, mol.BlockingCount)
fmt.Printf(" → Close with: bd close %s\n", mol.ID)
if mol.BlockingCount <= 3 {
fmt.Printf(" → Blocking: %v\n", mol.BlockingIssues)
}
} else {
fmt.Printf(" %s %s (%s)\n",
ui.RenderID(mol.ID), mol.Title, progress)
fmt.Printf(" → Close with: bd close %s\n", mol.ID)
}
fmt.Println()
}
// Summary
fmt.Printf("Total: %d stale", result.TotalCount)
if result.BlockingCount > 0 {
fmt.Printf(", %d blocking other work", result.BlockingCount)
}
fmt.Println()
}
// findStaleMolecules queries the database for stale molecules
func findStaleMolecules(ctx context.Context, s storage.Storage, blockingOnly, unassignedOnly, showAll bool) (*StaleResult, error) {
// Get all epics eligible for closure (complete but unclosed)
epicStatuses, err := s.GetEpicsEligibleForClosure(ctx)
if err != nil {
return nil, fmt.Errorf("querying epics: %w", err)
}
// Get blocked issues to find what each stale molecule is blocking
blockedIssues, err := s.GetBlockedIssues(ctx, types.WorkFilter{})
if err != nil {
return nil, fmt.Errorf("querying blocked issues: %w", err)
}
// Build map of issue ID -> what issues it's blocking
blockingMap := buildBlockingMap(blockedIssues)
var staleMolecules []*StaleMolecule
blockingCount := 0
for _, es := range epicStatuses {
// Skip if not eligible for close (not all children closed)
if !es.EligibleForClose {
continue
}
// Skip if no children and not showing all
if es.TotalChildren == 0 && !showAll {
continue
}
// Filter by unassigned if requested
if unassignedOnly && es.Epic.Assignee != "" {
continue
}
// Find what this molecule is blocking
blocking := blockingMap[es.Epic.ID]
blockingIssueCount := len(blocking)
// Filter by blocking if requested
if blockingOnly && blockingIssueCount == 0 {
continue
}
mol := &StaleMolecule{
ID: es.Epic.ID,
Title: es.Epic.Title,
TotalChildren: es.TotalChildren,
ClosedChildren: es.ClosedChildren,
Assignee: es.Epic.Assignee,
BlockingIssues: blocking,
BlockingCount: blockingIssueCount,
}
staleMolecules = append(staleMolecules, mol)
if blockingIssueCount > 0 {
blockingCount++
}
}
return &StaleResult{
StaleMolecules: staleMolecules,
TotalCount: len(staleMolecules),
BlockingCount: blockingCount,
}, nil
}
// buildBlockingMap creates a map of issue ID -> list of issues it's blocking
func buildBlockingMap(blockedIssues []*types.BlockedIssue) map[string][]string {
result := make(map[string][]string)
for _, blocked := range blockedIssues {
// Each blocked issue has a list of what's blocking it
for _, blockerID := range blocked.BlockedBy {
result[blockerID] = append(result[blockerID], blocked.ID)
}
}
return result
}
func init() {
molStaleCmd.Flags().Bool("blocking", false, "Only show molecules blocking other work")
molStaleCmd.Flags().Bool("unassigned", false, "Only show unassigned molecules")
molStaleCmd.Flags().Bool("all", false, "Include molecules with 0 children")
molStaleCmd.Flags().BoolVar(&jsonOutput, "json", false, "Output in JSON format")
molCmd.AddCommand(molStaleCmd)
}