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:
362
internal/storage/sqlite/dependencies.go
Normal file
362
internal/storage/sqlite/dependencies.go
Normal file
@@ -0,0 +1,362 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/steveyackey/beads/internal/types"
|
||||
)
|
||||
|
||||
// AddDependency adds a dependency between issues with cycle prevention
|
||||
func (s *SQLiteStorage) AddDependency(ctx context.Context, dep *types.Dependency, actor string) error {
|
||||
// Validate that both issues exist
|
||||
issueExists, err := s.GetIssue(ctx, dep.IssueID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check issue %s: %w", dep.IssueID, err)
|
||||
}
|
||||
if issueExists == nil {
|
||||
return fmt.Errorf("issue %s not found", dep.IssueID)
|
||||
}
|
||||
|
||||
dependsOnExists, err := s.GetIssue(ctx, dep.DependsOnID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check dependency %s: %w", dep.DependsOnID, err)
|
||||
}
|
||||
if dependsOnExists == nil {
|
||||
return fmt.Errorf("dependency target %s not found", dep.DependsOnID)
|
||||
}
|
||||
|
||||
// Prevent self-dependency
|
||||
if dep.IssueID == dep.DependsOnID {
|
||||
return fmt.Errorf("issue cannot depend on itself")
|
||||
}
|
||||
|
||||
dep.CreatedAt = time.Now()
|
||||
dep.CreatedBy = actor
|
||||
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to begin transaction: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Insert dependency
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO dependencies (issue_id, depends_on_id, type, created_at, created_by)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`, dep.IssueID, dep.DependsOnID, dep.Type, dep.CreatedAt, dep.CreatedBy)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to add dependency: %w", err)
|
||||
}
|
||||
|
||||
// Check if this creates a cycle (only for 'blocks' type dependencies)
|
||||
// We need to check if we can reach IssueID from DependsOnID
|
||||
// If yes, adding "IssueID depends on DependsOnID" would create a cycle
|
||||
if dep.Type == types.DepBlocks {
|
||||
var cycleExists bool
|
||||
err = tx.QueryRowContext(ctx, `
|
||||
WITH RECURSIVE paths AS (
|
||||
SELECT
|
||||
issue_id,
|
||||
depends_on_id,
|
||||
1 as depth
|
||||
FROM dependencies
|
||||
WHERE type = 'blocks'
|
||||
AND issue_id = ?
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
d.issue_id,
|
||||
d.depends_on_id,
|
||||
p.depth + 1
|
||||
FROM dependencies d
|
||||
JOIN paths p ON d.issue_id = p.depends_on_id
|
||||
WHERE d.type = 'blocks'
|
||||
AND p.depth < 100
|
||||
)
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM paths
|
||||
WHERE depends_on_id = ?
|
||||
)
|
||||
`, dep.DependsOnID, dep.IssueID).Scan(&cycleExists)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check for cycles: %w", err)
|
||||
}
|
||||
|
||||
if cycleExists {
|
||||
return fmt.Errorf("cannot add dependency: would create a cycle (%s → %s → ... → %s)",
|
||||
dep.IssueID, dep.DependsOnID, dep.IssueID)
|
||||
}
|
||||
}
|
||||
|
||||
// Record event
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO events (issue_id, event_type, actor, comment)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`, dep.IssueID, types.EventDependencyAdded, actor,
|
||||
fmt.Sprintf("Added dependency: %s %s %s", dep.IssueID, dep.Type, dep.DependsOnID))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to record event: %w", err)
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// RemoveDependency removes a dependency
|
||||
func (s *SQLiteStorage) RemoveDependency(ctx context.Context, issueID, dependsOnID string, actor string) error {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to begin transaction: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
DELETE FROM dependencies WHERE issue_id = ? AND depends_on_id = ?
|
||||
`, issueID, dependsOnID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to remove dependency: %w", err)
|
||||
}
|
||||
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO events (issue_id, event_type, actor, comment)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`, issueID, types.EventDependencyRemoved, actor,
|
||||
fmt.Sprintf("Removed dependency on %s", dependsOnID))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to record event: %w", err)
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// GetDependencies returns issues that this issue depends on
|
||||
func (s *SQLiteStorage) GetDependencies(ctx context.Context, issueID string) ([]*types.Issue, error) {
|
||||
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
|
||||
FROM issues i
|
||||
JOIN dependencies d ON i.id = d.depends_on_id
|
||||
WHERE d.issue_id = ?
|
||||
ORDER BY i.priority ASC
|
||||
`, issueID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get dependencies: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return scanIssues(rows)
|
||||
}
|
||||
|
||||
// GetDependents returns issues that depend on this issue
|
||||
func (s *SQLiteStorage) GetDependents(ctx context.Context, issueID string) ([]*types.Issue, error) {
|
||||
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
|
||||
FROM issues i
|
||||
JOIN dependencies d ON i.id = d.issue_id
|
||||
WHERE d.depends_on_id = ?
|
||||
ORDER BY i.priority ASC
|
||||
`, issueID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get dependents: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return scanIssues(rows)
|
||||
}
|
||||
|
||||
// GetDependencyTree returns the full dependency tree
|
||||
func (s *SQLiteStorage) GetDependencyTree(ctx context.Context, issueID string, maxDepth int) ([]*types.TreeNode, error) {
|
||||
if maxDepth <= 0 {
|
||||
maxDepth = 50
|
||||
}
|
||||
|
||||
// Use recursive CTE to build tree
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
WITH RECURSIVE tree AS (
|
||||
SELECT
|
||||
i.id, i.title, i.status, i.priority, i.description, i.design,
|
||||
i.acceptance_criteria, i.notes, i.issue_type, i.assignee,
|
||||
i.estimated_minutes, i.created_at, i.updated_at, i.closed_at,
|
||||
0 as depth
|
||||
FROM issues i
|
||||
WHERE i.id = ?
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
i.id, i.title, i.status, i.priority, i.description, i.design,
|
||||
i.acceptance_criteria, i.notes, i.issue_type, i.assignee,
|
||||
i.estimated_minutes, i.created_at, i.updated_at, i.closed_at,
|
||||
t.depth + 1
|
||||
FROM issues i
|
||||
JOIN dependencies d ON i.id = d.depends_on_id
|
||||
JOIN tree t ON d.issue_id = t.id
|
||||
WHERE t.depth < ?
|
||||
)
|
||||
SELECT * FROM tree
|
||||
ORDER BY depth, priority
|
||||
`, issueID, maxDepth)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get dependency tree: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var nodes []*types.TreeNode
|
||||
for rows.Next() {
|
||||
var node types.TreeNode
|
||||
var closedAt sql.NullTime
|
||||
var estimatedMinutes sql.NullInt64
|
||||
var assignee sql.NullString
|
||||
|
||||
err := rows.Scan(
|
||||
&node.ID, &node.Title, &node.Status, &node.Priority,
|
||||
&node.Description, &node.Design, &node.AcceptanceCriteria,
|
||||
&node.Notes, &node.IssueType, &assignee, &estimatedMinutes,
|
||||
&node.CreatedAt, &node.UpdatedAt, &closedAt, &node.Depth,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to scan tree node: %w", err)
|
||||
}
|
||||
|
||||
if closedAt.Valid {
|
||||
node.ClosedAt = &closedAt.Time
|
||||
}
|
||||
if estimatedMinutes.Valid {
|
||||
mins := int(estimatedMinutes.Int64)
|
||||
node.EstimatedMinutes = &mins
|
||||
}
|
||||
if assignee.Valid {
|
||||
node.Assignee = assignee.String
|
||||
}
|
||||
|
||||
node.Truncated = node.Depth == maxDepth
|
||||
|
||||
nodes = append(nodes, &node)
|
||||
}
|
||||
|
||||
return nodes, nil
|
||||
}
|
||||
|
||||
// DetectCycles finds circular dependencies and returns the actual cycle paths
|
||||
func (s *SQLiteStorage) DetectCycles(ctx context.Context) ([][]*types.Issue, error) {
|
||||
// Use recursive CTE to find cycles with full paths
|
||||
// We track the path as a string to work around SQLite's lack of arrays
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
WITH RECURSIVE paths AS (
|
||||
SELECT
|
||||
issue_id,
|
||||
depends_on_id,
|
||||
issue_id as start_id,
|
||||
issue_id || '→' || depends_on_id as path,
|
||||
0 as depth
|
||||
FROM dependencies
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
d.issue_id,
|
||||
d.depends_on_id,
|
||||
p.start_id,
|
||||
p.path || '→' || d.depends_on_id,
|
||||
p.depth + 1
|
||||
FROM dependencies d
|
||||
JOIN paths p ON d.issue_id = p.depends_on_id
|
||||
WHERE p.depth < 100
|
||||
AND p.path NOT LIKE '%' || d.depends_on_id || '→%'
|
||||
)
|
||||
SELECT DISTINCT path || '→' || start_id as cycle_path
|
||||
FROM paths
|
||||
WHERE depends_on_id = start_id
|
||||
ORDER BY cycle_path
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to detect cycles: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var cycles [][]*types.Issue
|
||||
seen := make(map[string]bool)
|
||||
|
||||
for rows.Next() {
|
||||
var pathStr string
|
||||
if err := rows.Scan(&pathStr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Skip if we've already seen this cycle (can happen with different entry points)
|
||||
if seen[pathStr] {
|
||||
continue
|
||||
}
|
||||
seen[pathStr] = true
|
||||
|
||||
// Parse the path string: "bd-1→bd-2→bd-3→bd-1"
|
||||
issueIDs := strings.Split(pathStr, "→")
|
||||
|
||||
// Remove the duplicate last element (cycle closes back to start)
|
||||
if len(issueIDs) > 1 && issueIDs[0] == issueIDs[len(issueIDs)-1] {
|
||||
issueIDs = issueIDs[:len(issueIDs)-1]
|
||||
}
|
||||
|
||||
// Fetch full issue details for each ID in the cycle
|
||||
var cycleIssues []*types.Issue
|
||||
for _, issueID := range issueIDs {
|
||||
issue, err := s.GetIssue(ctx, issueID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get issue %s: %w", issueID, err)
|
||||
}
|
||||
if issue != nil {
|
||||
cycleIssues = append(cycleIssues, issue)
|
||||
}
|
||||
}
|
||||
|
||||
if len(cycleIssues) > 0 {
|
||||
cycles = append(cycles, cycleIssues)
|
||||
}
|
||||
}
|
||||
|
||||
return cycles, nil
|
||||
}
|
||||
|
||||
// Helper function to scan issues from rows
|
||||
func scanIssues(rows *sql.Rows) ([]*types.Issue, error) {
|
||||
var issues []*types.Issue
|
||||
for rows.Next() {
|
||||
var issue types.Issue
|
||||
var closedAt sql.NullTime
|
||||
var estimatedMinutes sql.NullInt64
|
||||
var assignee sql.NullString
|
||||
|
||||
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,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to scan 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
|
||||
}
|
||||
|
||||
issues = append(issues, &issue)
|
||||
}
|
||||
|
||||
return issues, nil
|
||||
}
|
||||
147
internal/storage/sqlite/events.go
Normal file
147
internal/storage/sqlite/events.go
Normal file
@@ -0,0 +1,147 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
"github.com/steveyackey/beads/internal/types"
|
||||
)
|
||||
|
||||
// AddComment adds a comment to an issue
|
||||
func (s *SQLiteStorage) AddComment(ctx context.Context, issueID, actor, comment string) error {
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO events (issue_id, event_type, actor, comment)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`, issueID, types.EventCommented, actor, comment)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to add comment: %w", err)
|
||||
}
|
||||
|
||||
// Update issue updated_at timestamp
|
||||
_, err = s.db.ExecContext(ctx, `
|
||||
UPDATE issues SET updated_at = CURRENT_TIMESTAMP WHERE id = ?
|
||||
`, issueID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update timestamp: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetEvents returns the event history for an issue
|
||||
func (s *SQLiteStorage) GetEvents(ctx context.Context, issueID string, limit int) ([]*types.Event, error) {
|
||||
limitSQL := ""
|
||||
if limit > 0 {
|
||||
limitSQL = fmt.Sprintf(" LIMIT %d", limit)
|
||||
}
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
SELECT id, issue_id, event_type, actor, old_value, new_value, comment, created_at
|
||||
FROM events
|
||||
WHERE issue_id = ?
|
||||
ORDER BY created_at DESC
|
||||
%s
|
||||
`, limitSQL)
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, query, issueID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get events: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var events []*types.Event
|
||||
for rows.Next() {
|
||||
var event types.Event
|
||||
var oldValue, newValue, comment sql.NullString
|
||||
|
||||
err := rows.Scan(
|
||||
&event.ID, &event.IssueID, &event.EventType, &event.Actor,
|
||||
&oldValue, &newValue, &comment, &event.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to scan event: %w", err)
|
||||
}
|
||||
|
||||
if oldValue.Valid {
|
||||
event.OldValue = &oldValue.String
|
||||
}
|
||||
if newValue.Valid {
|
||||
event.NewValue = &newValue.String
|
||||
}
|
||||
if comment.Valid {
|
||||
event.Comment = &comment.String
|
||||
}
|
||||
|
||||
events = append(events, &event)
|
||||
}
|
||||
|
||||
return events, nil
|
||||
}
|
||||
|
||||
// GetStatistics returns aggregate statistics
|
||||
func (s *SQLiteStorage) GetStatistics(ctx context.Context) (*types.Statistics, error) {
|
||||
var stats types.Statistics
|
||||
|
||||
// Get counts
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
SELECT
|
||||
COUNT(*) as total,
|
||||
SUM(CASE WHEN status = 'open' THEN 1 ELSE 0 END) as open,
|
||||
SUM(CASE WHEN status = 'in_progress' THEN 1 ELSE 0 END) as in_progress,
|
||||
SUM(CASE WHEN status = 'closed' THEN 1 ELSE 0 END) as closed
|
||||
FROM issues
|
||||
`).Scan(&stats.TotalIssues, &stats.OpenIssues, &stats.InProgressIssues, &stats.ClosedIssues)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get issue counts: %w", err)
|
||||
}
|
||||
|
||||
// Get blocked count
|
||||
err = s.db.QueryRowContext(ctx, `
|
||||
SELECT COUNT(DISTINCT i.id)
|
||||
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')
|
||||
`).Scan(&stats.BlockedIssues)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get blocked count: %w", err)
|
||||
}
|
||||
|
||||
// Get ready count
|
||||
err = s.db.QueryRowContext(ctx, `
|
||||
SELECT COUNT(*)
|
||||
FROM issues i
|
||||
WHERE i.status = 'open'
|
||||
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')
|
||||
)
|
||||
`).Scan(&stats.ReadyIssues)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get ready count: %w", err)
|
||||
}
|
||||
|
||||
// Get average lead time (hours from created to closed)
|
||||
var avgLeadTime sql.NullFloat64
|
||||
err = s.db.QueryRowContext(ctx, `
|
||||
SELECT AVG(
|
||||
(julianday(closed_at) - julianday(created_at)) * 24
|
||||
)
|
||||
FROM issues
|
||||
WHERE closed_at IS NOT NULL
|
||||
`).Scan(&avgLeadTime)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("failed to get lead time: %w", err)
|
||||
}
|
||||
if avgLeadTime.Valid {
|
||||
stats.AverageLeadTime = avgLeadTime.Float64
|
||||
}
|
||||
|
||||
return &stats, nil
|
||||
}
|
||||
102
internal/storage/sqlite/labels.go
Normal file
102
internal/storage/sqlite/labels.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/steveyackey/beads/internal/types"
|
||||
)
|
||||
|
||||
// AddLabel adds a label to an issue
|
||||
func (s *SQLiteStorage) AddLabel(ctx context.Context, issueID, label, actor string) error {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to begin transaction: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
INSERT OR IGNORE INTO labels (issue_id, label)
|
||||
VALUES (?, ?)
|
||||
`, issueID, label)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to add label: %w", err)
|
||||
}
|
||||
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO events (issue_id, event_type, actor, comment)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`, issueID, types.EventLabelAdded, actor, fmt.Sprintf("Added label: %s", label))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to record event: %w", err)
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// RemoveLabel removes a label from an issue
|
||||
func (s *SQLiteStorage) RemoveLabel(ctx context.Context, issueID, label, actor string) error {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to begin transaction: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
DELETE FROM labels WHERE issue_id = ? AND label = ?
|
||||
`, issueID, label)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to remove label: %w", err)
|
||||
}
|
||||
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO events (issue_id, event_type, actor, comment)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`, issueID, types.EventLabelRemoved, actor, fmt.Sprintf("Removed label: %s", label))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to record event: %w", err)
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// GetLabels returns all labels for an issue
|
||||
func (s *SQLiteStorage) GetLabels(ctx context.Context, issueID string) ([]string, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT label FROM labels WHERE issue_id = ? ORDER BY label
|
||||
`, issueID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get labels: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var labels []string
|
||||
for rows.Next() {
|
||||
var label string
|
||||
if err := rows.Scan(&label); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
labels = append(labels, label)
|
||||
}
|
||||
|
||||
return labels, nil
|
||||
}
|
||||
|
||||
// GetIssuesByLabel returns issues with a specific label
|
||||
func (s *SQLiteStorage) GetIssuesByLabel(ctx context.Context, label string) ([]*types.Issue, error) {
|
||||
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
|
||||
FROM issues i
|
||||
JOIN labels l ON i.id = l.issue_id
|
||||
WHERE l.label = ?
|
||||
ORDER BY i.priority ASC, i.created_at DESC
|
||||
`, label)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get issues by label: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return scanIssues(rows)
|
||||
}
|
||||
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
|
||||
}
|
||||
93
internal/storage/sqlite/schema.go
Normal file
93
internal/storage/sqlite/schema.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package sqlite
|
||||
|
||||
const schema = `
|
||||
-- Issues table
|
||||
CREATE TABLE IF NOT EXISTS issues (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL CHECK(length(title) <= 500),
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
design TEXT NOT NULL DEFAULT '',
|
||||
acceptance_criteria TEXT NOT NULL DEFAULT '',
|
||||
notes TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'open',
|
||||
priority INTEGER NOT NULL DEFAULT 2 CHECK(priority >= 0 AND priority <= 4),
|
||||
issue_type TEXT NOT NULL DEFAULT 'task',
|
||||
assignee TEXT,
|
||||
estimated_minutes INTEGER,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
closed_at DATETIME
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_issues_status ON issues(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_issues_priority ON issues(priority);
|
||||
CREATE INDEX IF NOT EXISTS idx_issues_assignee ON issues(assignee);
|
||||
CREATE INDEX IF NOT EXISTS idx_issues_created_at ON issues(created_at);
|
||||
|
||||
-- Dependencies table
|
||||
CREATE TABLE IF NOT EXISTS dependencies (
|
||||
issue_id TEXT NOT NULL,
|
||||
depends_on_id TEXT NOT NULL,
|
||||
type TEXT NOT NULL DEFAULT 'blocks',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by TEXT NOT NULL,
|
||||
PRIMARY KEY (issue_id, depends_on_id),
|
||||
FOREIGN KEY (issue_id) REFERENCES issues(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (depends_on_id) REFERENCES issues(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_dependencies_issue ON dependencies(issue_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_dependencies_depends_on ON dependencies(depends_on_id);
|
||||
|
||||
-- Labels table
|
||||
CREATE TABLE IF NOT EXISTS labels (
|
||||
issue_id TEXT NOT NULL,
|
||||
label TEXT NOT NULL,
|
||||
PRIMARY KEY (issue_id, label),
|
||||
FOREIGN KEY (issue_id) REFERENCES issues(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_labels_label ON labels(label);
|
||||
|
||||
-- Events table (audit trail)
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
issue_id TEXT NOT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
actor TEXT NOT NULL,
|
||||
old_value TEXT,
|
||||
new_value TEXT,
|
||||
comment TEXT,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (issue_id) REFERENCES issues(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_events_issue ON events(issue_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_created_at ON events(created_at);
|
||||
|
||||
-- Ready work view
|
||||
CREATE VIEW IF NOT EXISTS ready_issues AS
|
||||
SELECT i.*
|
||||
FROM issues i
|
||||
WHERE i.status = 'open'
|
||||
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')
|
||||
);
|
||||
|
||||
-- Blocked issues view
|
||||
CREATE VIEW IF NOT EXISTS blocked_issues AS
|
||||
SELECT
|
||||
i.*,
|
||||
COUNT(d.depends_on_id) as blocked_by_count
|
||||
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;
|
||||
`
|
||||
424
internal/storage/sqlite/sqlite.go
Normal file
424
internal/storage/sqlite/sqlite.go
Normal file
@@ -0,0 +1,424 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
"github.com/steveyackey/beads/internal/types"
|
||||
)
|
||||
|
||||
// SQLiteStorage implements the Storage interface using SQLite
|
||||
type SQLiteStorage struct {
|
||||
db *sql.DB
|
||||
nextID int
|
||||
idMu sync.Mutex // Protects nextID from concurrent access
|
||||
}
|
||||
|
||||
// New creates a new SQLite storage backend
|
||||
func New(path string) (*SQLiteStorage, error) {
|
||||
// Ensure directory exists
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create directory: %w", err)
|
||||
}
|
||||
|
||||
// Open database with WAL mode for better concurrency
|
||||
db, err := sql.Open("sqlite3", path+"?_journal_mode=WAL&_foreign_keys=ON")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open database: %w", err)
|
||||
}
|
||||
|
||||
// Test connection
|
||||
if err := db.Ping(); err != nil {
|
||||
return nil, fmt.Errorf("failed to ping database: %w", err)
|
||||
}
|
||||
|
||||
// Initialize schema
|
||||
if _, err := db.Exec(schema); err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize schema: %w", err)
|
||||
}
|
||||
|
||||
// Get next ID
|
||||
nextID, err := getNextID(db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &SQLiteStorage{
|
||||
db: db,
|
||||
nextID: nextID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// getNextID determines the next issue ID to use
|
||||
func getNextID(db *sql.DB) (int, error) {
|
||||
var maxID sql.NullString
|
||||
err := db.QueryRow("SELECT MAX(id) FROM issues").Scan(&maxID)
|
||||
if err != nil {
|
||||
return 1, nil // Start from 1 if table is empty
|
||||
}
|
||||
|
||||
if !maxID.Valid || maxID.String == "" {
|
||||
return 1, nil
|
||||
}
|
||||
|
||||
// Parse "bd-123" to get 123
|
||||
parts := strings.Split(maxID.String, "-")
|
||||
if len(parts) != 2 {
|
||||
return 1, nil
|
||||
}
|
||||
|
||||
var num int
|
||||
if _, err := fmt.Sscanf(parts[1], "%d", &num); err != nil {
|
||||
return 1, nil
|
||||
}
|
||||
|
||||
return num + 1, nil
|
||||
}
|
||||
|
||||
// CreateIssue creates a new issue
|
||||
func (s *SQLiteStorage) CreateIssue(ctx context.Context, issue *types.Issue, actor string) error {
|
||||
// Validate issue before creating
|
||||
if err := issue.Validate(); err != nil {
|
||||
return fmt.Errorf("validation failed: %w", err)
|
||||
}
|
||||
|
||||
// Generate ID if not set (thread-safe)
|
||||
if issue.ID == "" {
|
||||
s.idMu.Lock()
|
||||
issue.ID = fmt.Sprintf("bd-%d", s.nextID)
|
||||
s.nextID++
|
||||
s.idMu.Unlock()
|
||||
}
|
||||
|
||||
// Set timestamps
|
||||
now := time.Now()
|
||||
issue.CreatedAt = now
|
||||
issue.UpdatedAt = now
|
||||
|
||||
// Start transaction
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to begin transaction: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Insert issue
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO issues (
|
||||
id, title, description, design, acceptance_criteria, notes,
|
||||
status, priority, issue_type, assignee, estimated_minutes,
|
||||
created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
issue.ID, issue.Title, issue.Description, issue.Design,
|
||||
issue.AcceptanceCriteria, issue.Notes, issue.Status,
|
||||
issue.Priority, issue.IssueType, issue.Assignee,
|
||||
issue.EstimatedMinutes, issue.CreatedAt, issue.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to insert issue: %w", err)
|
||||
}
|
||||
|
||||
// Record creation event
|
||||
eventData, _ := json.Marshal(issue)
|
||||
eventDataStr := string(eventData)
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO events (issue_id, event_type, actor, new_value)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`, issue.ID, types.EventCreated, actor, eventDataStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to record event: %w", err)
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// GetIssue retrieves an issue by ID
|
||||
func (s *SQLiteStorage) GetIssue(ctx context.Context, id string) (*types.Issue, error) {
|
||||
var issue types.Issue
|
||||
var closedAt sql.NullTime
|
||||
var estimatedMinutes sql.NullInt64
|
||||
var assignee sql.NullString
|
||||
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
SELECT id, title, description, design, acceptance_criteria, notes,
|
||||
status, priority, issue_type, assignee, estimated_minutes,
|
||||
created_at, updated_at, closed_at
|
||||
FROM issues
|
||||
WHERE id = ?
|
||||
`, id).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,
|
||||
)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get 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
|
||||
}
|
||||
|
||||
return &issue, nil
|
||||
}
|
||||
|
||||
// Allowed fields for update to prevent SQL injection
|
||||
var allowedUpdateFields = map[string]bool{
|
||||
"status": true,
|
||||
"priority": true,
|
||||
"title": true,
|
||||
"assignee": true,
|
||||
"description": true,
|
||||
"design": true,
|
||||
"acceptance_criteria": true,
|
||||
"notes": true,
|
||||
"issue_type": true,
|
||||
"estimated_minutes": true,
|
||||
}
|
||||
|
||||
// UpdateIssue updates fields on an issue
|
||||
func (s *SQLiteStorage) UpdateIssue(ctx context.Context, id string, updates map[string]interface{}, actor string) error {
|
||||
// Get old issue for event
|
||||
oldIssue, err := s.GetIssue(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if oldIssue == nil {
|
||||
return fmt.Errorf("issue %s not found", id)
|
||||
}
|
||||
|
||||
// Build update query with validated field names
|
||||
setClauses := []string{"updated_at = ?"}
|
||||
args := []interface{}{time.Now()}
|
||||
|
||||
for key, value := range updates {
|
||||
// Prevent SQL injection by validating field names
|
||||
if !allowedUpdateFields[key] {
|
||||
return fmt.Errorf("invalid field for update: %s", key)
|
||||
}
|
||||
|
||||
// Validate field values
|
||||
switch key {
|
||||
case "priority":
|
||||
if priority, ok := value.(int); ok {
|
||||
if priority < 0 || priority > 4 {
|
||||
return fmt.Errorf("priority must be between 0 and 4 (got %d)", priority)
|
||||
}
|
||||
}
|
||||
case "status":
|
||||
if status, ok := value.(string); ok {
|
||||
if !types.Status(status).IsValid() {
|
||||
return fmt.Errorf("invalid status: %s", status)
|
||||
}
|
||||
}
|
||||
case "issue_type":
|
||||
if issueType, ok := value.(string); ok {
|
||||
if !types.IssueType(issueType).IsValid() {
|
||||
return fmt.Errorf("invalid issue type: %s", issueType)
|
||||
}
|
||||
}
|
||||
case "title":
|
||||
if title, ok := value.(string); ok {
|
||||
if len(title) == 0 || len(title) > 500 {
|
||||
return fmt.Errorf("title must be 1-500 characters")
|
||||
}
|
||||
}
|
||||
case "estimated_minutes":
|
||||
if mins, ok := value.(int); ok {
|
||||
if mins < 0 {
|
||||
return fmt.Errorf("estimated_minutes cannot be negative")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setClauses = append(setClauses, fmt.Sprintf("%s = ?", key))
|
||||
args = append(args, value)
|
||||
}
|
||||
args = append(args, id)
|
||||
|
||||
// Start transaction
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to begin transaction: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Update issue
|
||||
query := fmt.Sprintf("UPDATE issues SET %s WHERE id = ?", strings.Join(setClauses, ", "))
|
||||
_, err = tx.ExecContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update issue: %w", err)
|
||||
}
|
||||
|
||||
// Record event
|
||||
oldData, _ := json.Marshal(oldIssue)
|
||||
newData, _ := json.Marshal(updates)
|
||||
oldDataStr := string(oldData)
|
||||
newDataStr := string(newData)
|
||||
|
||||
eventType := types.EventUpdated
|
||||
if statusVal, ok := updates["status"]; ok {
|
||||
if statusVal == string(types.StatusClosed) {
|
||||
eventType = types.EventClosed
|
||||
} else {
|
||||
eventType = types.EventStatusChanged
|
||||
}
|
||||
}
|
||||
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO events (issue_id, event_type, actor, old_value, new_value)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`, id, eventType, actor, oldDataStr, newDataStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to record event: %w", err)
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// CloseIssue closes an issue with a reason
|
||||
func (s *SQLiteStorage) CloseIssue(ctx context.Context, id string, reason string, actor string) error {
|
||||
now := time.Now()
|
||||
|
||||
// Update with special event handling
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to begin transaction: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
UPDATE issues SET status = ?, closed_at = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`, types.StatusClosed, now, now, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to close issue: %w", err)
|
||||
}
|
||||
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO events (issue_id, event_type, actor, comment)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`, id, types.EventClosed, actor, reason)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to record event: %w", err)
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// SearchIssues finds issues matching query and filters
|
||||
func (s *SQLiteStorage) SearchIssues(ctx context.Context, query string, filter types.IssueFilter) ([]*types.Issue, error) {
|
||||
whereClauses := []string{}
|
||||
args := []interface{}{}
|
||||
|
||||
if query != "" {
|
||||
whereClauses = append(whereClauses, "(title LIKE ? OR description LIKE ? OR id LIKE ?)")
|
||||
pattern := "%" + query + "%"
|
||||
args = append(args, pattern, pattern, pattern)
|
||||
}
|
||||
|
||||
if filter.Status != nil {
|
||||
whereClauses = append(whereClauses, "status = ?")
|
||||
args = append(args, *filter.Status)
|
||||
}
|
||||
|
||||
if filter.Priority != nil {
|
||||
whereClauses = append(whereClauses, "priority = ?")
|
||||
args = append(args, *filter.Priority)
|
||||
}
|
||||
|
||||
if filter.IssueType != nil {
|
||||
whereClauses = append(whereClauses, "issue_type = ?")
|
||||
args = append(args, *filter.IssueType)
|
||||
}
|
||||
|
||||
if filter.Assignee != nil {
|
||||
whereClauses = append(whereClauses, "assignee = ?")
|
||||
args = append(args, *filter.Assignee)
|
||||
}
|
||||
|
||||
whereSQL := ""
|
||||
if len(whereClauses) > 0 {
|
||||
whereSQL = "WHERE " + strings.Join(whereClauses, " AND ")
|
||||
}
|
||||
|
||||
limitSQL := ""
|
||||
if filter.Limit > 0 {
|
||||
limitSQL = fmt.Sprintf(" LIMIT %d", filter.Limit)
|
||||
}
|
||||
|
||||
querySQL := fmt.Sprintf(`
|
||||
SELECT id, title, description, design, acceptance_criteria, notes,
|
||||
status, priority, issue_type, assignee, estimated_minutes,
|
||||
created_at, updated_at, closed_at
|
||||
FROM issues
|
||||
%s
|
||||
ORDER BY priority ASC, created_at DESC
|
||||
%s
|
||||
`, whereSQL, limitSQL)
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, querySQL, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to search issues: %w", err)
|
||||
}
|
||||
defer 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
|
||||
|
||||
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,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to scan 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
|
||||
}
|
||||
|
||||
issues = append(issues, &issue)
|
||||
}
|
||||
|
||||
return issues, nil
|
||||
}
|
||||
|
||||
// Close closes the database connection
|
||||
func (s *SQLiteStorage) Close() error {
|
||||
return s.db.Close()
|
||||
}
|
||||
61
internal/storage/storage.go
Normal file
61
internal/storage/storage.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/steveyackey/beads/internal/types"
|
||||
)
|
||||
|
||||
// Storage defines the interface for issue storage backends
|
||||
type Storage interface {
|
||||
// Issues
|
||||
CreateIssue(ctx context.Context, issue *types.Issue, actor string) error
|
||||
GetIssue(ctx context.Context, id string) (*types.Issue, error)
|
||||
UpdateIssue(ctx context.Context, id string, updates map[string]interface{}, actor string) error
|
||||
CloseIssue(ctx context.Context, id string, reason string, actor string) error
|
||||
SearchIssues(ctx context.Context, query string, filter types.IssueFilter) ([]*types.Issue, error)
|
||||
|
||||
// Dependencies
|
||||
AddDependency(ctx context.Context, dep *types.Dependency, actor string) error
|
||||
RemoveDependency(ctx context.Context, issueID, dependsOnID string, actor string) error
|
||||
GetDependencies(ctx context.Context, issueID string) ([]*types.Issue, error)
|
||||
GetDependents(ctx context.Context, issueID string) ([]*types.Issue, error)
|
||||
GetDependencyTree(ctx context.Context, issueID string, maxDepth int) ([]*types.TreeNode, error)
|
||||
DetectCycles(ctx context.Context) ([][]*types.Issue, error)
|
||||
|
||||
// Labels
|
||||
AddLabel(ctx context.Context, issueID, label, actor string) error
|
||||
RemoveLabel(ctx context.Context, issueID, label, actor string) error
|
||||
GetLabels(ctx context.Context, issueID string) ([]string, error)
|
||||
GetIssuesByLabel(ctx context.Context, label string) ([]*types.Issue, error)
|
||||
|
||||
// Ready Work & Blocking
|
||||
GetReadyWork(ctx context.Context, filter types.WorkFilter) ([]*types.Issue, error)
|
||||
GetBlockedIssues(ctx context.Context) ([]*types.BlockedIssue, error)
|
||||
|
||||
// Events
|
||||
AddComment(ctx context.Context, issueID, actor, comment string) error
|
||||
GetEvents(ctx context.Context, issueID string, limit int) ([]*types.Event, error)
|
||||
|
||||
// Statistics
|
||||
GetStatistics(ctx context.Context) (*types.Statistics, error)
|
||||
|
||||
// Lifecycle
|
||||
Close() error
|
||||
}
|
||||
|
||||
// Config holds database configuration
|
||||
type Config struct {
|
||||
Backend string // "sqlite" or "postgres"
|
||||
|
||||
// SQLite config
|
||||
Path string // database file path
|
||||
|
||||
// PostgreSQL config
|
||||
Host string
|
||||
Port int
|
||||
Database string
|
||||
User string
|
||||
Password string
|
||||
SSLMode string
|
||||
}
|
||||
190
internal/types/types.go
Normal file
190
internal/types/types.go
Normal file
@@ -0,0 +1,190 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Issue represents a trackable work item
|
||||
type Issue struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Design string `json:"design,omitempty"`
|
||||
AcceptanceCriteria string `json:"acceptance_criteria,omitempty"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
Status Status `json:"status"`
|
||||
Priority int `json:"priority"`
|
||||
IssueType IssueType `json:"issue_type"`
|
||||
Assignee string `json:"assignee,omitempty"`
|
||||
EstimatedMinutes *int `json:"estimated_minutes,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ClosedAt *time.Time `json:"closed_at,omitempty"`
|
||||
}
|
||||
|
||||
// Validate checks if the issue has valid field values
|
||||
func (i *Issue) Validate() error {
|
||||
if len(i.Title) == 0 {
|
||||
return fmt.Errorf("title is required")
|
||||
}
|
||||
if len(i.Title) > 500 {
|
||||
return fmt.Errorf("title must be 500 characters or less (got %d)", len(i.Title))
|
||||
}
|
||||
if i.Priority < 0 || i.Priority > 4 {
|
||||
return fmt.Errorf("priority must be between 0 and 4 (got %d)", i.Priority)
|
||||
}
|
||||
if !i.Status.IsValid() {
|
||||
return fmt.Errorf("invalid status: %s", i.Status)
|
||||
}
|
||||
if !i.IssueType.IsValid() {
|
||||
return fmt.Errorf("invalid issue type: %s", i.IssueType)
|
||||
}
|
||||
if i.EstimatedMinutes != nil && *i.EstimatedMinutes < 0 {
|
||||
return fmt.Errorf("estimated_minutes cannot be negative")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Status represents the current state of an issue
|
||||
type Status string
|
||||
|
||||
const (
|
||||
StatusOpen Status = "open"
|
||||
StatusInProgress Status = "in_progress"
|
||||
StatusBlocked Status = "blocked"
|
||||
StatusClosed Status = "closed"
|
||||
)
|
||||
|
||||
// IsValid checks if the status value is valid
|
||||
func (s Status) IsValid() bool {
|
||||
switch s {
|
||||
case StatusOpen, StatusInProgress, StatusBlocked, StatusClosed:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IssueType categorizes the kind of work
|
||||
type IssueType string
|
||||
|
||||
const (
|
||||
TypeBug IssueType = "bug"
|
||||
TypeFeature IssueType = "feature"
|
||||
TypeTask IssueType = "task"
|
||||
TypeEpic IssueType = "epic"
|
||||
TypeChore IssueType = "chore"
|
||||
)
|
||||
|
||||
// IsValid checks if the issue type value is valid
|
||||
func (t IssueType) IsValid() bool {
|
||||
switch t {
|
||||
case TypeBug, TypeFeature, TypeTask, TypeEpic, TypeChore:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Dependency represents a relationship between issues
|
||||
type Dependency struct {
|
||||
IssueID string `json:"issue_id"`
|
||||
DependsOnID string `json:"depends_on_id"`
|
||||
Type DependencyType `json:"type"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
CreatedBy string `json:"created_by"`
|
||||
}
|
||||
|
||||
// DependencyType categorizes the relationship
|
||||
type DependencyType string
|
||||
|
||||
const (
|
||||
DepBlocks DependencyType = "blocks"
|
||||
DepRelated DependencyType = "related"
|
||||
DepParentChild DependencyType = "parent-child"
|
||||
)
|
||||
|
||||
// IsValid checks if the dependency type value is valid
|
||||
func (d DependencyType) IsValid() bool {
|
||||
switch d {
|
||||
case DepBlocks, DepRelated, DepParentChild:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Label represents a tag on an issue
|
||||
type Label struct {
|
||||
IssueID string `json:"issue_id"`
|
||||
Label string `json:"label"`
|
||||
}
|
||||
|
||||
// Event represents an audit trail entry
|
||||
type Event struct {
|
||||
ID int64 `json:"id"`
|
||||
IssueID string `json:"issue_id"`
|
||||
EventType EventType `json:"event_type"`
|
||||
Actor string `json:"actor"`
|
||||
OldValue *string `json:"old_value,omitempty"`
|
||||
NewValue *string `json:"new_value,omitempty"`
|
||||
Comment *string `json:"comment,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// EventType categorizes audit trail events
|
||||
type EventType string
|
||||
|
||||
const (
|
||||
EventCreated EventType = "created"
|
||||
EventUpdated EventType = "updated"
|
||||
EventStatusChanged EventType = "status_changed"
|
||||
EventCommented EventType = "commented"
|
||||
EventClosed EventType = "closed"
|
||||
EventReopened EventType = "reopened"
|
||||
EventDependencyAdded EventType = "dependency_added"
|
||||
EventDependencyRemoved EventType = "dependency_removed"
|
||||
EventLabelAdded EventType = "label_added"
|
||||
EventLabelRemoved EventType = "label_removed"
|
||||
)
|
||||
|
||||
// BlockedIssue extends Issue with blocking information
|
||||
type BlockedIssue struct {
|
||||
Issue
|
||||
BlockedByCount int `json:"blocked_by_count"`
|
||||
BlockedBy []string `json:"blocked_by"`
|
||||
}
|
||||
|
||||
// TreeNode represents a node in a dependency tree
|
||||
type TreeNode struct {
|
||||
Issue
|
||||
Depth int `json:"depth"`
|
||||
Truncated bool `json:"truncated"`
|
||||
}
|
||||
|
||||
// Statistics provides aggregate metrics
|
||||
type Statistics struct {
|
||||
TotalIssues int `json:"total_issues"`
|
||||
OpenIssues int `json:"open_issues"`
|
||||
InProgressIssues int `json:"in_progress_issues"`
|
||||
ClosedIssues int `json:"closed_issues"`
|
||||
BlockedIssues int `json:"blocked_issues"`
|
||||
ReadyIssues int `json:"ready_issues"`
|
||||
AverageLeadTime float64 `json:"average_lead_time_hours"`
|
||||
}
|
||||
|
||||
// IssueFilter is used to filter issue queries
|
||||
type IssueFilter struct {
|
||||
Status *Status
|
||||
Priority *int
|
||||
IssueType *IssueType
|
||||
Assignee *string
|
||||
Labels []string
|
||||
Limit int
|
||||
}
|
||||
|
||||
// WorkFilter is used to filter ready work queries
|
||||
type WorkFilter struct {
|
||||
Status Status
|
||||
Priority *int
|
||||
Assignee *string
|
||||
Limit int
|
||||
}
|
||||
Reference in New Issue
Block a user