diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 57e332dc6ea6dfbb771a38af392d0101578af564..7aff9d0fd5e8dc0965c600d3cb09e94508c040ae 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -24,7 +24,7 @@ stages: default: image: $DEFAULT_CI_IMAGE tags: - - $DEFAULT_JOB_TAG + - gitlab-org-experimental # All jobs are interruptible by default interruptible: true # Default job timeout doesn't work: https://gitlab.com/gitlab-org/gitlab/-/issues/387528 @@ -223,7 +223,7 @@ variables: BUNDLE_FROZEN: "true" BUNDLE_GEMFILE: Gemfile # we override the max_old_space_size to prevent OOM errors - NODE_OPTIONS: --max-old-space-size=10240 + NODE_OPTIONS: --max-old-space-size=7000 GIT_DEPTH: "20" # 'GIT_STRATEGY: clone' optimizes the pack-objects cache hit ratio GIT_STRATEGY: "clone" diff --git a/.gitlab/ci/frontend.gitlab-ci.yml b/.gitlab/ci/frontend.gitlab-ci.yml index 09c43aab20112aea3f8eb586484ea4d31912a571..d36739f4d522f9b4047cef27cf16dd5f1bd53d7f 100644 --- a/.gitlab/ci/frontend.gitlab-ci.yml +++ b/.gitlab/ci/frontend.gitlab-ci.yml @@ -268,7 +268,7 @@ jest-build-cache: .vue3: variables: VUE_VERSION: 3 - NODE_OPTIONS: --max-old-space-size=7680 + NODE_OPTIONS: --max-old-space-size=7000 allow_failure: true .with-jest-build-cache-vue3-ensure-compilable-sfcs-needs: diff --git a/README.md b/README.md index cb3f9230daa1156b04e6c921c5e21ccc50a492bc..63be8f128f737452d328c4ddd0a419b151c9a763 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# GitLab +# GitLab - Experiment ## Canonical source diff --git a/ee/app/finders/work_items/status_filter.rb b/ee/app/finders/work_items/status_filter.rb index b46939f9dbcd954bbae8c8a61257d7e3f0b70bbd..f7388f5ad37dc7f9d520ad381c6a841286bd1a9c 100644 --- a/ee/app/finders/work_items/status_filter.rb +++ b/ee/app/finders/work_items/status_filter.rb @@ -3,28 +3,83 @@ module WorkItems class StatusFilter < ::Issuables::BaseFilter def filter(issuables) - return issuables unless status_param_present? - return issuables unless @parent&.root_ancestor&.licensed_feature_available?(:work_item_status) - return issuables unless issuables.respond_to?(:with_status) + return issuables unless can_filter_by_status?(issuables) - status = params.dig(:status, :id) - status = find_status_by_name(params.dig(:status, :name)) unless status.present? - - return issuables.none unless status.present? + statuses_for_filtering = find_statuses_for_filtering + return issuables.none unless statuses_for_filtering.present? - issuables.with_status(status) + apply_status_filters(issuables, statuses_for_filtering) end private + def can_filter_by_status?(issuables) + status_param_present? && + root_ancestor&.licensed_feature_available?(:work_item_status) && + issuables.respond_to?(:with_status) + end + def status_param_present? params[:status].to_h&.slice(:id, :name).present? end + def find_statuses_for_filtering + requested_status = find_requested_status + return [] unless requested_status + + statuses = [{ status: requested_status, mapping: nil }] + + # Add any statuses that map to the requested status + if requested_status.is_a?(::WorkItems::Statuses::Custom::Status) + statuses.concat(find_statuses_mapping_to(requested_status)) + end + + statuses.uniq + end + + def find_requested_status + status = params.dig(:status, :id) + status = find_status_by_name(params.dig(:status, :name)) unless status.present? + status + end + def find_status_by_name(name) return unless name.present? - ::WorkItems::Statuses::Finder.new(@parent.root_ancestor, { 'name' => name }).execute + ::WorkItems::Statuses::Finder.new(root_ancestor, { 'name' => name }).execute + end + + def find_statuses_mapping_to(status) + mappings_to_status = load_cached_mappings(root_ancestor).select { |m| m.new_status_id == status.id } + return [] if mappings_to_status.empty? + + mappings_to_status.map do |mapping| + { + status: mapping.old_status, + mapping: mapping + } + end + end + + def load_cached_mappings(namespace) + cache_key = "work_items:status_mappings_for_filter:#{namespace.id}" + + ::Gitlab::SafeRequestStore.fetch(cache_key) do + ::WorkItems::Statuses::Custom::Mapping + .with_namespace_id(namespace.id) + .includes(:old_status) # rubocop:disable CodeReuse/ActiveRecord -- Preloading depends on the context + .to_a + end + end + + def apply_status_filters(issuables, statuses_for_filtering) + statuses_for_filtering.reduce(issuables.none) do |relation, status_mapping| + relation.or(issuables.with_status(status_mapping[:status], status_mapping[:mapping])) + end + end + + def root_ancestor + @parent&.root_ancestor end end end diff --git a/ee/app/models/concerns/work_items/has_status.rb b/ee/app/models/concerns/work_items/has_status.rb index 091f9d094cefa40a6fee3389c48de3e90338b4d9..8185efc98849a2ddb2d7805422b6e8c9d0c1cb61 100644 --- a/ee/app/models/concerns/work_items/has_status.rb +++ b/ee/app/models/concerns/work_items/has_status.rb @@ -8,15 +8,25 @@ module HasStatus has_one :current_status, class_name: 'WorkItems::Statuses::CurrentStatus', foreign_key: 'work_item_id', inverse_of: :work_item - scope :with_status, ->(status) { + scope :with_status, ->(status, mapping = nil) { relation = left_joins(:current_status) if status.is_a?(::WorkItems::Statuses::SystemDefined::Status) relation = with_system_defined_status(status) else + matching_condition = { work_item_current_statuses: { custom_status_id: status.id } } + + if mapping.present? + matching_condition[:work_item_type_id] = mapping.work_item_type_id + + if mapping.time_constrained? + matching_condition[:work_item_current_statuses][:updated_at] = mapping.time_range + end + end + relation = relation .where.not(work_item_current_statuses: { custom_status_id: nil }) - .where(work_item_current_statuses: { custom_status_id: status.id }) + .where(matching_condition) if status.converted_from_system_defined_status_identifier.present? system_defined_status = WorkItems::Statuses::SystemDefined::Status.find( diff --git a/ee/app/models/work_items/statuses/custom/mapping.rb b/ee/app/models/work_items/statuses/custom/mapping.rb index b90eb59fdff989ba06ac101bb763977f5c48a9da..fcbc30f8d23c6dccce3cdce505d0fd6cc133eb43 100644 --- a/ee/app/models/work_items/statuses/custom/mapping.rb +++ b/ee/app/models/work_items/statuses/custom/mapping.rb @@ -28,6 +28,22 @@ def applicable_for?(date) (valid_from.nil? || valid_from <= date) && (valid_until.nil? || valid_until > date) end + def time_range + return unless time_constrained? + + if valid_from.present? && valid_until.present? + valid_from..valid_until + elsif valid_from.present? + valid_from.. + elsif valid_until.present? + ..valid_until + end + end + + def time_constrained? + valid_from.present? || valid_until.present? + end + private def statuses_in_same_namespace diff --git a/ee/spec/models/work_item_spec.rb b/ee/spec/models/work_item_spec.rb index 80923af938eece6b250e52f0a80d2cb9f1485fcf..8f9017f9e7ec8d9f8df9e07547cf7c0d23dc1e44 100644 --- a/ee/spec/models/work_item_spec.rb +++ b/ee/spec/models/work_item_spec.rb @@ -627,7 +627,9 @@ end describe '.with_status' do - subject { described_class.with_status(status) } + let(:mapping) { nil } + + subject { described_class.with_status(status, mapping) } context 'with a system defined status' do let(:status) { system_defined_todo_status } @@ -668,6 +670,79 @@ is_expected.to contain_exactly(wi_custom) end end + + context 'with mapping' do + let_it_be(:wi_other_custom) { create(:work_item, project: project) } + + let(:status) { custom_status } + let(:valid_from) { nil } + let(:valid_until) { nil } + let(:mapping) do + create(:work_item_custom_status_mapping, + namespace: reusable_group, work_item_type: issue_work_item_type, + old_status: custom_status, new_status: lifecycle.default_open_status, + valid_from: valid_from, valid_until: valid_until) + end + + before_all do + create(:work_item_current_status, + work_item: wi_other_custom, custom_status: custom_status, updated_at: 2.days.ago) + end + + it 'returns items with matching current_status' do + is_expected.to contain_exactly(wi_custom, wi_other_custom) + end + + context 'with valid_from' do + let(:valid_from) { 1.day.ago } + + it 'returns items with matching current_status' do + is_expected.to contain_exactly(wi_custom) + end + + context 'and it covers both items' do + let(:valid_from) { 3.days.ago } + + it 'returns items with matching current_status' do + is_expected.to contain_exactly(wi_custom, wi_other_custom) + end + end + end + + context 'with valid_until' do + let(:valid_until) { 1.day.ago } + + it 'returns items with matching current_status' do + is_expected.to contain_exactly(wi_other_custom) + end + + context 'and it covers no item' do + let(:valid_until) { 3.days.ago } + + it 'returns no items' do + is_expected.to be_empty + end + end + end + + context 'with both valid_from and valid_until' do + let(:valid_from) { 1.day.ago } + let(:valid_until) { 1.day.from_now } + + it 'returns items with matching current_status' do + is_expected.to contain_exactly(wi_custom) + end + + context 'and it covers only the item with older status update' do + let(:valid_from) { 3.days.ago } + let(:valid_until) { 1.day.ago } + + it 'returns items with matching current_status' do + is_expected.to contain_exactly(wi_other_custom) + end + end + end + end end describe '.with_system_defined_status' do diff --git a/ee/spec/models/work_items/statuses/custom/mapping_spec.rb b/ee/spec/models/work_items/statuses/custom/mapping_spec.rb index 2e2b22483c2ae7504a94fbef14f3865c8cd8e308..12d6b99f770e4135ba25937ea06adce0b8bbee2b 100644 --- a/ee/spec/models/work_items/statuses/custom/mapping_spec.rb +++ b/ee/spec/models/work_items/statuses/custom/mapping_spec.rb @@ -3,6 +3,8 @@ require 'spec_helper' RSpec.describe WorkItems::Statuses::Custom::Mapping, feature_category: :team_planning do + using RSpec::Parameterized::TableSyntax + let_it_be_with_refind(:namespace) { create(:namespace) } let_it_be_with_refind(:other_namespace) { create(:namespace) } let_it_be_with_refind(:work_item_type) { create(:work_item_type) } @@ -490,6 +492,52 @@ end end + describe '#time_range' do + let(:mapping) { build(:work_item_custom_status_mapping, valid_from: valid_from, valid_until: valid_until) } + let(:from_time) { 5.days.ago } + let(:until_time) { 2.days.ago } + + subject(:time_range) { mapping.time_range } + + where(:valid_from, :valid_until, :expected_type, :expected_begin, :expected_end) do + ref(:from_time) | ref(:until_time) | Range | ref(:from_time) | ref(:until_time) + ref(:from_time) | nil | Range | ref(:from_time) | nil + nil | ref(:until_time) | Range | nil | ref(:until_time) + nil | nil | NilClass | nil | nil + end + + with_them do + it 'returns the expected range type and boundaries' do + if expected_type == NilClass + is_expected.to be_nil + else + is_expected.to be_a(Range) + expect(time_range.begin).to eq(expected_begin) + expect(time_range.end).to eq(expected_end) + end + end + end + end + + describe '#time_constrained?' do + let(:mapping) { build(:work_item_custom_status_mapping, valid_from: valid_from, valid_until: valid_until) } + + subject { mapping.time_constrained? } + + where(:valid_from, :valid_until, :expected_result) do + nil | nil | false + 3.days.ago | nil | true + nil | 1.day.from_now | true + 5.days.ago | 2.days.ago | true + 1.year.ago | nil | true + nil | 1.year.from_now | true + end + + with_them do + it { is_expected.to eq(expected_result) } + end + end + describe 'foreign key constraints' do let!(:mapping) do create(:work_item_custom_status_mapping, diff --git a/ee/spec/requests/api/graphql/work_items/status_filters_spec.rb b/ee/spec/requests/api/graphql/work_items/status_filters_spec.rb index 4e32b55e51d8fee8d0d6b1f9e4b5a5c5a6615c4b..20e84be3753583ae444e885f75b2a591396bd2b5 100644 --- a/ee/spec/requests/api/graphql/work_items/status_filters_spec.rb +++ b/ee/spec/requests/api/graphql/work_items/status_filters_spec.rb @@ -12,6 +12,9 @@ let(:board) { create(:board, resource_parent: resource_parent) } let(:label_list) { create(:list, board: board, label: group_label) } + let_it_be(:issue_work_item_type) { create(:work_item_type, :issue) } + let_it_be(:task_work_item_type) { create(:work_item_type, :task) } + let_it_be(:work_item_1) { create(:work_item, :issue, project: project, labels: [group_label]) } let_it_be(:work_item_2) { create(:work_item, :task, project: project, labels: [group_label]) } let_it_be(:work_item_3) { create(:work_item, :task, project: project, labels: [group_label]) } @@ -19,18 +22,21 @@ let(:current_user) { create(:user, guest_of: group) } + let(:expected_work_items) { [work_item_1, work_item_2, work_item_3] } + let(:expected_unfiltered_work_items) { [work_item_1, work_item_2, work_item_3, work_item_4] } + before do stub_licensed_features(work_item_status: true) end shared_examples 'a filtered list' do - it 'filters by status argument' do + it 'filters by status argument', :aggregate_failures do post_graphql(query, current_user: current_user) model_ids = items.map { |item| GlobalID.parse(item['id']).model_id.to_i } - expect(model_ids.size).to eq(3) - expect(model_ids).to contain_exactly(work_item_1.id, work_item_2.id, work_item_3.id) + expect(model_ids.size).to eq(expected_work_items.size) + expect(model_ids).to match_array(expected_work_items.map(&:id)) end end @@ -40,8 +46,8 @@ model_ids = items.map { |item| GlobalID.parse(item['id']).model_id.to_i } - expect(model_ids.size).to eq(4) - expect(model_ids).to contain_exactly(work_item_1.id, work_item_2.id, work_item_3.id, work_item_4.id) + expect(model_ids.size).to eq(expected_unfiltered_work_items.size) + expect(model_ids).to match_array(expected_unfiltered_work_items.map(&:id)) end end @@ -230,13 +236,13 @@ # We can't stub licensed features for let_it_be blocks. build(:work_item_type_custom_lifecycle, namespace: group, - work_item_type: create(:work_item_type, :issue), + work_item_type: issue_work_item_type, lifecycle: lifecycle ).save!(validate: false) build(:work_item_type_custom_lifecycle, namespace: group, - work_item_type: create(:work_item_type, :task), + work_item_type: task_work_item_type, lifecycle: lifecycle ).save!(validate: false) end @@ -253,5 +259,193 @@ end it_behaves_like 'filtering by status' + + # rubocop:disable RSpec/MultipleMemoizedHelpers -- we need additional memoization to fully test all paths + context 'with status mappings' do + let_it_be(:old_status) do + create(:work_item_custom_status, :without_conversion_mapping, namespace: group).tap do |status| + create(:work_item_custom_lifecycle_status, lifecycle: lifecycle, status: status) + end + end + + let_it_be(:new_status) do + create(:work_item_custom_status, :without_conversion_mapping, namespace: group).tap do |status| + create(:work_item_custom_lifecycle_status, lifecycle: lifecycle, status: status) + end + end + + let_it_be(:another_status) do + create(:work_item_custom_status, :without_conversion_mapping, namespace: group).tap do |status| + create(:work_item_custom_lifecycle_status, lifecycle: lifecycle, status: status) + end + end + + let_it_be(:converted_custom_status) do + create(:work_item_custom_status, :in_progress, namespace: group).tap do |status| + create(:work_item_custom_lifecycle_status, lifecycle: lifecycle, status: status) + end + end + + let_it_be(:work_item_issue_with_new_status) do + create(:work_item, :issue, project: project, labels: [group_label]).tap do |wi| + create(:work_item_current_status, :custom, work_item: wi, custom_status: new_status, updated_at: 1.day.ago) + end + end + + let_it_be(:work_item_issue_old) do + create(:work_item, :issue, project: project, labels: [group_label]).tap do |wi| + create(:work_item_current_status, :custom, work_item: wi, custom_status: old_status, updated_at: 1.day.ago) + end + end + + let_it_be(:work_item_issue_older) do + create(:work_item, :issue, project: project, labels: [group_label]).tap do |wi| + create(:work_item_current_status, :custom, work_item: wi, custom_status: old_status, updated_at: 5.days.ago) + end + end + + let_it_be(:work_item_task_recent) do + create(:work_item, :task, project: project, labels: [group_label]).tap do |wi| + create(:work_item_current_status, :custom, work_item: wi, custom_status: old_status, updated_at: 2.hours.ago) + end + end + + let_it_be(:work_item_issue_with_another_status) do + create(:work_item, :issue, project: project, labels: [group_label]).tap do |wi| + create(:work_item_current_status, :custom, work_item: wi, custom_status: another_status, + updated_at: 1.day.ago) + end + end + + let_it_be(:work_item_issue_with_system_status) do + create(:work_item, :issue, project: project, labels: [group_label]).tap do |wi| + # Skip validations since we are simulating an old record + # when the namespace still used the system defined lifecycle + build(:work_item_current_status, + work_item: wi, + system_defined_status_id: converted_custom_status.converted_from_system_defined_status_identifier, + updated_at: 1.day.ago + ).save!(validate: false) + end + end + + let(:status) { new_status } + + let(:expected_unfiltered_work_items) do + [work_item_1, work_item_2, work_item_3, work_item_4, work_item_issue_with_new_status, + work_item_issue_old, work_item_issue_older, work_item_task_recent, + work_item_issue_with_another_status, work_item_issue_with_system_status] + end + + context 'when unbounded mapping for both work item types is present' do + before_all do + [issue_work_item_type, task_work_item_type].each do |wit| + create(:work_item_custom_status_mapping, + namespace: group, + work_item_type: wit, + old_status: old_status, + new_status: new_status, + valid_from: nil, + valid_until: nil + ) + end + end + + let(:expected_work_items) do + [work_item_issue_with_new_status, work_item_issue_old, work_item_issue_older, + work_item_task_recent] + end + + it_behaves_like 'filtering by status' + end + + context 'when valid_until mapping for issues is present' do + before_all do + create(:work_item_custom_status_mapping, + namespace: group, + work_item_type: issue_work_item_type, + old_status: old_status, + new_status: new_status, + valid_from: nil, + valid_until: 3.days.ago + ) + end + + let(:expected_work_items) { [work_item_issue_with_new_status, work_item_issue_older] } + + it_behaves_like 'filtering by status' + end + + context 'when two mappings for issues are present with different time constraints' do + before_all do + create(:work_item_custom_status_mapping, + namespace: group, + work_item_type: issue_work_item_type, + old_status: old_status, + new_status: new_status, + valid_from: nil, + valid_until: 3.days.ago + ) + create(:work_item_custom_status_mapping, + namespace: group, + work_item_type: issue_work_item_type, + old_status: old_status, + new_status: new_status, + valid_from: 2.days.ago, + valid_until: nil + ) + end + + let(:expected_work_items) { [work_item_issue_with_new_status, work_item_issue_old, work_item_issue_older] } + + it_behaves_like 'filtering by status' + end + + context 'when two mappings for issues are present to different statuses' do + before_all do + create(:work_item_custom_status_mapping, + namespace: group, + work_item_type: issue_work_item_type, + old_status: old_status, + new_status: new_status, + valid_from: nil, + valid_until: nil + ) + create(:work_item_custom_status_mapping, + namespace: group, + work_item_type: issue_work_item_type, + old_status: another_status, + new_status: new_status, + valid_from: nil, + valid_until: nil + ) + end + + let(:expected_work_items) do + [work_item_issue_with_new_status, work_item_issue_old, work_item_issue_older, + work_item_issue_with_another_status] + end + + it_behaves_like 'filtering by status' + end + + context 'when mapping for tasks exists for converted system-defined status' do + before_all do + create(:work_item_custom_status_mapping, + namespace: group, + work_item_type: task_work_item_type, + old_status: converted_custom_status, + new_status: new_status, + valid_from: nil, + valid_until: nil + ) + end + + let(:expected_work_items) { [work_item_issue_with_new_status, work_item_issue_with_system_status] } + + it_behaves_like 'filtering by status' + end + end + # rubocop:enable RSpec/MultipleMemoizedHelpers end end diff --git a/lib/tasks/gitlab/assets.rake b/lib/tasks/gitlab/assets.rake index 4ded1aa388513419419ca80db35f04d996448792..455a7b3875e9ea68a2e9de027a6992ac3caf5ac9 100644 --- a/lib/tasks/gitlab/assets.rake +++ b/lib/tasks/gitlab/assets.rake @@ -50,7 +50,7 @@ namespace :gitlab do end end - ENV['NODE_OPTIONS'] = '--max-old-space-size=8192' if ENV.has_key?('CI') + ENV['NODE_OPTIONS'] = '--max-old-space-size=5120' if ENV.has_key?('CI') if ENV['GITLAB_LARGE_RUNNER_OPTIONAL'] == "saas-linux-large-amd64" ENV['NODE_OPTIONS'] = '--max-old-space-size=16384' end diff --git a/package.json b/package.json index 8b47f71ac44451119b80d3a9d951372da56ca6af..2fb21017e237bd611e9baeaf02b94874747965b8 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "deps:check": "depcruise --config config/dependency_cruiser.js --no-ignore-known", "deps:check:all": "yarn run deps:check .", "clean": "rm -rf public/assets tmp/cache/webpack tmp/cache/vite", - "dev-server": "NODE_OPTIONS=\"${NODE_OPTIONS:=--max-old-space-size=5120}\" node scripts/frontend/webpack_dev_server.js", + "dev-server": "NODE_OPTIONS=\"${NODE_OPTIONS:=--max-old-space-size=7000}\" node scripts/frontend/webpack_dev_server.js", "file-coverage": "scripts/frontend/file_test_coverage.js", "lint-docs": "scripts/lint-doc.sh", "internal:eslint": "eslint --cache --max-warnings 0 --report-unused-disable-directives", @@ -47,10 +47,10 @@ "storybook:start": "./scripts/frontend/start_storybook.sh", "storybook:start:skip-fixtures-update": "./scripts/frontend/start_storybook.sh --skip-fixtures-update", "swagger:validate": "swagger-cli validate", - "webpack": "NODE_OPTIONS=\"${NODE_OPTIONS:=--max-old-space-size=5120}\" webpack --config config/webpack.config.js", - "webpack-vendor": "NODE_OPTIONS=\"${NODE_OPTIONS:=--max-old-space-size=5120}\" webpack --config config/webpack.vendor.config.js", - "webpack-prod": "NODE_OPTIONS=\"${NODE_OPTIONS:=--max-old-space-size=5120}\" NODE_ENV=production webpack --config config/webpack.config.js", - "vite-prod": "yarn run tailwindcss:build && yarn run tailwindcss:cqs:build && NODE_OPTIONS=\"${NODE_OPTIONS:=--max-old-space-size=8000}\" NODE_ENV=production vite build" + "webpack": "NODE_OPTIONS=\"${NODE_OPTIONS:=--max-old-space-size=7000}\" webpack --config config/webpack.config.js", + "webpack-vendor": "NODE_OPTIONS=\"${NODE_OPTIONS:=--max-old-space-size=7000}\" webpack --config config/webpack.vendor.config.js", + "webpack-prod": "NODE_OPTIONS=\"${NODE_OPTIONS:=--max-old-space-size=7000}\" NODE_ENV=production webpack --config config/webpack.config.js", + "vite-prod": "yarn run tailwindcss:build && yarn run tailwindcss:cqs:build && NODE_OPTIONS=\"${NODE_OPTIONS:=--max-old-space-size=7000}\" NODE_ENV=production vite build" }, "dependencies": { "@apollo/client": "^3.5.10",