The SQL formatting warnings (G201) are safe because: - Placeholders only contain "?" markers for parameterized queries - WHERE/SET clauses use validated column names with ? placeholders - Refs are validated by validateRef() before use in AS OF queries - LIMIT values are safe integers from filter.Limit Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
499 lines
14 KiB
Go
499 lines
14 KiB
Go
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)
|
|
// nolint:gosec // G201: inClause contains only ? placeholders, actual values passed via args
|
|
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)
|
|
// nolint:gosec // G201: inClause contains only ? placeholders, actual values passed via args
|
|
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
|
|
}
|