diff --git a/ee/app/controllers/ee/search_controller.rb b/ee/app/controllers/ee/search_controller.rb index 52b8b043fbd2e46c986ab4ef6a1ec49d227061be..1109b56ee06aab0bf36c8b44cf819527d7e81b2f 100644 --- a/ee/app/controllers/ee/search_controller.rb +++ b/ee/app/controllers/ee/search_controller.rb @@ -101,6 +101,8 @@ def payload_metadata super.merge( 'meta.search.filters.source_branch' => filter_params[:source_branch], 'meta.search.filters.not_source_branch' => filter_params.dig(:not, :source_branch), + 'meta.search.filters.target_branch' => filter_params[:target_branch], + 'meta.search.filters.not_target_branch' => filter_params.dig(:not, :target_branch), 'meta.search.filters.author_username' => filter_params[:author_username], 'meta.search.filters.not_author_username' => filter_params.dig(:not, :author_username)) end @@ -125,7 +127,11 @@ def run_index_integrity_worker override :filter_params def filter_params - super.merge(params.permit(:source_branch, :author_username, not: [:source_branch, :author_username])) + permitted_filter_params = [:source_branch, :target_branch, :author_username] + super.merge(params.permit( + *permitted_filter_params, + not: permitted_filter_params + )) end end end diff --git a/ee/app/services/concerns/ee/search/filter.rb b/ee/app/services/concerns/ee/search/filter.rb index 8f11da561019f6ecd24838d1d27080e0f0d6362b..de8997f5dce24fd212168e4f5a5f42b7b37a8eb6 100644 --- a/ee/app/services/concerns/ee/search/filter.rb +++ b/ee/app/services/concerns/ee/search/filter.rb @@ -15,6 +15,8 @@ def filters label_name: params[:label_name], source_branch: params[:source_branch], not_source_branch: params[:not_source_branch], + target_branch: params[:target_branch], + not_target_branch: params[:not_target_branch], author_username: params[:author_username], not_author_username: params[:not_author_username] ) diff --git a/ee/config/feature_flags/gitlab_com_derisk/search_mr_filter_target_branch.yml b/ee/config/feature_flags/gitlab_com_derisk/search_mr_filter_target_branch.yml new file mode 100644 index 0000000000000000000000000000000000000000..0b12c480fe23fd8adcadd27ae83535a05e778ba4 --- /dev/null +++ b/ee/config/feature_flags/gitlab_com_derisk/search_mr_filter_target_branch.yml @@ -0,0 +1,9 @@ +--- +name: search_mr_filter_target_branch +feature_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/463375 +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/160210 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/473699 +milestone: '17.3' +group: group::global search +type: gitlab_com_derisk +default_enabled: false diff --git a/ee/lib/gitlab/elastic/search_results.rb b/ee/lib/gitlab/elastic/search_results.rb index 605da5263984e1274f76d5d8be7e20a4cf543684..8c56879b9bfb756ecc29855861f1ca469f9b06f8 100644 --- a/ee/lib/gitlab/elastic/search_results.rb +++ b/ee/lib/gitlab/elastic/search_results.rb @@ -326,9 +326,7 @@ def scope_options(scope) when :projects, :notes, :commits base_options.merge(filters.slice(:include_archived)) when :merge_requests - base_options.merge( - filters.slice(:order_by, :sort, :state, :include_archived, :source_branch, :not_source_branch, - :author_username, :not_author_username, :label_name)) + base_options.merge(merge_request_scope_options) when :issues base_options.merge( filters.slice(:order_by, :sort, :confidential, :state, :labels, :label_name, :include_archived), @@ -488,6 +486,22 @@ def issue_aggregations end strong_memoize_attr :issue_aggregations + def merge_request_scope_options + filters.slice( + :order_by, + :sort, + :state, + :include_archived, + :source_branch, + :not_source_branch, + :target_branch, + :not_target_branch, + :author_username, + :not_author_username, + :label_name + ) + end + def allowed_to_read_users? Ability.allowed?(current_user, :read_users_list) end diff --git a/ee/lib/search/elastic/filters.rb b/ee/lib/search/elastic/filters.rb index 1206fbae736fc2c76bf9175bf5f20b62d23094e7..40b9fc5adcd3908d20c225c39d9e5f6beb705bc3 100644 --- a/ee/lib/search/elastic/filters.rb +++ b/ee/lib/search/elastic/filters.rb @@ -34,6 +34,34 @@ def by_source_branch(query_hash:, options:) end end + def by_target_branch(query_hash:, options:) + target_branch = options[:target_branch] + not_target_branch = options[:not_target_branch] + + return query_hash unless target_branch || not_target_branch + + context.name(:filters) do + should = [] + if target_branch + should << { term: { target_branch: { _name: context.name(:target_branch), value: target_branch } } } + end + + if not_target_branch + should << { + bool: { + must_not: { + term: { target_branch: { _name: context.name(:not_target_branch), value: not_target_branch } } + } + } + } + end + + add_filter(query_hash, :query, :bool, :filter) do + { bool: { should: should, minimum_should_match: 1 } } + end + end + end + def by_author(query_hash:, options:) author_username = options[:author_username] not_author_username = options[:not_author_username] diff --git a/ee/lib/search/elastic/merge_request_query_builder.rb b/ee/lib/search/elastic/merge_request_query_builder.rb index 7eeb36ae221868d880832f6284eee60f1ebfc8fa..450d3a11108caf4b662ea9df85109b05f506b08a 100644 --- a/ee/lib/search/elastic/merge_request_query_builder.rb +++ b/ee/lib/search/elastic/merge_request_query_builder.rb @@ -8,22 +8,7 @@ class MergeRequestQueryBuilder < QueryBuilder DOC_TYPE = 'merge_request' def build - query_hash = - if query =~ /!(\d+)\z/ - ::Search::Elastic::Queries.by_iid(iid: Regexp.last_match(1), doc_type: DOC_TYPE) - else - # iid field can be added here as lenient option will - # pardon format errors, like integer out of range. - fields = %w[iid^3 title^2 description] - - if !::Search::Elastic::Queries::ADVANCED_QUERY_SYNTAX_REGEX.match?(query) && - Feature.enabled?(:search_uses_match_queries, options[:current_user]) - ::Search::Elastic::Queries.by_multi_match_query(fields: fields, query: query, options: options) - else - ::Search::Elastic::Queries.by_simple_query_string(fields: fields, query: query, options: options) - end - end - + query_hash = build_query_hash(query: query, options: options) query_hash = ::Search::Elastic::Filters.by_authorization(query_hash: query_hash, options: options) query_hash = ::Search::Elastic::Filters.by_state(query_hash: query_hash, options: options) query_hash = ::Search::Elastic::Filters.by_archived(query_hash: query_hash, options: options) @@ -32,6 +17,10 @@ def build query_hash = ::Search::Elastic::Filters.by_source_branch(query_hash: query_hash, options: options) end + if Feature.enabled?(:search_mr_filter_target_branch, options[:current_user]) + query_hash = ::Search::Elastic::Filters.by_target_branch(query_hash: query_hash, options: options) + end + if Feature.enabled?(:search_mr_filter_author, options[:current_user]) query_hash = ::Search::Elastic::Filters.by_author(query_hash: query_hash, options: options) end @@ -62,6 +51,23 @@ def extra_options authorization_use_traversal_ids: false # https://gitlab.com/gitlab-org/gitlab/-/issues/351279 } end + + def build_query_hash(query:, options:) + if query =~ /!(\d+)\z/ + ::Search::Elastic::Queries.by_iid(iid: Regexp.last_match(1), doc_type: DOC_TYPE) + else + # iid field can be added here as lenient option will + # pardon format errors, like integer out of range. + fields = %w[iid^3 title^2 description] + + if !::Search::Elastic::Queries::ADVANCED_QUERY_SYNTAX_REGEX.match?(query) && + Feature.enabled?(:search_uses_match_queries, options[:current_user]) + ::Search::Elastic::Queries.by_multi_match_query(fields: fields, query: query, options: options) + else + ::Search::Elastic::Queries.by_simple_query_string(fields: fields, query: query, options: options) + end + end + end end end end diff --git a/ee/spec/controllers/ee/search_controller_spec.rb b/ee/spec/controllers/ee/search_controller_spec.rb index 575429516c98f52765c2bb7ad959fe8983fc22ba..414789c2b8514ebf701a8356ee7629666ed9d2d4 100644 --- a/ee/spec/controllers/ee/search_controller_spec.rb +++ b/ee/spec/controllers/ee/search_controller_spec.rb @@ -300,6 +300,8 @@ def request expect(payload[:metadata]['meta.search.filters.source_branch']).to eq('included-branch') expect(payload[:metadata]['meta.search.filters.not_source_branch']).to eq('excluded-branch') + expect(payload[:metadata]['meta.search.filters.target_branch']).to eq('included-branch') + expect(payload[:metadata]['meta.search.filters.not_target_branch']).to eq('excluded-branch') expect(payload[:metadata]['meta.search.filters.author_username']).to eq('included-username') expect(payload[:metadata]['meta.search.filters.not_author_username']).to eq('excluded-username') end @@ -308,9 +310,11 @@ def request scope: 'issues', search: 'hello world', source_branch: 'included-branch', + target_branch: 'included-branch', author_username: 'included-username', not: { source_branch: 'excluded-branch', + target_branch: 'excluded-branch', author_username: 'excluded-username' } } diff --git a/ee/spec/lib/search/elastic/filters_spec.rb b/ee/spec/lib/search/elastic/filters_spec.rb index 3c50fb72d6c32ef74152ae11c97dd9ae6e0ed2a3..734f88cb59228cd94b95cc3cf3b8532a066223ef 100644 --- a/ee/spec/lib/search/elastic/filters_spec.rb +++ b/ee/spec/lib/search/elastic/filters_spec.rb @@ -12,7 +12,7 @@ end end - describe '#by_source_branch' do + describe '.by_source_branch' do subject(:by_source_branch) { described_class.by_source_branch(query_hash: query_hash, options: options) } context 'when options[:source_branch] and options[:not_source_branch] are empty' do @@ -83,7 +83,78 @@ end end - describe '#by_author' do + describe '.by_target_branch' do + subject(:by_target_branch) { described_class.by_target_branch(query_hash: query_hash, options: options) } + + context 'when options[:target_branch] and options[:not_target_branch] are empty' do + let(:options) { {} } + + it_behaves_like 'does not modify the query_hash' + end + + context 'when options[:target_branch] and options[:not_target_branch] are both provided' do + let(:options) { { target_branch: 'branch-1', not_target_branch: 'branch-2' } } + + it 'adds the target branch filter to query_hash' do + expected_filter = [ + { bool: + { should: [{ term: { target_branch: { _name: 'filters:target_branch', value: 'branch-1' } } }, + { bool: { + must_not: { + term: { target_branch: { _name: 'filters:not_target_branch', value: 'branch-2' } } + } + } }], + minimum_should_match: 1 } } + ] + + expect(by_target_branch.dig(:query, :bool, :filter)).to eq(expected_filter) + expect(by_target_branch.dig(:query, :bool, :must)).to be_empty + expect(by_target_branch.dig(:query, :bool, :must_not)).to be_empty + expect(by_target_branch.dig(:query, :bool, :should)).to be_empty + end + end + + context 'when options[:target_branch] is provided' do + let(:options) { { target_branch: 'foo-bar-branch' } } + + it 'adds the target branch filter to query_hash' do + expected_filter = [ + { bool: + { should: [{ term: { target_branch: { _name: 'filters:target_branch', value: 'foo-bar-branch' } } }], + minimum_should_match: 1 } } + ] + + expect(by_target_branch.dig(:query, :bool, :filter)).to eq(expected_filter) + expect(by_target_branch.dig(:query, :bool, :must)).to be_empty + expect(by_target_branch.dig(:query, :bool, :must_not)).to be_empty + expect(by_target_branch.dig(:query, :bool, :should)).to be_empty + end + end + + context 'when options[:not_target_branch] is provided' do + let(:options) { { not_target_branch: 'hello-branch' } } + + it 'adds the target branch filter to query_hash' do + expected_filter = [ + { bool: + { should: + [{ bool: { + must_not: { + term: { target_branch: { _name: 'filters:not_target_branch', value: 'hello-branch' } } + } + } }], + minimum_should_match: 1 } } + ] + + expect(by_target_branch.dig(:query, :bool, :filter)).to eq(expected_filter) + expect(by_target_branch.dig(:query, :bool, :must)).to be_empty + expect(by_target_branch.dig(:query, :bool, :must_not)).to be_empty + expect(by_target_branch.dig(:query, :bool, :should)).to be_empty + end + end + end + + describe '.by_author' do let_it_be(:included_user) { create(:user) } let_it_be(:excluded_user) { create(:user) } @@ -157,7 +228,7 @@ end end - describe '#by_not_hidden' do + describe '.by_not_hidden' do subject(:by_not_hidden) { described_class.by_not_hidden(query_hash: query_hash, options: options) } context 'when options[:current_user] is empty' do @@ -197,7 +268,7 @@ end end - describe '#by_state' do + describe '.by_state' do subject(:by_state) { described_class.by_state(query_hash: query_hash, options: options) } context 'when options[:state] is empty' do @@ -232,7 +303,7 @@ end end - describe '#by_archived' do + describe '.by_archived' do subject(:by_archived) { described_class.by_archived(query_hash: query_hash, options: options) } context 'when options[:include_archived] is empty or false' do @@ -285,7 +356,7 @@ end end - describe '#by_label_ids' do + describe '.by_label_ids' do let_it_be(:label_title) { 'My label' } let_it_be(:group) { create(:group) } let_it_be(:project) { create(:project, group: group) } @@ -506,7 +577,7 @@ end end - describe '#by_confidentiality' do + describe '.by_confidentiality' do let_it_be(:authorized_project) { create(:project, developers: [user]) } let_it_be(:private_project) { create(:project, :private) } @@ -652,7 +723,7 @@ end end - describe '#by_authorization' do + describe '.by_authorization' do let_it_be(:public_group) { create(:group, :public) } let_it_be(:authorized_project) { create(:project, group: public_group, developers: [user]) } let_it_be(:private_project) { create(:project, :private, group: public_group) } diff --git a/ee/spec/lib/search/elastic/merge_request_query_builder_spec.rb b/ee/spec/lib/search/elastic/merge_request_query_builder_spec.rb index c03da822b41e351a793aae9e44a76016406168b8..67d2982f3af608da00556273f9bf1e10776d7df1 100644 --- a/ee/spec/lib/search/elastic/merge_request_query_builder_spec.rb +++ b/ee/spec/lib/search/elastic/merge_request_query_builder_spec.rb @@ -87,46 +87,80 @@ end describe 'source_branch' do + it 'does not apply filters by default' do + assert_names_in_query(build, without: %w[filters:source_branch filters:not_source_branch]) + end + + context 'when source_branch option is provided' do + let(:options) { base_options.merge(source_branch: 'hello') } + + it 'applies the filter' do + assert_names_in_query(build, with: %w[filters:source_branch]) + end + end + + context 'when not_source_branch option is provided' do + let(:options) { base_options.merge(not_source_branch: 'world') } + + it 'applies the filter' do + assert_names_in_query(build, with: %w[filters:not_source_branch]) + end + end + context 'when search_mr_filter_source_branch flag is false' do before do stub_feature_flags(search_mr_filter_source_branch: false) end - it 'does not apply filters by default' do + it 'does not apply filters' do assert_names_in_query(build, without: %w[filters:source_branch filters:not_source_branch]) end context 'when source_branch options are provided' do let(:options) { base_options.merge(source_branch: 'hello', not_source_branch: 'world') } - it 'does not apply filters by default' do + it 'does not apply filters' do assert_names_in_query(build, without: %w[filters:source_branch filters:not_source_branch]) end end end + end - context 'when search_mr_filter_source_branch flag is true' do - before do - stub_feature_flags(search_mr_filter_source_branch: true) + describe 'target_branch' do + it 'does not apply filters by default' do + assert_names_in_query(build, without: %w[filters:target_branch filters:not_target_branch]) + end + + context 'when target_branch option is provided' do + let(:options) { base_options.merge(target_branch: 'hello') } + + it 'applies the filter' do + assert_names_in_query(build, with: %w[filters:target_branch]) end + end - it 'does not apply filters by default' do - assert_names_in_query(build, without: %w[filters:source_branch filters:not_source_branch]) + context 'when not_target_branch option is provided' do + let(:options) { base_options.merge(not_target_branch: 'world') } + + it 'applies the filter' do + assert_names_in_query(build, with: %w[filters:not_target_branch]) end + end - context 'when source_branch option is provided' do - let(:options) { base_options.merge(source_branch: 'hello') } + context 'when search_mr_filter_target_branch flag is false' do + before do + stub_feature_flags(search_mr_filter_target_branch: false) + end - it 'does not apply filters by default' do - assert_names_in_query(build, with: %w[filters:source_branch]) - end + it 'does not apply filters' do + assert_names_in_query(build, without: %w[filters:target_branch filters:not_target_branch]) end - context 'when not_source_branch option is provided' do - let(:options) { base_options.merge(not_source_branch: 'world') } + context 'when target_branch options are provided' do + let(:options) { base_options.merge(target_branch: 'hello', not_target_branch: 'world') } - it 'does not apply filters by default' do - assert_names_in_query(build, with: %w[filters:not_source_branch]) + it 'does not apply filters' do + assert_names_in_query(build, without: %w[filters:target_branch filters:not_target_branch]) end end end @@ -135,48 +169,42 @@ describe 'author' do let_it_be(:user) { create(:user) } - context 'when search_mr_filter_author flag is false' do - before do - stub_feature_flags(search_mr_filter_author: false) - end + it 'does not apply filters by default' do + assert_names_in_query(build, without: %w[filters:author filters:not_author]) + end - it 'does not apply filters by default' do - assert_names_in_query(build, without: %w[filters:author filters:not_author]) + context 'when author_username option is provided' do + let(:options) { base_options.merge(author_username: user.username) } + + it 'applies the filter' do + assert_names_in_query(build, with: %w[filters:author]) end + end - context 'when author_username options are provided' do - let(:options) do - base_options.merge(author_username: user.username, not_author_username: user.username) - end + context 'when not_author_username option is provided' do + let(:options) { base_options.merge(not_author_username: user.username) } - it 'does not apply filters by default' do - assert_names_in_query(build, without: %w[filters:author filters:not_author]) - end + it 'applies the filter' do + assert_names_in_query(build, with: %w[filters:not_author]) end end - context 'when search_mr_filter_author flag is true' do + context 'when search_mr_filter_author flag is false' do before do - stub_feature_flags(search_mr_filter_author: true) + stub_feature_flags(search_mr_filter_author: false) end - it 'does not apply filters by default' do + it 'does not apply filters' do assert_names_in_query(build, without: %w[filters:author filters:not_author]) end - context 'when author_username option is provided' do - let(:options) { base_options.merge(author_username: user.username) } - - it 'does not apply filters by default' do - assert_names_in_query(build, with: %w[filters:author]) + context 'when author_username options are provided' do + let(:options) do + base_options.merge(author_username: user.username, not_author_username: user.username) end - end - context 'when not_author_username option is provided' do - let(:options) { base_options.merge(not_author_username: user.username) } - - it 'does not apply filters by default' do - assert_names_in_query(build, with: %w[filters:not_author]) + it 'does not apply filters' do + assert_names_in_query(build, without: %w[filters:author filters:not_author]) end end end