From ad201dc5be39d1a648b33fc91cdbc45f3a6207a8 Mon Sep 17 00:00:00 2001 From: Alisa Frunza Date: Tue, 5 Aug 2025 17:26:20 +0200 Subject: [PATCH 01/15] Allow GLQL searching WorkItems index by various dates combinations Changelog: changed --- .../ee/work_items/glql/work_items_finder.rb | 41 +++- ee/lib/search/elastic/filters.rb | 180 ++++++++++++++++++ .../search/elastic/work_item_query_builder.rb | 5 + 3 files changed, 217 insertions(+), 9 deletions(-) diff --git a/ee/app/finders/ee/work_items/glql/work_items_finder.rb b/ee/app/finders/ee/work_items/glql/work_items_finder.rb index 9c4013317c83fd..b9144d4e0645ad 100644 --- a/ee/app/finders/ee/work_items/glql/work_items_finder.rb +++ b/ee/app/finders/ee/work_items/glql/work_items_finder.rb @@ -8,13 +8,13 @@ # context - GraphQL context object (an instance of GraphQL::Query::Context) that holds per-request metadata, # such as the HTTP request, current user, etc. # params: -# state - String with possible values of 'opened', 'closed', or 'all' -# group_id - ActiveRecord Group instance -# project_id - ActiveRecord Project instance -# label_name - Array of strings, can also accept wildcard values of "NONE" or "ANY" -# sort - Symbol with possible values of :created_desc or :created_asc -# confidential - Boolean -# author_username - String +# state - String with possible values of 'opened', 'closed', or 'all' +# group_id - ActiveRecord Group instance +# project_id - ActiveRecord Project instance +# label_name - Array of strings, can also accept wildcard values of "NONE" or "ANY" +# sort - Symbol with possible values of :created_desc or :created_asc +# confidential - Boolean +# author_username - String # milestone_title: - Array of strings (cannot be simultaneously used with milestone_wildcard_id) # milestone_wildcard_id: - String with possible values of 'none', 'any', 'upcoming', 'started' # (cannot be simultaneously used with milestone_title) @@ -25,6 +25,14 @@ # issue_types: - Array of strings (one of WorkItems::Type.base_types) # health_status_filter: - String with possible values of 'on_track', 'needs_attention' and 'at_risk', # can also accept wildcard values of "NONE" or "ANY" +# due_after: - Time object +# due_before: - Time object +# created_after: - Time object +# created_before: - Time object +# updated_after: - Time object +# updated_before: - Time object +# closed_after: - Time object +# closed_before: - Time object # not - Hash with keys that can be negated # or - Hash with keys that can be combined using OR logic # @@ -39,7 +47,8 @@ module WorkItemsFinder ALLOWED_ES_FILTERS = [ :label_name, :group_id, :project_id, :state, :confidential, :author_username, :milestone_title, :milestone_wildcard_id, :assignee_usernames, :assignee_wildcard_id, :not, :or, - :weight, :weight_wildcard_id, :issue_types, :health_status_filter + :weight, :weight_wildcard_id, :issue_types, :health_status_filter, :due_after, :due_before, + :created_after, :created_before, :updated_after, :updated_before, :closed_after, :closed_before ].freeze NOT_FILTERS = [:author_username, :milestone_title, :assignee_usernames, :label_name, :weight, :weight_wildcard_id, :health_status_filter, :milestone_wildcard_id].freeze @@ -113,7 +122,8 @@ def base_params milestone_params, assignee_params, weight_params, - health_status_params + health_status_params, + dates_params ) end @@ -180,6 +190,19 @@ def health_status_params } end + def dates_params + { + due_after: params[:due_after]&.to_i, + due_before: params[:due_before]&.to_i, + created_after: params[:created_after]&.to_i, + created_before: params[:created_before]&.to_i, + updated_after: params[:updated_after]&.to_i, + updated_before: params[:updated_before]&.to_i, + closed_after: params[:closed_after]&.to_i, + closed_before: params[:closed_before]&.to_i + } + end + def scope_param if params[:project_id].present? { project_id: params[:project_id]&.id } diff --git a/ee/lib/search/elastic/filters.rb b/ee/lib/search/elastic/filters.rb index 89453e18bfe7b5..9637d35ca3e312 100644 --- a/ee/lib/search/elastic/filters.rb +++ b/ee/lib/search/elastic/filters.rb @@ -123,6 +123,186 @@ def by_author(query_hash:, options:) end end + def by_closed_at(query_hash:, options:) + closed_after = options[:closed_after] + closed_before = options[:closed_before] + + return query_hash unless closed_after || closed_before + + context.name(:filters) do + if closed_after + add_filter(query_hash, :query, :bool, :filter) do + { + bool: { + _name: context.name(:closed_after), + must: { + range: { + 'closed_at' => { + gte: closed_after + } + } + } + } + } + end + end + + if closed_before + add_filter(query_hash, :query, :bool, :filter) do + { + bool: { + _name: context.name(:closed_before), + must: { + range: { + 'closed_at' => { + lte: closed_before + } + } + } + } + } + end + end + end + + query_hash + end + + def by_created_at(query_hash:, options:) + created_after = options[:created_after] + created_before = options[:created_before] + + return query_hash unless created_after || created_before + + context.name(:filters) do + if created_after + add_filter(query_hash, :query, :bool, :filter) do + { + bool: { + _name: context.name(:created_after), + must: { + range: { + 'created_at' => { + gte: created_after + } + } + } + } + } + end + end + + if created_before + add_filter(query_hash, :query, :bool, :filter) do + { + bool: { + _name: context.name(:created_before), + must: { + range: { + 'created_at' => { + lte: created_before + } + } + } + } + } + end + end + end + + query_hash + end + + def by_updated_at(query_hash:, options:) + updated_after = options[:updated_after] + updated_before = options[:updated_before] + + return query_hash unless updated_after || updated_before + + context.name(:filters) do + if updated_after + add_filter(query_hash, :query, :bool, :filter) do + { + bool: { + _name: context.name(:updated_after), + must: { + range: { + 'updated_at' => { + gte: updated_after + } + } + } + } + } + end + end + + if updated_before + add_filter(query_hash, :query, :bool, :filter) do + { + bool: { + _name: context.name(:updated_before), + must: { + range: { + 'updated_at' => { + lte: updated_before + } + } + } + } + } + end + end + end + + query_hash + end + + def by_due_date(query_hash:, options:) + due_after = options[:due_after] + due_before = options[:due_before] + + return query_hash unless due_after || due_before + + context.name(:filters) do + if due_after + add_filter(query_hash, :query, :bool, :filter) do + { + bool: { + _name: context.name(:due_after), + must: { + range: { + 'due_date' => { + gte: due_after + } + } + } + } + } + end + end + + if due_before + add_filter(query_hash, :query, :bool, :filter) do + { + bool: { + _name: context.name(:due_before), + must: { + range: { + 'due_date' => { + lte: due_before + } + } + } + } + } + end + end + end + + query_hash + end + def by_milestone(query_hash:, options:) # milestone_title filters and wildcard filters (any_milestones, none_milestones) # are mutually exclusive and should not be used together in the same query diff --git a/ee/lib/search/elastic/work_item_query_builder.rb b/ee/lib/search/elastic/work_item_query_builder.rb index c4218435eb27e2..489f82af3c7066 100644 --- a/ee/lib/search/elastic/work_item_query_builder.rb +++ b/ee/lib/search/elastic/work_item_query_builder.rb @@ -47,8 +47,13 @@ def build query_hash = ::Search::Elastic::Filters.by_label_names(query_hash: query_hash, options: options) query_hash = ::Search::Elastic::Filters.by_weight(query_hash: query_hash, options: options) query_hash = ::Search::Elastic::Filters.by_health_status(query_hash: query_hash, options: options) + query_hash = ::Search::Elastic::Filters.by_closed_at(query_hash: query_hash, options: options) end + query_hash = ::Search::Elastic::Filters.by_created_at(query_hash: query_hash, options: options) + query_hash = ::Search::Elastic::Filters.by_updated_at(query_hash: query_hash, options: options) + query_hash = ::Search::Elastic::Filters.by_due_date(query_hash: query_hash, options: options) + if hybrid_work_item_search? query_hash = ::Search::Elastic::Filters.by_knn(query_hash: query_hash, options: options) end -- GitLab From afe7a7cac6e9078f234dbfdc50e2dd89734a231d Mon Sep 17 00:00:00 2001 From: Alisa Frunza Date: Wed, 30 Jul 2025 15:24:05 +0200 Subject: [PATCH 02/15] Convert GLQL Finder to follow Vulnerabilities Search approach --- .../ee/work_items/glql/work_items_finder.rb | 42 +- ee/app/models/ee/work_item.rb | 4 - ee/lib/search/elastic/relation.rb | 4 + .../work_items/glql/work_items_finder_spec.rb | 579 ++++++++++++++---- spec/support/finder_collection_allowlist.yml | 1 + 5 files changed, 508 insertions(+), 122 deletions(-) diff --git a/ee/app/finders/ee/work_items/glql/work_items_finder.rb b/ee/app/finders/ee/work_items/glql/work_items_finder.rb index b9144d4e0645ad..a0a0375d88c88b 100644 --- a/ee/app/finders/ee/work_items/glql/work_items_finder.rb +++ b/ee/app/finders/ee/work_items/glql/work_items_finder.rb @@ -61,13 +61,22 @@ module WorkItemsFinder GLQL_SOURCE = 'glql' + INDEX_NAME = ::Search::Elastic::References::WorkItem.index + QUERY = '*' + attr_reader :current_user, :context, :params attr_accessor :resource_parent def execute - result = search_service.search_results.objects('issues') + work_item_query = ::Search::Elastic::WorkItemQueryBuilder.build(query: QUERY, options: search_params) + + ::Search::Elastic::Relation.new(::WorkItem, work_item_query, es_search_options) + end - ::WorkItem.glql_from_es_results(result) + def es_search_options + { + index_name: INDEX_NAME + } end override :use_elasticsearch_finder? @@ -99,10 +108,6 @@ def parent_param private - def search_service - ::SearchService.new(current_user, search_params) - end - def search_params base_params.merge(scope_param) end @@ -115,7 +120,14 @@ def base_params sort: 'created_desc', state: params[:state], confidential: params[:confidential], - work_item_type_ids: type_ids_from(params[:issue_types]) + work_item_type_ids: type_ids_from(params[:issue_types]), + current_user: current_user, + public_and_internal_projects: false, + search_level: search_level, + project_ids: ([resource_parent.id] if project_scope?), + group_id: (resource_parent.id if group_scope?), + group_ids: ([resource_parent.id] if group_scope?), + root_ancestor_ids: root_ancestor_ids }.merge( label_params, author_params, @@ -211,6 +223,22 @@ def scope_param end end + def search_level + project_scope? ? 'project' : 'group' + end + + def root_ancestor_ids + [resource_parent.id] + end + + def project_scope? + parent_param == :project_id + end + + def group_scope? + parent_param == :group_id + end + def any_milestones? params[:milestone_wildcard_id].to_s.downcase == FILTER_ANY end diff --git a/ee/app/models/ee/work_item.rb b/ee/app/models/ee/work_item.rb index 6225f0d74ce6f3..90207580cb3934 100644 --- a/ee/app/models/ee/work_item.rb +++ b/ee/app/models/ee/work_item.rb @@ -52,10 +52,6 @@ module WorkItem preloaded_data end - - scope :glql_from_es_results, ->(results) do - id_in(results.map(&:id)).order(created_at: :desc, id: :desc) - end end class_methods do diff --git a/ee/lib/search/elastic/relation.rb b/ee/lib/search/elastic/relation.rb index eca31c56c8633e..29edbc7e7349c9 100644 --- a/ee/lib/search/elastic/relation.rb +++ b/ee/lib/search/elastic/relation.rb @@ -50,6 +50,10 @@ def to_a records end + def size + response_mapper.total_count + end + private attr_reader :klass, :query, :options, :preload_values diff --git a/ee/spec/finders/ee/work_items/glql/work_items_finder_spec.rb b/ee/spec/finders/ee/work_items/glql/work_items_finder_spec.rb index f775321b218d6c..a03268a23afaf3 100644 --- a/ee/spec/finders/ee/work_items/glql/work_items_finder_spec.rb +++ b/ee/spec/finders/ee/work_items/glql/work_items_finder_spec.rb @@ -2,10 +2,11 @@ require 'spec_helper' -RSpec.describe WorkItems::Glql::WorkItemsFinder, feature_category: :markdown do +RSpec.describe WorkItems::Glql::WorkItemsFinder, :elastic_delete_by_query, :sidekiq_inline, feature_category: :markdown do using RSpec::Parameterized::TableSyntax let_it_be(:group) { create(:group) } + let(:finder) { described_class.new(current_user, context, resource_parent, params) } let_it_be(:project) { create(:project, group: group) } let_it_be(:resource_parent) { group } let_it_be(:current_user) { create(:user) } @@ -49,9 +50,11 @@ allow(context).to receive(:[]).with(:request).and_return(dummy_request) allow(Gitlab::CurrentSettings).to receive(:elasticsearch_search?).and_return(true) allow(resource_parent).to receive(:use_elasticsearch?).and_return(true) + + stub_ee_application_setting(elasticsearch_search: true, elasticsearch_indexing: true) end - subject(:finder) { described_class.new(current_user, context, resource_parent, params) } + subject(:execute) { finder.execute.to_a } describe '#use_elasticsearch_finder?' do context 'when falling back to legacy finder' do @@ -189,14 +192,14 @@ context 'when resource_parent is not allowed' do let_it_be(:resource_parent) { create(:merge_request) } - it 'sets the project_id and leaves group_id nil' do + it 'raises an error for unexpected parent type' do expect { finder.parent_param }.to raise_error(RuntimeError, 'Unexpected parent: MergeRequest') end end end describe '#execute' do - let(:search_params) do + let(:expected_base_params) do { source: described_class::GLQL_SOURCE, confidential: false, @@ -229,339 +232,608 @@ health_status: [::WorkItem.health_statuses[work_item1.health_status]], not_health_status: nil, none_health_status: false, - any_health_status: false + any_health_status: false, + current_user: current_user, + public_and_internal_projects: false } end - let(:search_results_double) { instance_double(Gitlab::Elastic::SearchResults, objects: [work_item1, work_item2]) } - before do finder.parent_param = resource_parent - - allow(SearchService).to receive(:new).and_call_original - allow_next_instance_of(SearchService) do |service_instance| - allow(service_instance).to receive(:search_results).and_return(search_results_double) - end - end - - shared_examples 'executes ES search with expected params' do - it 'executes ES search service' do - expect(SearchService).to receive(:new).with(current_user, search_params) - expect(finder.execute).to contain_exactly(work_item1, work_item2) - end end context 'when resource_parent is a Project' do let(:resource_parent) { project } - - before do - search_params.merge!(project_id: project.id) + let(:project_scope_params) do + { + search_level: 'project', + project_id: project.id, + project_ids: [project.id], + group_id: nil, + group_ids: nil, + root_ancestor_ids: [project.id] + } end - it_behaves_like 'executes ES search with expected params' + it 'calls WorkItemQueryBuilder with project scope parameters' do + expected_params = expected_base_params.merge(project_scope_params) + + expect(::Search::Elastic::WorkItemQueryBuilder).to receive(:build) + .with(query: described_class::QUERY, options: expected_params) + .and_call_original + + execute + end end context 'when resource_parent is a Group' do - before do - search_params.merge!(group_id: group.id) + let(:group_scope_params) do + { + search_level: 'group', + project_ids: nil, + group_id: group.id, + group_ids: [group.id], + root_ancestor_ids: [group.id] + } end - it_behaves_like 'executes ES search with expected params' + it 'calls WorkItemQueryBuilder with group scope parameters' do + expected_params = expected_base_params.merge(group_scope_params) + + expect(::Search::Elastic::WorkItemQueryBuilder).to receive(:build) + .with(query: described_class::QUERY, options: expected_params) + .and_call_original + + execute + end end context 'with additional params' do - before do - search_params.merge!(group_id: group.id) + let(:group_scope_params) do + { + search_level: 'group', + project_ids: nil, + group_id: group.id, + group_ids: [group.id], + root_ancestor_ids: [group.id] + } end context 'when not_author_username param provided' do before do params[:not][:author_username] = current_user.username - search_params.merge!(not_author_username: current_user.username) end - it_behaves_like 'executes ES search with expected params' + it 'calls WorkItemQueryBuilder with not_author_username parameter' do + expected_params = expected_base_params.merge(group_scope_params).merge( + not_author_username: current_user.username + ) + + expect(::Search::Elastic::WorkItemQueryBuilder).to receive(:build) + .with(query: described_class::QUERY, options: expected_params) + .and_call_original + + execute + end end context 'when not_milestone_title param provided' do before do params[:not][:milestone_title] = [milestone.title] - search_params.merge!(not_milestone_title: [milestone.title]) end - it_behaves_like 'executes ES search with expected params' + it 'calls WorkItemQueryBuilder with not_milestone_title parameter' do + expected_params = expected_base_params.merge(group_scope_params).merge( + not_milestone_title: [milestone.title] + ) + + expect(::Search::Elastic::WorkItemQueryBuilder).to receive(:build) + .with(query: described_class::QUERY, options: expected_params) + .and_call_original + + execute + end end context 'when not_weight param provided' do before do params[:not][:weight] = work_item1.weight - search_params.merge!(not_weight: work_item1.weight) end - it_behaves_like 'executes ES search with expected params' + it 'calls WorkItemQueryBuilder with not_weight parameter' do + expected_params = expected_base_params.merge(group_scope_params).merge( + not_weight: work_item1.weight + ) + + expect(::Search::Elastic::WorkItemQueryBuilder).to receive(:build) + .with(query: described_class::QUERY, options: expected_params) + .and_call_original + + execute + end end context 'when any_weight param provided' do before do params[:weight_wildcard_id] = described_class::FILTER_ANY - search_params.merge!(any_weight: true) end - it_behaves_like 'executes ES search with expected params' + it 'calls WorkItemQueryBuilder with any_weight parameter' do + expected_params = expected_base_params.merge(group_scope_params).merge( + any_weight: true + ) + + expect(::Search::Elastic::WorkItemQueryBuilder).to receive(:build) + .with(query: described_class::QUERY, options: expected_params) + .and_call_original + + execute + end end context 'when none_weight param provided' do before do params[:weight_wildcard_id] = described_class::FILTER_NONE - search_params.merge!(none_weight: true) end - it_behaves_like 'executes ES search with expected params' + it 'calls WorkItemQueryBuilder with none_weight parameter' do + expected_params = expected_base_params.merge(group_scope_params).merge( + none_weight: true + ) + + expect(::Search::Elastic::WorkItemQueryBuilder).to receive(:build) + .with(query: described_class::QUERY, options: expected_params) + .and_call_original + + execute + end end context 'when any_milestones param provided' do before do params[:milestone_wildcard_id] = described_class::FILTER_ANY - search_params.merge!(any_milestones: true) end - it_behaves_like 'executes ES search with expected params' + it 'calls WorkItemQueryBuilder with any_milestones parameter' do + expected_params = expected_base_params.merge(group_scope_params).merge( + any_milestones: true + ) + + expect(::Search::Elastic::WorkItemQueryBuilder).to receive(:build) + .with(query: described_class::QUERY, options: expected_params) + .and_call_original + + execute + end end context 'when none_milestones param provided' do before do params[:milestone_wildcard_id] = described_class::FILTER_NONE - search_params.merge!(none_milestones: true) end - it_behaves_like 'executes ES search with expected params' + it 'calls WorkItemQueryBuilder with none_milestones parameter' do + expected_params = expected_base_params.merge(group_scope_params).merge( + none_milestones: true + ) + + expect(::Search::Elastic::WorkItemQueryBuilder).to receive(:build) + .with(query: described_class::QUERY, options: expected_params) + .and_call_original + + execute + end end context 'when milestone_state_filters param provided' do context 'when milestone_wildcard_id param is UPCOMING' do before do params[:milestone_wildcard_id] = described_class::FILTER_MILESTONE_UPCOMING - search_params.merge!(milestone_state_filters: [:upcoming]) end - it_behaves_like 'executes ES search with expected params' + it 'calls WorkItemQueryBuilder with upcoming milestone filter' do + expected_params = expected_base_params.merge(group_scope_params).merge( + milestone_state_filters: [:upcoming] + ) + + expect(::Search::Elastic::WorkItemQueryBuilder).to receive(:build) + .with(query: described_class::QUERY, options: expected_params) + .and_call_original + + execute + end end context 'when milestone_wildcard_id param is STARTED' do before do params[:milestone_wildcard_id] = described_class::FILTER_MILESTONE_STARTED - search_params.merge!(milestone_state_filters: [:started]) end - it_behaves_like 'executes ES search with expected params' + it 'calls WorkItemQueryBuilder with started milestone filter' do + expected_params = expected_base_params.merge(group_scope_params).merge( + milestone_state_filters: [:started] + ) + + expect(::Search::Elastic::WorkItemQueryBuilder).to receive(:build) + .with(query: described_class::QUERY, options: expected_params) + .and_call_original + + execute + end end context 'when not milestone_wildcard_id param is UPCOMING' do before do params[:not][:milestone_wildcard_id] = described_class::FILTER_MILESTONE_UPCOMING - search_params.merge!(milestone_state_filters: [:not_upcoming]) end - it_behaves_like 'executes ES search with expected params' + it 'calls WorkItemQueryBuilder with not upcoming milestone filter' do + expected_params = expected_base_params.merge(group_scope_params).merge( + milestone_state_filters: [:not_upcoming] + ) + + expect(::Search::Elastic::WorkItemQueryBuilder).to receive(:build) + .with(query: described_class::QUERY, options: expected_params) + .and_call_original + + execute + end end context 'when not milestone_wildcard_id param is STARTED' do before do params[:not][:milestone_wildcard_id] = described_class::FILTER_MILESTONE_STARTED - search_params.merge!(milestone_state_filters: [:not_started]) end - it_behaves_like 'executes ES search with expected params' + it 'calls WorkItemQueryBuilder with not started milestone filter' do + expected_params = expected_base_params.merge(group_scope_params).merge( + milestone_state_filters: [:not_started] + ) + + expect(::Search::Elastic::WorkItemQueryBuilder).to receive(:build) + .with(query: described_class::QUERY, options: expected_params) + .and_call_original + + execute + end end context 'when milestone_wildcard_id is STARTED and not milestone_wildcard_id is UPCOMING' do before do params[:milestone_wildcard_id] = described_class::FILTER_MILESTONE_STARTED params[:not][:milestone_wildcard_id] = described_class::FILTER_MILESTONE_UPCOMING - search_params.merge!(milestone_state_filters: [:started, :not_upcoming]) end - it_behaves_like 'executes ES search with expected params' + it 'calls WorkItemQueryBuilder with combined milestone filters' do + expected_params = expected_base_params.merge(group_scope_params).merge( + milestone_state_filters: [:started, :not_upcoming] + ) + + expect(::Search::Elastic::WorkItemQueryBuilder).to receive(:build) + .with(query: described_class::QUERY, options: expected_params) + .and_call_original + + execute + end end end context 'when multiple assignee usernames provided' do before do params[:assignee_usernames] = [assignee_user.username, other_user.username] - search_params.merge!(assignee_ids: [assignee_user.id, other_user.id]) end - it_behaves_like 'executes ES search with expected params' + it 'calls WorkItemQueryBuilder with multiple assignee_ids parameter' do + expected_params = expected_base_params.merge(group_scope_params).merge( + assignee_ids: [assignee_user.id, other_user.id] + ) + + expect(::Search::Elastic::WorkItemQueryBuilder).to receive(:build) + .with(query: described_class::QUERY, options: expected_params) + .and_call_original + + execute + end end context 'when not_assignee_usernames param provided' do before do params[:not][:assignee_usernames] = [assignee_user.username] - search_params.merge!(not_assignee_ids: [assignee_user.id]) end - it_behaves_like 'executes ES search with expected params' + it 'calls WorkItemQueryBuilder with not_assignee_ids parameter' do + expected_params = expected_base_params.merge(group_scope_params).merge( + not_assignee_ids: [assignee_user.id] + ) + + expect(::Search::Elastic::WorkItemQueryBuilder).to receive(:build) + .with(query: described_class::QUERY, options: expected_params) + .and_call_original + + execute + end end context 'when or assignee param provided' do before do params[:or] = { assignee_usernames: [assignee_user.username, other_user.username] } - search_params.merge!(or_assignee_ids: [assignee_user.id, other_user.id]) end - it_behaves_like 'executes ES search with expected params' + it 'calls WorkItemQueryBuilder with or_assignee_ids parameter' do + expected_params = expected_base_params.merge(group_scope_params).merge( + or_assignee_ids: [assignee_user.id, other_user.id] + ) + + expect(::Search::Elastic::WorkItemQueryBuilder).to receive(:build) + .with(query: described_class::QUERY, options: expected_params) + .and_call_original + + execute + end end context 'when any_assignees param provided (assignee wildcard)' do before do params[:assignee_wildcard_id] = described_class::FILTER_ANY - search_params.merge!(any_assignees: true) end - it_behaves_like 'executes ES search with expected params' + it 'calls WorkItemQueryBuilder with any_assignees parameter' do + expected_params = expected_base_params.merge(group_scope_params).merge( + any_assignees: true + ) + + expect(::Search::Elastic::WorkItemQueryBuilder).to receive(:build) + .with(query: described_class::QUERY, options: expected_params) + .and_call_original + + execute + end end context 'when none_assignees param provided (assignee wildcard)' do before do params[:assignee_wildcard_id] = described_class::FILTER_NONE - search_params.merge!(none_assignees: true) end - it_behaves_like 'executes ES search with expected params' + it 'calls WorkItemQueryBuilder with none_assignees parameter' do + expected_params = expected_base_params.merge(group_scope_params).merge( + none_assignees: true + ) + + expect(::Search::Elastic::WorkItemQueryBuilder).to receive(:build) + .with(query: described_class::QUERY, options: expected_params) + .and_call_original + + execute + end end context 'when label_names param provided' do before do params[:label_name] = ['workflow::complete', 'backend'] - search_params.merge!(label_names: ['workflow::complete', 'backend']) end - it_behaves_like 'executes ES search with expected params' + it 'calls WorkItemQueryBuilder with label_names parameter' do + expected_params = expected_base_params.merge(group_scope_params).merge( + label_names: ['workflow::complete', 'backend'] + ) + + expect(::Search::Elastic::WorkItemQueryBuilder).to receive(:build) + .with(query: described_class::QUERY, options: expected_params) + .and_call_original + + execute + end end context 'when label_names param with wildcard provided' do before do params[:label_name] = ['workflow::*', 'frontend'] - search_params.merge!(label_names: ['workflow::*', 'frontend']) end - it_behaves_like 'executes ES search with expected params' + it 'calls WorkItemQueryBuilder with wildcard label_names parameter' do + expected_params = expected_base_params.merge(group_scope_params).merge( + label_names: ['workflow::*', 'frontend'] + ) + + expect(::Search::Elastic::WorkItemQueryBuilder).to receive(:build) + .with(query: described_class::QUERY, options: expected_params) + .and_call_original + + execute + end end context 'when not_label_names param provided' do before do params[:not][:label_name] = ['workflow::in dev'] - search_params.merge!(not_label_names: ['workflow::in dev']) end - it_behaves_like 'executes ES search with expected params' + it 'calls WorkItemQueryBuilder with not_label_names parameter' do + expected_params = expected_base_params.merge(group_scope_params).merge( + not_label_names: ['workflow::in dev'] + ) + + expect(::Search::Elastic::WorkItemQueryBuilder).to receive(:build) + .with(query: described_class::QUERY, options: expected_params) + .and_call_original + + execute + end end context 'when not_label_names param with wildcard provided' do before do params[:not][:label_name] = ['group::*'] - search_params.merge!(not_label_names: ['group::*']) end - it_behaves_like 'executes ES search with expected params' + it 'calls WorkItemQueryBuilder with wildcard not_label_names parameter' do + expected_params = expected_base_params.merge(group_scope_params).merge( + not_label_names: ['group::*'] + ) + + expect(::Search::Elastic::WorkItemQueryBuilder).to receive(:build) + .with(query: described_class::QUERY, options: expected_params) + .and_call_original + + execute + end end context 'when or_label_names param provided' do before do params[:or] = { label_names: ['workflow::complete', 'group::knowledge'] } - search_params.merge!(or_label_names: ['workflow::complete', 'group::knowledge']) end - it_behaves_like 'executes ES search with expected params' + it 'calls WorkItemQueryBuilder with or_label_names parameter' do + expected_params = expected_base_params.merge(group_scope_params).merge( + or_label_names: ['workflow::complete', 'group::knowledge'] + ) + + expect(::Search::Elastic::WorkItemQueryBuilder).to receive(:build) + .with(query: described_class::QUERY, options: expected_params) + .and_call_original + + execute + end end context 'when or_label_names param with wildcard provided' do before do params[:or] = { label_names: ['workflow::*', 'backend'] } - search_params.merge!(or_label_names: ['workflow::*', 'backend']) end - it_behaves_like 'executes ES search with expected params' + it 'calls WorkItemQueryBuilder with wildcard or_label_names parameter' do + expected_params = expected_base_params.merge(group_scope_params).merge( + or_label_names: ['workflow::*', 'backend'] + ) + + expect(::Search::Elastic::WorkItemQueryBuilder).to receive(:build) + .with(query: described_class::QUERY, options: expected_params) + .and_call_original + + execute + end end context 'when any_label_names param provided (label wildcard)' do before do params[:label_name] = [described_class::FILTER_ANY] - search_params.merge!( - label_names: nil, - any_label_names: true - ) end - it_behaves_like 'executes ES search with expected params' + it 'calls WorkItemQueryBuilder with any_label_names parameter' do + expected_params = expected_base_params.merge(group_scope_params).merge( + label_names: nil, any_label_names: true + ) + + expect(::Search::Elastic::WorkItemQueryBuilder).to receive(:build) + .with(query: described_class::QUERY, options: expected_params) + .and_call_original + + execute + end end context 'when none_label_names param provided (label wildcard)' do before do params[:label_name] = [described_class::FILTER_NONE] - search_params.merge!( - label_names: nil, - none_label_names: true - ) end - it_behaves_like 'executes ES search with expected params' + it 'calls WorkItemQueryBuilder with none_label_names parameter' do + expected_params = expected_base_params.merge(group_scope_params).merge( + label_names: nil, none_label_names: true + ) + + expect(::Search::Elastic::WorkItemQueryBuilder).to receive(:build) + .with(query: described_class::QUERY, options: expected_params) + .and_call_original + + execute + end end context 'when mixed NONE with nested NOT label provided' do before do params[:label_name] = [described_class::FILTER_NONE] params[:not][:label_name] = ['workflow::in dev'] - search_params.merge!( + end + + it 'calls WorkItemQueryBuilder with mixed NONE and NOT label parameters' do + expected_params = expected_base_params.merge(group_scope_params).merge( label_names: nil, none_label_names: true, not_label_names: ['workflow::in dev'] ) - end - it_behaves_like 'executes ES search with expected params' + expect(::Search::Elastic::WorkItemQueryBuilder).to receive(:build) + .with(query: described_class::QUERY, options: expected_params) + .and_call_original + + execute + end end context 'when mixed NONE with OR labels provided' do before do params[:label_name] = [described_class::FILTER_NONE] params[:or] = { label_names: %w[frontend backend] } - search_params.merge!( + end + + it 'calls WorkItemQueryBuilder with mixed NONE and OR label parameters' do + expected_params = expected_base_params.merge(group_scope_params).merge( label_names: nil, none_label_names: true, or_label_names: %w[frontend backend] ) - end - it_behaves_like 'executes ES search with expected params' + expect(::Search::Elastic::WorkItemQueryBuilder).to receive(:build) + .with(query: described_class::QUERY, options: expected_params) + .and_call_original + + execute + end end context 'when mixed ANY with nested NOT label provided' do before do params[:label_name] = [described_class::FILTER_ANY] params[:not][:label_name] = ['workflow::in dev'] - search_params.merge!( + end + + it 'calls WorkItemQueryBuilder with mixed ANY and NOT label parameters' do + expected_params = expected_base_params.merge(group_scope_params).merge( label_names: nil, any_label_names: true, not_label_names: ['workflow::in dev'] ) - end - it_behaves_like 'executes ES search with expected params' + expect(::Search::Elastic::WorkItemQueryBuilder).to receive(:build) + .with(query: described_class::QUERY, options: expected_params) + .and_call_original + + execute + end end context 'when mixed ANY with OR labels provided' do before do params[:label_name] = [described_class::FILTER_ANY] params[:or] = { label_names: %w[frontend backend] } - search_params.merge!( + end + + it 'calls WorkItemQueryBuilder with mixed ANY and OR label parameters' do + expected_params = expected_base_params.merge(group_scope_params).merge( label_names: nil, any_label_names: true, or_label_names: %w[frontend backend] ) - end - it_behaves_like 'executes ES search with expected params' + expect(::Search::Elastic::WorkItemQueryBuilder).to receive(:build) + .with(query: described_class::QUERY, options: expected_params) + .and_call_original + + execute + end end context 'when complex label filtering with wildcards provided' do @@ -569,49 +841,134 @@ params[:label_name] = ['workflow::complete'] params[:not][:label_name] = ['group::*'] params[:or] = { label_names: ['workflow::*', 'frontend'] } - search_params.merge!( + end + + it 'calls WorkItemQueryBuilder with complex label filtering parameters' do + expected_params = expected_base_params.merge(group_scope_params).merge( label_names: ['workflow::complete'], not_label_names: ['group::*'], or_label_names: ['workflow::*', 'frontend'] ) - end - it_behaves_like 'executes ES search with expected params' + expect(::Search::Elastic::WorkItemQueryBuilder).to receive(:build) + .with(query: described_class::QUERY, options: expected_params) + .and_call_original + + execute + end end context 'when not_health_status param provided' do before do params[:not][:health_status_filter] = work_item1.health_status - search_params.merge!( + end + + it 'calls WorkItemQueryBuilder with not_health_status parameter' do + expected_params = expected_base_params.merge(group_scope_params).merge( not_health_status: [::WorkItem.health_statuses[work_item1.health_status]] ) - end - it_behaves_like 'executes ES search with expected params' + expect(::Search::Elastic::WorkItemQueryBuilder).to receive(:build) + .with(query: described_class::QUERY, options: expected_params) + .and_call_original + + execute + end end context 'when any_health_status param provided' do before do params[:health_status_filter] = described_class::FILTER_ANY - search_params.merge!( - health_status: nil, - any_health_status: true - ) end - it_behaves_like 'executes ES search with expected params' + it 'calls WorkItemQueryBuilder with any_health_status parameter' do + expected_params = expected_base_params.merge(group_scope_params).merge( + health_status: nil, any_health_status: true + ) + + expect(::Search::Elastic::WorkItemQueryBuilder).to receive(:build) + .with(query: described_class::QUERY, options: expected_params) + .and_call_original + + execute + end end context 'when none_health_status param provided' do before do params[:health_status_filter] = described_class::FILTER_NONE - search_params.merge!( - health_status: nil, - none_health_status: true + end + + it 'calls WorkItemQueryBuilder with none_health_status parameter' do + expected_params = expected_base_params.merge(group_scope_params).merge( + health_status: nil, none_health_status: true ) + + expect(::Search::Elastic::WorkItemQueryBuilder).to receive(:build) + .with(query: described_class::QUERY, options: expected_params) + .and_call_original + + execute end + end + end + + describe 'parameter processing methods' do + before do + finder.parent_param = resource_parent + end - it_behaves_like 'executes ES search with expected params' + it 'correctly processes label params' do + label_params = finder.send(:label_params) + expect(label_params).to include( + label_names: ['test-label'], + none_label_names: false, + any_label_names: false + ) + end + + it 'correctly processes assignee params' do + assignee_params = finder.send(:assignee_params) + expect(assignee_params).to include( + assignee_ids: [assignee_user.id], + none_assignees: false, + any_assignees: false + ) + end + + it 'correctly processes health status params' do + health_params = finder.send(:health_status_params) + expect(health_params).to include( + health_status: [::WorkItem.health_statuses[work_item1.health_status]], + none_health_status: false, + any_health_status: false + ) + end + + it 'correctly processes weight params' do + weight_params = finder.send(:weight_params) + expect(weight_params).to include( + weight: work_item1.weight, + none_weight: false, + any_weight: false + ) + end + + it 'correctly processes milestone params' do + milestone_params = finder.send(:milestone_params) + expect(milestone_params).to include( + milestone_title: [milestone.title], + none_milestones: false, + any_milestones: false + ) + end + + it 'correctly processes author params' do + author_params = finder.send(:author_params) + expect(author_params).to include( + author_username: current_user.username, + not_author_username: nil + ) end end end diff --git a/spec/support/finder_collection_allowlist.yml b/spec/support/finder_collection_allowlist.yml index 682938441ba6c4..e276d351d56225 100644 --- a/spec/support/finder_collection_allowlist.yml +++ b/spec/support/finder_collection_allowlist.yml @@ -16,6 +16,7 @@ - Security::VulnerabilityElasticCountOverTimeFinder # Reason: The finder deals with Elasticsearch records and not DB records - AuditEvents::CombinedAuditEventFinder # Reason: The finder combines result from 4 different tables and also returns cursor to next page - Security::AnalyzerGroupStatusFinder # Reason: To give accurate counts, return all analyzer types, even when there is no DB record +- WorkItems::Glql::WorkItemsFinder # Reason: The finder deals with Elasticsearch records and not DB records # Temporary excludes (aka TODOs) # For example: -- GitLab From bfff4b9f01be6f3e0f6a34425d05052da8c08980 Mon Sep 17 00:00:00 2001 From: Alisa Frunza Date: Thu, 31 Jul 2025 10:43:53 +0200 Subject: [PATCH 03/15] Refactor spec to be integration test --- .../ee/work_items/glql/work_items_finder.rb | 2 +- .../work_items/glql/work_items_finder_spec.rb | 1143 ++++++++--------- ee/spec/lib/search/elastic/relation_spec.rb | 6 + 3 files changed, 551 insertions(+), 600 deletions(-) diff --git a/ee/app/finders/ee/work_items/glql/work_items_finder.rb b/ee/app/finders/ee/work_items/glql/work_items_finder.rb index a0a0375d88c88b..4a643b515032ab 100644 --- a/ee/app/finders/ee/work_items/glql/work_items_finder.rb +++ b/ee/app/finders/ee/work_items/glql/work_items_finder.rb @@ -136,7 +136,7 @@ def base_params weight_params, health_status_params, dates_params - ) + ).compact end def label_params diff --git a/ee/spec/finders/ee/work_items/glql/work_items_finder_spec.rb b/ee/spec/finders/ee/work_items/glql/work_items_finder_spec.rb index a03268a23afaf3..de721124720864 100644 --- a/ee/spec/finders/ee/work_items/glql/work_items_finder_spec.rb +++ b/ee/spec/finders/ee/work_items/glql/work_items_finder_spec.rb @@ -3,21 +3,19 @@ require 'spec_helper' RSpec.describe WorkItems::Glql::WorkItemsFinder, :elastic_delete_by_query, :sidekiq_inline, feature_category: :markdown do - using RSpec::Parameterized::TableSyntax - - let_it_be(:group) { create(:group) } - let(:finder) { described_class.new(current_user, context, resource_parent, params) } - let_it_be(:project) { create(:project, group: group) } - let_it_be(:resource_parent) { group } + let_it_be(:group) { create(:group) } + let_it_be(:project) { create(:project, group: group) } let_it_be(:current_user) { create(:user) } - let_it_be(:assignee_user) { create(:user) } - let_it_be(:other_user) { create(:user) } - let_it_be(:milestone) { create(:milestone, project: project) } - let_it_be(:work_item2) { create(:work_item, :satisfied_status, project: project) } let_it_be(:work_item1) do - create(:work_item, health_status: 1, weight: 5, project: project, author: current_user) + create(:work_item, project: project) + end + + let_it_be(:work_item2) do + create(:work_item, project: project) end + let(:params) { {} } + let(:finder) { described_class.new(current_user, context, resource_parent, params) } let(:context) { instance_double(GraphQL::Query::Context) } let(:request_params) { { 'operationName' => 'GLQL' } } let(:url_query) { 'useES=true' } @@ -31,21 +29,6 @@ ) end - let(:params) do - { - label_name: ['test-label'], - state: 'opened', - confidential: false, - author_username: current_user.username, - milestone_title: [milestone.title], - assignee_usernames: [assignee_user.username], - weight: work_item1.weight, - issue_types: [work_item1.work_item_type.base_type, work_item2.work_item_type.base_type], - health_status_filter: work_item1.health_status, - not: {} - } - end - before do allow(context).to receive(:[]).with(:request).and_return(dummy_request) allow(Gitlab::CurrentSettings).to receive(:elasticsearch_search?).and_return(true) @@ -57,6 +40,8 @@ subject(:execute) { finder.execute.to_a } describe '#use_elasticsearch_finder?' do + let(:resource_parent) { group } + context 'when falling back to legacy finder' do context 'when the request is not a GLQL request' do let(:request_params) { { 'operationName' => 'Not GLQL' } } @@ -127,7 +112,7 @@ end context 'when `or` operator is used with supported filter' do - let(:params) { { or: { assignee_usernames: [assignee_user.username] } } } + let(:params) { { or: { assignee_usernames: [current_user.username] } } } it 'returns true' do expect(finder.use_elasticsearch_finder?).to be_truthy @@ -170,6 +155,8 @@ describe '#parent_param=' do context 'when resource_parent is a Group' do + let(:resource_parent) { group } + it 'sets the group_id and leaves project_id nil' do finder.parent_param = resource_parent @@ -199,776 +186,734 @@ end describe '#execute' do - let(:expected_base_params) do - { - source: described_class::GLQL_SOURCE, - confidential: false, - label_names: ['test-label'], - or_label_names: nil, - not_label_names: nil, - any_label_names: false, - none_label_names: false, - per_page: 100, - search: '*', - sort: 'created_desc', - state: 'opened', - author_username: current_user.username, - not_author_username: nil, - milestone_title: [milestone.title], - not_milestone_title: nil, - any_milestones: false, - none_milestones: false, - milestone_state_filters: nil, - assignee_ids: [assignee_user.id], - not_assignee_ids: nil, - or_assignee_ids: nil, - any_assignees: false, - none_assignees: false, - weight: work_item1.weight, - not_weight: nil, - none_weight: false, - any_weight: false, - work_item_type_ids: [work_item1.work_item_type.id, work_item2.work_item_type.id], - health_status: [::WorkItem.health_statuses[work_item1.health_status]], - not_health_status: nil, - none_health_status: false, - any_health_status: false, - current_user: current_user, - public_and_internal_projects: false - } - end - - before do - finder.parent_param = resource_parent - end + context 'when resource_parent is a Group' do + let(:resource_parent) { group } - context 'when resource_parent is a Project' do - let(:resource_parent) { project } - let(:project_scope_params) do - { - search_level: 'project', - project_id: project.id, - project_ids: [project.id], - group_id: nil, - group_ids: nil, - root_ancestor_ids: [project.id] - } + before_all do + group.add_owner(current_user) end - it 'calls WorkItemQueryBuilder with project scope parameters' do - expected_params = expected_base_params.merge(project_scope_params) + context 'when searching for author_username' do + let(:another_user) { create(:user) } + let(:work_item1_with_author) { create(:work_item, project: project, author: current_user) } + let(:work_item2_with_author) { create(:work_item, project: project, author: another_user) } - expect(::Search::Elastic::WorkItemQueryBuilder).to receive(:build) - .with(query: described_class::QUERY, options: expected_params) - .and_call_original + before do + Elastic::ProcessBookkeepingService.track!(work_item1_with_author, work_item2_with_author) - execute - end - end + ensure_elasticsearch_index! + end - context 'when resource_parent is a Group' do - let(:group_scope_params) do - { - search_level: 'group', - project_ids: nil, - group_id: group.id, - group_ids: [group.id], - root_ancestor_ids: [group.id] - } - end + context 'when author_username param provided' do + let(:params) do + { author_username: [current_user.username] } + end - it 'calls WorkItemQueryBuilder with group scope parameters' do - expected_params = expected_base_params.merge(group_scope_params) + it 'returns work items with specified author username' do + expect(execute).to contain_exactly(work_item1_with_author) + end + end - expect(::Search::Elastic::WorkItemQueryBuilder).to receive(:build) - .with(query: described_class::QUERY, options: expected_params) - .and_call_original + context 'when not_author_username param provided' do + let(:params) do + { + not: { + author_username: [current_user.username] + } + } + end - execute + it 'returns work items without specified author username' do + expect(execute).to contain_exactly(work_item2_with_author) + end + end end - end - context 'with additional params' do - let(:group_scope_params) do - { - search_level: 'group', - project_ids: nil, - group_id: group.id, - group_ids: [group.id], - root_ancestor_ids: [group.id] - } - end + context 'when searching for milestone' do + let(:milestone1) { create(:milestone, group: group) } + let(:milestone2) { create(:milestone, group: group) } + let(:work_item1_with_milestone) { create(:work_item, project: project, milestone: milestone1) } + let(:work_item2_with_milestone) { create(:work_item, project: project, milestone: milestone2) } + let(:work_item3_without_milestone) { create(:work_item, project: project) } - context 'when not_author_username param provided' do before do - params[:not][:author_username] = current_user.username - end - - it 'calls WorkItemQueryBuilder with not_author_username parameter' do - expected_params = expected_base_params.merge(group_scope_params).merge( - not_author_username: current_user.username + Elastic::ProcessBookkeepingService.track!( + work_item1_with_milestone, + work_item2_with_milestone, + work_item3_without_milestone ) - expect(::Search::Elastic::WorkItemQueryBuilder).to receive(:build) - .with(query: described_class::QUERY, options: expected_params) - .and_call_original - - execute - end - end - - context 'when not_milestone_title param provided' do - before do - params[:not][:milestone_title] = [milestone.title] + ensure_elasticsearch_index! end - it 'calls WorkItemQueryBuilder with not_milestone_title parameter' do - expected_params = expected_base_params.merge(group_scope_params).merge( - not_milestone_title: [milestone.title] - ) - - expect(::Search::Elastic::WorkItemQueryBuilder).to receive(:build) - .with(query: described_class::QUERY, options: expected_params) - .and_call_original + context 'when milestone_title param provided' do + let(:params) do + { milestone_title: [milestone1.title] } + end - execute + it 'returns work items with specified title' do + expect(execute).to contain_exactly(work_item1_with_milestone) + end end - end - context 'when not_weight param provided' do - before do - params[:not][:weight] = work_item1.weight - end + context 'when not_milestone_title param provided' do + let(:params) do + { + not: { + milestone_title: [milestone2.title] + } + } + end - it 'calls WorkItemQueryBuilder with not_weight parameter' do - expected_params = expected_base_params.merge(group_scope_params).merge( - not_weight: work_item1.weight - ) + it 'returns work items without specified title' do + expect(execute).to contain_exactly(work_item1_with_milestone, work_item3_without_milestone) + end + end - expect(::Search::Elastic::WorkItemQueryBuilder).to receive(:build) - .with(query: described_class::QUERY, options: expected_params) - .and_call_original + context 'when milestone_title param with multiple titles provided' do + let(:params) do + { milestone_title: [milestone1.title, milestone2.title] } + end - execute + it 'returns work items with specified titles' do + expect(execute).to contain_exactly(work_item1_with_milestone, work_item2_with_milestone) + end end - end - context 'when any_weight param provided' do - before do - params[:weight_wildcard_id] = described_class::FILTER_ANY - end + context 'when milestone_wildcard_id with NONE provided' do + let(:params) do + { milestone_wildcard_id: 'NONE' } + end - it 'calls WorkItemQueryBuilder with any_weight parameter' do - expected_params = expected_base_params.merge(group_scope_params).merge( - any_weight: true - ) + it 'returns work items without milestone' do + expect(execute).to contain_exactly(work_item3_without_milestone) + end + end - expect(::Search::Elastic::WorkItemQueryBuilder).to receive(:build) - .with(query: described_class::QUERY, options: expected_params) - .and_call_original + context 'when milestone_wildcard_id with ANY provided' do + let(:params) do + { milestone_wildcard_id: 'ANY' } + end - execute + it 'returns all work items with any milestone' do + expect(execute).to contain_exactly(work_item1_with_milestone, work_item2_with_milestone) + end end end - context 'when none_weight param provided' do - before do - params[:weight_wildcard_id] = described_class::FILTER_NONE + context 'with milestone state wildcard filters' do + let(:upcoming_milestone) do + create(:milestone, group: group, start_date: 1.day.from_now, due_date: 1.week.from_now) end - it 'calls WorkItemQueryBuilder with none_weight parameter' do - expected_params = expected_base_params.merge(group_scope_params).merge( - none_weight: true - ) - - expect(::Search::Elastic::WorkItemQueryBuilder).to receive(:build) - .with(query: described_class::QUERY, options: expected_params) - .and_call_original - - execute + let(:started_milestone) do + create(:milestone, group: group, start_date: 1.day.ago, due_date: 1.week.from_now) end - end - context 'when any_milestones param provided' do - before do - params[:milestone_wildcard_id] = described_class::FILTER_ANY + let(:work_item_with_upcoming_milestone) do + create(:work_item, project: project, milestone: upcoming_milestone) end - it 'calls WorkItemQueryBuilder with any_milestones parameter' do - expected_params = expected_base_params.merge(group_scope_params).merge( - any_milestones: true - ) - - expect(::Search::Elastic::WorkItemQueryBuilder).to receive(:build) - .with(query: described_class::QUERY, options: expected_params) - .and_call_original - - execute + let(:work_item_with_started_milestone) do + create(:work_item, project: project, milestone: started_milestone) end - end - context 'when none_milestones param provided' do before do - params[:milestone_wildcard_id] = described_class::FILTER_NONE - end - - it 'calls WorkItemQueryBuilder with none_milestones parameter' do - expected_params = expected_base_params.merge(group_scope_params).merge( - none_milestones: true + Elastic::ProcessBookkeepingService.track!( + work_item_with_upcoming_milestone, + work_item_with_started_milestone ) - - expect(::Search::Elastic::WorkItemQueryBuilder).to receive(:build) - .with(query: described_class::QUERY, options: expected_params) - .and_call_original - - execute + ensure_elasticsearch_index! end - end - context 'when milestone_state_filters param provided' do - context 'when milestone_wildcard_id param is UPCOMING' do - before do - params[:milestone_wildcard_id] = described_class::FILTER_MILESTONE_UPCOMING + context 'when milestone_wildcard_id with UPCOMING provided' do + let(:params) do + { milestone_wildcard_id: 'UPCOMING' } end - it 'calls WorkItemQueryBuilder with upcoming milestone filter' do - expected_params = expected_base_params.merge(group_scope_params).merge( - milestone_state_filters: [:upcoming] - ) - - expect(::Search::Elastic::WorkItemQueryBuilder).to receive(:build) - .with(query: described_class::QUERY, options: expected_params) - .and_call_original - - execute + it 'returns work items with upcoming active milestones' do + expect(execute).to contain_exactly(work_item_with_upcoming_milestone) end end - context 'when milestone_wildcard_id param is STARTED' do - before do - params[:milestone_wildcard_id] = described_class::FILTER_MILESTONE_STARTED + context 'when milestone_wildcard_id with STARTED provided' do + let(:params) do + { milestone_wildcard_id: 'STARTED' } end - it 'calls WorkItemQueryBuilder with started milestone filter' do - expected_params = expected_base_params.merge(group_scope_params).merge( - milestone_state_filters: [:started] - ) + it 'returns work items with started active milestones' do + expect(execute).to contain_exactly(work_item_with_started_milestone) + end + end - expect(::Search::Elastic::WorkItemQueryBuilder).to receive(:build) - .with(query: described_class::QUERY, options: expected_params) - .and_call_original + context 'when NOT milestone_wildcard_id with UPCOMING provided' do + let(:params) do + { + not: { + milestone_wildcard_id: 'UPCOMING' + } + } + end - execute + it 'returns work items without upcoming milestones' do + expect(execute).to contain_exactly(work_item_with_started_milestone) end end - context 'when not milestone_wildcard_id param is UPCOMING' do - before do - params[:not][:milestone_wildcard_id] = described_class::FILTER_MILESTONE_UPCOMING + context 'when NOT milestone_wildcard_id with STARTED provided' do + let(:params) do + { + not: { + milestone_wildcard_id: 'STARTED' + } + } end - it 'calls WorkItemQueryBuilder with not upcoming milestone filter' do - expected_params = expected_base_params.merge(group_scope_params).merge( - milestone_state_filters: [:not_upcoming] - ) + it 'returns work items without started milestones' do + expect(execute).to contain_exactly(work_item_with_upcoming_milestone) + end + end + end - expect(::Search::Elastic::WorkItemQueryBuilder).to receive(:build) - .with(query: described_class::QUERY, options: expected_params) - .and_call_original + context 'when searching for weight' do + let(:work_item_with_weight_5) { create(:work_item, project: project, weight: 5) } + let(:work_item_with_weight_10) { create(:work_item, project: project, weight: 10) } + let(:work_item_without_weight) { create(:work_item, project: project, weight: nil) } - execute - end + before do + Elastic::ProcessBookkeepingService.track!( + work_item_with_weight_5, + work_item_with_weight_10, + work_item_without_weight + ) + + ensure_elasticsearch_index! end - context 'when not milestone_wildcard_id param is STARTED' do - before do - params[:not][:milestone_wildcard_id] = described_class::FILTER_MILESTONE_STARTED + context 'when weight param provided' do + let(:params) do + { weight: '5' } end - it 'calls WorkItemQueryBuilder with not started milestone filter' do - expected_params = expected_base_params.merge(group_scope_params).merge( - milestone_state_filters: [:not_started] - ) + it 'returns work items with specified weight' do + expect(execute).to contain_exactly(work_item_with_weight_5) + end + end - expect(::Search::Elastic::WorkItemQueryBuilder).to receive(:build) - .with(query: described_class::QUERY, options: expected_params) - .and_call_original + context 'when not_weight param provided' do + let(:params) do + { + not: { + weight: '10' + } + } + end - execute + it 'returns work items without specified weight' do + expect(execute).to contain_exactly(work_item_with_weight_5, work_item_without_weight) end end - context 'when milestone_wildcard_id is STARTED and not milestone_wildcard_id is UPCOMING' do - before do - params[:milestone_wildcard_id] = described_class::FILTER_MILESTONE_STARTED - params[:not][:milestone_wildcard_id] = described_class::FILTER_MILESTONE_UPCOMING + context 'when weight_wildcard_id with ANY provided' do + let(:params) do + { weight_wildcard_id: 'ANY' } end - it 'calls WorkItemQueryBuilder with combined milestone filters' do - expected_params = expected_base_params.merge(group_scope_params).merge( - milestone_state_filters: [:started, :not_upcoming] - ) + it 'returns all work items with any weight' do + expect(execute).to contain_exactly(work_item_with_weight_5, work_item_with_weight_10) + end + end - expect(::Search::Elastic::WorkItemQueryBuilder).to receive(:build) - .with(query: described_class::QUERY, options: expected_params) - .and_call_original + context 'when weight_wildcard_id with NONE provided' do + let(:params) do + { weight_wildcard_id: 'NONE' } + end - execute + it 'returns work items without weight' do + expect(execute).to contain_exactly(work_item_without_weight) end end end - context 'when multiple assignee usernames provided' do - before do - params[:assignee_usernames] = [assignee_user.username, other_user.username] - end + context 'when searching for assignee' do + let(:user1) { create(:user) } + let(:user2) { create(:user) } + let(:user3) { create(:user) } + let(:work_item_assigned_to_user1) { create(:work_item, project: project, assignees: [user1]) } + let(:work_item_assigned_to_user2) { create(:work_item, project: project, assignees: [user2]) } + let(:work_item_assigned_to_user3) { create(:work_item, project: project, assignees: [user3]) } + let(:work_item_assigned_to_multiple) { create(:work_item, project: project, assignees: [user1, user2]) } + let(:work_item_without_assignee) { create(:work_item, project: project, assignees: []) } - it 'calls WorkItemQueryBuilder with multiple assignee_ids parameter' do - expected_params = expected_base_params.merge(group_scope_params).merge( - assignee_ids: [assignee_user.id, other_user.id] + before do + Elastic::ProcessBookkeepingService.track!( + work_item_assigned_to_user1, + work_item_assigned_to_user2, + work_item_assigned_to_user3, + work_item_assigned_to_multiple, + work_item_without_assignee ) - expect(::Search::Elastic::WorkItemQueryBuilder).to receive(:build) - .with(query: described_class::QUERY, options: expected_params) - .and_call_original - - execute + ensure_elasticsearch_index! end - end - context 'when not_assignee_usernames param provided' do - before do - params[:not][:assignee_usernames] = [assignee_user.username] - end + context 'when assignee username provided' do + let(:params) do + { assignee_usernames: [user1.username] } + end - it 'calls WorkItemQueryBuilder with not_assignee_ids parameter' do - expected_params = expected_base_params.merge(group_scope_params).merge( - not_assignee_ids: [assignee_user.id] - ) + it 'returns work items assigned to specified user' do + expect(execute).to contain_exactly(work_item_assigned_to_user1, work_item_assigned_to_multiple) + end + end - expect(::Search::Elastic::WorkItemQueryBuilder).to receive(:build) - .with(query: described_class::QUERY, options: expected_params) - .and_call_original + context 'when multiple assignee usernames provided' do + let(:params) do + { assignee_usernames: [user1.username, user2.username] } + end - execute + it 'returns work items assigned to all specified users' do + expect(execute).to contain_exactly(work_item_assigned_to_multiple) + end end - end - context 'when or assignee param provided' do - before do - params[:or] = { assignee_usernames: [assignee_user.username, other_user.username] } - end + context 'when not_assignee_usernames param provided' do + let(:params) do + { + not: { + assignee_usernames: [user2.username] + } + } + end - it 'calls WorkItemQueryBuilder with or_assignee_ids parameter' do - expected_params = expected_base_params.merge(group_scope_params).merge( - or_assignee_ids: [assignee_user.id, other_user.id] - ) + it 'returns work items not assigned to specified user' do + expect(execute).to contain_exactly( + work_item_assigned_to_user1, + work_item_assigned_to_user3, + work_item_without_assignee + ) + end + end - expect(::Search::Elastic::WorkItemQueryBuilder).to receive(:build) - .with(query: described_class::QUERY, options: expected_params) - .and_call_original + context 'when or assignee param provided' do + let(:params) do + { + or: { + assignee_usernames: [user1.username, user3.username] + } + } + end - execute + it 'returns work items assigned to any of the specified users' do + expect(execute).to contain_exactly( + work_item_assigned_to_user1, + work_item_assigned_to_user3, + work_item_assigned_to_multiple + ) + end end - end - context 'when any_assignees param provided (assignee wildcard)' do - before do - params[:assignee_wildcard_id] = described_class::FILTER_ANY - end + context 'when assignee_wildcard_id with ANY provided' do + let(:params) do + { assignee_wildcard_id: 'ANY' } + end - it 'calls WorkItemQueryBuilder with any_assignees parameter' do - expected_params = expected_base_params.merge(group_scope_params).merge( - any_assignees: true - ) + it 'returns all work items with any assignee' do + expect(execute).to contain_exactly( + work_item_assigned_to_user1, + work_item_assigned_to_user2, + work_item_assigned_to_user3, + work_item_assigned_to_multiple + ) + end + end - expect(::Search::Elastic::WorkItemQueryBuilder).to receive(:build) - .with(query: described_class::QUERY, options: expected_params) - .and_call_original + context 'when assignee_wildcard_id with NONE provided' do + let(:params) do + { assignee_wildcard_id: 'NONE' } + end - execute + it 'returns work items without assignee' do + expect(execute).to contain_exactly(work_item_without_assignee) + end end end - context 'when none_assignees param provided (assignee wildcard)' do - before do - params[:assignee_wildcard_id] = described_class::FILTER_NONE - end - - it 'calls WorkItemQueryBuilder with none_assignees parameter' do - expected_params = expected_base_params.merge(group_scope_params).merge( - none_assignees: true - ) - - expect(::Search::Elastic::WorkItemQueryBuilder).to receive(:build) - .with(query: described_class::QUERY, options: expected_params) - .and_call_original + context 'when searching for label_names' do + let(:bug_label) { create(:label, project: project, title: 'bug') } + let(:feature_label) { create(:label, project: project, title: 'feature') } + let(:scoped_group_label) { create(:label, project: project, title: 'group::knowledge') } + let(:different_scoped_label) { create(:label, project: project, title: 'priority::high') } - execute + let(:work_item_with_bug_label) { create(:work_item, project: project, labels: [bug_label]) } + let(:work_item_with_feature_label) { create(:work_item, project: project, labels: [feature_label]) } + let(:work_item_with_scoped_group_label) { create(:work_item, project: project, labels: [scoped_group_label]) } + let(:work_item_with_different_scoped_label) do + create(:work_item, project: project, labels: [different_scoped_label]) end - end - context 'when label_names param provided' do - before do - params[:label_name] = ['workflow::complete', 'backend'] + let(:work_item_with_multiple_labels) do + create(:work_item, project: project, labels: [bug_label, scoped_group_label]) end - it 'calls WorkItemQueryBuilder with label_names parameter' do - expected_params = expected_base_params.merge(group_scope_params).merge( - label_names: ['workflow::complete', 'backend'] - ) - - expect(::Search::Elastic::WorkItemQueryBuilder).to receive(:build) - .with(query: described_class::QUERY, options: expected_params) - .and_call_original - - execute - end - end + let(:work_item_without_labels) { create(:work_item, project: project, labels: []) } - context 'when label_names param with wildcard provided' do before do - params[:label_name] = ['workflow::*', 'frontend'] - end - - it 'calls WorkItemQueryBuilder with wildcard label_names parameter' do - expected_params = expected_base_params.merge(group_scope_params).merge( - label_names: ['workflow::*', 'frontend'] + Elastic::ProcessBookkeepingService.track!( + work_item_with_bug_label, + work_item_with_feature_label, + work_item_with_scoped_group_label, + work_item_with_different_scoped_label, + work_item_with_multiple_labels, + work_item_without_labels ) - expect(::Search::Elastic::WorkItemQueryBuilder).to receive(:build) - .with(query: described_class::QUERY, options: expected_params) - .and_call_original - - execute + ensure_elasticsearch_index! end - end - context 'when not_label_names param provided' do - before do - params[:not][:label_name] = ['workflow::in dev'] - end + context 'when label_names param provided' do + let(:params) do + { label_name: ['bug'] } + end - it 'calls WorkItemQueryBuilder with not_label_names parameter' do - expected_params = expected_base_params.merge(group_scope_params).merge( - not_label_names: ['workflow::in dev'] - ) + it 'returns work items with specified label' do + expect(execute).to contain_exactly(work_item_with_bug_label, work_item_with_multiple_labels) + end + end - expect(::Search::Elastic::WorkItemQueryBuilder).to receive(:build) - .with(query: described_class::QUERY, options: expected_params) - .and_call_original + context 'when label_names param with wildcard provided' do + let(:params) do + { label_name: ['group::*'] } + end - execute + it 'returns work items with scoped labels matching wildcard pattern' do + expect(execute).to contain_exactly(work_item_with_scoped_group_label, work_item_with_multiple_labels) + end end - end - context 'when not_label_names param with wildcard provided' do - before do - params[:not][:label_name] = ['group::*'] - end + context 'when not_label_names param provided' do + let(:params) do + { + not: { + label_name: ['bug'] + } + } + end - it 'calls WorkItemQueryBuilder with wildcard not_label_names parameter' do - expected_params = expected_base_params.merge(group_scope_params).merge( - not_label_names: ['group::*'] - ) + it 'returns work items without specified label' do + expect(execute).to contain_exactly( + work_item_with_feature_label, + work_item_with_scoped_group_label, + work_item_with_different_scoped_label, + work_item_without_labels + ) + end + end - expect(::Search::Elastic::WorkItemQueryBuilder).to receive(:build) - .with(query: described_class::QUERY, options: expected_params) - .and_call_original + context 'when or_label_names param provided' do + let(:params) do + { + or: { + label_names: %w[bug feature] + } + } + end - execute + it 'returns work items with any of the specified labels' do + expect(execute).to contain_exactly( + work_item_with_bug_label, + work_item_with_feature_label, + work_item_with_multiple_labels + ) + end end - end - context 'when or_label_names param provided' do - before do - params[:or] = { label_names: ['workflow::complete', 'group::knowledge'] } - end + context 'when label_name with ANY provided' do + let(:params) do + { label_name: ['ANY'] } + end - it 'calls WorkItemQueryBuilder with or_label_names parameter' do - expected_params = expected_base_params.merge(group_scope_params).merge( - or_label_names: ['workflow::complete', 'group::knowledge'] - ) + it 'returns all work items with any label' do + expect(execute).to contain_exactly( + work_item_with_bug_label, + work_item_with_feature_label, + work_item_with_scoped_group_label, + work_item_with_different_scoped_label, + work_item_with_multiple_labels + ) + end + end - expect(::Search::Elastic::WorkItemQueryBuilder).to receive(:build) - .with(query: described_class::QUERY, options: expected_params) - .and_call_original + context 'when label_name with NONE provided' do + let(:params) do + { label_name: ['NONE'] } + end - execute + it 'returns work items without any labels' do + expect(execute).to contain_exactly(work_item_without_labels) + end end end - context 'when or_label_names param with wildcard provided' do - before do - params[:or] = { label_names: ['workflow::*', 'backend'] } - end + context 'when searching for health_status' do + let(:work_item_on_track) { create(:work_item, project: project, health_status: 'on_track') } + let(:work_item_needs_attention) { create(:work_item, project: project, health_status: 'needs_attention') } + let(:work_item_at_risk) { create(:work_item, project: project, health_status: 'at_risk') } + let(:work_item_without_health_status) { create(:work_item, project: project, health_status: nil) } - it 'calls WorkItemQueryBuilder with wildcard or_label_names parameter' do - expected_params = expected_base_params.merge(group_scope_params).merge( - or_label_names: ['workflow::*', 'backend'] + before do + Elastic::ProcessBookkeepingService.track!( + work_item_on_track, + work_item_needs_attention, + work_item_at_risk, + work_item_without_health_status ) - expect(::Search::Elastic::WorkItemQueryBuilder).to receive(:build) - .with(query: described_class::QUERY, options: expected_params) - .and_call_original - - execute + ensure_elasticsearch_index! end - end - context 'when any_label_names param provided (label wildcard)' do - before do - params[:label_name] = [described_class::FILTER_ANY] - end + context 'when health_status param provided' do + let(:params) do + { health_status_filter: 'on_track' } + end - it 'calls WorkItemQueryBuilder with any_label_names parameter' do - expected_params = expected_base_params.merge(group_scope_params).merge( - label_names: nil, any_label_names: true - ) + it 'returns work items with specified health status' do + expect(execute).to contain_exactly(work_item_on_track) + end + end - expect(::Search::Elastic::WorkItemQueryBuilder).to receive(:build) - .with(query: described_class::QUERY, options: expected_params) - .and_call_original + context 'when multiple health_status values provided' do + let(:params) do + { health_status_filter: %w[on_track at_risk] } + end - execute + it 'returns work items with any of the specified health statuses' do + expect(execute).to contain_exactly(work_item_on_track, work_item_at_risk) + end end - end - context 'when none_label_names param provided (label wildcard)' do - before do - params[:label_name] = [described_class::FILTER_NONE] + context 'when not_health_status param provided' do + let(:params) do + { + not: { + health_status_filter: ['needs_attention'] + } + } + end + + it 'returns work items without specified health status' do + expect(execute).to contain_exactly(work_item_on_track, work_item_at_risk, work_item_without_health_status) + end end - it 'calls WorkItemQueryBuilder with none_label_names parameter' do - expected_params = expected_base_params.merge(group_scope_params).merge( - label_names: nil, none_label_names: true - ) + context 'when health_status_filter with ANY provided' do + let(:params) do + { health_status_filter: 'ANY' } + end - expect(::Search::Elastic::WorkItemQueryBuilder).to receive(:build) - .with(query: described_class::QUERY, options: expected_params) - .and_call_original + it 'returns all work items with any health status' do + expect(execute).to contain_exactly(work_item_on_track, work_item_needs_attention, work_item_at_risk) + end + end - execute + context 'when health_status_filter with NONE provided' do + let(:params) do + { health_status_filter: 'NONE' } + end + + it 'returns work items without health status' do + expect(execute).to contain_exactly(work_item_without_health_status) + end end end - context 'when mixed NONE with nested NOT label provided' do + context 'when searching for state' do + let(:opened_work_item) { create(:work_item, project: project, state: 'opened') } + let(:closed_work_item) { create(:work_item, project: project, state: 'closed') } + before do - params[:label_name] = [described_class::FILTER_NONE] - params[:not][:label_name] = ['workflow::in dev'] - end + Elastic::ProcessBookkeepingService.track!(opened_work_item, closed_work_item) - it 'calls WorkItemQueryBuilder with mixed NONE and NOT label parameters' do - expected_params = expected_base_params.merge(group_scope_params).merge( - label_names: nil, - none_label_names: true, - not_label_names: ['workflow::in dev'] - ) + ensure_elasticsearch_index! + end - expect(::Search::Elastic::WorkItemQueryBuilder).to receive(:build) - .with(query: described_class::QUERY, options: expected_params) - .and_call_original + context 'when state param is opened' do + let(:params) do + { state: 'opened' } + end - execute + it 'returns only opened work items' do + expect(execute).to contain_exactly(opened_work_item) + end end - end - context 'when mixed NONE with OR labels provided' do - before do - params[:label_name] = [described_class::FILTER_NONE] - params[:or] = { label_names: %w[frontend backend] } + context 'when state param is closed' do + let(:params) do + { state: 'closed' } + end + + it 'returns only closed work items' do + expect(execute).to contain_exactly(closed_work_item) + end end - it 'calls WorkItemQueryBuilder with mixed NONE and OR label parameters' do - expected_params = expected_base_params.merge(group_scope_params).merge( - label_names: nil, - none_label_names: true, - or_label_names: %w[frontend backend] - ) + context 'when state param is all' do + let(:params) do + { state: 'all' } + end - expect(::Search::Elastic::WorkItemQueryBuilder).to receive(:build) - .with(query: described_class::QUERY, options: expected_params) - .and_call_original + it 'returns all work items regardless of state' do + expect(execute).to contain_exactly(opened_work_item, closed_work_item) + end + end - execute + context 'when state param is not provided' do + it 'returns all work items regardless of state' do + expect(execute).to contain_exactly(opened_work_item, closed_work_item) + end end end - context 'when mixed ANY with nested NOT label provided' do + context 'when searching for issue_types' do + let(:issue) { create(:work_item, :issue, project: project) } + let(:task) { create(:work_item, :task, project: project) } + let(:requirement) { create(:work_item, :requirement, project: project) } + before do - params[:label_name] = [described_class::FILTER_ANY] - params[:not][:label_name] = ['workflow::in dev'] - end + Elastic::ProcessBookkeepingService.track!(issue, task, requirement) - it 'calls WorkItemQueryBuilder with mixed ANY and NOT label parameters' do - expected_params = expected_base_params.merge(group_scope_params).merge( - label_names: nil, - any_label_names: true, - not_label_names: ['workflow::in dev'] - ) + ensure_elasticsearch_index! + end - expect(::Search::Elastic::WorkItemQueryBuilder).to receive(:build) - .with(query: described_class::QUERY, options: expected_params) - .and_call_original + context 'when issue_types param with single type provided' do + let(:params) do + { issue_types: ['issue'] } + end - execute + it 'returns only work items of specified type' do + expect(execute).to contain_exactly(issue) + end end - end - context 'when mixed ANY with OR labels provided' do - before do - params[:label_name] = [described_class::FILTER_ANY] - params[:or] = { label_names: %w[frontend backend] } - end + context 'when issue_types param with multiple types provided' do + let(:params) do + { issue_types: %w[issue task] } + end - it 'calls WorkItemQueryBuilder with mixed ANY and OR label parameters' do - expected_params = expected_base_params.merge(group_scope_params).merge( - label_names: nil, - any_label_names: true, - or_label_names: %w[frontend backend] - ) + it 'returns work items of any specified types' do + expect(execute).to contain_exactly(issue, task) + end + end - expect(::Search::Elastic::WorkItemQueryBuilder).to receive(:build) - .with(query: described_class::QUERY, options: expected_params) - .and_call_original + context 'when issue_types param with requirement type provided' do + let(:params) do + { issue_types: ['requirement'] } + end - execute + it 'returns only requirement work items' do + expect(execute).to contain_exactly(requirement) + end end - end - context 'when complex label filtering with wildcards provided' do - before do - params[:label_name] = ['workflow::complete'] - params[:not][:label_name] = ['group::*'] - params[:or] = { label_names: ['workflow::*', 'frontend'] } + context 'when issue_types param is not provided' do + it 'returns all work items regardless of type' do + expect(execute).to contain_exactly(issue, task, requirement) + end end - it 'calls WorkItemQueryBuilder with complex label filtering parameters' do - expected_params = expected_base_params.merge(group_scope_params).merge( - label_names: ['workflow::complete'], - not_label_names: ['group::*'], - or_label_names: ['workflow::*', 'frontend'] - ) - - expect(::Search::Elastic::WorkItemQueryBuilder).to receive(:build) - .with(query: described_class::QUERY, options: expected_params) - .and_call_original + context 'when issue_types param with all supported types provided' do + let(:params) do + { issue_types: %w[issue task requirement] } + end - execute + it 'returns work items of all specified types' do + expect(execute).to contain_exactly(issue, task, requirement) + end end end - context 'when not_health_status param provided' do - before do - params[:not][:health_status_filter] = work_item1.health_status + context 'when searching for confidential' do + let(:confidential_work_item) { create(:work_item, project: project, confidential: true) } + let(:non_confidential_work_item) { create(:work_item, project: project, confidential: false) } + + before_all do + project.add_owner(current_user) end - it 'calls WorkItemQueryBuilder with not_health_status parameter' do - expected_params = expected_base_params.merge(group_scope_params).merge( - not_health_status: [::WorkItem.health_statuses[work_item1.health_status]] + before do + Elastic::ProcessBookkeepingService.track!( + confidential_work_item, + non_confidential_work_item ) - expect(::Search::Elastic::WorkItemQueryBuilder).to receive(:build) - .with(query: described_class::QUERY, options: expected_params) - .and_call_original - - execute + ensure_elasticsearch_index! end - end - context 'when any_health_status param provided' do - before do - params[:health_status_filter] = described_class::FILTER_ANY - end + context 'when confidential param is true' do + let(:params) do + { confidential: true } + end - it 'calls WorkItemQueryBuilder with any_health_status parameter' do - expected_params = expected_base_params.merge(group_scope_params).merge( - health_status: nil, any_health_status: true - ) + it 'returns only confidential work items' do + expect(execute).to contain_exactly(confidential_work_item) + end + end - expect(::Search::Elastic::WorkItemQueryBuilder).to receive(:build) - .with(query: described_class::QUERY, options: expected_params) - .and_call_original + context 'when confidential param is false' do + let(:params) do + { confidential: false } + end - execute + it 'returns only non-confidential work items' do + expect(execute).to contain_exactly(non_confidential_work_item) + end end - end - context 'when none_health_status param provided' do - before do - params[:health_status_filter] = described_class::FILTER_NONE + context 'when confidential param is not provided' do + it 'returns all work items regardless of confidential status' do + expect(execute).to contain_exactly(confidential_work_item, non_confidential_work_item) + end end - it 'calls WorkItemQueryBuilder with none_health_status parameter' do - expected_params = expected_base_params.merge(group_scope_params).merge( - health_status: nil, none_health_status: true - ) + context 'when user lacks permissions for confidential items' do + let_it_be(:guest_user) { create(:user) } + let(:params) { { confidential: true } } + let(:finder) { described_class.new(guest_user, context, resource_parent, params) } - expect(::Search::Elastic::WorkItemQueryBuilder).to receive(:build) - .with(query: described_class::QUERY, options: expected_params) - .and_call_original + before_all do + project.add_guest(guest_user) + end - execute + it 'returns no confidential work items due to insufficient permissions' do + expect(execute).to be_empty + end end end end - describe 'parameter processing methods' do - before do - finder.parent_param = resource_parent - end - - it 'correctly processes label params' do - label_params = finder.send(:label_params) - expect(label_params).to include( - label_names: ['test-label'], - none_label_names: false, - any_label_names: false - ) - end + context 'when resource_parent is a Project' do + let_it_be(:other_project) { create(:project, namespace: group) } + let(:resource_parent) { project } - it 'correctly processes assignee params' do - assignee_params = finder.send(:assignee_params) - expect(assignee_params).to include( - assignee_ids: [assignee_user.id], - none_assignees: false, - any_assignees: false - ) - end + let_it_be(:work_item_in_project) { create(:work_item, project: project) } + let_it_be(:work_item_in_other_project) { create(:work_item, project: other_project) } - it 'correctly processes health status params' do - health_params = finder.send(:health_status_params) - expect(health_params).to include( - health_status: [::WorkItem.health_statuses[work_item1.health_status]], - none_health_status: false, - any_health_status: false - ) + before_all do + project.add_reporter(current_user) + other_project.add_reporter(current_user) end - it 'correctly processes weight params' do - weight_params = finder.send(:weight_params) - expect(weight_params).to include( - weight: work_item1.weight, - none_weight: false, - any_weight: false + before do + Elastic::ProcessBookkeepingService.track!( + work_item_in_project, + work_item_in_other_project ) - end - it 'correctly processes milestone params' do - milestone_params = finder.send(:milestone_params) - expect(milestone_params).to include( - milestone_title: [milestone.title], - none_milestones: false, - any_milestones: false - ) + ensure_elasticsearch_index! end - it 'correctly processes author params' do - author_params = finder.send(:author_params) - expect(author_params).to include( - author_username: current_user.username, - not_author_username: nil - ) + it 'returns work items only from the specified project' do + expect(execute).to contain_exactly(work_item_in_project) end end end diff --git a/ee/spec/lib/search/elastic/relation_spec.rb b/ee/spec/lib/search/elastic/relation_spec.rb index f70e0977b040d9..b4f44b485f2014 100644 --- a/ee/spec/lib/search/elastic/relation_spec.rb +++ b/ee/spec/lib/search/elastic/relation_spec.rb @@ -155,4 +155,10 @@ expect(relation.to_a).to eq(all_vulnerabilities) end end + + describe '#size' do + it 'returns the total count of records' do + expect(relation.size).to eq(all_vulnerabilities.count) + end + end end -- GitLab From 77f5ea4602a0fc3793ef8acabf2d0acde3e262ef Mon Sep 17 00:00:00 2001 From: Alisa Frunza Date: Fri, 1 Aug 2025 11:45:09 +0200 Subject: [PATCH 04/15] Fix projects ids by building the correct hierarchy --- .../ee/work_items/glql/work_items_finder.rb | 62 ++++++++++++------- .../work_items/glql/work_items_finder_spec.rb | 21 +++++++ 2 files changed, 62 insertions(+), 21 deletions(-) diff --git a/ee/app/finders/ee/work_items/glql/work_items_finder.rb b/ee/app/finders/ee/work_items/glql/work_items_finder.rb index 4a643b515032ab..6e42e63201012c 100644 --- a/ee/app/finders/ee/work_items/glql/work_items_finder.rb +++ b/ee/app/finders/ee/work_items/glql/work_items_finder.rb @@ -123,12 +123,10 @@ def base_params work_item_type_ids: type_ids_from(params[:issue_types]), current_user: current_user, public_and_internal_projects: false, - search_level: search_level, - project_ids: ([resource_parent.id] if project_scope?), - group_id: (resource_parent.id if group_scope?), - group_ids: ([resource_parent.id] if group_scope?), - root_ancestor_ids: root_ancestor_ids + root_ancestor_ids: [resource_parent.root_ancestor.id] }.merge( + project_params, + group_params, label_params, author_params, milestone_params, @@ -139,6 +137,44 @@ def base_params ).compact end + def project_scope? + parent_param == :project_id + end + + def group_scope? + parent_param == :group_id + end + + def group_params + return {} unless group_scope? + + { + search_level: 'group', + group_ids: [resource_parent.id], + group_id: resource_parent.id, + project_ids: project_ids + } + end + + def project_params + return {} unless project_scope? + + { + search_level: 'project', + project_ids: [resource_parent.id] + } + end + + def project_ids + projects = ::ProjectsFinder.new(current_user: current_user).execute + + return [] unless projects.present? + + projects + .inside_path(resource_parent.full_path) + .pluck_primary_key + end + def label_params { label_names: label_names(params[:label_name]), @@ -223,22 +259,6 @@ def scope_param end end - def search_level - project_scope? ? 'project' : 'group' - end - - def root_ancestor_ids - [resource_parent.id] - end - - def project_scope? - parent_param == :project_id - end - - def group_scope? - parent_param == :group_id - end - def any_milestones? params[:milestone_wildcard_id].to_s.downcase == FILTER_ANY end diff --git a/ee/spec/finders/ee/work_items/glql/work_items_finder_spec.rb b/ee/spec/finders/ee/work_items/glql/work_items_finder_spec.rb index de721124720864..6dde17a7ecddf6 100644 --- a/ee/spec/finders/ee/work_items/glql/work_items_finder_spec.rb +++ b/ee/spec/finders/ee/work_items/glql/work_items_finder_spec.rb @@ -915,6 +915,27 @@ it 'returns work items only from the specified project' do expect(execute).to contain_exactly(work_item_in_project) end + + context 'when resource_parent is a private Project' do + let_it_be(:private_project) { create(:project, :private, namespace: group) } + let_it_be(:user_without_access) { create(:user) } + let(:resource_parent) { private_project } + + let_it_be(:work_item_in_private_project) { create(:work_item, project: private_project) } + + before do + Elastic::ProcessBookkeepingService.track!(work_item_in_private_project) + ensure_elasticsearch_index! + end + + context 'when user has no access to private project' do + let(:finder) { described_class.new(user_without_access, context, resource_parent, params) } + + it 'returns no work items due to insufficient permissions' do + expect(execute).to be_empty + end + end + end end end end -- GitLab From 70ced509fffa58eec0a022d1feb197eca9a1f826 Mon Sep 17 00:00:00 2001 From: Alisa Frunza Date: Fri, 1 Aug 2025 13:31:27 +0200 Subject: [PATCH 05/15] Preload associations in inside path --- app/models/project.rb | 4 ++++ .../ee/work_items/glql/work_items_finder.rb | 2 +- spec/models/project_spec.rb | 20 ++++++++++++++++--- 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/app/models/project.rb b/app/models/project.rb index 5e8bc4e9b95042..15fc858398786a 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -868,6 +868,10 @@ def with_developer_access .where('rs.path LIKE ?', "#{sanitize_sql_like(path)}/%") .allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/421843') end + scope :inside_path_preloaded, ->(path) do + preload(:topics, :project_topics, :route) + .inside_path(path) + end scope :with_jira_installation, ->(installation_id) do joins(namespace: :jira_connect_subscriptions) diff --git a/ee/app/finders/ee/work_items/glql/work_items_finder.rb b/ee/app/finders/ee/work_items/glql/work_items_finder.rb index 6e42e63201012c..c1be136bc2d0e2 100644 --- a/ee/app/finders/ee/work_items/glql/work_items_finder.rb +++ b/ee/app/finders/ee/work_items/glql/work_items_finder.rb @@ -171,7 +171,7 @@ def project_ids return [] unless projects.present? projects - .inside_path(resource_parent.full_path) + .inside_path_preloaded(resource_parent.full_path) .pluck_primary_key end diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index 348c3ebc670e1b..e60782ef3db6c3 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -5446,14 +5446,28 @@ def create_project_statistics_with_size(project, size) end end - describe 'inside_path' do + context 'with inside_path' do let!(:project1) { create(:project, namespace: create(:namespace, path: 'name_pace')) } let!(:project2) { create(:project) } let!(:project3) { create(:project, namespace: create(:namespace, path: 'namespace')) } let!(:path) { project1.namespace.full_path } - it 'returns correct project' do - expect(described_class.inside_path(path)).to eq([project1]) + describe 'inside_path' do + it 'returns correct project' do + expect(described_class.inside_path(path)).to eq([project1]) + end + end + + describe '.inside_path_preloaded' do + it 'preloads the specified associations' do + projects = described_class.inside_path_preloaded(path) + + project = projects.first + + expect(project.association(:topics)).to be_loaded + expect(project.association(:project_topics)).to be_loaded + expect(project.association(:route)).to be_loaded + end end end -- GitLab From 15978226e4c0848f67a755935719dec95ff92851 Mon Sep 17 00:00:00 2001 From: Alisa Frunza Date: Tue, 5 Aug 2025 16:04:23 +0200 Subject: [PATCH 06/15] Fix failing spec by making sure we route ES query in stubbed test env --- spec/graphql/resolvers/work_items_resolver_spec.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/spec/graphql/resolvers/work_items_resolver_spec.rb b/spec/graphql/resolvers/work_items_resolver_spec.rb index 16d174c9835fbd..9a870bb56e2a32 100644 --- a/spec/graphql/resolvers/work_items_resolver_spec.rb +++ b/spec/graphql/resolvers/work_items_resolver_spec.rb @@ -324,7 +324,7 @@ end end - context 'when searching for work items in ES for GLQL request' do + context 'when searching for work items in ES for GLQL request', :elastic_delete_by_query, :sidekiq_inline do let(:request_params) { { 'operationName' => 'GLQL' } } let(:glql_ctx) do { request: instance_double(ActionDispatch::Request, params: request_params, referer: 'http://localhost') } @@ -338,6 +338,7 @@ context 'when feature flag is enabled' do before do stub_feature_flags(glql_es_integration: true) + stub_ee_application_setting(elasticsearch_search: true, elasticsearch_indexing: true) end it 'uses GLQL WorkItemsFinder' do -- GitLab From 82da3e9f893d0cc7e12a39a326ecbad8237df80a Mon Sep 17 00:00:00 2001 From: Alisa Frunza Date: Wed, 6 Aug 2025 15:00:42 +0200 Subject: [PATCH 07/15] Fix failing spec and add test case for multiple projects --- .../work_items/glql/work_items_finder_spec.rb | 44 +++++++++++++++++++ .../resolvers/work_items_resolver_spec.rb | 16 +++++-- 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/ee/spec/finders/ee/work_items/glql/work_items_finder_spec.rb b/ee/spec/finders/ee/work_items/glql/work_items_finder_spec.rb index 6dde17a7ecddf6..6962d58128d5af 100644 --- a/ee/spec/finders/ee/work_items/glql/work_items_finder_spec.rb +++ b/ee/spec/finders/ee/work_items/glql/work_items_finder_spec.rb @@ -937,5 +937,49 @@ end end end + + context 'when resource_parent is a public Group with mixed project visibility' do + let_it_be(:public_group) { create(:group, :public) } + let_it_be(:public_project_in_group) { create(:project, :public, namespace: public_group) } + let_it_be(:private_project_in_group) { create(:project, :private, namespace: public_group) } + let_it_be(:user_without_private_access) { create(:user) } + + let(:resource_parent) { public_group } + + let_it_be(:work_item_in_public_project) { create(:work_item, project: public_project_in_group) } + let_it_be(:work_item_in_private_project) { create(:work_item, project: private_project_in_group) } + + before_all do + # Give current_user access to both projects, + # while user_without_private_access has no explicit access to private project + public_project_in_group.add_reporter(current_user) + private_project_in_group.add_reporter(current_user) + end + + before do + Elastic::ProcessBookkeepingService.track!( + work_item_in_public_project, + work_item_in_private_project + ) + + ensure_elasticsearch_index! + end + + context 'when user has access to both projects' do + let(:finder) { described_class.new(current_user, context, resource_parent, params) } + + it 'returns work items from both public and private projects' do + expect(execute).to contain_exactly(work_item_in_public_project, work_item_in_private_project) + end + end + + context 'when user has access only to public project' do + let(:finder) { described_class.new(user_without_private_access, context, resource_parent, params) } + + it 'returns only work items from public project' do + expect(execute).to contain_exactly(work_item_in_public_project) + end + end + end end end diff --git a/spec/graphql/resolvers/work_items_resolver_spec.rb b/spec/graphql/resolvers/work_items_resolver_spec.rb index 9a870bb56e2a32..49b33abe813f57 100644 --- a/spec/graphql/resolvers/work_items_resolver_spec.rb +++ b/spec/graphql/resolvers/work_items_resolver_spec.rb @@ -324,7 +324,7 @@ end end - context 'when searching for work items in ES for GLQL request', :elastic_delete_by_query, :sidekiq_inline do + context 'when searching for work items in ES for GLQL request' do let(:request_params) { { 'operationName' => 'GLQL' } } let(:glql_ctx) do { request: instance_double(ActionDispatch::Request, params: request_params, referer: 'http://localhost') } @@ -333,18 +333,26 @@ before do allow(Gitlab::CurrentSettings).to receive(:elasticsearch_search?).and_return(true) allow(project).to receive(:use_elasticsearch?).and_return(true) + + allow(Gitlab::Search::Client).to receive(:execute_search).and_yield({ + 'hits' => { + 'hits' => [ + { '_id' => item1.id.to_s, '_source' => { 'id' => item1.id } } + ], + 'total' => { 'value' => 1 } + } + }) end context 'when feature flag is enabled' do before do stub_feature_flags(glql_es_integration: true) - stub_ee_application_setting(elasticsearch_search: true, elasticsearch_indexing: true) end it 'uses GLQL WorkItemsFinder' do - expect(::WorkItems::Glql::WorkItemsFinder).to receive(:new).and_call_original + result = batch_sync { resolve_items({ label_name: item1.labels }, glql_ctx).to_a } - batch_sync { resolve_items({ label_name: item1.labels }, glql_ctx).to_a } + expect(result).to contain_exactly(item1) end end -- GitLab From 1db24d47e8e7fecf5aa4dabe135f4578214809de Mon Sep 17 00:00:00 2001 From: Alisa Frunza Date: Wed, 6 Aug 2025 15:05:36 +0200 Subject: [PATCH 08/15] Refactor project_ids method --- ee/app/finders/ee/work_items/glql/work_items_finder.rb | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ee/app/finders/ee/work_items/glql/work_items_finder.rb b/ee/app/finders/ee/work_items/glql/work_items_finder.rb index c1be136bc2d0e2..505a81b042ad7c 100644 --- a/ee/app/finders/ee/work_items/glql/work_items_finder.rb +++ b/ee/app/finders/ee/work_items/glql/work_items_finder.rb @@ -166,13 +166,13 @@ def project_params end def project_ids - projects = ::ProjectsFinder.new(current_user: current_user).execute + projects = ::ProjectsFinder.new(current_user: current_user) + .execute + .inside_path_preloaded(resource_parent.full_path) return [] unless projects.present? - projects - .inside_path_preloaded(resource_parent.full_path) - .pluck_primary_key + projects.pluck_primary_key end def label_params -- GitLab From 32f2ca2ffbd3fc74256d6b67357b4cb4fd7a1a53 Mon Sep 17 00:00:00 2001 From: Alisa Frunza Date: Thu, 7 Aug 2025 11:52:12 +0200 Subject: [PATCH 09/15] Move test to EE and remove unnecessary stubs --- .../work_items/glql/work_items_finder_spec.rb | 2 - .../ee/resolvers/work_items_resolver_spec.rb | 47 +++++++++++++------ .../resolvers/work_items_resolver_spec.rb | 45 ------------------ 3 files changed, 33 insertions(+), 61 deletions(-) diff --git a/ee/spec/finders/ee/work_items/glql/work_items_finder_spec.rb b/ee/spec/finders/ee/work_items/glql/work_items_finder_spec.rb index 6962d58128d5af..8ee3fcad6d953a 100644 --- a/ee/spec/finders/ee/work_items/glql/work_items_finder_spec.rb +++ b/ee/spec/finders/ee/work_items/glql/work_items_finder_spec.rb @@ -31,8 +31,6 @@ before do allow(context).to receive(:[]).with(:request).and_return(dummy_request) - allow(Gitlab::CurrentSettings).to receive(:elasticsearch_search?).and_return(true) - allow(resource_parent).to receive(:use_elasticsearch?).and_return(true) stub_ee_application_setting(elasticsearch_search: true, elasticsearch_indexing: true) end diff --git a/ee/spec/graphql/ee/resolvers/work_items_resolver_spec.rb b/ee/spec/graphql/ee/resolvers/work_items_resolver_spec.rb index 977f18a36b57c4..e8833d521635f1 100644 --- a/ee/spec/graphql/ee/resolvers/work_items_resolver_spec.rb +++ b/ee/spec/graphql/ee/resolvers/work_items_resolver_spec.rb @@ -291,27 +291,46 @@ end end - context 'when filtering by parent_ids' do - let(:epic_work_item) { create(:work_item, :epic) } - let(:issue_work_item) { create(:work_item, :issue, project: project) } - let(:task_work_item) { create(:work_item, :task, project: project) } + context 'when searching for work items in ES for GLQL request' do + let(:request_params) { { 'operationName' => 'GLQL' } } + let(:glql_ctx) do + { request: instance_double(ActionDispatch::Request, params: request_params, referer: 'http://localhost') } + end before do - create(:parent_link, work_item_parent: epic_work_item, work_item: issue_work_item) - create(:parent_link, work_item_parent: issue_work_item, work_item: task_work_item) + stub_ee_application_setting(elasticsearch_search: true, elasticsearch_indexing: true) + + allow(Gitlab::Search::Client).to receive(:execute_search).and_yield({ + 'hits' => { + 'hits' => [ + { '_id' => work_item1.id.to_s, '_source' => { 'id' => work_item1.id } } + ], + 'total' => { 'value' => 1 } + } + }) end - context 'when include_descendant_work_items is true' do - it 'returns items from descendant work items' do - expect(resolve_items(parent_ids: [epic_work_item], include_descendant_work_items: true)) - .to contain_exactly(issue_work_item, task_work_item) + context 'when feature flag is enabled' do + before do + stub_feature_flags(glql_es_integration: true) + end + + it 'uses GLQL WorkItemsFinder' do + result = batch_sync { resolve_items({ label_name: work_item1.labels }, glql_ctx).to_a } + + expect(result).to contain_exactly(work_item1) end end - context 'when include_descendant_work_items is false' do - it 'does not return items from descendant work items' do - expect(resolve_items(parent_ids: [epic_work_item], include_descendant_work_items: false)) - .to contain_exactly(issue_work_item) + context 'when feature flag is not enabled' do + before do + stub_feature_flags(glql_es_integration: false) + end + + it 'falls back to old WorkItemsFinder' do + expect(::WorkItems::Glql::WorkItemsFinder).not_to receive(:new) + + batch_sync { resolve_items({ label_name: work_item1.labels }, glql_ctx).to_a } end end end diff --git a/spec/graphql/resolvers/work_items_resolver_spec.rb b/spec/graphql/resolvers/work_items_resolver_spec.rb index 49b33abe813f57..158cdab4db6989 100644 --- a/spec/graphql/resolvers/work_items_resolver_spec.rb +++ b/spec/graphql/resolvers/work_items_resolver_spec.rb @@ -324,51 +324,6 @@ end end - context 'when searching for work items in ES for GLQL request' do - let(:request_params) { { 'operationName' => 'GLQL' } } - let(:glql_ctx) do - { request: instance_double(ActionDispatch::Request, params: request_params, referer: 'http://localhost') } - end - - before do - allow(Gitlab::CurrentSettings).to receive(:elasticsearch_search?).and_return(true) - allow(project).to receive(:use_elasticsearch?).and_return(true) - - allow(Gitlab::Search::Client).to receive(:execute_search).and_yield({ - 'hits' => { - 'hits' => [ - { '_id' => item1.id.to_s, '_source' => { 'id' => item1.id } } - ], - 'total' => { 'value' => 1 } - } - }) - end - - context 'when feature flag is enabled' do - before do - stub_feature_flags(glql_es_integration: true) - end - - it 'uses GLQL WorkItemsFinder' do - result = batch_sync { resolve_items({ label_name: item1.labels }, glql_ctx).to_a } - - expect(result).to contain_exactly(item1) - end - end - - context 'when feature flag is not enabled' do - before do - stub_feature_flags(glql_es_integration: false) - end - - it 'falls back to old WorkItemsFinder' do - expect(::WorkItems::Glql::WorkItemsFinder).not_to receive(:new) - - batch_sync { resolve_items({ label_name: item1.labels }, glql_ctx).to_a } - end - end - end - def resolve_items(args = {}, context = {}) context[:current_user] = current_user arg_style = context[:arg_style] ||= :internal -- GitLab From 1f670dfa74f896ff4954453ed1985993a113371e Mon Sep 17 00:00:00 2001 From: Alisa Frunza Date: Thu, 7 Aug 2025 11:58:36 +0200 Subject: [PATCH 10/15] Add note about cleaning up the project_ids --- ee/app/finders/ee/work_items/glql/work_items_finder.rb | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ee/app/finders/ee/work_items/glql/work_items_finder.rb b/ee/app/finders/ee/work_items/glql/work_items_finder.rb index 505a81b042ad7c..3f0d980cb87ab3 100644 --- a/ee/app/finders/ee/work_items/glql/work_items_finder.rb +++ b/ee/app/finders/ee/work_items/glql/work_items_finder.rb @@ -165,6 +165,9 @@ def project_params } end + # NOTE: project_ids should be removed when the traversal_ids + # optimization is implemented for confidentiality filters + # https://gitlab.com/gitlab-org/gitlab/-/issues/558781 def project_ids projects = ::ProjectsFinder.new(current_user: current_user) .execute -- GitLab From dffe7f037a7fe26914d10990c68ed6e5275bdbf9 Mon Sep 17 00:00:00 2001 From: Alisa Frunza Date: Thu, 7 Aug 2025 14:02:21 +0200 Subject: [PATCH 11/15] Convert date to iso8601 format --- .../ee/work_items/glql/work_items_finder.rb | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/ee/app/finders/ee/work_items/glql/work_items_finder.rb b/ee/app/finders/ee/work_items/glql/work_items_finder.rb index 3f0d980cb87ab3..7618e4611497e8 100644 --- a/ee/app/finders/ee/work_items/glql/work_items_finder.rb +++ b/ee/app/finders/ee/work_items/glql/work_items_finder.rb @@ -243,15 +243,15 @@ def health_status_params def dates_params { - due_after: params[:due_after]&.to_i, - due_before: params[:due_before]&.to_i, - created_after: params[:created_after]&.to_i, - created_before: params[:created_before]&.to_i, - updated_after: params[:updated_after]&.to_i, - updated_before: params[:updated_before]&.to_i, - closed_after: params[:closed_after]&.to_i, - closed_before: params[:closed_before]&.to_i - } + due_after: params[:due_after], + due_before: params[:due_before], + created_after: params[:created_after], + created_before: params[:created_before], + updated_after: params[:updated_after], + updated_before: params[:updated_before], + closed_after: params[:closed_after], + closed_before: params[:closed_before] + }.compact.transform_values(&:iso8601) end def scope_param -- GitLab From c15898846ab033a66677e9b311f0a64785397ae7 Mon Sep 17 00:00:00 2001 From: Alisa Frunza Date: Thu, 7 Aug 2025 14:21:38 +0200 Subject: [PATCH 12/15] Add dates specs to GLQL WorkItems Finder --- .../work_items/glql/work_items_finder_spec.rb | 226 ++++++++++++++++++ 1 file changed, 226 insertions(+) diff --git a/ee/spec/finders/ee/work_items/glql/work_items_finder_spec.rb b/ee/spec/finders/ee/work_items/glql/work_items_finder_spec.rb index 8ee3fcad6d953a..70ffa78ef0e2b3 100644 --- a/ee/spec/finders/ee/work_items/glql/work_items_finder_spec.rb +++ b/ee/spec/finders/ee/work_items/glql/work_items_finder_spec.rb @@ -887,6 +887,232 @@ end end end + + context 'when searching for due dates' do + let(:due_today) { Time.current.beginning_of_day } + let(:due_yesterday) { 1.day.ago.beginning_of_day } + let(:due_tomorrow) { 1.day.from_now.beginning_of_day } + + let(:work_item_due_yesterday) { create(:work_item, project: project, due_date: due_yesterday) } + let(:work_item_due_today) { create(:work_item, project: project, due_date: due_today) } + let(:work_item_due_tomorrow) { create(:work_item, project: project, due_date: due_tomorrow) } + let(:work_item_without_due_date) { create(:work_item, project: project, due_date: nil) } + + before do + Elastic::ProcessBookkeepingService.track!( + work_item_due_yesterday, + work_item_due_today, + work_item_due_tomorrow, + work_item_without_due_date + ) + + ensure_elasticsearch_index! + end + + context 'when due_after param provided' do + let(:params) do + { due_after: due_today } + end + + it 'returns work items due after specified date' do + expect(execute).to contain_exactly(work_item_due_today, work_item_due_tomorrow) + end + end + + context 'when due_before param provided' do + let(:params) do + { due_before: due_today } + end + + it 'returns work items due before specified date' do + expect(execute).to contain_exactly(work_item_due_yesterday, work_item_due_today) + end + end + + context 'when both due_after and due_before params provided' do + let(:params) do + { due_after: due_yesterday, due_before: due_tomorrow } + end + + it 'returns work items due within specified date range' do + expect(execute).to contain_exactly( + work_item_due_yesterday, + work_item_due_today, + work_item_due_tomorrow + ) + end + end + end + + context 'when searching for created dates' do + let(:created_yesterday) { 1.day.ago } + let(:created_today) { Time.current } + let(:created_tomorrow) { 1.day.from_now } + + let!(:work_item_created_yesterday) { create(:work_item, project: project, created_at: created_yesterday) } + let!(:work_item_created_today) { create(:work_item, project: project, created_at: created_today) } + let!(:work_item_created_tomorrow) { create(:work_item, project: project, created_at: created_tomorrow) } + + before do + Elastic::ProcessBookkeepingService.track!( + work_item_created_yesterday, + work_item_created_today, + work_item_created_tomorrow + ) + + ensure_elasticsearch_index! + end + + context 'when created_after param provided' do + let(:params) do + { created_after: created_today } + end + + it 'returns work items created after specified date' do + expect(execute).to contain_exactly(work_item_created_today, work_item_created_tomorrow) + end + end + + context 'when created_before param provided' do + let(:params) do + { created_before: created_today } + end + + it 'returns work items created before specified date' do + expect(execute).to contain_exactly(work_item_created_yesterday, work_item_created_today) + end + end + + context 'when both created_after and created_before params provided' do + let(:params) do + { created_after: created_yesterday, created_before: created_tomorrow } + end + + it 'returns work items created within specified date range' do + expect(execute).to contain_exactly( + work_item_created_yesterday, + work_item_created_today, + work_item_created_tomorrow + ) + end + end + end + + context 'when searching for updated dates' do + let(:updated_yesterday) { 1.day.ago } + let(:updated_today) { Time.current } + let(:updated_tomorrow) { 1.day.from_now } + + let!(:work_item_updated_yesterday) { create(:work_item, project: project, updated_at: updated_yesterday) } + let!(:work_item_updated_today) { create(:work_item, project: project, updated_at: updated_today) } + let!(:work_item_updated_tomorrow) { create(:work_item, project: project, updated_at: updated_tomorrow) } + + before do + Elastic::ProcessBookkeepingService.track!( + work_item_updated_yesterday, + work_item_updated_today, + work_item_updated_tomorrow + ) + + ensure_elasticsearch_index! + end + + context 'when updated_after param provided' do + let(:params) do + { updated_after: updated_today } + end + + it 'returns work items updated after specified date' do + expect(execute).to contain_exactly(work_item_updated_today, work_item_updated_tomorrow) + end + end + + context 'when updated_before param provided' do + let(:params) do + { updated_before: updated_today } + end + + it 'returns work items updated before specified date' do + expect(execute).to contain_exactly(work_item_updated_yesterday, work_item_updated_today) + end + end + + context 'when both updated_after and updated_before params provided' do + let(:params) do + { updated_after: updated_yesterday, updated_before: updated_tomorrow } + end + + it 'returns work items updated within specified date range' do + expect(execute).to contain_exactly( + work_item_updated_yesterday, + work_item_updated_today, + work_item_updated_tomorrow + ) + end + end + end + + context 'when searching for closed dates' do + let(:closed_yesterday) { 1.day.ago } + let(:closed_today) { Time.current } + let(:closed_tomorrow) { 1.day.from_now } + + let!(:work_item_closed_yesterday) do + create(:work_item, project: project, state: 'closed', closed_at: closed_yesterday) + end + + let!(:work_item_closed_today) { create(:work_item, project: project, state: 'closed', closed_at: closed_today) } + let!(:work_item_closed_tomorrow) do + create(:work_item, project: project, state: 'closed', closed_at: closed_tomorrow) + end + + let!(:work_item_still_open) { create(:work_item, project: project, state: 'opened') } + + before do + Elastic::ProcessBookkeepingService.track!( + work_item_closed_yesterday, + work_item_closed_today, + work_item_closed_tomorrow, + work_item_still_open + ) + + ensure_elasticsearch_index! + end + + context 'when closed_after param provided' do + let(:params) do + { closed_after: closed_today } + end + + it 'returns work items closed after specified date' do + expect(execute).to contain_exactly(work_item_closed_today, work_item_closed_tomorrow) + end + end + + context 'when closed_before param provided' do + let(:params) do + { closed_before: closed_today } + end + + it 'returns work items closed before specified date' do + expect(execute).to contain_exactly(work_item_closed_yesterday, work_item_closed_today) + end + end + + context 'when both closed_after and closed_before params provided' do + let(:params) do + { closed_after: closed_yesterday, closed_before: closed_tomorrow } + end + + it 'returns work items closed within specified date range' do + expect(execute).to contain_exactly( + work_item_closed_yesterday, + work_item_closed_today, + work_item_closed_tomorrow + ) + end + end + end end context 'when resource_parent is a Project' do -- GitLab From 975fe35658fa424ba3a6af898684f32ae22a829d Mon Sep 17 00:00:00 2001 From: Alisa Frunza Date: Thu, 7 Aug 2025 14:26:22 +0200 Subject: [PATCH 13/15] Add dates specs for work items query builder --- .../elastic/work_item_query_builder_spec.rb | 164 ++++++++++++++++++ 1 file changed, 164 insertions(+) diff --git a/ee/spec/lib/search/elastic/work_item_query_builder_spec.rb b/ee/spec/lib/search/elastic/work_item_query_builder_spec.rb index f290ce811b490c..ca3cfb6896f666 100644 --- a/ee/spec/lib/search/elastic/work_item_query_builder_spec.rb +++ b/ee/spec/lib/search/elastic/work_item_query_builder_spec.rb @@ -768,6 +768,170 @@ end end end + + describe 'closed_at' do + before do + set_elasticsearch_migration_to(:add_extra_fields_to_work_items, including: true) + end + + it 'does not apply closed_at filters by default' do + assert_names_in_query(build, + without: %w[ + filters:closed_after + filters:closed_before + ]) + end + + context 'when closed_after option is provided' do + let(:options) { base_options.merge(closed_after: '2025-01-01T00:00:00Z') } + + it 'applies the filter' do + assert_names_in_query(build, with: %w[filters:closed_after]) + end + end + + context 'when closed_before option is provided' do + let(:options) { base_options.merge(closed_before: '2025-12-31T23:59:59Z') } + + it 'applies the filter' do + assert_names_in_query(build, with: %w[filters:closed_before]) + end + end + + context 'when both closed_after and closed_before options are provided' do + let(:options) do + base_options.merge(closed_after: '2025-01-01T00:00:00Z', closed_before: '2025-12-31T23:59:59Z') + end + + it 'applies both filters' do + assert_names_in_query(build, with: %w[filters:closed_after filters:closed_before]) + end + end + + context 'when migration is not finished' do + before do + set_elasticsearch_migration_to(:add_extra_fields_to_work_items, including: false) + end + + let(:options) do + base_options.merge(closed_after: '2025-01-01T00:00:00Z', closed_before: '2025-12-31T23:59:59Z') + end + + it 'does not apply closed_at filters' do + assert_names_in_query(build, + without: %w[ + filters:closed_after + filters:closed_before + ]) + end + end + end + + describe 'created_at' do + it 'does not apply created_at filters by default' do + assert_names_in_query(build, + without: %w[ + filters:created_after + filters:created_before + ]) + end + + context 'when created_after option is provided' do + let(:options) { base_options.merge(created_after: '2025-01-01T00:00:00Z') } + + it 'applies the filter' do + assert_names_in_query(build, with: %w[filters:created_after]) + end + end + + context 'when created_before option is provided' do + let(:options) { base_options.merge(created_before: '2025-12-31T23:59:59Z') } + + it 'applies the filter' do + assert_names_in_query(build, with: %w[filters:created_before]) + end + end + + context 'when both created_after and created_before options are provided' do + let(:options) do + base_options.merge(created_after: '2025-01-01T00:00:00Z', created_before: '2025-12-31T23:59:59Z') + end + + it 'applies both filters' do + assert_names_in_query(build, with: %w[filters:created_after filters:created_before]) + end + end + end + + describe 'updated_at' do + it 'does not apply updated_at filters by default' do + assert_names_in_query(build, + without: %w[ + filters:updated_after + filters:updated_before + ]) + end + + context 'when updated_after option is provided' do + let(:options) { base_options.merge(updated_after: '2025-01-01T00:00:00Z') } + + it 'applies the filter' do + assert_names_in_query(build, with: %w[filters:updated_after]) + end + end + + context 'when updated_before option is provided' do + let(:options) { base_options.merge(updated_before: '2025-12-31T23:59:59Z') } + + it 'applies the filter' do + assert_names_in_query(build, with: %w[filters:updated_before]) + end + end + + context 'when both updated_after and updated_before options are provided' do + let(:options) do + base_options.merge(updated_after: '2025-01-01T00:00:00Z', updated_before: '2025-12-31T23:59:59Z') + end + + it 'applies both filters' do + assert_names_in_query(build, with: %w[filters:updated_after filters:updated_before]) + end + end + end + + describe 'due_date' do + it 'does not apply due_date filters by default' do + assert_names_in_query(build, + without: %w[ + filters:due_after + filters:due_before + ]) + end + + context 'when due_after option is provided' do + let(:options) { base_options.merge(due_after: '2025-01-01T00:00:00Z') } + + it 'applies the filter' do + assert_names_in_query(build, with: %w[filters:due_after]) + end + end + + context 'when due_before option is provided' do + let(:options) { base_options.merge(due_before: '2025-12-31T23:59:59Z') } + + it 'applies the filter' do + assert_names_in_query(build, with: %w[filters:due_before]) + end + end + + context 'when both due_after and due_before options are provided' do + let(:options) { base_options.merge(due_after: '2025-01-01T00:00:00Z', due_before: '2025-12-31T23:59:59Z') } + + it 'applies both filters' do + assert_names_in_query(build, with: %w[filters:due_after filters:due_before]) + end + end + end end it_behaves_like 'a sorted query' -- GitLab From 3a14bb73610ec94f2b87ecad0294b2bb2ffdf160 Mon Sep 17 00:00:00 2001 From: Alisa Frunza Date: Thu, 7 Aug 2025 14:31:27 +0200 Subject: [PATCH 14/15] Add dates spec for corresponding dates filters --- ee/spec/lib/search/elastic/filters_spec.rb | 324 +++++++++++++++++++++ 1 file changed, 324 insertions(+) diff --git a/ee/spec/lib/search/elastic/filters_spec.rb b/ee/spec/lib/search/elastic/filters_spec.rb index 25d13599b09e68..09efe775aac230 100644 --- a/ee/spec/lib/search/elastic/filters_spec.rb +++ b/ee/spec/lib/search/elastic/filters_spec.rb @@ -4120,4 +4120,328 @@ it_behaves_like 'adds filter to query_hash' end end + + describe '.by_closed_at' do + subject(:by_closed_at) { described_class.by_closed_at(query_hash: query_hash, options: options) } + + context 'when all closed_at options are empty' do + let(:options) { {} } + + it_behaves_like 'does not modify the query_hash' + end + + context 'when options[:closed_after] is provided' do + let(:options) { { closed_after: '2025-01-01T00:00:00Z' } } + let(:expected_filter) do + [{ + bool: { + _name: 'filters:closed_after', + must: { + range: { + 'closed_at' => { + gte: '2025-01-01T00:00:00Z' + } + } + } + } + }] + end + + it_behaves_like 'adds filter to query_hash' + end + + context 'when options[:closed_before] is provided' do + let(:options) { { closed_before: '2025-12-31T23:59:59Z' } } + let(:expected_filter) do + [{ + bool: { + _name: 'filters:closed_before', + must: { + range: { + 'closed_at' => { + lte: '2025-12-31T23:59:59Z' + } + } + } + } + }] + end + + it_behaves_like 'adds filter to query_hash' + end + + context 'when both options[:closed_after] and options[:closed_before] are provided' do + let(:options) { { closed_after: '2025-01-01T00:00:00Z', closed_before: '2025-12-31T23:59:59Z' } } + let(:expected_filter) do + [{ + bool: { + _name: 'filters:closed_after', + must: { + range: { + 'closed_at' => { + gte: '2025-01-01T00:00:00Z' + } + } + } + } + }, { + bool: { + _name: 'filters:closed_before', + must: { + range: { + 'closed_at' => { + lte: '2025-12-31T23:59:59Z' + } + } + } + } + }] + end + + it_behaves_like 'adds filter to query_hash' + end + end + + describe '.by_created_at' do + subject(:by_created_at) { described_class.by_created_at(query_hash: query_hash, options: options) } + + context 'when all created_at options are empty' do + let(:options) { {} } + + it_behaves_like 'does not modify the query_hash' + end + + context 'when options[:created_after] is provided' do + let(:options) { { created_after: '2025-01-01T00:00:00Z' } } + let(:expected_filter) do + [{ + bool: { + _name: 'filters:created_after', + must: { + range: { + 'created_at' => { + gte: '2025-01-01T00:00:00Z' + } + } + } + } + }] + end + + it_behaves_like 'adds filter to query_hash' + end + + context 'when options[:created_before] is provided' do + let(:options) { { created_before: '2025-12-31T23:59:59Z' } } + let(:expected_filter) do + [{ + bool: { + _name: 'filters:created_before', + must: { + range: { + 'created_at' => { + lte: '2025-12-31T23:59:59Z' + } + } + } + } + }] + end + + it_behaves_like 'adds filter to query_hash' + end + + context 'when both options[:created_after] and options[:created_before] are provided' do + let(:options) { { created_after: '2025-01-01T00:00:00Z', created_before: '2025-12-31T23:59:59Z' } } + let(:expected_filter) do + [{ + bool: { + _name: 'filters:created_after', + must: { + range: { + 'created_at' => { + gte: '2025-01-01T00:00:00Z' + } + } + } + } + }, { + bool: { + _name: 'filters:created_before', + must: { + range: { + 'created_at' => { + lte: '2025-12-31T23:59:59Z' + } + } + } + } + }] + end + + it_behaves_like 'adds filter to query_hash' + end + end + + describe '.by_updated_at' do + subject(:by_updated_at) { described_class.by_updated_at(query_hash: query_hash, options: options) } + + context 'when all updated_at options are empty' do + let(:options) { {} } + + it_behaves_like 'does not modify the query_hash' + end + + context 'when options[:updated_after] is provided' do + let(:options) { { updated_after: '2025-01-01T00:00:00Z' } } + let(:expected_filter) do + [{ + bool: { + _name: 'filters:updated_after', + must: { + range: { + 'updated_at' => { + gte: '2025-01-01T00:00:00Z' + } + } + } + } + }] + end + + it_behaves_like 'adds filter to query_hash' + end + + context 'when options[:updated_before] is provided' do + let(:options) { { updated_before: '2025-12-31T23:59:59Z' } } + let(:expected_filter) do + [{ + bool: { + _name: 'filters:updated_before', + must: { + range: { + 'updated_at' => { + lte: '2025-12-31T23:59:59Z' + } + } + } + } + }] + end + + it_behaves_like 'adds filter to query_hash' + end + + context 'when both options[:updated_after] and options[:updated_before] are provided' do + let(:options) { { updated_after: '2025-01-01T00:00:00Z', updated_before: '2025-12-31T23:59:59Z' } } + let(:expected_filter) do + [{ + bool: { + _name: 'filters:updated_after', + must: { + range: { + 'updated_at' => { + gte: '2025-01-01T00:00:00Z' + } + } + } + } + }, { + bool: { + _name: 'filters:updated_before', + must: { + range: { + 'updated_at' => { + lte: '2025-12-31T23:59:59Z' + } + } + } + } + }] + end + + it_behaves_like 'adds filter to query_hash' + end + end + + describe '.by_due_date' do + subject(:by_due_date) { described_class.by_due_date(query_hash: query_hash, options: options) } + + context 'when all due_date options are empty' do + let(:options) { {} } + + it_behaves_like 'does not modify the query_hash' + end + + context 'when options[:due_after] is provided' do + let(:options) { { due_after: '2025-01-01T00:00:00Z' } } + let(:expected_filter) do + [{ + bool: { + _name: 'filters:due_after', + must: { + range: { + 'due_date' => { + gte: '2025-01-01T00:00:00Z' + } + } + } + } + }] + end + + it_behaves_like 'adds filter to query_hash' + end + + context 'when options[:due_before] is provided' do + let(:options) { { due_before: '2025-12-31T23:59:59Z' } } + let(:expected_filter) do + [{ + bool: { + _name: 'filters:due_before', + must: { + range: { + 'due_date' => { + lte: '2025-12-31T23:59:59Z' + } + } + } + } + }] + end + + it_behaves_like 'adds filter to query_hash' + end + + context 'when both options[:due_after] and options[:due_before] are provided' do + let(:options) { { due_after: '2025-01-01T00:00:00Z', due_before: '2025-12-31T23:59:59Z' } } + let(:expected_filter) do + [{ + bool: { + _name: 'filters:due_after', + must: { + range: { + 'due_date' => { + gte: '2025-01-01T00:00:00Z' + } + } + } + } + }, { + bool: { + _name: 'filters:due_before', + must: { + range: { + 'due_date' => { + lte: '2025-12-31T23:59:59Z' + } + } + } + } + }] + end + + it_behaves_like 'adds filter to query_hash' + end + end end -- GitLab From b27b56c5c684e4e768e44c16404c922688f19a12 Mon Sep 17 00:00:00 2001 From: Alisa Frunza Date: Thu, 7 Aug 2025 15:22:25 +0200 Subject: [PATCH 15/15] Allow GLQL searching ES by work item iid --- .../ee/work_items/glql/work_items_finder.rb | 14 ++++- ee/lib/search/elastic/filters.rb | 23 ++++++++ .../search/elastic/work_item_query_builder.rb | 1 + .../work_items/glql/work_items_finder_spec.rb | 41 +++++++++++++++ ee/spec/lib/search/elastic/filters_spec.rb | 52 +++++++++++++++++++ .../elastic/work_item_query_builder_spec.rb | 25 +++++++++ 6 files changed, 154 insertions(+), 2 deletions(-) diff --git a/ee/app/finders/ee/work_items/glql/work_items_finder.rb b/ee/app/finders/ee/work_items/glql/work_items_finder.rb index 7618e4611497e8..fac511fded2f7d 100644 --- a/ee/app/finders/ee/work_items/glql/work_items_finder.rb +++ b/ee/app/finders/ee/work_items/glql/work_items_finder.rb @@ -33,6 +33,7 @@ # updated_before: - Time object # closed_after: - Time object # closed_before: - Time object +# iids - Array of strings of work items' iids # not - Hash with keys that can be negated # or - Hash with keys that can be combined using OR logic # @@ -48,7 +49,7 @@ module WorkItemsFinder :label_name, :group_id, :project_id, :state, :confidential, :author_username, :milestone_title, :milestone_wildcard_id, :assignee_usernames, :assignee_wildcard_id, :not, :or, :weight, :weight_wildcard_id, :issue_types, :health_status_filter, :due_after, :due_before, - :created_after, :created_before, :updated_after, :updated_before, :closed_after, :closed_before + :created_after, :created_before, :updated_after, :updated_before, :closed_after, :closed_before, :iids ].freeze NOT_FILTERS = [:author_username, :milestone_title, :assignee_usernames, :label_name, :weight, :weight_wildcard_id, :health_status_filter, :milestone_wildcard_id].freeze @@ -133,7 +134,8 @@ def base_params assignee_params, weight_params, health_status_params, - dates_params + dates_params, + iids_params ).compact end @@ -254,6 +256,14 @@ def dates_params }.compact.transform_values(&:iso8601) end + def iids_params + # NOTE: We receive iids as strings on the API level, + # while iid is stored as integer in ES + { + iids: params[:iids]&.map(&:to_i) + } + end + def scope_param if params[:project_id].present? { project_id: params[:project_id]&.id } diff --git a/ee/lib/search/elastic/filters.rb b/ee/lib/search/elastic/filters.rb index 9637d35ca3e312..ab7b10451b5243 100644 --- a/ee/lib/search/elastic/filters.rb +++ b/ee/lib/search/elastic/filters.rb @@ -303,6 +303,29 @@ def by_due_date(query_hash:, options:) query_hash end + def by_iids(query_hash:, options:) + iids = options[:iids] + + return query_hash unless iids + + context.name(:filters) do + add_filter(query_hash, :query, :bool, :filter) do + { + bool: { + _name: context.name(:iids), + must: { + terms: { + 'iid' => iids + } + } + } + } + end + end + + query_hash + end + def by_milestone(query_hash:, options:) # milestone_title filters and wildcard filters (any_milestones, none_milestones) # are mutually exclusive and should not be used together in the same query diff --git a/ee/lib/search/elastic/work_item_query_builder.rb b/ee/lib/search/elastic/work_item_query_builder.rb index 489f82af3c7066..37822e0563cae4 100644 --- a/ee/lib/search/elastic/work_item_query_builder.rb +++ b/ee/lib/search/elastic/work_item_query_builder.rb @@ -53,6 +53,7 @@ def build query_hash = ::Search::Elastic::Filters.by_created_at(query_hash: query_hash, options: options) query_hash = ::Search::Elastic::Filters.by_updated_at(query_hash: query_hash, options: options) query_hash = ::Search::Elastic::Filters.by_due_date(query_hash: query_hash, options: options) + query_hash = ::Search::Elastic::Filters.by_iids(query_hash: query_hash, options: options) if hybrid_work_item_search? query_hash = ::Search::Elastic::Filters.by_knn(query_hash: query_hash, options: options) diff --git a/ee/spec/finders/ee/work_items/glql/work_items_finder_spec.rb b/ee/spec/finders/ee/work_items/glql/work_items_finder_spec.rb index 70ffa78ef0e2b3..92dba0a1baccec 100644 --- a/ee/spec/finders/ee/work_items/glql/work_items_finder_spec.rb +++ b/ee/spec/finders/ee/work_items/glql/work_items_finder_spec.rb @@ -1113,6 +1113,47 @@ end end end + + context 'when searching for iids' do + let!(:work_item1) { create(:work_item, project: project) } + let!(:work_item2) { create(:work_item, project: project) } + + before do + Elastic::ProcessBookkeepingService.track!(work_item1, work_item2) + + ensure_elasticsearch_index! + end + + context 'when iids param with single iid provided' do + let(:params) do + { iids: [work_item1.iid] } + end + + it 'returns work item with specified iid' do + expect(execute).to contain_exactly(work_item1) + end + end + + context 'when iids param with multiple iids provided' do + let(:params) do + { iids: [work_item1.iid, work_item2.iid] } + end + + it 'returns work items with specified iids' do + expect(execute).to contain_exactly(work_item1, work_item2) + end + end + + context 'when iids param with non-existent iid provided' do + let(:params) do + { iids: [999999] } + end + + it 'returns no work items' do + expect(execute).to be_empty + end + end + end end context 'when resource_parent is a Project' do diff --git a/ee/spec/lib/search/elastic/filters_spec.rb b/ee/spec/lib/search/elastic/filters_spec.rb index 09efe775aac230..9c6fb9b7181468 100644 --- a/ee/spec/lib/search/elastic/filters_spec.rb +++ b/ee/spec/lib/search/elastic/filters_spec.rb @@ -4444,4 +4444,56 @@ it_behaves_like 'adds filter to query_hash' end end + + describe '.by_iids' do + subject(:by_iids) { described_class.by_iids(query_hash: query_hash, options: options) } + + context 'when iids option is empty' do + let(:options) { {} } + + it_behaves_like 'does not modify the query_hash' + end + + context 'when iids option is nil' do + let(:options) { { iids: nil } } + + it_behaves_like 'does not modify the query_hash' + end + + context 'when options[:iids] is provided with single iid' do + let(:options) { { iids: [1] } } + let(:expected_filter) do + [{ + bool: { + _name: 'filters:iids', + must: { + terms: { + 'iid' => [1] + } + } + } + }] + end + + it_behaves_like 'adds filter to query_hash' + end + + context 'when options[:iids] is provided with multiple iids' do + let(:options) { { iids: [1, 2, 3] } } + let(:expected_filter) do + [{ + bool: { + _name: 'filters:iids', + must: { + terms: { + 'iid' => [1, 2, 3] + } + } + } + }] + end + + it_behaves_like 'adds filter to query_hash' + end + end end diff --git a/ee/spec/lib/search/elastic/work_item_query_builder_spec.rb b/ee/spec/lib/search/elastic/work_item_query_builder_spec.rb index ca3cfb6896f666..bab68332a42936 100644 --- a/ee/spec/lib/search/elastic/work_item_query_builder_spec.rb +++ b/ee/spec/lib/search/elastic/work_item_query_builder_spec.rb @@ -932,6 +932,31 @@ end end end + + describe 'iids' do + it 'does not apply iids filter by default' do + assert_names_in_query(build, + without: %w[ + filters:iids + ]) + end + + context 'when iids option is provided with single iid' do + let(:options) { base_options.merge(iids: [1]) } + + it 'applies the filter' do + assert_names_in_query(build, with: %w[filters:iids]) + end + end + + context 'when iids option is provided with multiple iids' do + let(:options) { base_options.merge(iids: [1, 2, 3]) } + + it 'applies the filter' do + assert_names_in_query(build, with: %w[filters:iids]) + end + end + end end it_behaves_like 'a sorted query' -- GitLab