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>
167 lines
4.9 KiB
Go
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
|
|
}
|