diff --git a/internal/storage/sqlite/dependencies.go b/internal/storage/sqlite/dependencies.go index 4b5d4736..3c1f184f 100644 --- a/internal/storage/sqlite/dependencies.go +++ b/internal/storage/sqlite/dependencies.go @@ -650,6 +650,9 @@ func (s *SQLiteStorage) DetectCycles(ctx context.Context) ([][]*types.Issue, err // Helper function to scan issues from rows func (s *SQLiteStorage) scanIssues(ctx context.Context, rows *sql.Rows) ([]*types.Issue, error) { var issues []*types.Issue + var issueIDs []string + + // First pass: scan all issues for rows.Next() { var issue types.Issue var contentHash sql.NullString @@ -689,14 +692,21 @@ func (s *SQLiteStorage) scanIssues(ctx context.Context, rows *sql.Rows) ([]*type issue.SourceRepo = sourceRepo.String } - // Fetch labels for this issue - labels, err := s.GetLabels(ctx, issue.ID) - if err != nil { - return nil, fmt.Errorf("failed to get labels for issue %s: %w", issue.ID, err) - } - issue.Labels = labels - issues = append(issues, &issue) + issueIDs = append(issueIDs, issue.ID) + } + + // Second pass: batch-load labels for all issues + labelsMap, err := s.GetLabelsForIssues(ctx, issueIDs) + if err != nil { + return nil, fmt.Errorf("failed to batch get labels: %w", err) + } + + // Assign labels to issues + for _, issue := range issues { + if labels, ok := labelsMap[issue.ID]; ok { + issue.Labels = labels + } } return issues, nil diff --git a/internal/storage/sqlite/labels.go b/internal/storage/sqlite/labels.go index 7fd66811..9dbc6db2 100644 --- a/internal/storage/sqlite/labels.go +++ b/internal/storage/sqlite/labels.go @@ -93,6 +93,56 @@ func (s *SQLiteStorage) GetLabels(ctx context.Context, issueID string) ([]string return labels, nil } +// GetLabelsForIssues fetches labels for multiple issues in a single query +// Returns a map of issue_id -> []labels +func (s *SQLiteStorage) GetLabelsForIssues(ctx context.Context, issueIDs []string) (map[string][]string, error) { + if len(issueIDs) == 0 { + return make(map[string][]string), nil + } + + // Build placeholders for IN clause + placeholders := make([]interface{}, len(issueIDs)) + for i, id := range issueIDs { + placeholders[i] = id + } + + query := fmt.Sprintf(` + SELECT issue_id, label + FROM labels + WHERE issue_id IN (%s) + ORDER BY issue_id, label + `, buildPlaceholders(len(issueIDs))) + + rows, err := s.db.QueryContext(ctx, query, placeholders...) + if err != nil { + return nil, fmt.Errorf("failed to batch get labels: %w", err) + } + defer func() { _ = rows.Close() }() + + result := make(map[string][]string) + for rows.Next() { + var issueID, label string + if err := rows.Scan(&issueID, &label); err != nil { + return nil, err + } + result[issueID] = append(result[issueID], label) + } + + return result, nil +} + +// buildPlaceholders creates a comma-separated list of SQL placeholders +func buildPlaceholders(count int) string { + if count == 0 { + return "" + } + result := "?" + for i := 1; i < count; i++ { + result += ",?" + } + return result +} + // GetIssuesByLabel returns issues with a specific label func (s *SQLiteStorage) GetIssuesByLabel(ctx context.Context, label string) ([]*types.Issue, error) { rows, err := s.db.QueryContext(ctx, `