Initial commit: Beads issue tracker with security fixes
Core features:
- Dependency-aware issue tracking with SQLite backend
- Ready work detection (issues with no open blockers)
- Dependency tree visualization
- Cycle detection and prevention
- Full audit trail
- CLI with colored output
Security and correctness fixes applied:
- Fixed SQL injection vulnerability in UpdateIssue (whitelisted fields)
- Fixed race condition in ID generation (added mutex)
- Fixed cycle detection to return full paths (not just issue IDs)
- Added cycle prevention in AddDependency (validates before commit)
- Added comprehensive input validation (priority, status, types, etc.)
- Fixed N+1 query in GetBlockedIssues (using GROUP_CONCAT)
- Improved query building in GetReadyWork (proper string joining)
- Fixed P0 priority filter bug (using Changed() instead of value check)
All critical and major issues from code review have been addressed.
🤖 Generated with Claude Code
This commit is contained in:
135
internal/storage/sqlite/ready.go
Normal file
135
internal/storage/sqlite/ready.go
Normal file
@@ -0,0 +1,135 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/steveyackey/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)
|
||||
}
|
||||
|
||||
// Single query template
|
||||
query := fmt.Sprintf(`
|
||||
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
|
||||
FROM issues i
|
||||
WHERE %s
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM dependencies d
|
||||
JOIN issues blocked ON d.depends_on_id = blocked.id
|
||||
WHERE d.issue_id = i.id
|
||||
AND d.type = 'blocks'
|
||||
AND blocked.status IN ('open', 'in_progress', 'blocked')
|
||||
)
|
||||
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,
|
||||
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 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, &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
|
||||
}
|
||||
|
||||
// Parse comma-separated blocker IDs
|
||||
if blockerIDsStr != "" {
|
||||
issue.BlockedBy = strings.Split(blockerIDsStr, ",")
|
||||
}
|
||||
|
||||
blocked = append(blocked, &issue)
|
||||
}
|
||||
|
||||
return blocked, nil
|
||||
}
|
||||
Reference in New Issue
Block a user