Implements a complete Dolt storage backend that mirrors the SQLite implementation with MySQL-compatible syntax and adds version control capabilities. Key features: - Full Storage interface implementation (~50 methods) - Version control operations: commit, push, pull, branch, merge, checkout - History queries via AS OF and dolt_history_* tables - Cell-level merge instead of line-level JSONL merge - SQL injection protection with input validation Bug fixes applied during implementation: - Added missing quality_score, work_type, source_system to scanIssue - Fixed Status() to properly parse boolean staged column - Added validation to CreateIssues (was missing in batch create) - Made RenameDependencyPrefix transactional - Expanded GetIssueHistory to return more complete data Test coverage: 17 tests covering CRUD, dependencies, labels, search, comments, events, statistics, and SQL injection protection. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
548 lines
15 KiB
Go
548 lines
15 KiB
Go
package dolt
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/steveyegge/beads/internal/types"
|
|
)
|
|
|
|
// SearchIssues finds issues matching query and filters
|
|
func (s *DoltStore) SearchIssues(ctx context.Context, query string, filter types.IssueFilter) ([]*types.Issue, error) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
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.TitleSearch != "" {
|
|
whereClauses = append(whereClauses, "title LIKE ?")
|
|
args = append(args, "%"+filter.TitleSearch+"%")
|
|
}
|
|
|
|
if filter.TitleContains != "" {
|
|
whereClauses = append(whereClauses, "title LIKE ?")
|
|
args = append(args, "%"+filter.TitleContains+"%")
|
|
}
|
|
if filter.DescriptionContains != "" {
|
|
whereClauses = append(whereClauses, "description LIKE ?")
|
|
args = append(args, "%"+filter.DescriptionContains+"%")
|
|
}
|
|
if filter.NotesContains != "" {
|
|
whereClauses = append(whereClauses, "notes LIKE ?")
|
|
args = append(args, "%"+filter.NotesContains+"%")
|
|
}
|
|
|
|
if filter.Status != nil {
|
|
whereClauses = append(whereClauses, "status = ?")
|
|
args = append(args, *filter.Status)
|
|
} else if !filter.IncludeTombstones {
|
|
whereClauses = append(whereClauses, "status != ?")
|
|
args = append(args, types.StatusTombstone)
|
|
}
|
|
|
|
if len(filter.ExcludeStatus) > 0 {
|
|
placeholders := make([]string, len(filter.ExcludeStatus))
|
|
for i, s := range filter.ExcludeStatus {
|
|
placeholders[i] = "?"
|
|
args = append(args, string(s))
|
|
}
|
|
whereClauses = append(whereClauses, fmt.Sprintf("status NOT IN (%s)", strings.Join(placeholders, ",")))
|
|
}
|
|
|
|
if len(filter.ExcludeTypes) > 0 {
|
|
placeholders := make([]string, len(filter.ExcludeTypes))
|
|
for i, t := range filter.ExcludeTypes {
|
|
placeholders[i] = "?"
|
|
args = append(args, string(t))
|
|
}
|
|
whereClauses = append(whereClauses, fmt.Sprintf("issue_type NOT IN (%s)", strings.Join(placeholders, ",")))
|
|
}
|
|
|
|
if filter.Priority != nil {
|
|
whereClauses = append(whereClauses, "priority = ?")
|
|
args = append(args, *filter.Priority)
|
|
}
|
|
if filter.PriorityMin != nil {
|
|
whereClauses = append(whereClauses, "priority >= ?")
|
|
args = append(args, *filter.PriorityMin)
|
|
}
|
|
if filter.PriorityMax != nil {
|
|
whereClauses = append(whereClauses, "priority <= ?")
|
|
args = append(args, *filter.PriorityMax)
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
// Date ranges
|
|
if filter.CreatedAfter != nil {
|
|
whereClauses = append(whereClauses, "created_at > ?")
|
|
args = append(args, filter.CreatedAfter.Format(time.RFC3339))
|
|
}
|
|
if filter.CreatedBefore != nil {
|
|
whereClauses = append(whereClauses, "created_at < ?")
|
|
args = append(args, filter.CreatedBefore.Format(time.RFC3339))
|
|
}
|
|
if filter.UpdatedAfter != nil {
|
|
whereClauses = append(whereClauses, "updated_at > ?")
|
|
args = append(args, filter.UpdatedAfter.Format(time.RFC3339))
|
|
}
|
|
if filter.UpdatedBefore != nil {
|
|
whereClauses = append(whereClauses, "updated_at < ?")
|
|
args = append(args, filter.UpdatedBefore.Format(time.RFC3339))
|
|
}
|
|
|
|
// Empty/null checks
|
|
if filter.EmptyDescription {
|
|
whereClauses = append(whereClauses, "(description IS NULL OR description = '')")
|
|
}
|
|
if filter.NoAssignee {
|
|
whereClauses = append(whereClauses, "(assignee IS NULL OR assignee = '')")
|
|
}
|
|
if filter.NoLabels {
|
|
whereClauses = append(whereClauses, "id NOT IN (SELECT DISTINCT issue_id FROM labels)")
|
|
}
|
|
|
|
// Label filtering (AND)
|
|
if len(filter.Labels) > 0 {
|
|
for _, label := range filter.Labels {
|
|
whereClauses = append(whereClauses, "id IN (SELECT issue_id FROM labels WHERE label = ?)")
|
|
args = append(args, label)
|
|
}
|
|
}
|
|
|
|
// Label filtering (OR)
|
|
if len(filter.LabelsAny) > 0 {
|
|
placeholders := make([]string, len(filter.LabelsAny))
|
|
for i, label := range filter.LabelsAny {
|
|
placeholders[i] = "?"
|
|
args = append(args, label)
|
|
}
|
|
whereClauses = append(whereClauses, fmt.Sprintf("id IN (SELECT issue_id FROM labels WHERE label IN (%s))", strings.Join(placeholders, ", ")))
|
|
}
|
|
|
|
// ID filtering
|
|
if len(filter.IDs) > 0 {
|
|
placeholders := make([]string, len(filter.IDs))
|
|
for i, id := range filter.IDs {
|
|
placeholders[i] = "?"
|
|
args = append(args, id)
|
|
}
|
|
whereClauses = append(whereClauses, fmt.Sprintf("id IN (%s)", strings.Join(placeholders, ", ")))
|
|
}
|
|
|
|
if filter.IDPrefix != "" {
|
|
whereClauses = append(whereClauses, "id LIKE ?")
|
|
args = append(args, filter.IDPrefix+"%")
|
|
}
|
|
|
|
// Wisp filtering
|
|
if filter.Ephemeral != nil {
|
|
if *filter.Ephemeral {
|
|
whereClauses = append(whereClauses, "ephemeral = 1")
|
|
} else {
|
|
whereClauses = append(whereClauses, "(ephemeral = 0 OR ephemeral IS NULL)")
|
|
}
|
|
}
|
|
|
|
// Pinned filtering
|
|
if filter.Pinned != nil {
|
|
if *filter.Pinned {
|
|
whereClauses = append(whereClauses, "pinned = 1")
|
|
} else {
|
|
whereClauses = append(whereClauses, "(pinned = 0 OR pinned IS NULL)")
|
|
}
|
|
}
|
|
|
|
// Template filtering
|
|
if filter.IsTemplate != nil {
|
|
if *filter.IsTemplate {
|
|
whereClauses = append(whereClauses, "is_template = 1")
|
|
} else {
|
|
whereClauses = append(whereClauses, "(is_template = 0 OR is_template IS NULL)")
|
|
}
|
|
}
|
|
|
|
// Parent filtering
|
|
if filter.ParentID != nil {
|
|
whereClauses = append(whereClauses, "id IN (SELECT issue_id FROM dependencies WHERE type = 'parent-child' AND depends_on_id = ?)")
|
|
args = append(args, *filter.ParentID)
|
|
}
|
|
|
|
// Molecule type filtering
|
|
if filter.MolType != nil {
|
|
whereClauses = append(whereClauses, "mol_type = ?")
|
|
args = append(args, string(*filter.MolType))
|
|
}
|
|
|
|
// Time-based scheduling filters
|
|
if filter.Deferred {
|
|
whereClauses = append(whereClauses, "defer_until IS NOT NULL")
|
|
}
|
|
if filter.Overdue {
|
|
whereClauses = append(whereClauses, "due_at IS NOT NULL AND due_at < ? AND status != ?")
|
|
args = append(args, time.Now().Format(time.RFC3339), types.StatusClosed)
|
|
}
|
|
|
|
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 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()
|
|
|
|
return s.scanIssueIDs(ctx, rows)
|
|
}
|
|
|
|
// GetReadyWork returns issues that are ready to work on (not blocked)
|
|
func (s *DoltStore) GetReadyWork(ctx context.Context, filter types.WorkFilter) ([]*types.Issue, error) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
whereClauses := []string{"status = 'open'", "(ephemeral = 0 OR ephemeral IS NULL)"}
|
|
args := []interface{}{}
|
|
|
|
if filter.Priority != nil {
|
|
whereClauses = append(whereClauses, "priority = ?")
|
|
args = append(args, *filter.Priority)
|
|
}
|
|
if filter.Type != "" {
|
|
whereClauses = append(whereClauses, "issue_type = ?")
|
|
args = append(args, filter.Type)
|
|
}
|
|
if filter.Assignee != nil {
|
|
whereClauses = append(whereClauses, "assignee = ?")
|
|
args = append(args, *filter.Assignee)
|
|
}
|
|
if len(filter.Labels) > 0 {
|
|
for _, label := range filter.Labels {
|
|
whereClauses = append(whereClauses, "id IN (SELECT issue_id FROM labels WHERE label = ?)")
|
|
args = append(args, label)
|
|
}
|
|
}
|
|
|
|
// Exclude blocked issues using subquery
|
|
whereClauses = append(whereClauses, `
|
|
id NOT IN (
|
|
SELECT DISTINCT d.issue_id
|
|
FROM dependencies d
|
|
JOIN issues blocker ON d.depends_on_id = blocker.id
|
|
WHERE d.type = 'blocks'
|
|
AND blocker.status IN ('open', 'in_progress', 'blocked', 'deferred', 'hooked')
|
|
)
|
|
`)
|
|
|
|
whereSQL := "WHERE " + strings.Join(whereClauses, " AND ")
|
|
|
|
limitSQL := ""
|
|
if filter.Limit > 0 {
|
|
limitSQL = fmt.Sprintf(" LIMIT %d", filter.Limit)
|
|
}
|
|
|
|
query := fmt.Sprintf(`
|
|
SELECT id FROM issues
|
|
%s
|
|
ORDER BY priority ASC, 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 s.scanIssueIDs(ctx, rows)
|
|
}
|
|
|
|
// GetBlockedIssues returns issues that are blocked by other issues
|
|
func (s *DoltStore) GetBlockedIssues(ctx context.Context, filter types.WorkFilter) ([]*types.BlockedIssue, error) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
rows, err := s.db.QueryContext(ctx, `
|
|
SELECT i.id, 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', 'deferred', 'hooked')
|
|
AND d.type = 'blocks'
|
|
AND blocker.status IN ('open', 'in_progress', 'blocked', 'deferred', 'hooked')
|
|
GROUP BY i.id
|
|
ORDER BY i.priority ASC, i.created_at DESC
|
|
`)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get blocked issues: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var results []*types.BlockedIssue
|
|
for rows.Next() {
|
|
var id string
|
|
var count int
|
|
if err := rows.Scan(&id, &count); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
issue, err := s.GetIssue(ctx, id)
|
|
if err != nil || issue == nil {
|
|
continue
|
|
}
|
|
|
|
// Get blocker IDs
|
|
var blockerIDs []string
|
|
blockerRows, err := s.db.QueryContext(ctx, `
|
|
SELECT d.depends_on_id
|
|
FROM dependencies d
|
|
JOIN issues blocker ON d.depends_on_id = blocker.id
|
|
WHERE d.issue_id = ?
|
|
AND d.type = 'blocks'
|
|
AND blocker.status IN ('open', 'in_progress', 'blocked', 'deferred', 'hooked')
|
|
`, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for blockerRows.Next() {
|
|
var blockerID string
|
|
if err := blockerRows.Scan(&blockerID); err != nil {
|
|
blockerRows.Close()
|
|
return nil, err
|
|
}
|
|
blockerIDs = append(blockerIDs, blockerID)
|
|
}
|
|
blockerRows.Close()
|
|
|
|
results = append(results, &types.BlockedIssue{
|
|
Issue: *issue,
|
|
BlockedByCount: count,
|
|
BlockedBy: blockerIDs,
|
|
})
|
|
}
|
|
|
|
return results, rows.Err()
|
|
}
|
|
|
|
// GetEpicsEligibleForClosure returns epics whose children are all closed
|
|
func (s *DoltStore) GetEpicsEligibleForClosure(ctx context.Context) ([]*types.EpicStatus, error) {
|
|
rows, err := s.db.QueryContext(ctx, `
|
|
SELECT e.id,
|
|
(SELECT COUNT(*) FROM dependencies d JOIN issues c ON d.issue_id = c.id
|
|
WHERE d.depends_on_id = e.id AND d.type = 'parent-child') as total_children,
|
|
(SELECT COUNT(*) FROM dependencies d JOIN issues c ON d.issue_id = c.id
|
|
WHERE d.depends_on_id = e.id AND d.type = 'parent-child' AND c.status = 'closed') as closed_children
|
|
FROM issues e
|
|
WHERE e.issue_type = 'epic'
|
|
AND e.status != 'closed'
|
|
AND e.status != 'tombstone'
|
|
HAVING total_children > 0 AND total_children = closed_children
|
|
`)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get epics eligible for closure: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var results []*types.EpicStatus
|
|
for rows.Next() {
|
|
var id string
|
|
var total, closed int
|
|
if err := rows.Scan(&id, &total, &closed); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
issue, err := s.GetIssue(ctx, id)
|
|
if err != nil || issue == nil {
|
|
continue
|
|
}
|
|
|
|
results = append(results, &types.EpicStatus{
|
|
Epic: issue,
|
|
TotalChildren: total,
|
|
ClosedChildren: closed,
|
|
EligibleForClose: total > 0 && total == closed,
|
|
})
|
|
}
|
|
|
|
return results, rows.Err()
|
|
}
|
|
|
|
// GetStaleIssues returns issues that haven't been updated recently
|
|
func (s *DoltStore) GetStaleIssues(ctx context.Context, filter types.StaleFilter) ([]*types.Issue, error) {
|
|
cutoff := time.Now().AddDate(0, 0, -filter.Days)
|
|
|
|
statusClause := "status IN ('open', 'in_progress')"
|
|
if filter.Status != "" {
|
|
statusClause = "status = ?"
|
|
}
|
|
|
|
query := fmt.Sprintf(`
|
|
SELECT id FROM issues
|
|
WHERE updated_at < ?
|
|
AND %s
|
|
AND (ephemeral = 0 OR ephemeral IS NULL)
|
|
ORDER BY updated_at ASC
|
|
`, statusClause)
|
|
args := []interface{}{cutoff}
|
|
if filter.Status != "" {
|
|
args = append(args, filter.Status)
|
|
}
|
|
|
|
if filter.Limit > 0 {
|
|
query += fmt.Sprintf(" LIMIT %d", filter.Limit)
|
|
}
|
|
|
|
rows, err := s.db.QueryContext(ctx, query, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get stale issues: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
return s.scanIssueIDs(ctx, rows)
|
|
}
|
|
|
|
// GetStatistics returns summary statistics
|
|
func (s *DoltStore) GetStatistics(ctx context.Context) (*types.Statistics, error) {
|
|
stats := &types.Statistics{}
|
|
|
|
// Count by status
|
|
err := s.db.QueryRowContext(ctx, `
|
|
SELECT
|
|
COUNT(*) as total,
|
|
SUM(CASE WHEN status = 'open' THEN 1 ELSE 0 END) as open_count,
|
|
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,
|
|
SUM(CASE WHEN status = 'blocked' THEN 1 ELSE 0 END) as blocked,
|
|
SUM(CASE WHEN status = 'deferred' THEN 1 ELSE 0 END) as deferred,
|
|
SUM(CASE WHEN status = 'tombstone' THEN 1 ELSE 0 END) as tombstone,
|
|
SUM(CASE WHEN pinned = 1 THEN 1 ELSE 0 END) as pinned
|
|
FROM issues
|
|
WHERE status != 'tombstone'
|
|
`).Scan(
|
|
&stats.TotalIssues,
|
|
&stats.OpenIssues,
|
|
&stats.InProgressIssues,
|
|
&stats.ClosedIssues,
|
|
&stats.BlockedIssues,
|
|
&stats.DeferredIssues,
|
|
&stats.TombstoneIssues,
|
|
&stats.PinnedIssues,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get statistics: %w", err)
|
|
}
|
|
|
|
return stats, nil
|
|
}
|
|
|
|
// GetMoleculeProgress returns progress stats for a molecule
|
|
func (s *DoltStore) GetMoleculeProgress(ctx context.Context, moleculeID string) (*types.MoleculeProgressStats, error) {
|
|
stats := &types.MoleculeProgressStats{
|
|
MoleculeID: moleculeID,
|
|
}
|
|
|
|
// Get molecule title
|
|
var title sql.NullString
|
|
err := s.db.QueryRowContext(ctx, "SELECT title FROM issues WHERE id = ?", moleculeID).Scan(&title)
|
|
if err == nil && title.Valid {
|
|
stats.MoleculeTitle = title.String
|
|
}
|
|
|
|
err = s.db.QueryRowContext(ctx, `
|
|
SELECT
|
|
COUNT(*) as total,
|
|
SUM(CASE WHEN status = 'closed' THEN 1 ELSE 0 END) as completed,
|
|
SUM(CASE WHEN status = 'in_progress' THEN 1 ELSE 0 END) as in_progress
|
|
FROM issues i
|
|
JOIN dependencies d ON i.id = d.issue_id
|
|
WHERE d.depends_on_id = ?
|
|
AND d.type = 'parent-child'
|
|
`, moleculeID).Scan(&stats.Total, &stats.Completed, &stats.InProgress)
|
|
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get molecule progress: %w", err)
|
|
}
|
|
|
|
// Get first in_progress step ID
|
|
var stepID sql.NullString
|
|
_ = s.db.QueryRowContext(ctx, `
|
|
SELECT i.id FROM issues i
|
|
JOIN dependencies d ON i.id = d.issue_id
|
|
WHERE d.depends_on_id = ?
|
|
AND d.type = 'parent-child'
|
|
AND i.status = 'in_progress'
|
|
ORDER BY i.created_at ASC
|
|
LIMIT 1
|
|
`, moleculeID).Scan(&stepID)
|
|
if stepID.Valid {
|
|
stats.CurrentStepID = stepID.String
|
|
}
|
|
|
|
return stats, nil
|
|
}
|
|
|
|
// GetNextChildID returns the next available child ID for a parent
|
|
func (s *DoltStore) GetNextChildID(ctx context.Context, parentID string) (string, error) {
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
// Get or create counter
|
|
var lastChild int
|
|
err = tx.QueryRowContext(ctx, "SELECT last_child FROM child_counters WHERE parent_id = ?", parentID).Scan(&lastChild)
|
|
if err == sql.ErrNoRows {
|
|
lastChild = 0
|
|
} else if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
nextChild := lastChild + 1
|
|
|
|
_, err = tx.ExecContext(ctx, `
|
|
INSERT INTO child_counters (parent_id, last_child) VALUES (?, ?)
|
|
ON DUPLICATE KEY UPDATE last_child = ?
|
|
`, parentID, nextChild, nextChild)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return fmt.Sprintf("%s.%d", parentID, nextChild), nil
|
|
}
|