Files
beads/internal/storage/sqlite/ready.go
Steve Yegge f6a75415a5 fix: Propagate blocking through parent-child hierarchy [fixes #19]
When an epic is blocked, all its children should also be considered
blocked in the ready work calculation. Previously, only direct blocking
dependencies were checked, allowing children of blocked epics to appear
as ready work.

Implementation:
- Use recursive CTE to propagate blocking from parents to descendants
- Only 'parent-child' dependencies propagate blocking (not 'related')
- Changed NOT IN to NOT EXISTS for better NULL safety and performance
- Added depth limit of 50 to prevent pathological cases

Test coverage:
- TestParentBlockerBlocksChildren: Basic parent→child propagation
- TestGrandparentBlockerBlocksGrandchildren: Multi-level depth
- TestMultipleParentsOneBlocked: Child blocked if ANY parent blocked
- TestBlockerClosedUnblocksChildren: Dynamic unblocking works
- TestRelatedDoesNotPropagate: Only parent-child propagates

Closes: https://github.com/steveyegge/beads/issues/19
Resolves: bd-58

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 13:02:12 -07:00

167 lines
4.9 KiB
Go

package sqlite
import (
"context"
"database/sql"
"fmt"
"strings"
"github.com/steveyegge/beads/internal/types"
)
// GetReadyWork returns issues with no open blockers
func (s *SQLiteStorage) GetReadyWork(ctx context.Context, filter types.WorkFilter) ([]*types.Issue, error) {
whereClauses := []string{}
args := []interface{}{}
// Default to open status if not specified
if filter.Status == "" {
filter.Status = types.StatusOpen
}
whereClauses = append(whereClauses, "i.status = ?")
args = append(args, filter.Status)
if filter.Priority != nil {
whereClauses = append(whereClauses, "i.priority = ?")
args = append(args, *filter.Priority)
}
if filter.Assignee != nil {
whereClauses = append(whereClauses, "i.assignee = ?")
args = append(args, *filter.Assignee)
}
// Build WHERE clause properly
whereSQL := strings.Join(whereClauses, " AND ")
// Build LIMIT clause using parameter
limitSQL := ""
if filter.Limit > 0 {
limitSQL = " LIMIT ?"
args = append(args, filter.Limit)
}
// Query with recursive CTE to propagate blocking through parent-child hierarchy
// Algorithm:
// 1. Find issues directly blocked by 'blocks' dependencies
// 2. Recursively propagate blockage to all descendants via 'parent-child' links
// 3. Exclude all blocked issues (both direct and transitive) from ready work
query := fmt.Sprintf(`
WITH RECURSIVE
-- Step 1: Find issues blocked directly by dependencies
blocked_directly AS (
SELECT DISTINCT d.issue_id
FROM dependencies d
JOIN issues blocker ON d.depends_on_id = blocker.id
WHERE d.type = 'blocks'
AND blocker.status IN ('open', 'in_progress', 'blocked')
),
-- Step 2: Propagate blockage to all descendants via parent-child
blocked_transitively AS (
-- Base case: directly blocked issues
SELECT issue_id, 0 as depth
FROM blocked_directly
UNION ALL
-- Recursive case: children of blocked issues inherit blockage
SELECT d.issue_id, bt.depth + 1
FROM blocked_transitively bt
JOIN dependencies d ON d.depends_on_id = bt.issue_id
WHERE d.type = 'parent-child'
AND bt.depth < 50
)
-- Step 3: Select ready issues (excluding all blocked)
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,
i.created_at, i.updated_at, i.closed_at, i.external_ref
FROM issues i
WHERE %s
AND NOT EXISTS (
SELECT 1 FROM blocked_transitively WHERE issue_id = i.id
)
ORDER BY i.priority ASC, i.created_at DESC
%s
`, whereSQL, limitSQL)
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("failed to get ready work: %w", err)
}
defer rows.Close()
return scanIssues(rows)
}
// GetBlockedIssues returns issues that are blocked by dependencies
func (s *SQLiteStorage) GetBlockedIssues(ctx context.Context) ([]*types.BlockedIssue, error) {
// Use GROUP_CONCAT to get all blocker IDs in a single query (no N+1)
rows, err := s.db.QueryContext(ctx, `
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,
i.created_at, i.updated_at, i.closed_at, i.external_ref,
COUNT(d.depends_on_id) as blocked_by_count,
GROUP_CONCAT(d.depends_on_id, ',') as blocker_ids
FROM issues i
JOIN dependencies d ON i.id = d.issue_id
JOIN issues blocker ON d.depends_on_id = blocker.id
WHERE i.status IN ('open', 'in_progress', 'blocked')
AND d.type = 'blocks'
AND blocker.status IN ('open', 'in_progress', 'blocked')
GROUP BY i.id
ORDER BY i.priority ASC
`)
if err != nil {
return nil, fmt.Errorf("failed to get blocked issues: %w", err)
}
defer rows.Close()
var blocked []*types.BlockedIssue
for rows.Next() {
var issue types.BlockedIssue
var closedAt sql.NullTime
var estimatedMinutes sql.NullInt64
var assignee sql.NullString
var externalRef sql.NullString
var blockerIDsStr string
err := rows.Scan(
&issue.ID, &issue.Title, &issue.Description, &issue.Design,
&issue.AcceptanceCriteria, &issue.Notes, &issue.Status,
&issue.Priority, &issue.IssueType, &assignee, &estimatedMinutes,
&issue.CreatedAt, &issue.UpdatedAt, &closedAt, &externalRef, &issue.BlockedByCount,
&blockerIDsStr,
)
if err != nil {
return nil, fmt.Errorf("failed to scan blocked issue: %w", err)
}
if closedAt.Valid {
issue.ClosedAt = &closedAt.Time
}
if estimatedMinutes.Valid {
mins := int(estimatedMinutes.Int64)
issue.EstimatedMinutes = &mins
}
if assignee.Valid {
issue.Assignee = assignee.String
}
if externalRef.Valid {
issue.ExternalRef = &externalRef.String
}
// Parse comma-separated blocker IDs
if blockerIDsStr != "" {
issue.BlockedBy = strings.Split(blockerIDsStr, ",")
}
blocked = append(blocked, &issue)
}
return blocked, nil
}