diff --git a/app/services/issuable/callbacks/base.rb b/app/services/issuable/callbacks/base.rb index b697fb514808376dba85e38fe928bb0daa656b72..0ee412b112c47259a7b3629b66fbc061a48d31bc 100644 --- a/app/services/issuable/callbacks/base.rb +++ b/app/services/issuable/callbacks/base.rb @@ -13,6 +13,7 @@ def initialize(issuable:, current_user:, params: {}) end def after_initialize; end + def before_create; end def before_update; end def after_update_commit; end def after_save_commit; end diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb index c5554ed689b499251edfb785a42069ccb3b17a34..db570114cb669593a3373721531f6d1d58e808a0 100644 --- a/app/services/issuable_base_service.rb +++ b/app/services/issuable_base_service.rb @@ -254,6 +254,7 @@ def create(issuable, skip_system_notes: false) before_create(issuable) issuable_saved = issuable.with_transaction_returning_status do + @callbacks.each(&:before_create) transaction_create(issuable) end diff --git a/doc/development/work_items_widgets.md b/doc/development/work_items_widgets.md index 332dfde8a682be63c17f3e35225def69a7501f76..4579207530f6c1bd5c110fa2f54e48d977bfe829 100644 --- a/doc/development/work_items_widgets.md +++ b/doc/development/work_items_widgets.md @@ -162,6 +162,8 @@ legacy issue, merge request, or epic updates. - `after_initialize` is called after the work item is initialized by the `BuildService` and before the work item is saved by the `CreateService` and `UpdateService`. This callback runs outside the creation or update database transaction. +- `before_create` is called before the work item is saved by the `CreateService`. This callback runs + within the create database transaction. - `before_update` is called before the work item is saved by the `UpdateService`. This callback runs within the update database transaction. - `after_update_commit` is called after the DB update transaction is committed by the `UpdateService`. diff --git a/ee/app/services/concerns/work_items/sync_as_epic.rb b/ee/app/services/concerns/work_items/sync_as_epic.rb index f461fb7609e1819da52f7a9b8a196f6cb05b95ed..807ce7ec3268a18a247c89dac23d030262486dda 100644 --- a/ee/app/services/concerns/work_items/sync_as_epic.rb +++ b/ee/app/services/concerns/work_items/sync_as_epic.rb @@ -6,6 +6,17 @@ module SyncAsEpic private + def create_epic_for!(work_item) + return true unless work_item.namespace.work_item_sync_to_epic_enabled? + + epic = Epic.create!(create_params(work_item)) + + work_item.relative_position = epic.id + work_item.save!(touch: false) + rescue StandardError => error + handle_error!(:create, error, work_item) + end + def update_epic_for!(work_item) epic = work_item.synced_epic return true unless epic @@ -16,27 +27,51 @@ def update_epic_for!(work_item) handle_error!(:update, error, work_item) end + def create_params(work_item) + epic_params = {} + + epic_params[:author] = work_item.author + epic_params[:group] = work_item.namespace + epic_params[:issue_id] = work_item.id + epic_params[:iid] = work_item.iid + epic_params[:created_at] = work_item.created_at + + epic_params + .merge(callback_params) + .merge(base_attributes_params(work_item)) + end + def update_params(work_item) - epic_params = callback_params + callback_params + .merge(base_attributes_params(work_item)) + end + + def base_attributes_params(work_item) + base_params = {} - epic_params[:confidential] = params[:confidential] if params.has_key?(:confidential) - epic_params[:title] = params[:title] if params.has_key?(:title) - epic_params[:title_html] = work_item.title_html if params.has_key?(:title) - epic_params[:updated_by] = work_item.updated_by - epic_params[:updated_at] = work_item.updated_at - epic_params[:external_key] = params[:external_key] if params[:external_key] + if params.has_key?(:title) + base_params[:title] = params[:title] + base_params[:title_html] = work_item.title_html + end + + base_params[:confidential] = params[:confidential] if params.has_key?(:confidential) + base_params[:updated_by] = work_item.updated_by + base_params[:updated_at] = work_item.updated_at + base_params[:external_key] = params[:external_key] if params[:external_key] if work_item.edited? - epic_params[:last_edited_at] = work_item.last_edited_at - epic_params[:last_edited_by] = work_item.last_edited_by + base_params[:last_edited_at] = work_item.last_edited_at + base_params[:last_edited_by] = work_item.last_edited_by end - epic_params + base_params end def callback_params callbacks.reduce({}) do |params, callback| - params.merge!(callback.synced_epic_params) if callback.synced_epic_params.present? + next params unless callback.synced_epic_params.present? + + params.merge!(callback.synced_epic_params) end end @@ -45,10 +80,10 @@ def handle_error!(action, error, work_item) message: "Not able to #{action} epic", error_message: error.message, group_id: work_item.namespace_id, - work_item_id: work_item.id + work_item_id: work_item&.id ) - ::Gitlab::ErrorTracking.track_and_raise_exception(error, work_item_id: work_item.id) + ::Gitlab::ErrorTracking.track_and_raise_exception(error, group_id: work_item.namespace_id) end end end diff --git a/ee/app/services/ee/work_items/create_service.rb b/ee/app/services/ee/work_items/create_service.rb index b28a237508c9c67c50d7cfae86223e80ca5c618f..e80d7c9f03a364ce454117e415f2d0b05ff37e08 100644 --- a/ee/app/services/ee/work_items/create_service.rb +++ b/ee/app/services/ee/work_items/create_service.rb @@ -4,9 +4,23 @@ module EE module WorkItems module CreateService extend ::Gitlab::Utils::Override + include ::WorkItems::SyncAsEpic private + attr_reader :widget_params, :callbacks + + override :transaction_create + def transaction_create(work_item) + return super unless work_item.epic_work_item? + + super.tap do |save_result| + break save_result unless save_result + + create_epic_for!(work_item) + end + end + override :iid_param_allowed? def iid_param_allowed? sync_work_item? || super diff --git a/ee/app/services/work_items/callbacks/color.rb b/ee/app/services/work_items/callbacks/color.rb index 94fa1ecb674a26362a4d900739bc4f3883a50952..27ccb7ba6f106b29f495f64a701b08b5f0de3fd2 100644 --- a/ee/app/services/work_items/callbacks/color.rb +++ b/ee/app/services/work_items/callbacks/color.rb @@ -5,10 +5,14 @@ module Callbacks class Color < Base ALLOWED_PARAMS = %i[color skip_system_notes].freeze - def after_initialize + def before_create + handle_color_change unless excluded_in_new_type? + end + + def before_update return work_item.color.destroy! if work_item.color.present? && excluded_in_new_type? - handle_color_change if params.key?(:color) && can_set_color? + handle_color_change end def after_save_commit @@ -18,13 +22,17 @@ def after_save_commit private def handle_color_change + return unless params.key?(:color) + return unless can_set_color? + @previous_color = work_item&.color&.color&.to_s color = work_item.color || work_item.build_color return if params[:color] == color.color.to_s color.color = params[:color] - if color.save + if color.valid? + work_item.color = color synced_epic_params[:color] = ::Gitlab::Color.of(params[:color]) else raise_error(color.errors.full_messages.join(', ')) diff --git a/ee/spec/services/work_items/callbacks/color_spec.rb b/ee/spec/services/work_items/callbacks/color_spec.rb index a7af16b6925f87d7eb65042cef2856750e2b55b2..a6e2daae7cb5b6f48e8a4242d590a3162389f073 100644 --- a/ee/spec/services/work_items/callbacks/color_spec.rb +++ b/ee/spec/services/work_items/callbacks/color_spec.rb @@ -7,7 +7,6 @@ let_it_be(:reporter) { create(:user) } let_it_be(:group) { create(:group, reporters: reporter) } let_it_be_with_reload(:work_item) { create(:work_item, :epic, namespace: group, author: user) } - let_it_be_with_reload(:color) { create(:color, work_item: work_item, color: '#1068bf') } let_it_be(:error_class) { ::WorkItems::Widgets::BaseService::WidgetError } let(:current_user) { reporter } @@ -18,105 +17,120 @@ def work_item_color work_item.reload.color&.color.to_s end - describe '#after_initialize' do - subject(:after_initialize_callback) { callback.after_initialize } - - shared_examples 'work item and color is unchanged' do - it 'does not change work item color value' do - expect { after_initialize_callback } - .to not_change { work_item_color } - .and not_change { work_item.updated_at } - end + shared_examples 'work item and color is unchanged' do + it 'does not change work item color value' do + expect { subject } + .to not_change { work_item_color } + .and not_change { work_item.updated_at } + end - it 'does not set synced_epic_params' do - after_initialize_callback + it 'does not set synced_epic_params' do + subject - expect(callback.synced_epic_params).to be_empty - end + expect(callback.synced_epic_params).to be_empty end + end - shared_examples 'color is updated' do |color| - it 'updates work item color value' do - expect { after_initialize_callback }.to change { work_item_color }.to(color) - end + shared_examples 'color is updated' do |color| + it 'updates work item color value' do + expect { subject }.to change { work_item_color }.to(color) + end - it 'sets synced_epic_params' do - after_initialize_callback + it 'sets synced_epic_params' do + subject - expect(callback.synced_epic_params[:color].to_s).to eq(color) - end + expect(callback.synced_epic_params[:color].to_s).to eq(color) end + end + + shared_examples 'raises a WidgetError' do + it { expect { subject }.to raise_error(error_class, message) } + end - shared_examples 'raises a WidgetError' do - it { expect { after_initialize_callback }.to raise_error(error_class, message) } + shared_examples 'when epic_colors feature is licensed' do + before do + stub_licensed_features(epic_colors: true) end - context 'when epic_colors feature is licensed' do - before do - stub_licensed_features(epic_colors: true) + context 'when color param is present' do + context 'when color param is valid' do + let(:params) { { color: '#454545' } } + + it_behaves_like 'color is updated', '#454545' end + end - context 'when color param is present' do - context 'when color param is valid' do - let(:params) { { color: '#454545' } } + context 'when color param is not present' do + let(:params) { {} } - it_behaves_like 'color is updated', '#454545' - end + it_behaves_like 'work item and color is unchanged' - context 'when widget does not exist in new type' do - let(:params) { {} } + context 'when widget does not exist in type' do + let(:params) { {} } - before do - allow(callback).to receive(:excluded_in_new_type?).and_return(true) - work_item.color = color - end + before do + allow(callback).to receive(:excluded_in_new_type?).and_return(true) + end - it "removes the work item's color" do - expect { callback.after_initialize }.to change { work_item.reload.color }.from(color).to(nil) - end + it "does not set the color" do + subject + + expect(work_item.reload.color).to be_nil end end + end - context 'when color param is not present' do - let(:params) { {} } + context 'when color param is nil' do + let(:params) { { color: nil } } - it_behaves_like 'work item and color is unchanged' + it_behaves_like 'raises a WidgetError' do + let(:message) { "Color can't be blank" } end + end - context 'when color is same as work item color' do - let(:params) { { color: '#1068bf' } } + context 'when user cannot admin_work_item' do + let(:current_user) { user } + let(:params) { { color: '#1068bf' } } - it_behaves_like 'work item and color is unchanged' - end + it_behaves_like 'work item and color is unchanged' + end + end - context 'when color param is nil' do - let(:params) { { color: nil } } + shared_examples 'when epic_colors feature is unlicensed' do + before do + stub_licensed_features(epic_colors: false) + end - it_behaves_like 'raises a WidgetError' do - let(:message) { "Color can't be blank" } - end - end + it_behaves_like 'work item and color is unchanged' + end - context 'when user cannot admin_work_item' do - let(:current_user) { user } - let(:params) { { color: '#1068bf' } } + describe '#before_update' do + subject(:before_update_callback) { callback.before_update } - it_behaves_like 'work item and color is unchanged' - end - end + let_it_be_with_reload(:color) { create(:color, work_item: work_item, color: '#1068bf') } - context 'when epic_colors feature is unlicensed' do - before do - stub_licensed_features(epic_colors: false) - end + it_behaves_like 'when epic_colors feature is licensed' + it_behaves_like 'when epic_colors feature is unlicensed' + + context 'when color is same as work item color' do + let(:params) { { color: '#1068bf' } } it_behaves_like 'work item and color is unchanged' end end + describe '#before_create' do + subject(:before_create_callback) { callback.before_create } + + it_behaves_like 'when epic_colors feature is licensed' + it_behaves_like 'when epic_colors feature is unlicensed' + end + describe '#after_save_commit' do subject(:after_save_commit_callback) { callback.after_save_commit } + let_it_be_with_reload(:color) { create(:color, work_item: work_item, color: '#1068bf') } + it "does not create system notes when color didn't change" do expect { after_save_commit_callback }.to not_change { work_item.notes.count } end diff --git a/ee/spec/services/work_items/create_service_spec.rb b/ee/spec/services/work_items/create_service_spec.rb index baef0b744041539051560b829d2e78fa07c54a51..a48863bad0cb11f6c0c556f0df3e9a487544f083 100644 --- a/ee/spec/services/work_items/create_service_spec.rb +++ b/ee/spec/services/work_items/create_service_spec.rb @@ -107,4 +107,113 @@ it_behaves_like 'creates work item in container', :project it_behaves_like 'creates work item in container', :project_namespace it_behaves_like 'creates work item in container', :group + + context 'for legacy epics' do + include_context 'with container for work items service', :group + + let(:epic) { Epic.last } + let(:type) { WorkItems::Type.default_by_type(:epic) } + + let(:start_date) { (Time.current + 1.day).to_date } + let(:due_date) { (Time.current + 2.days).to_date } + + let(:widget_params) do + { + description_widget: { + description: 'new description' + }, + color_widget: { + color: '#FF0000' + }, + start_and_due_date_widget: { start_date: start_date, due_date: due_date } + } + end + + let(:opts) { { title: 'new title', external_key: 'external_key', confidential: true, work_item_type: type } } + let(:current_user) { reporter } + + before do + stub_licensed_features(epics: true, epic_colors: true) + stub_feature_flags(make_synced_work_item_read_only: false) + end + + subject(:service_result) { service.execute } + + it_behaves_like 'syncs all data from a work_item to an epic' + + context 'when not creating an epic work item' do + let(:type) { WorkItems::Type.default_by_type(:task) } + + it 'only creates a work item' do + expect { service_result } + .to not_change { Epic.count } + .and change { WorkItem.count } + end + end + + context 'when creating the work item fails' do + before do + allow_next_instance_of(WorkItem) do |work_item| + allow(work_item).to receive(:save!).and_raise(ActiveRecord::RecordInvalid.new) + end + end + + it 'does not update the epic or work item' do + expect(Gitlab::EpicWorkItemSync::Logger).to receive(:error) + .with({ + message: "Not able to create epic", + error_message: "Record invalid", + group_id: group.id, + work_item_id: an_instance_of(Integer) + }) + + expect { service_result } + .to not_change { Epic.count } + .and not_change { WorkItem.count } + .and raise_error(ActiveRecord::RecordInvalid) + end + end + + context 'when creating the epic fails' do + it 'does not create an epic or work item' do + allow(Epic).to receive(:create!).and_raise(ActiveRecord::RecordInvalid.new) + + expect(Gitlab::EpicWorkItemSync::Logger).to receive(:error) + .with({ + message: "Not able to create epic", + error_message: "Record invalid", + group_id: group.id, + work_item_id: an_instance_of(Integer) + }) + + expect { service_result } + .to not_change { WorkItem.count } + .and not_change { Epic.count } + .and raise_error(ActiveRecord::RecordInvalid) + end + end + + context 'when changes are invalid' do + let(:widget_params) { {} } + let(:opts) { { title: '' } } + + it 'does not create an epic or work item' do + expect { service_result } + .to not_change { WorkItem.count } + .and not_change { Epic.count } + end + end + + context 'when feature flag is disabled' do + before do + stub_feature_flags(make_synced_work_item_read_only: false, sync_work_item_to_epic: false) + end + + it 'only creates a work item but not the epic' do + expect { service_result } + .to change { WorkItem.count } + .and not_change { Epic.count } + end + end + end end diff --git a/ee/spec/services/work_items/update_service_spec.rb b/ee/spec/services/work_items/update_service_spec.rb index 545c631a7d0bc99daa5217e46d232b8cfb1ee1a9..e999e35855b0b3e680441866ea9093933d137b50 100644 --- a/ee/spec/services/work_items/update_service_spec.rb +++ b/ee/spec/services/work_items/update_service_spec.rb @@ -394,6 +394,15 @@ let(:start_date) { (Time.current + 1.day).to_date } let(:due_date) { (Time.current + 2.days).to_date } + let(:service) do + described_class.new( + container: group, + current_user: current_user, + params: params, + widget_params: widget_params + ) + end + let(:widget_params) do { description_widget: {