diff --git a/app/services/work_items/parent_links/reorder_service.rb b/app/services/work_items/parent_links/reorder_service.rb index 8422df030f52c43f38483344bdf3635e16c38a11..dbbc6860ed791eac9e16d3c4ee7dc67fd39da68a 100644 --- a/app/services/work_items/parent_links/reorder_service.rb +++ b/app/services/work_items/parent_links/reorder_service.rb @@ -9,9 +9,10 @@ class ReorderService < WorkItems::ParentLinks::BaseService def relate_issuables(work_item) notes_are_expected = work_item.work_item_parent != issuable link = set_parent(issuable, work_item) - reorder(link, params[:adjacent_work_item], params[:relative_position]) - create_notes(work_item) if link.save && notes_are_expected + if reorder(link, params[:adjacent_work_item], params[:relative_position]) && notes_are_expected + create_notes(work_item) + end link end @@ -19,18 +20,14 @@ def relate_issuables(work_item) def reorder(link, adjacent_work_item, relative_position) WorkItems::ParentLink.move_nulls_to_end(RelativePositioning.mover.context(link).relative_siblings) - adjacent_parent_link = adjacent_work_item.parent_link - # if issuable is an epic, we can create the missing parent link between epic work item and adjacent_work_item - if adjacent_parent_link.blank? && adjacent_work_item.synced_epic - adjacent_parent_link = set_parent(issuable, adjacent_work_item) - adjacent_parent_link.relative_position = adjacent_work_item.synced_epic.relative_position - adjacent_parent_link.save! - end - - return unless adjacent_parent_link + move_link(link, adjacent_work_item, relative_position) + end - link.move_before(adjacent_parent_link) if relative_position == 'BEFORE' - link.move_after(adjacent_parent_link) if relative_position == 'AFTER' + # overriden in EE + def move_link(link, adjacent_work_item, relative_position) + link.move_before(adjacent_work_item.parent_link) if relative_position == 'BEFORE' + link.move_after(adjacent_work_item.parent_link) if relative_position == 'AFTER' + link.save end override :render_conflict_error? diff --git a/ee/app/services/ee/work_items/parent_links/reorder_service.rb b/ee/app/services/ee/work_items/parent_links/reorder_service.rb index 1240c8ed0a54d82b79b1cad6cffd5e823dad3555..541ac5959dbb6486ad8370ba77237625ba6819b5 100644 --- a/ee/app/services/ee/work_items/parent_links/reorder_service.rb +++ b/ee/app/services/ee/work_items/parent_links/reorder_service.rb @@ -7,8 +7,78 @@ module ReorderService extend ActiveSupport::Concern extend ::Gitlab::Utils::Override + def execute + super + rescue ::WorkItems::SyncAsEpic::SyncAsEpicError => _error + error(_("Couldn't re-order due to an internal error."), 422) + end + private + override :move_link + def move_link(link, adjacent_work_item, relative_position) + create_missing_synced_link!(adjacent_work_item) + return unless adjacent_work_item.parent_link + + return super unless sync_to_epic?(link, adjacent_work_item) + + ApplicationRecord.transaction do + move_synced_object!(link, adjacent_work_item, relative_position) if super + end + end + + def create_missing_synced_link!(adjacent_work_item) + adjacent_parent_link = adjacent_work_item.parent_link + # if issuable is an epic, we can create the missing parent link between epic work item and adjacent_work_item + return unless adjacent_parent_link.blank? && adjacent_work_item.synced_epic + + adjacent_parent_link = set_parent(issuable, adjacent_work_item) + adjacent_parent_link.relative_position = adjacent_work_item.synced_epic.relative_position + adjacent_parent_link.save! + + # we update the adjacent_work_item's parent link but use the adjacent_work_item object. + adjacent_work_item.reset + end + + def move_synced_object!(link, adjacent_work_item, relative_position) + synced_moving_object = synced_object_for(link.work_item) + synced_adjacent_object = synced_object_for(adjacent_work_item) + + synced_moving_object.move_before(synced_adjacent_object) if relative_position == 'BEFORE' + synced_moving_object.move_after(synced_adjacent_object) if relative_position == 'AFTER' + + synced_moving_object.save!(touch: false) + rescue StandardError => error + ::Gitlab::EpicWorkItemSync::Logger.error( + message: "Not able to sync re-ordering work item", + error_message: error.message, + namespace_id: issuable.namespace_id, + synced_moving_object_id: synced_moving_object.id, + synced_moving_object_class: synced_moving_object.class + ) + + ::Gitlab::ErrorTracking.track_exception(error, namespace_id: issuable.namespace_id) + + raise ::WorkItems::SyncAsEpic::SyncAsEpicError + end + + def synced_object_for(work_item) + case work_item.synced_epic + when nil + ::EpicIssue.find_by_issue_id(work_item.id) + when ::Epic + work_item.synced_epic + end + end + + def sync_to_epic?(link, adjacent_work_item) + return false if synced_work_item + return false if link.work_item_parent.synced_epic.nil? + return false unless link.work_item.namespace.root_ancestor.work_item_sync_to_epic_enabled? + + synced_object_for(link.work_item) && synced_object_for(adjacent_work_item) + end + override :can_admin_link? def can_admin_link?(work_item) return true if synced_work_item diff --git a/ee/spec/services/ee/work_items/parent_links/reorder_service_spec.rb b/ee/spec/services/ee/work_items/parent_links/reorder_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..e88b83a9ee09c3b9f9d220cbaa5fe4c545b43929 --- /dev/null +++ b/ee/spec/services/ee/work_items/parent_links/reorder_service_spec.rb @@ -0,0 +1,254 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe WorkItems::ParentLinks::ReorderService, feature_category: :portfolio_management do + describe '#execute' do + let_it_be(:group) { create(:group) } + let_it_be(:user) { create(:user, developer_of: group) } + + let_it_be_with_reload(:parent) { create(:work_item, :epic_with_legacy_epic, namespace: group) } + let_it_be_with_reload(:top_adjacent) { create(:work_item, :epic_with_legacy_epic, namespace: group) } + let_it_be_with_reload(:last_adjacent) { create(:work_item, :epic_with_legacy_epic, namespace: group) } + let_it_be_with_reload(:work_item) { create(:work_item, :epic_with_legacy_epic, namespace: group) } + + let(:relative_position) { 'AFTER' } + let(:adjacent_work_item) { top_adjacent } + let(:base_params) { { target_issuable: work_item, relative_position: relative_position } } + let(:params) { base_params.merge(adjacent_work_item: adjacent_work_item) } + + let(:work_item_position) { work_item.parent_link.reload.relative_position } + let(:top_adjacent_position) { top_adjacent.parent_link.reload.relative_position } + let(:last_adjacent_position) { last_adjacent.parent_link.reload.relative_position } + let(:synced_moving_object) { work_item.synced_epic.reload } + + subject(:execute) { described_class.new(parent, user, params).execute } + + shared_examples 'reorders the hierarchy' do + context 'when relative_position is AFTER' do + let(:relative_position) { 'AFTER' } + + it 'reorders correctly' do + subject + + expect(work_item_position).to be > top_adjacent_position + expect(work_item_position).to be < last_adjacent_position + + if synced_moving_object + expect(synced_moving_object.relative_position).to be > top_adjacent_position + expect(synced_moving_object.relative_position).to be < last_adjacent_position + end + end + end + + context 'when relative_position is BEFORE' do + let(:relative_position) { 'BEFORE' } + + it 'reorders correctly' do + subject + + expect(work_item_position).to be < top_adjacent_position + expect(work_item_position).to be < last_adjacent_position + + if synced_moving_object + expect(synced_moving_object.relative_position).to be < top_adjacent_position + expect(synced_moving_object.relative_position).to be < last_adjacent_position + end + end + end + end + + shared_examples 'only changes work item' do + it 'does not change synced moving object relative position but work item one' do + expect { subject }.to not_change { synced_moving_object.reload.relative_position } + .and change { work_item.parent_link.reload.relative_position } + end + end + + shared_examples 'when saving fails' do |failing_class, expect_error_log: false, expect_error_message: false| + it "does not change any position when saving #{failing_class}" do + allow_next_found_instance_of(failing_class) do |instance| + # Epic and EpicIssue are saved with ! + allow(instance).to receive(:save!).and_raise(ActiveRecord::RecordInvalid.new) + # WorkItem::ParentLink is saved without a ! + allow(instance).to receive(:save).and_return(false) + end + + if expect_error_log + expect(Gitlab::EpicWorkItemSync::Logger).to receive(:error) + .with({ + message: "Not able to sync re-ordering work item", + error_message: 'Record invalid', + namespace_id: group.id, + synced_moving_object_id: synced_moving_object.id, + synced_moving_object_class: synced_moving_object.class + }) + + end + + expect { execute }.to not_change { work_item.parent_link.reload.relative_position } + .and not_change { synced_moving_object.reload.relative_position } + + if expect_error_message + expect(execute) + .to eq({ status: :error, message: "Couldn't re-order due to an internal error.", http_status: 422 }) + end + end + end + + before do + stub_feature_flags(make_synced_work_item_read_only: false) + end + + context 'when adjacent_work_item parent link is missing' do + let(:synced_moving_object) { nil } + + before do + create(:parent_link, work_item: work_item, work_item_parent: parent) + end + + context 'when adjacent work item has a synced epic' do + it 'creates a new parent link' do + expect(top_adjacent.reload.parent_link).to be_nil + expect { execute }.to change { ::WorkItems::ParentLink.count }.by(1) + expect(top_adjacent.reload.parent_link).to be_present + end + end + + context 'when adjacent work item has no synced epic' do + let_it_be_with_reload(:top_adjacent) { create(:work_item, :epic, namespace: group) } + + it 'does not create a new parent link' do + expect { execute }.not_to change { WorkItems::ParentLink.count } + end + end + end + + context 'when moving an epic work item' do + let(:synced_moving_object) { work_item.synced_epic.reload } + + let_it_be_with_reload(:top_adjacent_link) do + create(:parent_link, work_item: top_adjacent, work_item_parent: parent, relative_position: 20) + end + + let_it_be_with_reload(:last_adjacent_link) do + create(:parent_link, work_item: last_adjacent, work_item_parent: parent, relative_position: 30) + end + + let_it_be_with_reload(:work_item_link) do + create(:parent_link, work_item: work_item, work_item_parent: parent, relative_position: 40) + end + + context 'when synced epics for the work items exist' do + before do + top_adjacent.synced_epic.update!(parent: parent.synced_epic, relative_position: 20) + last_adjacent.synced_epic.update!(parent: parent.synced_epic, relative_position: 30) + work_item.synced_epic.update!(parent: parent.synced_epic, relative_position: 40) + end + + it_behaves_like 'reorders the hierarchy' + + context 'when synced_work_item param is set' do + let(:synced_moving_object) { nil } + let(:params) { base_params.merge(adjacent_work_item: adjacent_work_item, synced_work_item: true) } + + it_behaves_like 'reorders the hierarchy' + it_behaves_like 'only changes work item' do + let(:synced_moving_object) { work_item.synced_epic.reload } + end + end + + context 'when sync_work_item_to_epic feature flag is disabled' do + before do + stub_feature_flags(sync_work_item_to_epic: false, make_synced_work_item_read_only: false) + end + + it_behaves_like 'only changes work item' + end + end + + context 'when synced epic for the moving work item do not exist' do + let(:synced_moving_object) { nil } + let_it_be_with_reload(:work_item) { create(:work_item, :epic, namespace: group) } + + it_behaves_like 'reorders the hierarchy' + end + + context 'when synced epics for the parent epic do not exist' do + let_it_be_with_reload(:parent) { create(:work_item, :epic, namespace: group) } + + it_behaves_like 'only changes work item' + end + + context 'when synced epic for the adjacent work item does not exist' do + let(:synced_moving_object) { nil } + + before do + top_adjacent.synced_epic.update!(issue_id: nil) + end + + it_behaves_like 'reorders the hierarchy' + end + + it_behaves_like 'when saving fails', Epic, expect_error_log: true, expect_error_message: true + it_behaves_like 'when saving fails', WorkItems::ParentLink + end + + context 'when moving an issue work item' do + let(:synced_moving_object) { epic_issue.reload } + + let_it_be_with_reload(:work_item) { create(:work_item, :issue, namespace: group) } + + let_it_be(:epic_issue) do + create(:epic_issue, epic: parent.synced_epic, issue: Issue.find(work_item.id), relative_position: 40) + end + + let_it_be_with_reload(:top_adjacent_link) do + create(:parent_link, work_item: top_adjacent, work_item_parent: parent, relative_position: 20) + end + + let_it_be_with_reload(:last_adjacent_link) do + create(:parent_link, work_item: last_adjacent, work_item_parent: parent, relative_position: 30) + end + + let_it_be_with_reload(:work_item_link) do + create(:parent_link, work_item: work_item, work_item_parent: parent, relative_position: 40) + end + + before do + top_adjacent.synced_epic.update!(parent: parent.synced_epic, relative_position: 20) + last_adjacent.synced_epic.update!(parent: parent.synced_epic, relative_position: 30) + end + + it_behaves_like 'reorders the hierarchy' + + context 'when sync_work_item_to_epic feature flag is disabled' do + before do + stub_feature_flags(sync_work_item_to_epic: false, make_synced_work_item_read_only: false) + end + + it_behaves_like 'only changes work item' + end + + context 'when synced EpicIssue for the moving work item do not exist' do + let(:synced_moving_object) { nil } + let_it_be_with_reload(:epic_issue) { nil } + + it_behaves_like 'reorders the hierarchy' + end + + context 'when synced epic for the adjacent work item does not exist' do + let(:synced_moving_object) { nil } + + before do + top_adjacent.synced_epic.update!(issue_id: nil) + end + + it_behaves_like 'reorders the hierarchy' + end + + it_behaves_like 'when saving fails', EpicIssue, expect_error_log: true, expect_error_message: true + it_behaves_like 'when saving fails', WorkItems::ParentLink + end + end +end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 1d724f58e9c7a2557d1441d09d8d443915db4640..f95fa850d9bacb0bb2d2c8d4ac7e5c363a9cab96 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -15035,6 +15035,9 @@ msgstr "" msgid "Couldn't link issues. You must have at least the Guest role in both projects." msgstr "" +msgid "Couldn't re-order due to an internal error." +msgstr "" + msgid "Couldn't reorder child due to an internal error." msgstr ""