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:
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
|
||||
}
|
||||
Reference in New Issue
Block a user