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) // Use blocked_issues_cache for performance (bd-5qim) // Cache is maintained by invalidateBlockedCache() called on dependency/status changes // #nosec G201 - safe SQL with controlled formatting query := fmt.Sprintf(` 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_issues_cache 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` } }