diff --git a/cmd/bd/list.go b/cmd/bd/list.go index 702f0d27..ff2879ee 100644 --- a/cmd/bd/list.go +++ b/cmd/bd/list.go @@ -1194,7 +1194,7 @@ var listCmd = &cobra.Command{ } labelsMap, _ := store.GetLabelsForIssues(ctx, issueIDs) depCounts, _ := store.GetDependencyCounts(ctx, issueIDs) - allDeps, _ := store.GetAllDependencyRecords(ctx) + allDeps, _ := store.GetDependencyRecordsForIssues(ctx, issueIDs) // Populate labels and dependencies for JSON output for _, issue := range issues { diff --git a/internal/storage/dolt/dependencies.go b/internal/storage/dolt/dependencies.go index ac6f46c1..e733774d 100644 --- a/internal/storage/dolt/dependencies.go +++ b/internal/storage/dolt/dependencies.go @@ -187,6 +187,45 @@ func (s *DoltStore) GetAllDependencyRecords(ctx context.Context) (map[string][]* return result, rows.Err() } +// GetDependencyRecordsForIssues returns dependency records for specific issues +func (s *DoltStore) GetDependencyRecordsForIssues(ctx context.Context, issueIDs []string) (map[string][]*types.Dependency, error) { + if len(issueIDs) == 0 { + return make(map[string][]*types.Dependency), 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, ",") + + // nolint:gosec // G201: inClause contains only ? placeholders, actual values passed via args + query := fmt.Sprintf(` + SELECT issue_id, depends_on_id, type, created_at, created_by, metadata, thread_id + FROM dependencies + WHERE issue_id IN (%s) + ORDER BY issue_id + `, inClause) + + rows, err := s.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("failed to get dependency records for issues: %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 { diff --git a/internal/storage/memory/memory.go b/internal/storage/memory/memory.go index cffb4f03..6e5ba0d4 100644 --- a/internal/storage/memory/memory.go +++ b/internal/storage/memory/memory.go @@ -875,6 +875,20 @@ func (m *MemoryStorage) GetAllDependencyRecords(ctx context.Context) (map[string return result, nil } +// GetDependencyRecordsForIssues returns dependency records for specific issues +func (m *MemoryStorage) GetDependencyRecordsForIssues(ctx context.Context, issueIDs []string) (map[string][]*types.Dependency, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + result := make(map[string][]*types.Dependency) + for _, id := range issueIDs { + if deps, ok := m.dependencies[id]; ok { + result[id] = deps + } + } + return result, nil +} + // GetDirtyIssueHash returns the hash for dirty issue tracking func (m *MemoryStorage) GetDirtyIssueHash(ctx context.Context, issueID string) (string, error) { // Memory storage doesn't track dirty hashes, return empty string diff --git a/internal/storage/sqlite/dependencies.go b/internal/storage/sqlite/dependencies.go index bdfce813..bdabf34a 100644 --- a/internal/storage/sqlite/dependencies.go +++ b/internal/storage/sqlite/dependencies.go @@ -478,6 +478,64 @@ func (s *SQLiteStorage) GetAllDependencyRecords(ctx context.Context) (map[string return depsMap, nil } +// GetDependencyRecordsForIssues returns dependency records for specific issues +// This is optimized for operations that only need deps for a subset of issues +func (s *SQLiteStorage) GetDependencyRecordsForIssues(ctx context.Context, issueIDs []string) (map[string][]*types.Dependency, error) { + if len(issueIDs) == 0 { + return make(map[string][]*types.Dependency), nil + } + + // Hold read lock during database operations to prevent reconnect() from + // closing the connection mid-query (GH#607 race condition fix) + s.reconnectMu.RLock() + defer s.reconnectMu.RUnlock() + + // Build parameterized IN clause + placeholders := make([]string, len(issueIDs)) + args := make([]interface{}, len(issueIDs)) + for i, id := range issueIDs { + placeholders[i] = "?" + args[i] = id + } + inClause := strings.Join(placeholders, ",") + + // nolint:gosec // G201: inClause contains only ? placeholders, actual values passed via args + query := fmt.Sprintf(` + SELECT issue_id, depends_on_id, type, created_at, created_by, + COALESCE(metadata, '{}') as metadata, COALESCE(thread_id, '') as thread_id + FROM dependencies + WHERE issue_id IN (%s) + ORDER BY issue_id, created_at ASC + `, inClause) + + rows, err := s.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("failed to get dependency records for issues: %w", err) + } + defer func() { _ = rows.Close() }() + + // Group dependencies by issue ID + depsMap := make(map[string][]*types.Dependency) + for rows.Next() { + var dep types.Dependency + err := rows.Scan( + &dep.IssueID, + &dep.DependsOnID, + &dep.Type, + &dep.CreatedAt, + &dep.CreatedBy, + &dep.Metadata, + &dep.ThreadID, + ) + if err != nil { + return nil, fmt.Errorf("failed to scan dependency: %w", err) + } + depsMap[dep.IssueID] = append(depsMap[dep.IssueID], &dep) + } + + return depsMap, nil +} + // GetDependencyTree returns the full dependency tree with optional deduplication // When showAllPaths is false (default), nodes appearing via multiple paths (diamond dependencies) // appear only once at their shallowest depth in the tree. diff --git a/internal/storage/storage.go b/internal/storage/storage.go index 7442495a..ef4e0775 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -101,6 +101,7 @@ type Storage interface { GetDependentsWithMetadata(ctx context.Context, issueID string) ([]*types.IssueWithDependencyMetadata, error) GetDependencyRecords(ctx context.Context, issueID string) ([]*types.Dependency, error) GetAllDependencyRecords(ctx context.Context) (map[string][]*types.Dependency, error) + GetDependencyRecordsForIssues(ctx context.Context, issueIDs []string) (map[string][]*types.Dependency, error) GetDependencyCounts(ctx context.Context, issueIDs []string) (map[string]*types.DependencyCounts, error) GetDependencyTree(ctx context.Context, issueID string, maxDepth int, showAllPaths bool, reverse bool) ([]*types.TreeNode, error) DetectCycles(ctx context.Context) ([][]*types.Issue, error) diff --git a/internal/storage/storage_test.go b/internal/storage/storage_test.go index ccbf8f70..ae99dc34 100644 --- a/internal/storage/storage_test.go +++ b/internal/storage/storage_test.go @@ -69,6 +69,9 @@ func (m *mockStorage) GetDependencyRecords(ctx context.Context, issueID string) func (m *mockStorage) GetAllDependencyRecords(ctx context.Context) (map[string][]*types.Dependency, error) { return nil, nil } +func (m *mockStorage) GetDependencyRecordsForIssues(ctx context.Context, issueIDs []string) (map[string][]*types.Dependency, error) { + return nil, nil +} func (m *mockStorage) GetDependencyCounts(ctx context.Context, issueIDs []string) (map[string]*types.DependencyCounts, error) { return nil, nil }