feat(storage): add Dolt backend for version-controlled issue storage
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>
This commit is contained in:
496
internal/storage/dolt/dependencies.go
Normal file
496
internal/storage/dolt/dependencies.go
Normal file
@@ -0,0 +1,496 @@
|
||||
package dolt
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/steveyegge/beads/internal/types"
|
||||
)
|
||||
|
||||
// AddDependency adds a dependency between two issues
|
||||
func (s *DoltStore) AddDependency(ctx context.Context, dep *types.Dependency, actor string) error {
|
||||
metadata := dep.Metadata
|
||||
if metadata == "" {
|
||||
metadata = "{}"
|
||||
}
|
||||
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO dependencies (issue_id, depends_on_id, type, created_at, created_by, metadata, thread_id)
|
||||
VALUES (?, ?, ?, NOW(), ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE type = VALUES(type), metadata = VALUES(metadata)
|
||||
`, dep.IssueID, dep.DependsOnID, dep.Type, actor, metadata, dep.ThreadID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to add dependency: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveDependency removes a dependency between two issues
|
||||
func (s *DoltStore) RemoveDependency(ctx context.Context, issueID, dependsOnID string, actor string) error {
|
||||
_, err := s.db.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)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetDependencies retrieves issues that this issue depends on
|
||||
func (s *DoltStore) GetDependencies(ctx context.Context, issueID string) ([]*types.Issue, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT i.id FROM issues i
|
||||
JOIN dependencies d ON i.id = d.depends_on_id
|
||||
WHERE d.issue_id = ?
|
||||
ORDER BY i.priority ASC, i.created_at DESC
|
||||
`, issueID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get dependencies: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return s.scanIssueIDs(ctx, rows)
|
||||
}
|
||||
|
||||
// GetDependents retrieves issues that depend on this issue
|
||||
func (s *DoltStore) GetDependents(ctx context.Context, issueID string) ([]*types.Issue, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT i.id FROM issues i
|
||||
JOIN dependencies d ON i.id = d.issue_id
|
||||
WHERE d.depends_on_id = ?
|
||||
ORDER BY i.priority ASC, i.created_at DESC
|
||||
`, issueID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get dependents: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return s.scanIssueIDs(ctx, rows)
|
||||
}
|
||||
|
||||
// GetDependenciesWithMetadata returns dependencies with metadata
|
||||
func (s *DoltStore) GetDependenciesWithMetadata(ctx context.Context, issueID string) ([]*types.IssueWithDependencyMetadata, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT d.depends_on_id, d.type, d.created_at, d.created_by, d.metadata, d.thread_id
|
||||
FROM dependencies d
|
||||
WHERE d.issue_id = ?
|
||||
`, issueID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get dependencies with metadata: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var results []*types.IssueWithDependencyMetadata
|
||||
for rows.Next() {
|
||||
var depID, depType, createdBy string
|
||||
var createdAt sql.NullTime
|
||||
var metadata, threadID sql.NullString
|
||||
|
||||
if err := rows.Scan(&depID, &depType, &createdAt, &createdBy, &metadata, &threadID); err != nil {
|
||||
return nil, fmt.Errorf("failed to scan dependency: %w", err)
|
||||
}
|
||||
|
||||
issue, err := s.GetIssue(ctx, depID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if issue == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
result := &types.IssueWithDependencyMetadata{
|
||||
Issue: *issue,
|
||||
DependencyType: types.DependencyType(depType),
|
||||
}
|
||||
results = append(results, result)
|
||||
}
|
||||
return results, rows.Err()
|
||||
}
|
||||
|
||||
// GetDependentsWithMetadata returns dependents with metadata
|
||||
func (s *DoltStore) GetDependentsWithMetadata(ctx context.Context, issueID string) ([]*types.IssueWithDependencyMetadata, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT d.issue_id, d.type, d.created_at, d.created_by, d.metadata, d.thread_id
|
||||
FROM dependencies d
|
||||
WHERE d.depends_on_id = ?
|
||||
`, issueID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get dependents with metadata: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var results []*types.IssueWithDependencyMetadata
|
||||
for rows.Next() {
|
||||
var depID, depType, createdBy string
|
||||
var createdAt sql.NullTime
|
||||
var metadata, threadID sql.NullString
|
||||
|
||||
if err := rows.Scan(&depID, &depType, &createdAt, &createdBy, &metadata, &threadID); err != nil {
|
||||
return nil, fmt.Errorf("failed to scan dependent: %w", err)
|
||||
}
|
||||
|
||||
issue, err := s.GetIssue(ctx, depID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if issue == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
result := &types.IssueWithDependencyMetadata{
|
||||
Issue: *issue,
|
||||
DependencyType: types.DependencyType(depType),
|
||||
}
|
||||
results = append(results, result)
|
||||
}
|
||||
return results, rows.Err()
|
||||
}
|
||||
|
||||
// GetDependencyRecords returns raw dependency records for an issue
|
||||
func (s *DoltStore) GetDependencyRecords(ctx context.Context, issueID string) ([]*types.Dependency, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT issue_id, depends_on_id, type, created_at, created_by, metadata, thread_id
|
||||
FROM dependencies
|
||||
WHERE issue_id = ?
|
||||
`, issueID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get dependency records: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return scanDependencyRows(rows)
|
||||
}
|
||||
|
||||
// GetAllDependencyRecords returns all dependency records
|
||||
func (s *DoltStore) GetAllDependencyRecords(ctx context.Context) (map[string][]*types.Dependency, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT issue_id, depends_on_id, type, created_at, created_by, metadata, thread_id
|
||||
FROM dependencies
|
||||
ORDER BY issue_id
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get all dependency records: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
result := make(map[string][]*types.Dependency)
|
||||
for rows.Next() {
|
||||
dep, err := scanDependencyRow(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result[dep.IssueID] = append(result[dep.IssueID], dep)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
// GetDependencyCounts returns dependency counts for multiple issues
|
||||
func (s *DoltStore) GetDependencyCounts(ctx context.Context, issueIDs []string) (map[string]*types.DependencyCounts, error) {
|
||||
if len(issueIDs) == 0 {
|
||||
return make(map[string]*types.DependencyCounts), nil
|
||||
}
|
||||
|
||||
placeholders := make([]string, len(issueIDs))
|
||||
args := make([]interface{}, len(issueIDs))
|
||||
for i, id := range issueIDs {
|
||||
placeholders[i] = "?"
|
||||
args[i] = id
|
||||
}
|
||||
inClause := strings.Join(placeholders, ",")
|
||||
|
||||
// Query for dependencies (blockers)
|
||||
depQuery := fmt.Sprintf(`
|
||||
SELECT issue_id, COUNT(*) as cnt
|
||||
FROM dependencies
|
||||
WHERE issue_id IN (%s) AND type = 'blocks'
|
||||
GROUP BY issue_id
|
||||
`, inClause)
|
||||
|
||||
depRows, err := s.db.QueryContext(ctx, depQuery, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get dependency counts: %w", err)
|
||||
}
|
||||
defer depRows.Close()
|
||||
|
||||
result := make(map[string]*types.DependencyCounts)
|
||||
for _, id := range issueIDs {
|
||||
result[id] = &types.DependencyCounts{}
|
||||
}
|
||||
|
||||
for depRows.Next() {
|
||||
var id string
|
||||
var cnt int
|
||||
if err := depRows.Scan(&id, &cnt); err != nil {
|
||||
return nil, fmt.Errorf("failed to scan dep count: %w", err)
|
||||
}
|
||||
if c, ok := result[id]; ok {
|
||||
c.DependencyCount = cnt
|
||||
}
|
||||
}
|
||||
|
||||
// Query for dependents (blocking)
|
||||
blockingQuery := fmt.Sprintf(`
|
||||
SELECT depends_on_id, COUNT(*) as cnt
|
||||
FROM dependencies
|
||||
WHERE depends_on_id IN (%s) AND type = 'blocks'
|
||||
GROUP BY depends_on_id
|
||||
`, inClause)
|
||||
|
||||
blockingRows, err := s.db.QueryContext(ctx, blockingQuery, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get blocking counts: %w", err)
|
||||
}
|
||||
defer blockingRows.Close()
|
||||
|
||||
for blockingRows.Next() {
|
||||
var id string
|
||||
var cnt int
|
||||
if err := blockingRows.Scan(&id, &cnt); err != nil {
|
||||
return nil, fmt.Errorf("failed to scan blocking count: %w", err)
|
||||
}
|
||||
if c, ok := result[id]; ok {
|
||||
c.DependentCount = cnt
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetDependencyTree returns a dependency tree for visualization
|
||||
func (s *DoltStore) GetDependencyTree(ctx context.Context, issueID string, maxDepth int, showAllPaths bool, reverse bool) ([]*types.TreeNode, error) {
|
||||
// Simple implementation - can be optimized with CTE
|
||||
visited := make(map[string]bool)
|
||||
return s.buildDependencyTree(ctx, issueID, 0, maxDepth, reverse, visited)
|
||||
}
|
||||
|
||||
func (s *DoltStore) buildDependencyTree(ctx context.Context, issueID string, depth, maxDepth int, reverse bool, visited map[string]bool) ([]*types.TreeNode, error) {
|
||||
if depth >= maxDepth || visited[issueID] {
|
||||
return nil, nil
|
||||
}
|
||||
visited[issueID] = true
|
||||
|
||||
issue, err := s.GetIssue(ctx, issueID)
|
||||
if err != nil || issue == nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var childIDs []string
|
||||
var query string
|
||||
if reverse {
|
||||
query = "SELECT issue_id FROM dependencies WHERE depends_on_id = ?"
|
||||
} else {
|
||||
query = "SELECT depends_on_id FROM dependencies WHERE issue_id = ?"
|
||||
}
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, query, issueID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
childIDs = append(childIDs, id)
|
||||
}
|
||||
|
||||
node := &types.TreeNode{
|
||||
Issue: *issue,
|
||||
Depth: depth,
|
||||
}
|
||||
|
||||
// TreeNode doesn't have Children field - return flat list
|
||||
nodes := []*types.TreeNode{node}
|
||||
for _, childID := range childIDs {
|
||||
children, err := s.buildDependencyTree(ctx, childID, depth+1, maxDepth, reverse, visited)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nodes = append(nodes, children...)
|
||||
}
|
||||
|
||||
return nodes, nil
|
||||
}
|
||||
|
||||
// DetectCycles finds circular dependencies
|
||||
func (s *DoltStore) DetectCycles(ctx context.Context) ([][]*types.Issue, error) {
|
||||
// Get all dependencies
|
||||
deps, err := s.GetAllDependencyRecords(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Build adjacency list
|
||||
graph := make(map[string][]string)
|
||||
for issueID, records := range deps {
|
||||
for _, dep := range records {
|
||||
if dep.Type == types.DepBlocks {
|
||||
graph[issueID] = append(graph[issueID], dep.DependsOnID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Find cycles using DFS
|
||||
var cycles [][]*types.Issue
|
||||
visited := make(map[string]bool)
|
||||
recStack := make(map[string]bool)
|
||||
path := make([]string, 0)
|
||||
|
||||
var dfs func(node string) bool
|
||||
dfs = func(node string) bool {
|
||||
visited[node] = true
|
||||
recStack[node] = true
|
||||
path = append(path, node)
|
||||
|
||||
for _, neighbor := range graph[node] {
|
||||
if !visited[neighbor] {
|
||||
if dfs(neighbor) {
|
||||
return true
|
||||
}
|
||||
} else if recStack[neighbor] {
|
||||
// Found cycle - extract it
|
||||
cycleStart := -1
|
||||
for i, n := range path {
|
||||
if n == neighbor {
|
||||
cycleStart = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if cycleStart >= 0 {
|
||||
cyclePath := path[cycleStart:]
|
||||
var cycleIssues []*types.Issue
|
||||
for _, id := range cyclePath {
|
||||
issue, _ := s.GetIssue(ctx, id)
|
||||
if issue != nil {
|
||||
cycleIssues = append(cycleIssues, issue)
|
||||
}
|
||||
}
|
||||
if len(cycleIssues) > 0 {
|
||||
cycles = append(cycles, cycleIssues)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
path = path[:len(path)-1]
|
||||
recStack[node] = false
|
||||
return false
|
||||
}
|
||||
|
||||
for node := range graph {
|
||||
if !visited[node] {
|
||||
dfs(node)
|
||||
}
|
||||
}
|
||||
|
||||
return cycles, nil
|
||||
}
|
||||
|
||||
// IsBlocked checks if an issue has open blockers
|
||||
func (s *DoltStore) IsBlocked(ctx context.Context, issueID string) (bool, []string, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT d.depends_on_id
|
||||
FROM dependencies d
|
||||
JOIN issues i ON d.depends_on_id = i.id
|
||||
WHERE d.issue_id = ?
|
||||
AND d.type = 'blocks'
|
||||
AND i.status IN ('open', 'in_progress', 'blocked', 'deferred', 'hooked')
|
||||
`, issueID)
|
||||
if err != nil {
|
||||
return false, nil, fmt.Errorf("failed to check blockers: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var blockers []string
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return false, nil, err
|
||||
}
|
||||
blockers = append(blockers, id)
|
||||
}
|
||||
|
||||
return len(blockers) > 0, blockers, rows.Err()
|
||||
}
|
||||
|
||||
// GetNewlyUnblockedByClose finds issues that become unblocked when an issue is closed
|
||||
func (s *DoltStore) GetNewlyUnblockedByClose(ctx context.Context, closedIssueID string) ([]*types.Issue, error) {
|
||||
// Find issues that were blocked only by the closed issue
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT DISTINCT d.issue_id
|
||||
FROM dependencies d
|
||||
JOIN issues i ON d.issue_id = i.id
|
||||
WHERE d.depends_on_id = ?
|
||||
AND d.type = 'blocks'
|
||||
AND i.status IN ('open', 'blocked')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM dependencies d2
|
||||
JOIN issues blocker ON d2.depends_on_id = blocker.id
|
||||
WHERE d2.issue_id = d.issue_id
|
||||
AND d2.type = 'blocks'
|
||||
AND d2.depends_on_id != ?
|
||||
AND blocker.status IN ('open', 'in_progress', 'blocked', 'deferred', 'hooked')
|
||||
)
|
||||
`, closedIssueID, closedIssueID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to find newly unblocked: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return s.scanIssueIDs(ctx, rows)
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
func (s *DoltStore) scanIssueIDs(ctx context.Context, rows *sql.Rows) ([]*types.Issue, error) {
|
||||
var issues []*types.Issue
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, fmt.Errorf("failed to scan issue id: %w", err)
|
||||
}
|
||||
issue, err := s.GetIssue(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if issue != nil {
|
||||
issues = append(issues, issue)
|
||||
}
|
||||
}
|
||||
return issues, rows.Err()
|
||||
}
|
||||
|
||||
func scanDependencyRows(rows *sql.Rows) ([]*types.Dependency, error) {
|
||||
var deps []*types.Dependency
|
||||
for rows.Next() {
|
||||
dep, err := scanDependencyRow(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
deps = append(deps, dep)
|
||||
}
|
||||
return deps, rows.Err()
|
||||
}
|
||||
|
||||
func scanDependencyRow(rows *sql.Rows) (*types.Dependency, error) {
|
||||
var dep types.Dependency
|
||||
var createdAt sql.NullTime
|
||||
var metadata, threadID sql.NullString
|
||||
|
||||
if err := rows.Scan(&dep.IssueID, &dep.DependsOnID, &dep.Type, &createdAt, &dep.CreatedBy, &metadata, &threadID); err != nil {
|
||||
return nil, fmt.Errorf("failed to scan dependency: %w", err)
|
||||
}
|
||||
|
||||
if createdAt.Valid {
|
||||
dep.CreatedAt = createdAt.Time
|
||||
}
|
||||
if threadID.Valid {
|
||||
dep.ThreadID = threadID.String
|
||||
}
|
||||
|
||||
return &dep, nil
|
||||
}
|
||||
Reference in New Issue
Block a user