Files
beads/internal/storage/dolt/dependencies.go
beads/crew/dave 28a7f10955 fix(lint): add nolint comments for gosec G201/G104 in dolt storage
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>
2026-01-15 11:42:05 -08:00

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
}