From da62cfe7fbad252cde98b7c66244d9cbdcbade9c Mon Sep 17 00:00:00 2001 From: Eugenia Grieff <egrieff@gitlab.com> Date: Fri, 19 Apr 2024 16:32:38 +0000 Subject: [PATCH] Sync epic child reordering with associated work item The a child epic is reordered the corresponding work item parent link should match its position --- ee/app/models/ee/group.rb | 5 + .../epics/epic_links/update_service.rb | 41 +++- .../epics/epic_links_controller_spec.rb | 4 +- .../epics/epic_links/update_service_spec.rb | 175 ++++++++++++++---- locale/gitlab.pot | 3 + 5 files changed, 189 insertions(+), 39 deletions(-) diff --git a/ee/app/models/ee/group.rb b/ee/app/models/ee/group.rb index 2fdca22c7610b..475277ad7f6ac 100644 --- a/ee/app/models/ee/group.rb +++ b/ee/app/models/ee/group.rb @@ -257,6 +257,11 @@ def epic_sync_to_work_item_enabled? def work_item_sync_to_epic_enabled? ::Feature.enabled?(:sync_work_item_to_epic, self, type: :wip) end + + def sync_epic_work_item_order_enabled? + epic_sync_to_work_item_enabled? && + ::Feature.enabled?(:sync_epic_work_item_order, self, type: :gitlab_com_derisk) + end end override :supports_saved_replies? diff --git a/ee/app/services/epics/epic_links/update_service.rb b/ee/app/services/epics/epic_links/update_service.rb index 0a9068c201931..89e63c47db4c0 100644 --- a/ee/app/services/epics/epic_links/update_service.rb +++ b/ee/app/services/epics/epic_links/update_service.rb @@ -19,8 +19,18 @@ def execute move_epic! success - rescue ActiveRecord::RecordNotFound - error('Epic not found for given params', 404) + rescue ActiveRecord::RecordNotFound, ::Epics::SyncAsWorkItem::SyncAsWorkItemError => e + message = + if e.is_a?(ActiveRecord::RecordNotFound) + _('Epic not found for given params') + else + Gitlab::ErrorTracking.track_exception(e, moving_epic_id: epic.id) + Gitlab::EpicWorkItemSync::Logger.error(error: e) + + _("Couldn't reorder child due to an internal error.") + end + + error(message, 422) end private @@ -31,8 +41,31 @@ def move_epic! before_epic = Epic.in_parents(epic.parent_id).find(params[:move_before_id]) if params[:move_before_id] after_epic = Epic.in_parents(epic.parent_id).find(params[:move_after_id]) if params[:move_after_id] - epic.move_between(before_epic, after_epic) - epic.save!(touch: false) + ::ApplicationRecord.transaction do + epic.move_between(before_epic, after_epic) + epic.save!(touch: false) + sync_work_items_relative_position!(before_epic, after_epic, epic) + end + end + + def sync_work_items_relative_position!(before_epic, after_epic, epic) + return true unless sync_work_item_parent_links?(epic, before_epic, after_epic) + + parent_link = epic.work_item.parent_link + + parent_link.move_between(before_epic&.work_item&.parent_link, after_epic&.work_item&.parent_link) + return true if parent_link.save!(touch: false) + + error = "Not able to sync child relative position: #{parent_link.errors.full_messages.to_sentence}" + raise SyncAsWorkItem::SyncAsWorkItemError, error + end + + def sync_work_item_parent_links?(epic, before_epic, after_epic) + return false unless epic.parent.group.sync_epic_work_item_order_enabled? && epic.work_item&.parent_link.present? + return false if after_epic && after_epic.work_item&.parent_link.blank? + return false if before_epic && before_epic.work_item&.parent_link.blank? + + true end end end diff --git a/ee/spec/requests/groups/epics/epic_links_controller_spec.rb b/ee/spec/requests/groups/epics/epic_links_controller_spec.rb index 25b222e64191a..821ed26833979 100644 --- a/ee/spec/requests/groups/epics/epic_links_controller_spec.rb +++ b/ee/spec/requests/groups/epics/epic_links_controller_spec.rb @@ -274,10 +274,10 @@ def get_epics context 'when move_before_id is not a sibling epic' do let(:move_before_epic) { create(:epic, group: group) } - it 'returns status 404' do + it 'returns status 422' do subject - expect(response).to have_gitlab_http_status(:not_found) + expect(response).to have_gitlab_http_status(:unprocessable_entity) end end end diff --git a/ee/spec/services/epics/epic_links/update_service_spec.rb b/ee/spec/services/epics/epic_links/update_service_spec.rb index 744c971e3e04f..cdb131e41a8a1 100644 --- a/ee/spec/services/epics/epic_links/update_service_spec.rb +++ b/ee/spec/services/epics/epic_links/update_service_spec.rb @@ -3,44 +3,61 @@ require 'spec_helper' RSpec.describe Epics::EpicLinks::UpdateService, feature_category: :portfolio_management do - let(:user) { create(:user) } - let(:group) { create(:group) } - let(:parent_epic) { create(:epic, group: group) } + let_it_be(:guest) { create(:user) } + let_it_be(:group) { create(:group).tap { |g| g.add_guest(guest) } } + let_it_be(:parent_epic) { create(:epic, group: group) } - let(:child_epic1) { create(:epic, group: group, parent: parent_epic, relative_position: 1) } - let(:child_epic2) { create(:epic, group: group, parent: parent_epic, relative_position: 2) } - let(:child_epic3) { create(:epic, group: group, parent: parent_epic, relative_position: 300) } - let(:child_epic4) { create(:epic, group: group, parent: parent_epic, relative_position: 400) } + let_it_be_with_reload(:child_epic1) { create_child_epic(1) } + let_it_be_with_reload(:child_epic2) { create_child_epic(2) } + let_it_be_with_reload(:child_epic3) { create_child_epic(300) } + let_it_be_with_reload(:child_epic4) { create_child_epic(400) } let(:epic_to_move) { child_epic3 } let(:params) { {} } + let(:current_user) { guest } - subject do - described_class.new(epic_to_move, user, params).execute + subject(:reorder_child) do + described_class.new(epic_to_move, current_user, params).execute end def ordered_epics Epic.where(parent_id: parent_epic.id).order('relative_position, id DESC') end - describe '#execute' do - before do - group.add_guest(user) - end + def ordered_work_items + parent_epic.work_item.reload.work_item_children_by_relative_position + end + + def create_child_epic(relative_position) + child = create(:epic, group: group, parent: parent_epic, relative_position: relative_position) + create( + :parent_link, + work_item: child.work_item, + work_item_parent: parent_epic.work_item, + relative_position: relative_position + ) + + child + end + describe '#execute' do shared_examples 'updating timestamps' do it 'does not update moved epic' do updated_at = epic_to_move.updated_at - subject + work_item_updated_at = epic_to_move.work_item.updated_at + reorder_child expect(epic_to_move.reload.updated_at.change(usec: 0)).to eq(updated_at.change(usec: 0)) + expect(epic_to_move.work_item.updated_at.change(usec: 0)).to eq(work_item_updated_at.change(usec: 0)) end it 'does not update parent epic' do updated_at = parent_epic.updated_at - subject + work_item_updated_at = parent_epic.work_item.updated_at + reorder_child expect(parent_epic.reload.updated_at.change(usec: 0)).to eq(updated_at.change(usec: 0)) + expect(parent_epic.work_item.updated_at.change(usec: 0)).to eq(work_item_updated_at.change(usec: 0)) end end @@ -48,7 +65,7 @@ def ordered_epics it 'returns an error' do stub_licensed_features(epics: true, subepics: false) - expect(subject).to eq(message: 'Epic not found for given params', status: :error, http_status: 404) + expect(reorder_child).to eq(message: 'Epic not found for given params', status: :error, http_status: 404) end end @@ -58,12 +75,10 @@ def ordered_epics end context 'when user has insufficient permissions' do - before do - user.group_members.delete_all - end + let(:current_user) { create(:user) } it 'returns an error' do - expect(subject).to eq(message: 'Epic not found for given params', status: :error, http_status: 404) + expect(reorder_child).to eq(message: 'Epic not found for given params', status: :error, http_status: 404) end end @@ -71,8 +86,11 @@ def ordered_epics let(:params) { { move_before_id: nil, move_after_id: nil } } it 'does not change order of child epics' do - expect(subject).to include(status: :success) - expect(ordered_epics).to eq([child_epic1, child_epic2, child_epic3, child_epic4]) + expect(reorder_child).to include(status: :success) + expect(ordered_epics).to match_array([child_epic1, child_epic2, child_epic3, child_epic4]) + expect(ordered_work_items).to match_array([ + child_epic1.work_item, child_epic2.work_item, child_epic3.work_item, child_epic4.work_item + ]) end end @@ -81,9 +99,12 @@ def ordered_epics it_behaves_like 'updating timestamps' - it 'reorders child epics' do - expect(subject).to include(status: :success) - expect(ordered_epics).to eq([child_epic3, child_epic1, child_epic2, child_epic4]) + it 'reorders child epics and sync positions with work items' do + expect(reorder_child).to include(status: :success) + expect(ordered_epics).to match_array([child_epic3, child_epic1, child_epic2, child_epic4]) + expect(ordered_work_items).to match_array( + [child_epic3.work_item, child_epic1.work_item, child_epic2.work_item, child_epic4.work_item] + ) end end @@ -92,9 +113,12 @@ def ordered_epics it_behaves_like 'updating timestamps' - it 'reorders child epics' do - expect(subject).to include(status: :success) - expect(ordered_epics).to eq([child_epic1, child_epic2, child_epic4, child_epic3]) + it 'reorders child epics and sync positions with work items' do + expect(reorder_child).to include(status: :success) + expect(ordered_epics).to match_array([child_epic1, child_epic2, child_epic4, child_epic3]) + expect(ordered_work_items).to match_array( + [child_epic1.work_item, child_epic2.work_item, child_epic4.work_item, child_epic3.work_item] + ) end end @@ -104,18 +128,26 @@ def ordered_epics it_behaves_like 'updating timestamps' it 'reorders child epics' do - expect(subject).to include(status: :success) - expect(ordered_epics).to eq([child_epic1, child_epic3, child_epic2, child_epic4]) + expect(reorder_child).to include(status: :success) + expect(ordered_epics).to match_array([child_epic1, child_epic3, child_epic2, child_epic4]) + expect(ordered_work_items).to match_array( + [child_epic1.work_item, child_epic3.work_item, child_epic2.work_item, child_epic4.work_item] + ) end end context 'when params are invalid' do - let(:other_epic) { create(:epic, group: group) } + let_it_be(:other_epic) { create(:epic, group: group) } shared_examples 'returns error' do it 'does not change order of child epics and returns error' do - expect(subject).to include(message: 'Epic not found for given params', status: :error, http_status: 404) - expect(ordered_epics).to eq([child_epic1, child_epic2, child_epic3, child_epic4]) + expect(reorder_child).to include( + message: 'Epic not found for given params', status: :error, http_status: 422 + ) + expect(ordered_epics).to match_array([child_epic1, child_epic2, child_epic3, child_epic4]) + expect(ordered_work_items).to match_array( + [child_epic1.work_item, child_epic2.work_item, child_epic3.work_item, child_epic4.work_item] + ) end end @@ -131,6 +163,83 @@ def ordered_epics it_behaves_like 'returns error' end end + + context 'when syncing with work items fails' do + let(:params) { { move_before_id: child_epic4.id, move_after_id: nil } } + + it 'does not change order of child epics and returns error' do + allow(epic_to_move.work_item.parent_link).to receive(:save!).and_return(false) + + expect(Gitlab::ErrorTracking).to receive(:track_exception).with( + instance_of(Epics::SyncAsWorkItem::SyncAsWorkItemError), { moving_epic_id: epic_to_move.id } + ) + expect(reorder_child).to include( + message: "Couldn't reorder child due to an internal error.", status: :error, http_status: 422 + ) + expect(ordered_epics).to match_array([child_epic1, child_epic2, child_epic3, child_epic4]) + expect(ordered_work_items).to match_array( + [child_epic1.work_item, child_epic2.work_item, child_epic3.work_item, child_epic4.work_item] + ) + end + end + + context 'when work item should not be synced' do + let(:params) { { move_before_id: child_epic1.id, move_after_id: child_epic2.id } } + let(:expected_work_items) do + [child_epic1.work_item, child_epic2.work_item, child_epic3.work_item, child_epic4.work_item] + end + + shared_examples 'reordering without syncing relative positions' do + it 'only changes order of child epics and not the order of synced work items' do + expect(reorder_child).to include(status: :success) + expect(ordered_epics).to match_array([child_epic1, child_epic3, child_epic2, child_epic4]) + expect(ordered_work_items).to match_array(expected_work_items) + end + end + + context 'when sync_epic_to_work_item feature flag is disabled' do + before do + stub_feature_flags(sync_epic_to_work_item: false) + end + + it_behaves_like 'reordering without syncing relative positions' + end + + context 'when sync_epic_work_item_order feature flag is disabled' do + before do + stub_feature_flags(sync_epic_work_item_order: false) + end + + it_behaves_like 'reordering without syncing relative positions' + end + + context 'when moving child does not have a synced work item parent link' do + before do + WorkItems::ParentLink.where(work_item_id: epic_to_move.issue_id).delete_all + expected_work_items.delete_at(2) + end + + it_behaves_like 'reordering without syncing relative positions' + end + + context 'when move_before child does not have a synced work item parent link' do + before do + WorkItems::ParentLink.where(work_item_id: child_epic1.issue_id).delete_all + expected_work_items.delete_at(0) + end + + it_behaves_like 'reordering without syncing relative positions' + end + + context 'when move_after child does not have a synced work item parent link' do + before do + WorkItems::ParentLink.where(work_item_id: child_epic2.issue_id).delete_all + expected_work_items.delete_at(1) + end + + it_behaves_like 'reordering without syncing relative positions' + end + end end end end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index ad39104531418..839e633468dce 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -14998,6 +14998,9 @@ msgstr "" msgid "Couldn't link epics. You must have at least the Guest role in the epic's group." msgstr "" +msgid "Couldn't reorder child due to an internal error." +msgstr "" + msgid "Country / Region" msgstr "" -- GitLab