Files
beads/internal/storage/sqlite/ready.go

339 lines
9.6 KiB
Go

package sqlite
import (
"context"
"database/sql"
"fmt"
"strings"
"github.com/steveyegge/beads/internal/types"
)
// GetReadyWork returns issues with no open blockers
// By default, shows both 'open' and 'in_progress' issues so epics/tasks
// ready to close are visible (bd-165)
func (s *SQLiteStorage) GetReadyWork(ctx context.Context, filter types.WorkFilter) ([]*types.Issue, error) {
whereClauses := []string{}
args := []interface{}{}
// Default to open OR in_progress if not specified (bd-165)
if filter.Status == "" {
whereClauses = append(whereClauses, "i.status IN ('open', 'in_progress')")
} else {
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)
}
// Label filtering (AND semantics)
if len(filter.Labels) > 0 {
for _, label := range filter.Labels {
whereClauses = append(whereClauses, `
EXISTS (
SELECT 1 FROM labels
WHERE issue_id = i.id AND label = ?
)
`)
args = append(args, label)
}
}
// Label filtering (OR semantics)
if len(filter.LabelsAny) > 0 {
placeholders := make([]string, len(filter.LabelsAny))
for i := range filter.LabelsAny {
placeholders[i] = "?"
}
whereClauses = append(whereClauses, fmt.Sprintf(`
EXISTS (
SELECT 1 FROM labels
WHERE issue_id = i.id AND label IN (%s)
)
`, strings.Join(placeholders, ",")))
for _, label := range filter.LabelsAny {
args = append(args, label)
}
}
// 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)
}
// Default to hybrid sort for backwards compatibility
sortPolicy := filter.SortPolicy
if sortPolicy == "" {
sortPolicy = types.SortPolicyHybrid
}
orderBySQL := buildOrderByClause(sortPolicy)
// 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
// #nosec G201 - safe SQL with controlled formatting
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.content_hash, 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, i.source_repo
FROM issues i
WHERE %s
AND NOT EXISTS (
SELECT 1 FROM blocked_transitively WHERE issue_id = i.id
)
%s
%s
`, whereSQL, orderBySQL, limitSQL)
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("failed to get ready work: %w", err)
}
defer func() { _ = rows.Close() }()
return s.scanIssues(ctx, rows)
}
// GetStaleIssues returns issues that haven't been updated recently
func (s *SQLiteStorage) GetStaleIssues(ctx context.Context, filter types.StaleFilter) ([]*types.Issue, error) {
// Build query with optional status filter
query := `
SELECT
id, content_hash, title, description, design, acceptance_criteria, notes,
status, priority, issue_type, assignee, estimated_minutes,
created_at, updated_at, closed_at, external_ref, source_repo,
compaction_level, compacted_at, compacted_at_commit, original_size
FROM issues
WHERE status != 'closed'
AND datetime(updated_at) < datetime('now', '-' || ? || ' days')
`
args := []interface{}{filter.Days}
// Add optional status filter
if filter.Status != "" {
query += " AND status = ?"
args = append(args, filter.Status)
}
query += " ORDER BY updated_at ASC"
// Add limit
if filter.Limit > 0 {
query += " LIMIT ?"
args = append(args, filter.Limit)
}
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("failed to query stale issues: %w", err)
}
defer func() { _ = rows.Close() }()
var issues []*types.Issue
for rows.Next() {
var issue types.Issue
var closedAt sql.NullTime
var estimatedMinutes sql.NullInt64
var assignee sql.NullString
var externalRef sql.NullString
var sourceRepo sql.NullString
var contentHash sql.NullString
var compactionLevel sql.NullInt64
var compactedAt sql.NullTime
var compactedAtCommit sql.NullString
var originalSize sql.NullInt64
err := rows.Scan(
&issue.ID, &contentHash, &issue.Title, &issue.Description, &issue.Design,
&issue.AcceptanceCriteria, &issue.Notes, &issue.Status,
&issue.Priority, &issue.IssueType, &assignee, &estimatedMinutes,
&issue.CreatedAt, &issue.UpdatedAt, &closedAt, &externalRef, &sourceRepo,
&compactionLevel, &compactedAt, &compactedAtCommit, &originalSize,
)
if err != nil {
return nil, fmt.Errorf("failed to scan stale issue: %w", err)
}
if contentHash.Valid {
issue.ContentHash = contentHash.String
}
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
}
if sourceRepo.Valid {
issue.SourceRepo = sourceRepo.String
}
if compactionLevel.Valid {
issue.CompactionLevel = int(compactionLevel.Int64)
}
if compactedAt.Valid {
issue.CompactedAt = &compactedAt.Time
}
if compactedAtCommit.Valid {
issue.CompactedAtCommit = &compactedAtCommit.String
}
if originalSize.Valid {
issue.OriginalSize = int(originalSize.Int64)
}
issues = append(issues, &issue)
}
return issues, rows.Err()
}
// 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, i.source_repo,
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 func() { _ = 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 sourceRepo 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, &sourceRepo, &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
}
if sourceRepo.Valid {
issue.SourceRepo = sourceRepo.String
}
// Parse comma-separated blocker IDs
if blockerIDsStr != "" {
issue.BlockedBy = strings.Split(blockerIDsStr, ",")
}
blocked = append(blocked, &issue)
}
return blocked, nil
}
// buildOrderByClause generates the ORDER BY clause based on sort policy
func buildOrderByClause(policy types.SortPolicy) string {
switch policy {
case types.SortPolicyPriority:
return `ORDER BY i.priority ASC, i.created_at ASC`
case types.SortPolicyOldest:
return `ORDER BY i.created_at ASC`
case types.SortPolicyHybrid:
fallthrough
default:
return `ORDER BY
CASE
WHEN datetime(i.created_at) >= datetime('now', '-48 hours') THEN 0
ELSE 1
END ASC,
CASE
WHEN datetime(i.created_at) >= datetime('now', '-48 hours') THEN i.priority
ELSE NULL
END ASC,
CASE
WHEN datetime(i.created_at) < datetime('now', '-48 hours') THEN i.created_at
ELSE NULL
END ASC,
i.created_at ASC`
}
}