Adds the ability to filter ready work for issues with no assignee, which is useful for the SCAVENGE protocol in Gas Town where polecats need to query the "Salvage Yard" for unclaimed work. Changes: - Add Unassigned bool field to types.WorkFilter - Add --unassigned/-u flag to bd ready command - Update SQL query in GetReadyWork to filter for NULL/empty assignee - Add Unassigned field to RPC ReadyArgs for daemon support - Add tests for the new functionality Closes: gt-3rp 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
320 lines
9.2 KiB
Go
320 lines
9.2 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.Unassigned {
|
|
whereClauses = append(whereClauses, "(i.assignee IS NULL OR i.assignee = '')")
|
|
} else 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)
|
|
// This optimization replaces the recursive CTE that computed blocked issues on every query.
|
|
// Performance improvement: 752ms → 29ms on 10K issues (25x speedup).
|
|
//
|
|
// The cache is automatically maintained by invalidateBlockedCache() which is called:
|
|
// - When adding/removing 'blocks' or 'parent-child' dependencies
|
|
// - When any issue status changes
|
|
// - When closing any issue
|
|
//
|
|
// Cache rebuild is fast (<50ms) and happens within the same transaction as the
|
|
// triggering change, ensuring consistency. See blocked_cache.go for full details.
|
|
// #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`
|
|
}
|
|
}
|