From 76faa9ea2235f0d7870a12240cd46013ad48713c Mon Sep 17 00:00:00 2001 From: Siddharth Dungarwal Date: Wed, 22 Oct 2025 21:35:37 +0530 Subject: [PATCH 01/11] Add Search::Scopes registry to centralize scope definitions --- app/services/search/global_service.rb | 6 +- app/services/search/project_service.rb | 7 +- ee/app/services/ee/search/global_service.rb | 13 +- ee/lib/ee/search/navigation.rb | 9 +- ee/lib/ee/search/scopes.rb | 76 +++++ ee/spec/lib/ee/search/scopes_spec.rb | 175 +++++++++++ lib/gitlab/metrics/global_search_slis.rb | 2 +- lib/gitlab/search/abuse_detection.rb | 19 +- lib/search/navigation.rb | 99 +++---- lib/search/scopes.rb | 248 ++++++++++++++++ .../lib/gitlab/search/abuse_detection_spec.rb | 2 +- spec/lib/search/scopes_spec.rb | 276 ++++++++++++++++++ 12 files changed, 837 insertions(+), 95 deletions(-) create mode 100644 ee/lib/ee/search/scopes.rb create mode 100644 ee/spec/lib/ee/search/scopes_spec.rb create mode 100644 lib/search/scopes.rb create mode 100644 spec/lib/search/scopes_spec.rb diff --git a/app/services/search/global_service.rb b/app/services/search/global_service.rb index d5a88a776713ea..b2ecafbf795b95 100644 --- a/app/services/search/global_service.rb +++ b/app/services/search/global_service.rb @@ -6,7 +6,6 @@ class GlobalService include Gitlab::Utils::StrongMemoize DEFAULT_SCOPE = 'projects' - ALLOWED_SCOPES = %w[projects issues merge_requests milestones users].freeze attr_accessor :current_user, :params @@ -31,7 +30,10 @@ def projects end def allowed_scopes - ALLOWED_SCOPES + Search::Scopes.available_for_context( + context: :global, + search_type: Search::Scopes.current_search_type + ) end def scope diff --git a/app/services/search/project_service.rb b/app/services/search/project_service.rb index c2e789790afec6..17b0dc1e591318 100644 --- a/app/services/search/project_service.rb +++ b/app/services/search/project_service.rb @@ -5,8 +5,6 @@ class ProjectService include Search::Filter include Gitlab::Utils::StrongMemoize - ALLOWED_SCOPES = %w[blobs issues merge_requests wiki_blobs commits notes milestones users].freeze - attr_accessor :project, :current_user, :params def initialize(user, project, params) @@ -27,7 +25,10 @@ def execute end def allowed_scopes - ALLOWED_SCOPES + Search::Scopes.available_for_context( + context: :project, + search_type: Search::Scopes.current_search_type + ) end def scope diff --git a/ee/app/services/ee/search/global_service.rb b/ee/app/services/ee/search/global_service.rb index 4ac4a4e100f7b7..d1e4ab8bdf2f0d 100644 --- a/ee/app/services/ee/search/global_service.rb +++ b/ee/app/services/ee/search/global_service.rb @@ -72,17 +72,8 @@ def zoekt_project_id; end def zoekt_group_id; end - override :allowed_scopes - def allowed_scopes - scopes = super - return scopes if params[:search_type] == 'basic' - - scopes += %w[blobs commits epics notes wiki_blobs] if use_elasticsearch? - scopes += %w[blobs] if use_zoekt? - - scopes.uniq - end - strong_memoize_attr :allowed_scopes + # Note: allowed_scopes logic is now handled by Search::Scopes.available_for_context + # which considers the current search type (basic/advanced/zoekt) automatically end end end diff --git a/ee/lib/ee/search/navigation.rb b/ee/lib/ee/search/navigation.rb index 986e361f62a3ae..3247a39f907de1 100644 --- a/ee/lib/ee/search/navigation.rb +++ b/ee/lib/ee/search/navigation.rb @@ -7,18 +7,13 @@ module Navigation override :tabs def tabs + # CE now automatically includes epics from Search::Scopes.scope_definitions + # We just need to handle the work_item_scope_frontend feature flag case (super || {}).tap do |nav| if ::Feature.enabled?(:work_item_scope_frontend, user) nav[:issues][:sub_items] ||= {} nav[:issues][:sub_items].merge!(get_epic_sub_item) - next end - - nav[:epics] ||= { - sort: 3, - label: _("Epics"), - condition: show_epics_search_tab? - } end end diff --git a/ee/lib/ee/search/scopes.rb b/ee/lib/ee/search/scopes.rb new file mode 100644 index 00000000000000..b654d4f1cfcba3 --- /dev/null +++ b/ee/lib/ee/search/scopes.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +module EE + module Search + module Scopes + # EE scope definitions + # rubocop:disable Cop/StaticTranslationDefinition -- labels are defined in constant for registry + EE_SCOPE_DEFINITIONS = { + epics: { + label: _('Epics'), + sort: 3, + contexts: [:group, :global], + search_types: [:advanced] + } + }.freeze + # rubocop:enable Cop/StaticTranslationDefinition + + # EE-specific global search settings + EE_GLOBAL_SEARCH_SETTING_MAP = { + 'blobs' => :global_search_code_enabled?, + 'wiki_blobs' => :global_search_wiki_enabled?, + 'commits' => :global_search_commits_enabled?, + 'epics' => :global_search_epics_enabled? + }.freeze + + def self.prepended(base) + class << base + prepend ClassMethods + end + end + + module ClassMethods + extend ::Gitlab::Utils::Override + + override :scope_definitions + def scope_definitions + # Merge CE and EE definitions + super.merge(EE::Search::Scopes::EE_SCOPE_DEFINITIONS) + end + + override :current_search_type + def current_search_type + # Determine current search type based on what's enabled + # Priority: zoekt > advanced > basic + return :zoekt if zoekt_enabled? + return :advanced if elasticsearch_enabled? + + :basic + end + + private + + override :global_search_setting_map + def global_search_setting_map + # Merge CE and EE settings maps + super.merge(EE::Search::Scopes::EE_GLOBAL_SEARCH_SETTING_MAP) + end + + def elasticsearch_enabled? + Gitlab::CurrentSettings.elasticsearch_search? || + Gitlab::CurrentSettings.elasticsearch_indexing? + rescue StandardError + false + end + + def zoekt_enabled? + # rubocop:disable Gitlab/FeatureFlagWithoutActor -- ops-type flag checked globally + ::Feature.enabled?(:zoekt_cross_namespace_search, type: :ops) + # rubocop:enable Gitlab/FeatureFlagWithoutActor + rescue StandardError + false + end + end + end + end +end diff --git a/ee/spec/lib/ee/search/scopes_spec.rb b/ee/spec/lib/ee/search/scopes_spec.rb new file mode 100644 index 00000000000000..315f6e050973d8 --- /dev/null +++ b/ee/spec/lib/ee/search/scopes_spec.rb @@ -0,0 +1,175 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Search::Scopes, feature_category: :global_search do + describe '.available_for_context' do + context 'for global context' do + it 'includes EE scopes with advanced search' do + allow(Gitlab::CurrentSettings).to receive(:elasticsearch_search?).and_return(true) + scopes = described_class.available_for_context(context: :global, search_type: :advanced) + + expect(scopes).to include('epics') + end + + it 'includes code scopes with advanced search' do + allow(Gitlab::CurrentSettings).to receive(:elasticsearch_search?).and_return(true) + scopes = described_class.available_for_context(context: :global, search_type: :advanced) + + expect(scopes).to include('blobs', 'commits', 'wiki_blobs', 'notes') + end + end + + context 'for group context' do + it 'includes epics with advanced search' do + allow(Gitlab::CurrentSettings).to receive(:elasticsearch_search?).and_return(true) + scopes = described_class.available_for_context(context: :group, search_type: :advanced) + + expect(scopes).to include('epics') + end + end + end + + describe '.valid_scope?' do + it 'returns true for EE scopes' do + expect(described_class.valid_scope?('epics')).to be true + end + + it 'returns true for CE scopes' do + expect(described_class.valid_scope?('projects')).to be true + expect(described_class.valid_scope?('issues')).to be true + end + + context 'with context parameter' do + it 'validates epics scope for group context' do + expect(described_class.valid_scope?('epics', context: :group)).to be true + expect(described_class.valid_scope?('epics', context: :global)).to be true + expect(described_class.valid_scope?('epics', context: :project)).to be false + end + end + end + + describe '.scope_enabled_for_global_search?' do + let(:settings) { instance_double(ApplicationSetting) } + + context 'with EE-specific scopes' do + it 'checks EE-specific settings for code search' do + allow(settings).to receive(:global_search_code_enabled?).and_return(false) + + expect(described_class.scope_enabled_for_global_search?('blobs', settings, :global)).to be false + end + + it 'checks EE-specific settings for epics' do + allow(settings).to receive(:global_search_epics_enabled?).and_return(false) + + expect(described_class.scope_enabled_for_global_search?('epics', settings, :global)).to be false + end + + it 'checks EE-specific settings for wiki' do + allow(settings).to receive(:global_search_wiki_enabled?).and_return(false) + + expect(described_class.scope_enabled_for_global_search?('wiki_blobs', settings, :global)).to be false + end + + it 'checks EE-specific settings for commits' do + allow(settings).to receive(:global_search_commits_enabled?).and_return(false) + + expect(described_class.scope_enabled_for_global_search?('commits', settings, :global)).to be false + end + end + end + + describe '.default_scope_options' do + context 'with advanced search enabled' do + before do + stub_application_setting(elasticsearch_search: true, elasticsearch_indexing: false) + stub_feature_flags(zoekt_cross_namespace_search: false) + end + + it 'includes EE scopes and advanced search scopes' do + options = described_class.default_scope_options + values = options.pluck(:value) + + expect(values).to include('epics', 'blobs', 'commits') + end + + it 'maintains sort order with EE scopes' do + options = described_class.default_scope_options + values = options.pluck(:value) + + # Epics (sort: 3) should be between blobs (sort: 2) and issues (sort: 4) + expect(values.index('blobs')).to be < values.index('epics') + expect(values.index('epics')).to be < values.index('issues') + end + end + end + + describe '.validate_default_scope' do + let(:settings) { instance_double(ApplicationSetting) } + + context 'with EE scopes' do + it 'validates EE scopes correctly when enabled' do + allow(settings).to receive(:global_search_epics_enabled?).and_return(true) + errors = described_class.validate_default_scope('epics', settings) + + expect(errors).to be_empty + end + + it 'returns error when EE scope is disabled' do + allow(settings).to receive(:global_search_epics_enabled?).and_return(false) + errors = described_class.validate_default_scope('epics', settings) + + expect(errors).to include(match(/currently disabled/)) + end + + it 'validates code scope with settings' do + allow(settings).to receive(:global_search_code_enabled?).and_return(false) + errors = described_class.validate_default_scope('blobs', settings) + + expect(errors).to include(match(/currently disabled/)) + end + end + end + + describe '.current_search_type' do + it 'returns :basic by default' do + stub_application_setting(elasticsearch_search: false, elasticsearch_indexing: false) + stub_feature_flags(zoekt_cross_namespace_search: false) + + expect(described_class.current_search_type).to eq(:basic) + end + + context 'with elasticsearch enabled' do + before do + stub_application_setting(elasticsearch_search: true, elasticsearch_indexing: false) + stub_feature_flags(zoekt_cross_namespace_search: false) + end + + it 'returns :advanced' do + expect(described_class.current_search_type).to eq(:advanced) + end + end + + context 'with zoekt enabled' do + before do + stub_application_setting(elasticsearch_search: false, elasticsearch_indexing: false) + stub_feature_flags(zoekt_cross_namespace_search: true) + end + + it 'returns :zoekt' do + expect(described_class.current_search_type).to eq(:zoekt) + end + end + + context 'with both elasticsearch and zoekt enabled' do + before do + stub_application_setting(elasticsearch_search: true, elasticsearch_indexing: false) + stub_feature_flags(zoekt_cross_namespace_search: true) + end + + it 'prioritizes zoekt over elasticsearch' do + expect(described_class.current_search_type).to eq(:zoekt) + end + end + end +end diff --git a/lib/gitlab/metrics/global_search_slis.rb b/lib/gitlab/metrics/global_search_slis.rb index 5c21d4f32ce017..2e56e23f855855 100644 --- a/lib/gitlab/metrics/global_search_slis.rb +++ b/lib/gitlab/metrics/global_search_slis.rb @@ -64,7 +64,7 @@ def search_levels end def search_scopes - Gitlab::Search::AbuseDetection::ALLOWED_SCOPES + ::Search::Scopes.all_scope_names end def endpoint_ids diff --git a/lib/gitlab/search/abuse_detection.rb b/lib/gitlab/search/abuse_detection.rb index 8edc6d549b4cff..226ce0f3bb27bd 100644 --- a/lib/gitlab/search/abuse_detection.rb +++ b/lib/gitlab/search/abuse_detection.rb @@ -10,21 +10,6 @@ class AbuseDetection ABUSIVE_TERM_SIZE = 100 ALLOWED_CHARS_REGEX = %r{\A[[:alnum:]_\-\+\/\.!]+\z} - ALLOWED_SCOPES = %w[ - blobs - code - commits - epics - issues - merge_requests - milestones - notes - projects - snippet_titles - users - wiki_blobs - ].freeze - READABLE_PARAMS = %i[ group_id project_id @@ -42,7 +27,9 @@ class AbuseDetection validates :project_id, :group_id, numericality: { only_integer: true, message: "abusive ID detected" }, allow_blank: true - validates :scope, inclusion: { in: ALLOWED_SCOPES, message: 'abusive scope detected' }, allow_blank: true + validates :scope, inclusion: { in: -> { + ::Search::Scopes.all_scope_names + }, message: 'abusive scope detected' }, allow_blank: true validates :repository_ref, :project_ref, format: { with: ALLOWED_CHARS_REGEX, message: "abusive characters detected" }, allow_blank: true diff --git a/lib/search/navigation.rb b/lib/search/navigation.rb index 093605a6faa5b6..e9b071455adc60 100644 --- a/lib/search/navigation.rb +++ b/lib/search/navigation.rb @@ -19,61 +19,20 @@ def tab_enabled_for_project?(tab) end def tabs - nav = { - projects: { - sort: 1, - label: _("Projects"), - data: { testid: 'projects-tab' }, - condition: project.nil? - }, - blobs: { - sort: 2, - label: _("Code"), - data: { testid: 'code-tab' }, - condition: show_code_search_tab? - }, - # sort: 3 is reserved for EE items - issues: { - sort: 4, - label: _("Issues"), - condition: show_issues_search_tab? - }, - merge_requests: { - sort: 5, - label: _("Merge requests"), - condition: show_merge_requests_search_tab? - }, - wiki_blobs: { - sort: 6, - label: _("Wiki"), - condition: show_wiki_search_tab? - }, - commits: { - sort: 7, - label: _("Commits"), - condition: show_commits_search_tab? - }, - notes: { - sort: 8, - label: _("Comments"), - condition: show_comments_search_tab? - }, - milestones: { - sort: 9, label: _("Milestones"), - condition: show_milestones_search_tab? - }, - users: { - sort: 10, - label: _("Users"), - condition: show_user_search_tab? - }, - snippet_titles: { - sort: 11, - label: _("Snippets"), - search: { snippets: true, group_id: nil, project_id: nil }, - condition: show_snippets_search_tab? + # Build tabs from Search::Scopes registry + # This automatically includes EE scopes when running in EE + nav = {} + Search::Scopes.scope_definitions.each do |scope_key, definition| + nav[scope_key] = { + sort: definition[:sort], + label: definition[:label], + data: scope_key == :blobs ? { testid: 'code-tab' } : { testid: "#{scope_key.to_s.tr('_', '-')}-tab" }, + condition: scope_visibility_condition(scope_key) } - } + + # Add special search params for snippet_titles + nav[scope_key][:search] = { snippets: true, group_id: nil, project_id: nil } if scope_key == :snippet_titles + end return nav unless ::Feature.enabled?(:work_item_scope_frontend, user) @@ -89,6 +48,38 @@ def tabs private + # Map scope key to its visibility condition method + # This method is called for each scope defined in Search::Scopes::SCOPE_DEFINITIONS + def scope_visibility_condition(scope_key) + case scope_key + when :projects + project.nil? + when :blobs + show_code_search_tab? + when :issues + show_issues_search_tab? + when :merge_requests + show_merge_requests_search_tab? + when :wiki_blobs + show_wiki_search_tab? + when :commits + show_commits_search_tab? + when :notes + show_comments_search_tab? + when :milestones + show_milestones_search_tab? + when :users + show_user_search_tab? + when :snippet_titles + show_snippets_search_tab? + when :epics + # EE scope - will be false in CE, overridden in EE + respond_to?(:show_epics_search_tab?, true) ? show_epics_search_tab? : false + else + false + end + end + def get_sub_items ::WorkItems::Type::TYPE_NAMES.each_with_object({}) do |(key, value, index), hash| next if key.to_s == 'epic' diff --git a/lib/search/scopes.rb b/lib/search/scopes.rb new file mode 100644 index 00000000000000..222168310aea20 --- /dev/null +++ b/lib/search/scopes.rb @@ -0,0 +1,248 @@ +# frozen_string_literal: true + +module Search + # Central registry for search scopes across all search contexts + # Provides a single source of truth for scope definitions, availability, + # and validation logic + class Scopes + # Scope definitions with metadata (CE scopes only) + # Format: { scope_key => { label:, sort:, contexts:, search_types: } } + # EE scopes are defined in ee/lib/ee/search/scopes.rb + # rubocop:disable Cop/StaticTranslationDefinition -- labels are defined in constant for registry + SCOPE_DEFINITIONS = { + projects: { + label: _('Projects'), + sort: 1, + contexts: [:global, :group], + search_types: [:basic, :advanced, :zoekt] + }, + blobs: { + label: _('Code'), + sort: 2, + contexts: [:project, :group, :global], + search_types: [:basic, :advanced, :zoekt], + requires_advanced_search_for: [:global, :group], + requires_zoekt_for: [:global, :group] + }, + # sort: 3 is reserved for EE scopes (epics) + issues: { + label: _('Issues'), + sort: 4, + contexts: [:project, :group, :global], + search_types: [:basic, :advanced, :zoekt] + }, + merge_requests: { + label: _('Merge requests'), + sort: 5, + contexts: [:project, :group, :global], + search_types: [:basic, :advanced, :zoekt] + }, + wiki_blobs: { + label: _('Wiki'), + sort: 6, + contexts: [:project, :group, :global], + search_types: [:basic, :advanced], + requires_advanced_search_for: [:global, :group] + }, + commits: { + label: _('Commits'), + sort: 7, + contexts: [:project, :group, :global], + search_types: [:basic, :advanced], + requires_advanced_search_for: [:global, :group] + }, + notes: { + label: _('Comments'), + sort: 8, + contexts: [:project, :group, :global], + search_types: [:basic, :advanced], + requires_advanced_search_for: [:global, :group] + }, + milestones: { + label: _('Milestones'), + sort: 9, + contexts: [:project, :group, :global], + search_types: [:basic, :advanced, :zoekt] + }, + users: { + label: _('Users'), + sort: 10, + contexts: [:project, :group, :global], + search_types: [:basic, :advanced, :zoekt] + }, + snippet_titles: { + label: _('Snippets'), + sort: 11, + contexts: [:global, :group], + search_types: [:basic, :advanced] + } + }.freeze + # rubocop:enable Cop/StaticTranslationDefinition + + # Map of scopes to their required application setting for global search (CE scopes) + # EE scopes are added in ee/lib/ee/search/scopes.rb + GLOBAL_SEARCH_SETTING_MAP = { + 'issues' => :global_search_issues_enabled?, + 'merge_requests' => :global_search_merge_requests_enabled?, + 'snippet_titles' => :global_search_snippet_titles_enabled?, + 'users' => :global_search_users_enabled? + }.freeze + + class << self + # Get all scope names + def all_scope_names + SCOPE_DEFINITIONS.keys.map(&:to_s) + end + + # Get scopes available for a specific context (global, group, project) + # @param context [Symbol] :global, :group, or :project + # @param search_type [Symbol] :basic, :advanced, or :zoekt + # @return [Array] Array of scope names available for the context + def available_for_context(context:, search_type: :basic) + scope_definitions.select do |_key, definition| + definition[:contexts].include?(context) && + search_type_matches?(definition[:search_types], search_type, context, definition) + end.keys.map(&:to_s) + end + + # Check if a scope is valid for the current instance + # @param scope [String] Scope name to check + # @param context [Symbol] :global, :group, or :project (optional) + # @return [Boolean] True if scope is valid + def valid_scope?(scope, context: nil) + return false if scope.blank? + + scope_sym = scope.to_sym + definition = scope_definitions[scope_sym] + return false unless definition + + # Check context if provided + return false if context && definition[:contexts].exclude?(context) + + true + end + + # Get scopes that are enabled based on global search settings + # @param context [Symbol] :global, :group, or :project + # @param settings [ApplicationSetting] Application settings object + # @return [Array] Array of enabled scope names + def enabled_scopes(context:, settings: nil) + settings ||= ::Gitlab::CurrentSettings.current_application_settings + + available_for_context( + context: context, + search_type: current_search_type + ).select do |scope| + scope_enabled_for_global_search?(scope, settings, context) + end + end + + # Check if a scope is enabled for global search based on settings + # @param scope [String] Scope name + # @param settings [ApplicationSetting] Application settings object + # @param context [Symbol] Search context + # @return [Boolean] True if scope is enabled + def scope_enabled_for_global_search?(scope, settings, context) + # Project searches don't check global search settings + return true if context == :project + + setting_method = global_search_setting_map[scope.to_s] + return true unless setting_method + + settings.public_send(setting_method) # rubocop:disable GitlabSecurity/PublicSend -- setting_method is validated from GLOBAL_SEARCH_SETTING_MAP + end + + # Get the label for a scope + # @param scope [String] Scope name + # @return [String, nil] Human-readable label + def label_for(scope) + scope_definitions[scope.to_sym]&.dig(:label) + end + + # Get scopes suitable for default scope dropdown in admin UI + # Returns scopes that: + # - Are available on this instance (CE vs EE) + # - Can be used in multiple contexts (not snippet_titles which is special) + # - Are commonly useful as defaults + # @return [Array] Array of { value:, text: } for dropdown + def default_scope_options + options = available_for_context( + context: :global, + search_type: current_search_type + ).map do |scope| + { + value: scope, + text: label_for(scope) || scope.to_s.titleize + } + end + + # Sort by the defined sort order + options.sort_by do |opt| + scope_definitions[opt[:value].to_sym]&.dig(:sort) || 999 + end + end + + # Validate that a default scope is compatible with current settings + # @param scope [String] Scope to validate + # @param settings [ApplicationSetting] Application settings + # @return [Array] Array of error messages (empty if valid) + def validate_default_scope(scope, settings) + errors = [] + + return errors if scope.blank? + + unless valid_scope?(scope, context: :global) + errors << "#{scope} is not a valid search scope for this instance" + return errors + end + + # Check if scope is disabled by global search settings + setting_method = global_search_setting_map[scope] + # rubocop:disable GitlabSecurity/PublicSend -- setting_method is validated from GLOBAL_SEARCH_SETTING_MAP + if setting_method && !settings.public_send(setting_method) + # rubocop:enable GitlabSecurity/PublicSend + setting_name = setting_method.to_s.delete('?').humanize + errors << "#{label_for(scope)} scope is currently disabled. " \ + "Enable '#{setting_name}' before setting it as the default." + end + + errors + end + + # Returns the scope definitions (can be overridden in EE) + def scope_definitions + SCOPE_DEFINITIONS + end + + # Returns the current search type (can be overridden in EE) + def current_search_type + # CE only has basic search + :basic + end + + private + + # Returns the global search setting map (can be overridden in EE) + def global_search_setting_map + GLOBAL_SEARCH_SETTING_MAP + end + + def search_type_matches?(allowed_types, search_type, context, definition) + return false unless allowed_types.include?(search_type) + + # Check if this scope requires advanced search or zoekt for the given context + if search_type == :basic + requires_advanced = definition[:requires_advanced_search_for] + requires_zoekt = definition[:requires_zoekt_for] + + return false if requires_advanced&.include?(context) + return false if requires_zoekt&.include?(context) + end + + true + end + end + end +end + +Search::Scopes.prepend_mod diff --git a/spec/lib/gitlab/search/abuse_detection_spec.rb b/spec/lib/gitlab/search/abuse_detection_spec.rb index 5ec6693eceb5b3..2f0f5e94dbc456 100644 --- a/spec/lib/gitlab/search/abuse_detection_spec.rb +++ b/spec/lib/gitlab/search/abuse_detection_spec.rb @@ -9,7 +9,7 @@ describe 'abusive scopes validation' do it 'allows only approved scopes' do - described_class::ALLOWED_SCOPES.each do |scope| + Search::Scopes.all_scope_names.each do |scope| expect(described_class.new({ scope: scope })).to be_valid end end diff --git a/spec/lib/search/scopes_spec.rb b/spec/lib/search/scopes_spec.rb new file mode 100644 index 00000000000000..79c2d54600f450 --- /dev/null +++ b/spec/lib/search/scopes_spec.rb @@ -0,0 +1,276 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Search::Scopes, feature_category: :global_search do + # Since tests run in EE environment by default, we need to explicitly check + # We'll mark CE-specific tests with stub_ee(false) where needed + + describe '.all_scope_names' do + it 'returns all defined scope names as strings' do + scope_names = described_class.all_scope_names + + expect(scope_names).to include('projects', 'issues', 'merge_requests', 'blobs', 'users') + expect(scope_names).to all(be_a(String)) + end + end + + describe '.available_for_context' do + context 'for global context' do + it 'returns scopes available for global search' do + scopes = described_class.available_for_context(context: :global, search_type: :basic) + + expect(scopes).to include('projects', 'issues', 'merge_requests', 'milestones', 'users') + expect(scopes).not_to include('blobs') # requires advanced search for global + end + + it 'includes code scope when search_type is advanced' do + scopes = described_class.available_for_context(context: :global, search_type: :advanced) + + expect(scopes).to include('blobs', 'commits', 'wiki_blobs', 'notes') + end + + it 'includes EE scopes in EE environment', if: Gitlab.ee? do + scopes = described_class.available_for_context(context: :global, search_type: :advanced) + + expect(scopes).to include('epics') + end + end + + context 'for project context' do + it 'returns scopes available for project search' do + scopes = described_class.available_for_context(context: :project, search_type: :basic) + + expect(scopes).to include('blobs', 'issues', 'merge_requests', 'wiki_blobs', 'commits', 'notes', 'milestones', + 'users') + expect(scopes).not_to include('projects') # not available in project context + end + + it 'includes code scope even in basic search for projects' do + scopes = described_class.available_for_context(context: :project, search_type: :basic) + + expect(scopes).to include('blobs') + end + + it 'excludes epics in project context' do + scopes = described_class.available_for_context(context: :project, search_type: :advanced) + + expect(scopes).not_to include('epics') + end + end + + context 'for group context' do + it 'returns scopes available for group search' do + scopes = described_class.available_for_context(context: :group, search_type: :advanced) + + expect(scopes).to include('projects', 'issues', 'merge_requests', 'blobs') + expect(scopes).to include('milestones', 'users') + end + + it 'includes epics in group context on EE', if: Gitlab.ee? do + scopes = described_class.available_for_context(context: :group, search_type: :advanced) + + expect(scopes).to include('epics') + end + end + end + + describe '.valid_scope?' do + it 'returns true for valid CE scopes' do + expect(described_class.valid_scope?('projects')).to be true + expect(described_class.valid_scope?('issues')).to be true + expect(described_class.valid_scope?('blobs')).to be true + end + + it 'returns false for invalid scopes' do + expect(described_class.valid_scope?('invalid_scope')).to be false + expect(described_class.valid_scope?('')).to be false + expect(described_class.valid_scope?(nil)).to be false + end + + it 'returns true for EE scopes on EE instance', if: Gitlab.ee? do + expect(described_class.valid_scope?('epics')).to be true + end + + context 'with context parameter' do + it 'validates scope is available in given context' do + expect(described_class.valid_scope?('projects', context: :global)).to be true + expect(described_class.valid_scope?('projects', context: :project)).to be false + end + + it 'validates blobs scope for different contexts' do + expect(described_class.valid_scope?('blobs', context: :project)).to be true + expect(described_class.valid_scope?('blobs', context: :global)).to be true + end + end + end + + describe '.enabled_scopes' do + let(:settings) { instance_double(ApplicationSetting) } + + before do + # Stub CE settings + allow(settings).to receive_messages( + global_search_issues_enabled?: true, + global_search_merge_requests_enabled?: true, + global_search_snippet_titles_enabled?: true, + global_search_users_enabled?: true + ) + + # Stub EE settings (if running in EE) + if Gitlab.ee? + allow(settings).to receive_messages(global_search_code_enabled?: true, global_search_wiki_enabled?: true, + global_search_commits_enabled?: true, global_search_epics_enabled?: true) + end + end + + context 'for global context' do + it 'returns only enabled scopes based on settings' do + allow(settings).to receive_messages(global_search_issues_enabled?: true, + global_search_merge_requests_enabled?: false, global_search_users_enabled?: true) + + allow(settings).to receive(:global_search_code_enabled?).and_return(false) if Gitlab.ee? + + scopes = described_class.enabled_scopes(context: :global, settings: settings) + + expect(scopes).to include('issues', 'users', 'projects', 'milestones') + expect(scopes).not_to include('merge_requests') + end + end + + context 'for project context' do + it 'returns all available scopes regardless of global settings' do + allow(settings).to receive_messages(global_search_issues_enabled?: false, + global_search_merge_requests_enabled?: false) + + scopes = described_class.enabled_scopes(context: :project, settings: settings) + + expect(scopes).to include('issues', 'merge_requests', 'blobs') + end + end + end + + describe '.scope_enabled_for_global_search?' do + let(:settings) { instance_double(ApplicationSetting) } + + before do + allow(settings).to receive_messages( + global_search_issues_enabled?: true, + global_search_merge_requests_enabled?: true, + global_search_snippet_titles_enabled?: true, + global_search_users_enabled?: true + ) + end + + it 'returns true for scopes without settings check' do + expect(described_class.scope_enabled_for_global_search?('projects', settings, :global)).to be true + expect(described_class.scope_enabled_for_global_search?('milestones', settings, :global)).to be true + end + + it 'checks application settings for scopes with settings' do + allow(settings).to receive(:global_search_issues_enabled?).and_return(false) + + expect(described_class.scope_enabled_for_global_search?('issues', settings, :global)).to be false + end + + it 'returns true for project context regardless of global settings' do + allow(settings).to receive(:global_search_issues_enabled?).and_return(false) + + expect(described_class.scope_enabled_for_global_search?('issues', settings, :project)).to be true + end + end + + describe '.label_for' do + it 'returns the human-readable label for a scope' do + expect(described_class.label_for('projects')).to eq('Projects') + expect(described_class.label_for('blobs')).to eq('Code') + expect(described_class.label_for('merge_requests')).to eq('Merge requests') + end + + it 'returns nil for invalid scope' do + expect(described_class.label_for('invalid')).to be_nil + end + end + + describe '.default_scope_options' do + it 'returns array of hashes with value and text' do + options = described_class.default_scope_options + + expect(options).to be_an(Array) + expect(options.first).to have_key(:value) + expect(options.first).to have_key(:text) + end + + it 'sorts options by defined sort order' do + options = described_class.default_scope_options + values = options.pluck(:value) + + # Projects (sort: 1) should come before Issues (sort: 4) + expect(values.index('projects')).to be < values.index('issues') + end + + it 'includes only scopes available on CE' do + options = described_class.default_scope_options + values = options.pluck(:value) + + expect(values).to include('projects', 'issues', 'users') + expect(values).not_to include('epics') + end + end + + describe '.validate_default_scope' do + let(:settings) { instance_double(ApplicationSetting) } + + before do + allow(settings).to receive_messages( + global_search_issues_enabled?: true, + global_search_merge_requests_enabled?: true, + global_search_snippet_titles_enabled?: true, + global_search_users_enabled?: true + ) + + if Gitlab.ee? + allow(settings).to receive_messages( + global_search_code_enabled?: true, + global_search_wiki_enabled?: true, + global_search_commits_enabled?: true, + global_search_epics_enabled?: true + ) + end + end + + it 'returns empty array for valid scope' do + errors = described_class.validate_default_scope('projects', settings) + + expect(errors).to be_empty + end + + it 'returns empty array for blank scope' do + errors = described_class.validate_default_scope('', settings) + + expect(errors).to be_empty + end + + it 'returns error for invalid scope' do + errors = described_class.validate_default_scope('invalid_scope', settings) + + expect(errors).to include(match(/is not a valid search scope/)) + end + + it 'returns error when scope is disabled by settings' do + allow(settings).to receive(:global_search_issues_enabled?).and_return(false) + errors = described_class.validate_default_scope('issues', settings) + + expect(errors).to include(match(/currently disabled/)) + expect(errors.first).to include('Global search issues enabled') + end + + it 'validates EE scopes correctly on EE instance', if: Gitlab.ee? do + allow(settings).to receive(:global_search_epics_enabled?).and_return(false) + errors = described_class.validate_default_scope('epics', settings) + + expect(errors).not_to be_empty + expect(errors).to include(match(/currently disabled/)) + end + end +end -- GitLab From 167abd87a3cc45041041dfd520d07e290f58b161 Mon Sep 17 00:00:00 2001 From: Siddharth Dungarwal Date: Wed, 22 Oct 2025 22:37:11 +0530 Subject: [PATCH 02/11] Fix scope errors --- lib/search/scopes.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/search/scopes.rb b/lib/search/scopes.rb index 222168310aea20..d3a1f4a1a417b9 100644 --- a/lib/search/scopes.rb +++ b/lib/search/scopes.rb @@ -91,7 +91,7 @@ class Scopes class << self # Get all scope names def all_scope_names - SCOPE_DEFINITIONS.keys.map(&:to_s) + scope_definitions.keys.map(&:to_s) end # Get scopes available for a specific context (global, group, project) -- GitLab From cbc3ba698e2b0189815eac180d11b0d80ac34762 Mon Sep 17 00:00:00 2001 From: Siddharth Dungarwal Date: Wed, 22 Oct 2025 23:09:07 +0530 Subject: [PATCH 03/11] Fix failing specs --- app/services/search/global_service.rb | 14 ++++++++- app/services/search/project_service.rb | 14 ++++++++- ee/app/services/ee/search/global_service.rb | 31 ++++++++++++++++++-- ee/app/services/ee/search/project_service.rb | 12 ++++++++ 4 files changed, 67 insertions(+), 4 deletions(-) diff --git a/app/services/search/global_service.rb b/app/services/search/global_service.rb index b2ecafbf795b95..f1d41657db05f5 100644 --- a/app/services/search/global_service.rb +++ b/app/services/search/global_service.rb @@ -32,15 +32,27 @@ def projects def allowed_scopes Search::Scopes.available_for_context( context: :global, - search_type: Search::Scopes.current_search_type + search_type: allowed_scopes_search_type ) end + def search_type + 'basic' + end + def scope strong_memoize(:scope) do allowed_scopes.include?(params[:scope]) ? params[:scope] : DEFAULT_SCOPE end end + + private + + # Determines search type for allowed_scopes calculation + # Cannot use full search_type method as it may depend on scope (circular dependency) + def allowed_scopes_search_type + :basic + end end end diff --git a/app/services/search/project_service.rb b/app/services/search/project_service.rb index 17b0dc1e591318..ba6899ff588142 100644 --- a/app/services/search/project_service.rb +++ b/app/services/search/project_service.rb @@ -27,10 +27,14 @@ def execute def allowed_scopes Search::Scopes.available_for_context( context: :project, - search_type: Search::Scopes.current_search_type + search_type: allowed_scopes_search_type ) end + def search_type + 'basic' + end + def scope strong_memoize(:scope) do search_navigation = Search::Navigation.new(user: current_user, project: project) @@ -42,6 +46,14 @@ def scope end end end + + private + + # Determines search type for allowed_scopes calculation + # Cannot use full search_type method as it may depend on scope (circular dependency) + def allowed_scopes_search_type + :basic + end end end diff --git a/ee/app/services/ee/search/global_service.rb b/ee/app/services/ee/search/global_service.rb index d1e4ab8bdf2f0d..c616fefa7a590d 100644 --- a/ee/app/services/ee/search/global_service.rb +++ b/ee/app/services/ee/search/global_service.rb @@ -72,8 +72,35 @@ def zoekt_project_id; end def zoekt_group_id; end - # Note: allowed_scopes logic is now handled by Search::Scopes.available_for_context - # which considers the current search type (basic/advanced/zoekt) automatically + private + + override :allowed_scopes_search_type + def allowed_scopes_search_type + # For allowed_scopes calculation, we check elasticsearch/zoekt availability + # but don't check scope (which would create circular dependency) + return params[:search_type].to_sym if params[:search_type] + return :zoekt if zoekt_enabled_for_scopes? + return :advanced if elasticsearch_enabled_for_scopes? + + :basic + end + + def elasticsearch_enabled_for_scopes? + # Use search_using_elasticsearch? with nil scope to check global search availability + # This properly handles elasticsearch_search, elasticsearch_indexing, + # elasticsearch_limit_indexing, and global_search_limited_indexing_enabled settings + ::Gitlab::CurrentSettings.search_using_elasticsearch?(scope: nil) + rescue StandardError + false + end + + def zoekt_enabled_for_scopes? + # Check if zoekt is available for the user + # This uses the same logic as Search::Zoekt but without depending on scope + zoekt_searchable_scope? && ::Search::Zoekt.enabled_for_user?(current_user) + rescue StandardError + false + end end end end diff --git a/ee/app/services/ee/search/project_service.rb b/ee/app/services/ee/search/project_service.rb index 5952056dafd50f..94f489078b9db1 100644 --- a/ee/app/services/ee/search/project_service.rb +++ b/ee/app/services/ee/search/project_service.rb @@ -77,6 +77,18 @@ def zoekt_projects def zoekt_nodes @zoekt_nodes ||= ::Search::Zoekt::Node.searchable_for_project(zoekt_searchable_scope) end + + private + + override :allowed_scopes_search_type + def allowed_scopes_search_type + # For allowed_scopes calculation, we check elasticsearch availability + # but don't check scope or repository_ref (which would create circular dependency) + return params[:search_type].to_sym if params[:search_type] + return :advanced if use_elasticsearch? + + :basic + end end end end -- GitLab From 26ef706f44a621ae78c15a5bbec8872ee9f76381 Mon Sep 17 00:00:00 2001 From: Siddharth Dungarwal Date: Wed, 22 Oct 2025 23:20:35 +0530 Subject: [PATCH 04/11] Remove N+1 by memoisation --- app/services/search/global_service.rb | 10 ++++++---- app/services/search/project_service.rb | 10 ++++++---- ee/spec/lib/ee/search/scopes_spec.rb | 16 +++++++--------- 3 files changed, 19 insertions(+), 17 deletions(-) diff --git a/app/services/search/global_service.rb b/app/services/search/global_service.rb index f1d41657db05f5..2d280e9c3c7324 100644 --- a/app/services/search/global_service.rb +++ b/app/services/search/global_service.rb @@ -30,10 +30,12 @@ def projects end def allowed_scopes - Search::Scopes.available_for_context( - context: :global, - search_type: allowed_scopes_search_type - ) + strong_memoize(:allowed_scopes) do + Search::Scopes.available_for_context( + context: :global, + search_type: allowed_scopes_search_type + ) + end end def search_type diff --git a/app/services/search/project_service.rb b/app/services/search/project_service.rb index ba6899ff588142..3b7e600364687f 100644 --- a/app/services/search/project_service.rb +++ b/app/services/search/project_service.rb @@ -25,10 +25,12 @@ def execute end def allowed_scopes - Search::Scopes.available_for_context( - context: :project, - search_type: allowed_scopes_search_type - ) + strong_memoize(:allowed_scopes) do + Search::Scopes.available_for_context( + context: :project, + search_type: allowed_scopes_search_type + ) + end end def search_type diff --git a/ee/spec/lib/ee/search/scopes_spec.rb b/ee/spec/lib/ee/search/scopes_spec.rb index 315f6e050973d8..22c59285263cc1 100644 --- a/ee/spec/lib/ee/search/scopes_spec.rb +++ b/ee/spec/lib/ee/search/scopes_spec.rb @@ -82,8 +82,7 @@ describe '.default_scope_options' do context 'with advanced search enabled' do before do - stub_application_setting(elasticsearch_search: true, elasticsearch_indexing: false) - stub_feature_flags(zoekt_cross_namespace_search: false) + allow(described_class).to receive(:current_search_type).and_return(:advanced) end it 'includes EE scopes and advanced search scopes' do @@ -133,26 +132,25 @@ describe '.current_search_type' do it 'returns :basic by default' do - stub_application_setting(elasticsearch_search: false, elasticsearch_indexing: false) + allow(Gitlab::CurrentSettings).to receive_messages(elasticsearch_search?: false, elasticsearch_indexing?: false) stub_feature_flags(zoekt_cross_namespace_search: false) expect(described_class.current_search_type).to eq(:basic) end context 'with elasticsearch enabled' do - before do - stub_application_setting(elasticsearch_search: true, elasticsearch_indexing: false) + it 'returns :advanced' do stub_feature_flags(zoekt_cross_namespace_search: false) - end + settings = ::Gitlab::CurrentSettings.current_application_settings + allow(settings).to receive_messages(elasticsearch_search?: true, elasticsearch_indexing?: false) - it 'returns :advanced' do expect(described_class.current_search_type).to eq(:advanced) end end context 'with zoekt enabled' do before do - stub_application_setting(elasticsearch_search: false, elasticsearch_indexing: false) + allow(Gitlab::CurrentSettings).to receive_messages(elasticsearch_search?: false, elasticsearch_indexing?: false) stub_feature_flags(zoekt_cross_namespace_search: true) end @@ -163,7 +161,7 @@ context 'with both elasticsearch and zoekt enabled' do before do - stub_application_setting(elasticsearch_search: true, elasticsearch_indexing: false) + allow(Gitlab::CurrentSettings).to receive_messages(elasticsearch_search?: true, elasticsearch_indexing?: false) stub_feature_flags(zoekt_cross_namespace_search: true) end -- GitLab From 6d7028d17f7e5b1ab44a43b05188209f0e8660fa Mon Sep 17 00:00:00 2001 From: Siddharth Dungarwal Date: Thu, 23 Oct 2025 16:29:31 +0530 Subject: [PATCH 05/11] Fix failing specs --- ee/app/services/ee/search/group_service.rb | 31 ++++++++++++++++++++++ ee/spec/lib/ee/search/scopes_spec.rb | 3 +-- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/ee/app/services/ee/search/group_service.rb b/ee/app/services/ee/search/group_service.rb index f0dc6acdf6beaa..0a5a65494fbdca 100644 --- a/ee/app/services/ee/search/group_service.rb +++ b/ee/app/services/ee/search/group_service.rb @@ -65,6 +65,37 @@ def allowed_scopes end.uniq end strong_memoize_attr :allowed_scopes + + private + + override :allowed_scopes_search_type + def allowed_scopes_search_type + # For allowed_scopes calculation, we check elasticsearch/zoekt availability + # but don't check scope (which would create circular dependency) + return params[:search_type].to_sym if params[:search_type] + return :zoekt if zoekt_enabled_for_scopes? + return :advanced if elasticsearch_enabled_for_scopes_in_group? + + :basic + end + + def elasticsearch_enabled_for_scopes_in_group? + # Use search_using_elasticsearch? with group scope to check group search availability + # This properly handles elasticsearch_search, elasticsearch_indexing, + # elasticsearch_limit_indexing, and group-level settings + ::Gitlab::CurrentSettings.search_using_elasticsearch?(scope: group) + rescue StandardError + false + end + + def zoekt_enabled_for_scopes? + # Check if zoekt is available for the group + ::Search::Zoekt.enabled_for_user?(current_user) && + ::Search::Zoekt.search?(group) && + zoekt_node_available_for_search? + rescue StandardError + false + end end end end diff --git a/ee/spec/lib/ee/search/scopes_spec.rb b/ee/spec/lib/ee/search/scopes_spec.rb index 22c59285263cc1..17d7fbeda3c609 100644 --- a/ee/spec/lib/ee/search/scopes_spec.rb +++ b/ee/spec/lib/ee/search/scopes_spec.rb @@ -141,8 +141,7 @@ context 'with elasticsearch enabled' do it 'returns :advanced' do stub_feature_flags(zoekt_cross_namespace_search: false) - settings = ::Gitlab::CurrentSettings.current_application_settings - allow(settings).to receive_messages(elasticsearch_search?: true, elasticsearch_indexing?: false) + stub_ee_application_setting(elasticsearch_search: true, elasticsearch_indexing: false) expect(described_class.current_search_type).to eq(:advanced) end -- GitLab From 587f9b8c1d7aa0063e23654480627467b032c8b6 Mon Sep 17 00:00:00 2001 From: Siddharth Dungarwal Date: Thu, 23 Oct 2025 16:47:55 +0530 Subject: [PATCH 06/11] Fix failing specs --- ee/spec/lib/ee/search/scopes_spec.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ee/spec/lib/ee/search/scopes_spec.rb b/ee/spec/lib/ee/search/scopes_spec.rb index 17d7fbeda3c609..732c594287a8ea 100644 --- a/ee/spec/lib/ee/search/scopes_spec.rb +++ b/ee/spec/lib/ee/search/scopes_spec.rb @@ -132,7 +132,7 @@ describe '.current_search_type' do it 'returns :basic by default' do - allow(Gitlab::CurrentSettings).to receive_messages(elasticsearch_search?: false, elasticsearch_indexing?: false) + stub_ee_application_setting(elasticsearch_search: false, elasticsearch_indexing: false) stub_feature_flags(zoekt_cross_namespace_search: false) expect(described_class.current_search_type).to eq(:basic) @@ -141,7 +141,7 @@ context 'with elasticsearch enabled' do it 'returns :advanced' do stub_feature_flags(zoekt_cross_namespace_search: false) - stub_ee_application_setting(elasticsearch_search: true, elasticsearch_indexing: false) + stub_ee_application_setting(elasticsearch_search: true, elasticsearch_indexing: true) expect(described_class.current_search_type).to eq(:advanced) end @@ -149,7 +149,7 @@ context 'with zoekt enabled' do before do - allow(Gitlab::CurrentSettings).to receive_messages(elasticsearch_search?: false, elasticsearch_indexing?: false) + stub_ee_application_setting(elasticsearch_search: true, elasticsearch_indexing: true) stub_feature_flags(zoekt_cross_namespace_search: true) end -- GitLab From 821b7e0bfef096be85d89e7ac5d93d58d52a8403 Mon Sep 17 00:00:00 2001 From: Siddharth Dungarwal Date: Thu, 23 Oct 2025 23:05:40 +0530 Subject: [PATCH 07/11] Fix failing spec --- ee/app/services/ee/search/group_service.rb | 7 +------ ee/spec/lib/ee/search/scopes_spec.rb | 2 +- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/ee/app/services/ee/search/group_service.rb b/ee/app/services/ee/search/group_service.rb index 0a5a65494fbdca..a30a49a07c14fb 100644 --- a/ee/app/services/ee/search/group_service.rb +++ b/ee/app/services/ee/search/group_service.rb @@ -70,8 +70,7 @@ def allowed_scopes override :allowed_scopes_search_type def allowed_scopes_search_type - # For allowed_scopes calculation, we check elasticsearch/zoekt availability - # but don't check scope (which would create circular dependency) + # Avoid circular dependency by checking availability without depending on scope return params[:search_type].to_sym if params[:search_type] return :zoekt if zoekt_enabled_for_scopes? return :advanced if elasticsearch_enabled_for_scopes_in_group? @@ -80,16 +79,12 @@ def allowed_scopes_search_type end def elasticsearch_enabled_for_scopes_in_group? - # Use search_using_elasticsearch? with group scope to check group search availability - # This properly handles elasticsearch_search, elasticsearch_indexing, - # elasticsearch_limit_indexing, and group-level settings ::Gitlab::CurrentSettings.search_using_elasticsearch?(scope: group) rescue StandardError false end def zoekt_enabled_for_scopes? - # Check if zoekt is available for the group ::Search::Zoekt.enabled_for_user?(current_user) && ::Search::Zoekt.search?(group) && zoekt_node_available_for_search? diff --git a/ee/spec/lib/ee/search/scopes_spec.rb b/ee/spec/lib/ee/search/scopes_spec.rb index 732c594287a8ea..6f9c4409001a55 100644 --- a/ee/spec/lib/ee/search/scopes_spec.rb +++ b/ee/spec/lib/ee/search/scopes_spec.rb @@ -160,7 +160,7 @@ context 'with both elasticsearch and zoekt enabled' do before do - allow(Gitlab::CurrentSettings).to receive_messages(elasticsearch_search?: true, elasticsearch_indexing?: false) + stub_ee_application_setting(elasticsearch_search: true, elasticsearch_indexing: true) stub_feature_flags(zoekt_cross_namespace_search: true) end -- GitLab From fc5aa3da03b52f42abed5d3f55e6fc726c918f9e Mon Sep 17 00:00:00 2001 From: Siddharth Dungarwal Date: Thu, 23 Oct 2025 23:32:45 +0530 Subject: [PATCH 08/11] Address review comments --- app/services/search/global_service.rb | 2 -- app/services/search/project_service.rb | 2 -- ee/app/services/ee/search/global_service.rb | 32 ++------------------ ee/app/services/ee/search/group_service.rb | 19 ++---------- ee/app/services/ee/search/project_service.rb | 2 -- ee/lib/ee/search/scopes.rb | 11 ++----- lib/search/navigation.rb | 5 ++- lib/search/scopes.rb | 29 +++++++++--------- 8 files changed, 24 insertions(+), 78 deletions(-) diff --git a/app/services/search/global_service.rb b/app/services/search/global_service.rb index 2d280e9c3c7324..b7a2fa5c692296 100644 --- a/app/services/search/global_service.rb +++ b/app/services/search/global_service.rb @@ -50,8 +50,6 @@ def scope private - # Determines search type for allowed_scopes calculation - # Cannot use full search_type method as it may depend on scope (circular dependency) def allowed_scopes_search_type :basic end diff --git a/app/services/search/project_service.rb b/app/services/search/project_service.rb index 3b7e600364687f..d3d3a00d224012 100644 --- a/app/services/search/project_service.rb +++ b/app/services/search/project_service.rb @@ -51,8 +51,6 @@ def scope private - # Determines search type for allowed_scopes calculation - # Cannot use full search_type method as it may depend on scope (circular dependency) def allowed_scopes_search_type :basic end diff --git a/ee/app/services/ee/search/global_service.rb b/ee/app/services/ee/search/global_service.rb index c616fefa7a590d..433e6b1f3339a6 100644 --- a/ee/app/services/ee/search/global_service.rb +++ b/ee/app/services/ee/search/global_service.rb @@ -35,8 +35,6 @@ def root_ancestor; end override :zoekt_node_id def zoekt_node_id; end - # This method isn't compatible with multi-node search, so we override it - # to always return true. override :zoekt_node_available_for_search? def zoekt_node_available_for_search? true @@ -51,13 +49,6 @@ def elastic_global end def elastic_projects - # For elasticsearch we need the list of projects to be as small as - # possible since they are loaded from the DB and sent in the - # Elasticsearch query. It should only be strictly the project IDs the - # user has been given authorization for. The Elasticsearch query will - # additionally take care of public projects. This behaves differently - # to the searching Postgres case in which this list of projects is - # intended to be all projects that should appear in the results. if current_user&.can_read_all_resources? :any elsif current_user @@ -76,31 +67,12 @@ def zoekt_group_id; end override :allowed_scopes_search_type def allowed_scopes_search_type - # For allowed_scopes calculation, we check elasticsearch/zoekt availability - # but don't check scope (which would create circular dependency) return params[:search_type].to_sym if params[:search_type] - return :zoekt if zoekt_enabled_for_scopes? - return :advanced if elasticsearch_enabled_for_scopes? + return :zoekt if use_zoekt? + return :advanced if use_elasticsearch? :basic end - - def elasticsearch_enabled_for_scopes? - # Use search_using_elasticsearch? with nil scope to check global search availability - # This properly handles elasticsearch_search, elasticsearch_indexing, - # elasticsearch_limit_indexing, and global_search_limited_indexing_enabled settings - ::Gitlab::CurrentSettings.search_using_elasticsearch?(scope: nil) - rescue StandardError - false - end - - def zoekt_enabled_for_scopes? - # Check if zoekt is available for the user - # This uses the same logic as Search::Zoekt but without depending on scope - zoekt_searchable_scope? && ::Search::Zoekt.enabled_for_user?(current_user) - rescue StandardError - false - end end end end diff --git a/ee/app/services/ee/search/group_service.rb b/ee/app/services/ee/search/group_service.rb index a30a49a07c14fb..11f4df3b3c521a 100644 --- a/ee/app/services/ee/search/group_service.rb +++ b/ee/app/services/ee/search/group_service.rb @@ -70,27 +70,12 @@ def allowed_scopes override :allowed_scopes_search_type def allowed_scopes_search_type - # Avoid circular dependency by checking availability without depending on scope return params[:search_type].to_sym if params[:search_type] - return :zoekt if zoekt_enabled_for_scopes? - return :advanced if elasticsearch_enabled_for_scopes_in_group? + return :zoekt if use_zoekt? + return :advanced if use_elasticsearch? :basic end - - def elasticsearch_enabled_for_scopes_in_group? - ::Gitlab::CurrentSettings.search_using_elasticsearch?(scope: group) - rescue StandardError - false - end - - def zoekt_enabled_for_scopes? - ::Search::Zoekt.enabled_for_user?(current_user) && - ::Search::Zoekt.search?(group) && - zoekt_node_available_for_search? - rescue StandardError - false - end end end end diff --git a/ee/app/services/ee/search/project_service.rb b/ee/app/services/ee/search/project_service.rb index 94f489078b9db1..fe704f00083508 100644 --- a/ee/app/services/ee/search/project_service.rb +++ b/ee/app/services/ee/search/project_service.rb @@ -82,8 +82,6 @@ def zoekt_nodes override :allowed_scopes_search_type def allowed_scopes_search_type - # For allowed_scopes calculation, we check elasticsearch availability - # but don't check scope or repository_ref (which would create circular dependency) return params[:search_type].to_sym if params[:search_type] return :advanced if use_elasticsearch? diff --git a/ee/lib/ee/search/scopes.rb b/ee/lib/ee/search/scopes.rb index b654d4f1cfcba3..c1c38bd55f3faa 100644 --- a/ee/lib/ee/search/scopes.rb +++ b/ee/lib/ee/search/scopes.rb @@ -4,16 +4,14 @@ module EE module Search module Scopes # EE scope definitions - # rubocop:disable Cop/StaticTranslationDefinition -- labels are defined in constant for registry EE_SCOPE_DEFINITIONS = { epics: { - label: _('Epics'), + label: -> { _('Epics') }, sort: 3, contexts: [:group, :global], search_types: [:advanced] } }.freeze - # rubocop:enable Cop/StaticTranslationDefinition # EE-specific global search settings EE_GLOBAL_SEARCH_SETTING_MAP = { @@ -57,18 +55,13 @@ def global_search_setting_map end def elasticsearch_enabled? - Gitlab::CurrentSettings.elasticsearch_search? || - Gitlab::CurrentSettings.elasticsearch_indexing? - rescue StandardError - false + ::Gitlab::CurrentSettings.search_using_elasticsearch?(scope: nil) end def zoekt_enabled? # rubocop:disable Gitlab/FeatureFlagWithoutActor -- ops-type flag checked globally ::Feature.enabled?(:zoekt_cross_namespace_search, type: :ops) # rubocop:enable Gitlab/FeatureFlagWithoutActor - rescue StandardError - false end end end diff --git a/lib/search/navigation.rb b/lib/search/navigation.rb index e9b071455adc60..445ed36a60ffdd 100644 --- a/lib/search/navigation.rb +++ b/lib/search/navigation.rb @@ -23,9 +23,12 @@ def tabs # This automatically includes EE scopes when running in EE nav = {} Search::Scopes.scope_definitions.each do |scope_key, definition| + label = definition[:label] + label = label.call if label.respond_to?(:call) + nav[scope_key] = { sort: definition[:sort], - label: definition[:label], + label: label, data: scope_key == :blobs ? { testid: 'code-tab' } : { testid: "#{scope_key.to_s.tr('_', '-')}-tab" }, condition: scope_visibility_condition(scope_key) } diff --git a/lib/search/scopes.rb b/lib/search/scopes.rb index d3a1f4a1a417b9..35e4f1b6d56231 100644 --- a/lib/search/scopes.rb +++ b/lib/search/scopes.rb @@ -8,16 +8,15 @@ class Scopes # Scope definitions with metadata (CE scopes only) # Format: { scope_key => { label:, sort:, contexts:, search_types: } } # EE scopes are defined in ee/lib/ee/search/scopes.rb - # rubocop:disable Cop/StaticTranslationDefinition -- labels are defined in constant for registry SCOPE_DEFINITIONS = { projects: { - label: _('Projects'), + label: -> { _('Projects') }, sort: 1, contexts: [:global, :group], search_types: [:basic, :advanced, :zoekt] }, blobs: { - label: _('Code'), + label: -> { _('Code') }, sort: 2, contexts: [:project, :group, :global], search_types: [:basic, :advanced, :zoekt], @@ -26,58 +25,57 @@ class Scopes }, # sort: 3 is reserved for EE scopes (epics) issues: { - label: _('Issues'), + label: -> { _('Issues') }, sort: 4, contexts: [:project, :group, :global], search_types: [:basic, :advanced, :zoekt] }, merge_requests: { - label: _('Merge requests'), + label: -> { _('Merge requests') }, sort: 5, contexts: [:project, :group, :global], search_types: [:basic, :advanced, :zoekt] }, wiki_blobs: { - label: _('Wiki'), + label: -> { _('Wiki') }, sort: 6, contexts: [:project, :group, :global], search_types: [:basic, :advanced], requires_advanced_search_for: [:global, :group] }, commits: { - label: _('Commits'), + label: -> { _('Commits') }, sort: 7, contexts: [:project, :group, :global], search_types: [:basic, :advanced], requires_advanced_search_for: [:global, :group] }, notes: { - label: _('Comments'), + label: -> { _('Comments') }, sort: 8, contexts: [:project, :group, :global], search_types: [:basic, :advanced], requires_advanced_search_for: [:global, :group] }, milestones: { - label: _('Milestones'), + label: -> { _('Milestones') }, sort: 9, contexts: [:project, :group, :global], search_types: [:basic, :advanced, :zoekt] }, users: { - label: _('Users'), + label: -> { _('Users') }, sort: 10, contexts: [:project, :group, :global], search_types: [:basic, :advanced, :zoekt] }, snippet_titles: { - label: _('Snippets'), + label: -> { _('Snippets') }, sort: 11, contexts: [:global, :group], search_types: [:basic, :advanced] } }.freeze - # rubocop:enable Cop/StaticTranslationDefinition # Map of scopes to their required application setting for global search (CE scopes) # EE scopes are added in ee/lib/ee/search/scopes.rb @@ -147,16 +145,17 @@ def scope_enabled_for_global_search?(scope, settings, context) return true if context == :project setting_method = global_search_setting_map[scope.to_s] - return true unless setting_method + return false if setting_method && !settings.public_send(setting_method) # rubocop:disable GitlabSecurity/PublicSend -- setting_method is validated from GLOBAL_SEARCH_SETTING_MAP - settings.public_send(setting_method) # rubocop:disable GitlabSecurity/PublicSend -- setting_method is validated from GLOBAL_SEARCH_SETTING_MAP + true end # Get the label for a scope # @param scope [String] Scope name # @return [String, nil] Human-readable label def label_for(scope) - scope_definitions[scope.to_sym]&.dig(:label) + label = scope_definitions[scope.to_sym]&.dig(:label) + label.respond_to?(:call) ? label.call : label end # Get scopes suitable for default scope dropdown in admin UI -- GitLab From c692d5424e01e4ef2cf0f13d89de99a671a51a05 Mon Sep 17 00:00:00 2001 From: Siddharth Dungarwal Date: Thu, 23 Oct 2025 23:44:54 +0530 Subject: [PATCH 09/11] Add old comments back --- ee/app/services/ee/search/global_service.rb | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/ee/app/services/ee/search/global_service.rb b/ee/app/services/ee/search/global_service.rb index 433e6b1f3339a6..ab0ce9d8a61e7e 100644 --- a/ee/app/services/ee/search/global_service.rb +++ b/ee/app/services/ee/search/global_service.rb @@ -35,6 +35,8 @@ def root_ancestor; end override :zoekt_node_id def zoekt_node_id; end + # This method isn't compatible with multi-node search, so we override it + # to always return true. override :zoekt_node_available_for_search? def zoekt_node_available_for_search? true @@ -49,6 +51,13 @@ def elastic_global end def elastic_projects + # For elasticsearch we need the list of projects to be as small as + # possible since they are loaded from the DB and sent in the + # Elasticsearch query. It should only be strictly the project IDs the + # user has been given authorization for. The Elasticsearch query will + # additionally take care of public projects. This behaves differently + # to the searching Postgres case in which this list of projects is + # intended to be all projects that should appear in the results. if current_user&.can_read_all_resources? :any elsif current_user -- GitLab From 91755c5dc8f948027a1150d479831a3c67904412 Mon Sep 17 00:00:00 2001 From: Siddharth Dungarwal Date: Thu, 23 Oct 2025 23:54:35 +0530 Subject: [PATCH 10/11] Apply 1 suggestion(s) to 1 file(s) Co-authored-by: GitLab Duo --- lib/search/navigation.rb | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/search/navigation.rb b/lib/search/navigation.rb index 445ed36a60ffdd..8315ef2a9e2b2e 100644 --- a/lib/search/navigation.rb +++ b/lib/search/navigation.rb @@ -29,10 +29,10 @@ def tabs nav[scope_key] = { sort: definition[:sort], label: label, - data: scope_key == :blobs ? { testid: 'code-tab' } : { testid: "#{scope_key.to_s.tr('_', '-')}-tab" }, - condition: scope_visibility_condition(scope_key) - } - + nav[scope_key] = { + sort: definition[:sort], + label: label, + data: { testid: scope_key == :blobs ? 'code-tab' : "#{scope_key.to_s.tr('_', '-')}-tab" }, # Add special search params for snippet_titles nav[scope_key][:search] = { snippets: true, group_id: nil, project_id: nil } if scope_key == :snippet_titles end -- GitLab From 416ea4c97804d6cd14f4a57c0718ab85bdee5279 Mon Sep 17 00:00:00 2001 From: Siddharth Dungarwal Date: Fri, 24 Oct 2025 00:30:38 +0530 Subject: [PATCH 11/11] Fix syntax error in suggestion by duo --- lib/search/navigation.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/search/navigation.rb b/lib/search/navigation.rb index 8315ef2a9e2b2e..6f97cefd4e095c 100644 --- a/lib/search/navigation.rb +++ b/lib/search/navigation.rb @@ -26,13 +26,13 @@ def tabs label = definition[:label] label = label.call if label.respond_to?(:call) - nav[scope_key] = { - sort: definition[:sort], - label: label, nav[scope_key] = { sort: definition[:sort], label: label, data: { testid: scope_key == :blobs ? 'code-tab' : "#{scope_key.to_s.tr('_', '-')}-tab" }, + condition: scope_visibility_condition(scope_key) + } + # Add special search params for snippet_titles nav[scope_key][:search] = { snippets: true, group_id: nil, project_id: nil } if scope_key == :snippet_titles end -- GitLab