From f0100260568ae8583363c967a76980b7d21334ed Mon Sep 17 00:00:00 2001 From: Alex Kozenko <9867893@gmail.com> Date: Tue, 8 Jul 2025 20:34:46 +0300 Subject: [PATCH 01/10] Fix spec for Group#supports_group_work_items? in Community Edition --- spec/models/group_spec.rb | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/spec/models/group_spec.rb b/spec/models/group_spec.rb index 08e1958afdad65..876459a24cb2cb 100644 --- a/spec/models/group_spec.rb +++ b/spec/models/group_spec.rb @@ -4352,7 +4352,11 @@ def define_cache_expectations(cache_key) subject { group.supports_group_work_items? } - it { is_expected.to be false } + context 'when CE edition', unless: Gitlab.ee? do + it 'returns false' do + expect(subject).to be false + end + end end describe '#continue_indented_text_feature_flag_enabled?' do -- GitLab From 6a47f220fb990ec86f8c90950a5944092efe51a1 Mon Sep 17 00:00:00 2001 From: Alex Kozenko <9867893@gmail.com> Date: Tue, 8 Jul 2025 20:36:05 +0300 Subject: [PATCH 02/10] Add #has_active_hooks? method to Group model --- app/models/group.rb | 5 +++ ee/app/models/ee/group.rb | 6 +++ ee/spec/models/ee/group_spec.rb | 67 +++++++++++++++++++++++++++++++++ spec/models/group_spec.rb | 12 ++++++ 4 files changed, 90 insertions(+) diff --git a/app/models/group.rb b/app/models/group.rb index 873dc3d4b060ff..55d024696e563a 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -1127,6 +1127,11 @@ def supports_group_work_items? false end + # overriden in EE + def has_active_hooks?(hooks_scope = :push_hooks) + false + end + def create_group_level_work_items_feature_flag_enabled? ::Feature.enabled?(:create_group_level_work_items, self, type: :wip) && supports_group_work_items? end diff --git a/ee/app/models/ee/group.rb b/ee/app/models/ee/group.rb index 939674cd897045..3b22778bf400d9 100644 --- a/ee/app/models/ee/group.rb +++ b/ee/app/models/ee/group.rb @@ -810,6 +810,12 @@ def execute_hooks(data, hooks_scope) execute_async_hooks(group_hooks, hooks_scope, data) end + override :has_active_hooks? + def has_active_hooks?(hooks_scope = :push_hooks) + feature_available?(:group_webhooks) && + GroupHook.where(group_id: self_and_ancestors).hooks_for(hooks_scope).any? + end + override :git_transfer_in_progress? def git_transfer_in_progress? reference_counter(type: ::Gitlab::GlRepository::WIKI).value > 0 diff --git a/ee/spec/models/ee/group_spec.rb b/ee/spec/models/ee/group_spec.rb index bcc3240c6ee666..0dbd655330c5a1 100644 --- a/ee/spec/models/ee/group_spec.rb +++ b/ee/spec/models/ee/group_spec.rb @@ -3000,6 +3000,73 @@ def webhook_headers end end + describe '#has_active_hooks?' do + let_it_be_with_reload(:parent_group) { create(:group) } + let_it_be_with_reload(:group) { create(:group, parent: parent_group) } + let(:hooks_scope) { :push_hooks } + + subject { group.has_active_hooks?(hooks_scope) } + + context 'when group_webhooks feature is available' do + before do + stub_licensed_features(group_webhooks: true) + end + + context 'when no hooks exist' do + it 'returns false' do + expect(subject).to be false + end + end + + context 'when hook exists for the group' do + let_it_be_with_reload(:group_hook) { create(:group_hook, group: group, push_events: true) } + + it 'returns true for push_hooks scope' do + expect(subject).to be true + end + + context 'with different hook scope' do + let(:hooks_scope) { :issues_hooks } + + it 'returns false' do + expect(subject).to be false + end + end + end + + context 'when hook exists for the ancestor group' do + let!(:parent_hook) { create(:group_hook, group: parent_group, push_events: true) } + + it 'returns true when checking group that inherits hooks from ancestor' do + expect(subject).to be true + end + end + + context 'when hook exists for unrelated group' do + let!(:unrelated_group) { create(:group) } + let!(:unrelated_group_hook) { create(:group_hook, group: unrelated_group, push_events: true) } + + it 'returns false' do + expect(subject).to be false + end + end + end + + context 'when group_webhooks feature is not available' do + before do + stub_licensed_features(group_webhooks: false) + end + + context 'even when hook exists' do + let!(:group_hook) { create(:group_hook, group: group, push_events: true) } + + it 'returns false' do + expect(subject).to be false + end + end + end + end + describe '#disable_personal_access_tokens?' do before do group.update!(disable_personal_access_tokens: true) diff --git a/spec/models/group_spec.rb b/spec/models/group_spec.rb index 876459a24cb2cb..503b0345250b17 100644 --- a/spec/models/group_spec.rb +++ b/spec/models/group_spec.rb @@ -4359,6 +4359,18 @@ def define_cache_expectations(cache_key) end end + describe '#has_active_hooks?' do + let(:group) { build(:group) } + + subject { group.has_active_hooks? } + + context 'when CE edition', unless: Gitlab.ee? do + it 'returns false' do + expect(subject).to be false + end + end + end + describe '#continue_indented_text_feature_flag_enabled?' do it_behaves_like 'checks self and root ancestor feature flag' do let(:feature_flag) { :continue_indented_text } -- GitLab From 0be7560a051539cbfdde7629f2a0e5d1161594da Mon Sep 17 00:00:00 2001 From: Alex Kozenko <9867893@gmail.com> Date: Thu, 10 Jul 2025 14:55:06 +0300 Subject: [PATCH 03/10] Add milestone-related webhooks on group level --- app/helpers/integrations_helper.rb | 1 + app/services/milestones/base_service.rb | 2 - app/services/milestones/close_service.rb | 4 +- app/services/milestones/create_service.rb | 4 +- app/services/milestones/destroy_service.rb | 2 +- app/services/milestones/reopen_service.rb | 4 +- ee/app/models/hooks/group_hook.rb | 1 + .../ee/gitlab/hook_data/milestone_builder.rb | 24 ++++++ .../groups/hooks_controller_spec.rb | 1 + ee/spec/factories/group_hooks.rb | 1 + lib/gitlab/data_builder/milestone.rb | 1 + lib/gitlab/hook_data/milestone_builder.rb | 30 ++++---- locale/gitlab.pot | 3 + spec/helpers/integrations_helper_spec.rb | 1 + .../services/milestones/close_service_spec.rb | 65 ++++++++++++----- .../milestones/create_service_spec.rb | 73 ++++++++++++------- .../milestones/destroy_service_spec.rb | 44 ++++++++--- .../milestones/reopen_service_spec.rb | 69 +++++++++++++----- 18 files changed, 235 insertions(+), 95 deletions(-) create mode 100644 ee/lib/ee/gitlab/hook_data/milestone_builder.rb diff --git a/app/helpers/integrations_helper.rb b/app/helpers/integrations_helper.rb index 55f4b1d2c4db69..e3f0487f2c467c 100644 --- a/app/helpers/integrations_helper.rb +++ b/app/helpers/integrations_helper.rb @@ -212,6 +212,7 @@ def integration_webhook_event_human_name(event) project_events: s_('Webhooks|Project events'), push_events: _('Push events'), releases_events: s_('Webhooks|Releases events'), + milestone_events: s_('Webhooks|Milestone events'), repository_update_events: _('Repository update events'), resource_access_token_events: s_('Webhooks|Project or group access token events'), subgroup_events: s_('Webhooks|Subgroup events'), diff --git a/app/services/milestones/base_service.rb b/app/services/milestones/base_service.rb index ecfe7db325e514..6c5451d7fd8da4 100644 --- a/app/services/milestones/base_service.rb +++ b/app/services/milestones/base_service.rb @@ -16,8 +16,6 @@ def initialize(parent, user, params = {}) private def execute_hooks(milestone, action) - # At the moment, only project milestones support webhooks, not group milestones - return unless milestone.project_milestone? return unless milestone.parent.has_active_hooks?(:milestone_hooks) payload = Gitlab::DataBuilder::Milestone.build(milestone, action) diff --git a/app/services/milestones/close_service.rb b/app/services/milestones/close_service.rb index 616c6d85b7c55a..968494eeaac548 100644 --- a/app/services/milestones/close_service.rb +++ b/app/services/milestones/close_service.rb @@ -3,8 +3,8 @@ module Milestones class CloseService < Milestones::BaseService def execute(milestone) - if milestone.close && milestone.project_milestone? - event_service.close_milestone(milestone, current_user) + if milestone.close + event_service.close_milestone(milestone, current_user) if milestone.project_milestone? execute_hooks(milestone, 'close') end diff --git a/app/services/milestones/create_service.rb b/app/services/milestones/create_service.rb index d8a9be6aafebd4..a8d43cbcf1563e 100644 --- a/app/services/milestones/create_service.rb +++ b/app/services/milestones/create_service.rb @@ -7,8 +7,8 @@ def execute before_create(milestone) - if milestone.save && milestone.project_milestone? - event_service.open_milestone(milestone, current_user) + if milestone.save + event_service.open_milestone(milestone, current_user) if milestone.project_milestone? execute_hooks(milestone, 'create') end diff --git a/app/services/milestones/destroy_service.rb b/app/services/milestones/destroy_service.rb index dc605472e37bf0..39d32709ec622a 100644 --- a/app/services/milestones/destroy_service.rb +++ b/app/services/milestones/destroy_service.rb @@ -14,7 +14,7 @@ def execute(milestone) return unless milestone.destroyed? - execute_hooks(milestone, 'delete') if milestone.project_milestone? + execute_hooks(milestone, 'delete') milestone end diff --git a/app/services/milestones/reopen_service.rb b/app/services/milestones/reopen_service.rb index a4a9ccfd4011dc..13e138bd5615de 100644 --- a/app/services/milestones/reopen_service.rb +++ b/app/services/milestones/reopen_service.rb @@ -3,8 +3,8 @@ module Milestones class ReopenService < Milestones::BaseService def execute(milestone) - if milestone.activate && milestone.project_milestone? - event_service.reopen_milestone(milestone, current_user) + if milestone.activate + event_service.reopen_milestone(milestone, current_user) if milestone.project_milestone? execute_hooks(milestone, 'reopen') end diff --git a/ee/app/models/hooks/group_hook.rb b/ee/app/models/hooks/group_hook.rb index 2026d1ec685d96..f87861ee3adc1a 100644 --- a/ee/app/models/hooks/group_hook.rb +++ b/ee/app/models/hooks/group_hook.rb @@ -28,6 +28,7 @@ class GroupHook < WebHook :project_hooks, :push_hooks, :release_hooks, + :milestone_hooks, :resource_access_token_hooks, :subgroup_hooks, :tag_push_hooks, diff --git a/ee/lib/ee/gitlab/hook_data/milestone_builder.rb b/ee/lib/ee/gitlab/hook_data/milestone_builder.rb new file mode 100644 index 00000000000000..fb77f38984df4e --- /dev/null +++ b/ee/lib/ee/gitlab/hook_data/milestone_builder.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module EE + module Gitlab + module HookData + module MilestoneBuilder + extend ActiveSupport::Concern + + EE_SAFE_HOOK_ATTRIBUTES = %i[ + group_id + ].freeze + + class_methods do + extend ::Gitlab::Utils::Override + + override :safe_hook_attributes + def safe_hook_attributes + super + EE_SAFE_HOOK_ATTRIBUTES + end + end + end + end + end +end diff --git a/ee/spec/controllers/groups/hooks_controller_spec.rb b/ee/spec/controllers/groups/hooks_controller_spec.rb index 7d2a1ad029fc31..9d44d88aa5ca7a 100644 --- a/ee/spec/controllers/groups/hooks_controller_spec.rb +++ b/ee/spec/controllers/groups/hooks_controller_spec.rb @@ -141,6 +141,7 @@ def it_renders_correctly wiki_page_events: true, deployment_events: true, releases_events: true, + milestone_events: true, member_events: true, subgroup_events: true, url_variables: [ diff --git a/ee/spec/factories/group_hooks.rb b/ee/spec/factories/group_hooks.rb index c3b1b07301dc0a..7a6d18d65e1587 100644 --- a/ee/spec/factories/group_hooks.rb +++ b/ee/spec/factories/group_hooks.rb @@ -29,6 +29,7 @@ pipeline_events { true } wiki_page_events { true } releases_events { true } + milestone_events { true } subgroup_events { true } emoji_events { true } resource_access_token_events { true } diff --git a/lib/gitlab/data_builder/milestone.rb b/lib/gitlab/data_builder/milestone.rb index 6f6e269c082513..ce9c017973e378 100644 --- a/lib/gitlab/data_builder/milestone.rb +++ b/lib/gitlab/data_builder/milestone.rb @@ -22,6 +22,7 @@ def build(milestone, action) object_kind: 'milestone', event_type: 'milestone', project: milestone.project&.hook_attrs, + group: milestone.group&.hook_attrs, object_attributes: milestone.hook_attrs, action: action } diff --git a/lib/gitlab/hook_data/milestone_builder.rb b/lib/gitlab/hook_data/milestone_builder.rb index f2022a956eb25b..3e523e3d4f71da 100644 --- a/lib/gitlab/hook_data/milestone_builder.rb +++ b/lib/gitlab/hook_data/milestone_builder.rb @@ -3,18 +3,20 @@ module Gitlab module HookData class MilestoneBuilder < BaseBuilder - SAFE_HOOK_ATTRIBUTES = %i[ - id - iid - title - description - state - created_at - updated_at - due_date - start_date - project_id - ].freeze + def self.safe_hook_attributes + %i[ + id + iid + title + description + state + created_at + updated_at + due_date + start_date + project_id + ].freeze + end alias_method :milestone, :object @@ -22,8 +24,10 @@ def build milestone .attributes .with_indifferent_access - .slice(*SAFE_HOOK_ATTRIBUTES) + .slice(*self.class.safe_hook_attributes) end end end end + +Gitlab::HookData::MilestoneBuilder.prepend_mod diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 2a40be45c24962..fe7761b11fd913 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -69663,6 +69663,9 @@ msgstr "" msgid "Webhooks|Merge request events" msgstr "" +msgid "Webhooks|Milestone events" +msgstr "" + msgid "Webhooks|Must match part of URL" msgstr "" diff --git a/spec/helpers/integrations_helper_spec.rb b/spec/helpers/integrations_helper_spec.rb index cd11007dc77d83..1e79bdeb3b1231 100644 --- a/spec/helpers/integrations_helper_spec.rb +++ b/spec/helpers/integrations_helper_spec.rb @@ -405,6 +405,7 @@ def relation :deployment_events | 'Deployment events' :feature_flag_events | 'Feature flag events' :releases_events | 'Releases events' + :milestone_events | 'Milestone events' :resource_access_token_events | 'Project or group access token events' :vulnerability_events | 'Vulnerability events' end diff --git a/spec/services/milestones/close_service_spec.rb b/spec/services/milestones/close_service_spec.rb index 2c0caa38bb6ec3..385d45959ed623 100644 --- a/spec/services/milestones/close_service_spec.rb +++ b/spec/services/milestones/close_service_spec.rb @@ -31,36 +31,72 @@ end end - shared_examples 'closes the milestone' do |with_project_hooks:| + shared_examples 'closes the milestone' do |with_hooks:, with_event:| it 'executes hooks with close action and creates new event' do expect(service).to receive(:execute_hooks).with(milestone, 'close').and_call_original - expect(project).to receive(:execute_hooks).with(kind_of(Hash), :milestone_hooks) if with_project_hooks + expect(milestone.parent).to receive(:execute_hooks).with(kind_of(Hash), :milestone_hooks) if with_hooks + expect(Event).to receive(:new).exactly(1).time.and_call_original if with_event - expect { service.execute(milestone) }.to change { Event.count }.by(1) + expect { service.execute(milestone) }.to change { milestone.state }.to('closed') end end shared_examples 'does not close the milestone' do it 'does not execute hooks and does not create new event' do expect(service).not_to receive(:execute_hooks) + expect(Event).not_to receive(:new) - expect { service.execute(milestone) }.not_to change { Event.count } + expect { service.execute(milestone) }.not_to change { milestone.state } end end context 'when milestone is successfully closed' do - context 'when project has active milestone hooks' do - let(:project) do - create(:project).tap do |project| - create(:project_hook, project: project, milestone_events: true) + context 'when milestone parent is a project' do + context 'when project has active milestone hooks' do + let(:project) do + create(:project).tap do |project| + create(:project_hook, project: project, milestone_events: true) + end end + + it_behaves_like 'closes the milestone', with_hooks: true, with_event: true end - it_behaves_like 'closes the milestone', with_project_hooks: true + context 'when project has no active milestone hooks' do + it_behaves_like 'closes the milestone', with_hooks: false, with_event: true + end end - context 'when project has no active milestone hooks' do - it_behaves_like 'closes the milestone', with_project_hooks: false + context 'when milestone parent is a group' do + let(:group) { create(:group) } + let(:milestone) { create(:milestone, group: group) } + let(:service) { described_class.new(group, user, {}) } + + context 'with Enterprise Edition', if: Gitlab.ee? do + context 'when group webhooks are available' do + before do + stub_licensed_features(group_webhooks: true) + end + + context 'when group has no active milestone hooks' do + it_behaves_like 'closes the milestone', with_hooks: false, with_event: false + end + + context 'when group has active milestone hooks' do + let(:group) do + create(:group).tap do |group| + create(:group_hook, group: group, milestone_events: true) + end + end + + it_behaves_like 'closes the milestone', with_hooks: true, with_event: false + end + end + end + + context 'without Enterprise Edition', unless: Gitlab.ee? do + it_behaves_like 'closes the milestone', with_hooks: false, with_event: false + end end end @@ -70,13 +106,6 @@ it_behaves_like 'does not close the milestone' end - - context 'when milestone is a group milestone' do - let(:group) { create(:group) } - let(:milestone) { create(:milestone, group: group) } - - it_behaves_like 'does not close the milestone' - end end end end diff --git a/spec/services/milestones/create_service_spec.rb b/spec/services/milestones/create_service_spec.rb index 1999b1d40392bb..dd0d236d8c7b99 100644 --- a/spec/services/milestones/create_service_spec.rb +++ b/spec/services/milestones/create_service_spec.rb @@ -30,27 +30,65 @@ expect(milestone.description).to eq('Description') end - shared_examples 'creates the milestone' do |with_project_hooks:| + shared_examples 'creates the milestone' do |with_hooks:, with_event:| it 'executes hooks with create action and creates new event' do expect(create_milestone).to receive(:execute_hooks).with(kind_of(Milestone), 'create').and_call_original - expect(project).to receive(:execute_hooks).with(kind_of(Hash), :milestone_hooks) if with_project_hooks + expect(milestone.parent).to receive(:execute_hooks).with(kind_of(Hash), :milestone_hooks) if with_hooks + expect(Event).to receive(:new).exactly(1).time.and_call_original if with_event - expect { create_milestone.execute }.to change { Event.count }.by(1) + expect { create_milestone.execute }.to change { Milestone.count }.by(1) end end - context 'when project has active milestone hooks' do - let(:project) do - create(:project).tap do |project| - create(:project_hook, project: project, milestone_events: true) + context 'when milestone is a project milestone' do + let(:milestone) { create(:milestone, project: project) } + + context 'when project has active milestone hooks' do + let(:project) do + create(:project).tap do |project| + create(:project_hook, project: project, milestone_events: true) + end end + + it_behaves_like 'creates the milestone', with_hooks: true, with_event: true end - it_behaves_like 'creates the milestone', with_project_hooks: true + context 'when project has no active milestone hooks' do + it_behaves_like 'creates the milestone', with_hooks: false, with_event: true + end end - context 'when project has no active milestone hooks' do - it_behaves_like 'creates the milestone', with_project_hooks: false + context 'when milestone is a group milestone' do + let(:group) { create(:group) } + let(:milestone) { create(:milestone, group: group) } + + subject(:create_milestone) { described_class.new(group, user, params) } + + context 'with Enterprise Edition', if: Gitlab.ee? do + context 'when group webhooks are available' do + before do + stub_licensed_features(group_webhooks: true) + end + + context 'when group has active milestone hooks' do + let(:group) do + create(:group).tap do |group| + create(:group_hook, group: group, milestone_events: true) + end + end + + it_behaves_like 'creates the milestone', with_hooks: true, with_event: false + end + + context 'when group has no active milestone hooks' do + it_behaves_like 'creates the milestone', with_hooks: false, with_event: false + end + end + end + + context 'without Enterprise Edition', unless: Gitlab.ee? do + it_behaves_like 'creates the milestone', with_hooks: false, with_event: false + end end end @@ -85,21 +123,6 @@ end end - context 'when milestone is a group milestone' do - let(:group) { create(:group) } - let(:group_service) { described_class.new(group, user, params) } - - it 'does not execute hooks for group milestones' do - milestone = build(:milestone, group: group) - allow(milestone).to receive(:save).and_return(true) - allow(group_service).to receive(:build_milestone).and_return(milestone) - - expect(group).not_to receive(:execute_hooks) - - group_service.execute - end - end - it 'calls before_create method' do expect(create_milestone).to receive(:before_create) create_milestone.execute diff --git a/spec/services/milestones/destroy_service_spec.rb b/spec/services/milestones/destroy_service_spec.rb index c76edd2648c361..eca8c8a906e9cd 100644 --- a/spec/services/milestones/destroy_service_spec.rb +++ b/spec/services/milestones/destroy_service_spec.rb @@ -27,7 +27,6 @@ updated_attributes: %w[milestone_id] ) - milestone.reload issues.each do |issue| expect(issue.reload.milestone).to be_nil end @@ -73,23 +72,48 @@ end end - context 'on group milestones' do - let_it_be_with_reload(:milestone) { create(:milestone, group: group) } - - let(:container) { group } + shared_examples 'deletes group-milestone' do |with_hooks:| + it 'conditionally executes webhooks and does not create a new event' do + expect(service).to receive(:execute_hooks).with(milestone, 'delete').and_call_original + expect(milestone.parent).to receive(:execute_hooks).with(kind_of(Hash), :milestone_hooks) if with_hooks + expect(Event).not_to receive(:new) - it 'deletes milestone' do service.execute(milestone) - expect { milestone.reload }.to raise_error ActiveRecord::RecordNotFound end + end + + context 'on group milestones' do + let(:milestone) { create(:milestone, group: group) } + + let(:container) { group } it_behaves_like 'deletes milestone id from issuables' - it 'does not log destroy event and does not run on-delete webhook' do - expect(service).not_to receive(:execute_hooks).with(milestone, 'delete') + context 'with Enterprise Edition', if: Gitlab.ee? do + context 'when group webhooks are available' do + before do + stub_licensed_features(group_webhooks: true) + end + + context 'when group has active milestone hooks' do + let(:group) do + create(:group).tap do |group| + create(:group_hook, group: group, milestone_events: true) + end + end + + it_behaves_like 'deletes group-milestone', with_hooks: true + end + + context 'when group has no active milestone hooks' do + it_behaves_like 'deletes group-milestone', with_hooks: false + end + end + end - expect { service.execute(milestone) }.not_to change { Event.count } + context 'without Enterprise Edition', unless: Gitlab.ee? do + it_behaves_like 'deletes group-milestone', with_hooks: false end end end diff --git a/spec/services/milestones/reopen_service_spec.rb b/spec/services/milestones/reopen_service_spec.rb index fc7874cbc84fb5..a473970eab33d3 100644 --- a/spec/services/milestones/reopen_service_spec.rb +++ b/spec/services/milestones/reopen_service_spec.rb @@ -32,38 +32,74 @@ end end - shared_examples 'reopens the milestone' do |with_project_hooks:| + shared_examples 'reopens the milestone' do |with_hooks:, with_event:| it 'executes hooks with reopen action and creates new event' do expect(service).to receive(:execute_hooks).with(milestone, 'reopen').and_call_original - expect(project).to receive(:execute_hooks).with(kind_of(Hash), :milestone_hooks) if with_project_hooks + expect(milestone.parent).to receive(:execute_hooks).with(kind_of(Hash), :milestone_hooks) if with_hooks + expect(Event).to receive(:new).exactly(1).time.and_call_original if with_event - expect { service.execute(milestone) }.to change { Event.count }.by(1) + expect { service.execute(milestone) }.to change { milestone.state }.to('active') end end shared_examples 'does not reopen the milestone' do it 'does not execute hooks and does not create new event' do expect(service).not_to receive(:execute_hooks) - expect { service.execute(milestone) }.not_to change { Event.count } + + expect { service.execute(milestone) }.not_to change { milestone.state } end end context 'when milestone is successfully reopened' do - let(:milestone) { create(:milestone, :closed, project: project) } - - context 'when project has active milestone hooks' do - let(:project) do - create(:project).tap do |project| - create(:project_hook, project: project, milestone_events: true) + context 'when milestone is a project milestone' do + let(:milestone) { create(:milestone, :closed, project: project) } + + context 'when project has active milestone hooks' do + let(:project) do + create(:project).tap do |project| + create(:project_hook, project: project, milestone_events: true) + end end + + it_behaves_like 'reopens the milestone', with_hooks: true, with_event: true end - it_behaves_like 'reopens the milestone', with_project_hooks: true + context 'when project has no active milestone hooks' do + it_behaves_like 'reopens the milestone', with_hooks: false, with_event: true + end end - context 'when project has no active milestone hooks' do - it_behaves_like 'reopens the milestone', with_project_hooks: false + context 'when milestone is a group milestone' do + let(:group) { create(:group) } + let(:milestone) { create(:milestone, :closed, group: group) } + let(:service) { described_class.new(group, user, {}) } + + context 'with Enterprise Edition', if: Gitlab.ee? do + context 'when group webhooks are available' do + before do + stub_licensed_features(group_webhooks: true) + end + + context 'when group has active milestone hooks' do + let(:group) do + create(:group).tap do |group| + create(:group_hook, group: group, milestone_events: true) + end + end + + it_behaves_like 'reopens the milestone', with_hooks: true, with_event: false + end + + context 'when group has no active milestone hooks' do + it_behaves_like 'reopens the milestone', with_hooks: false, with_event: false + end + end + end + + context 'without Enterprise Edition', unless: Gitlab.ee? do + it_behaves_like 'reopens the milestone', with_hooks: false, with_event: false + end end end @@ -73,13 +109,6 @@ it_behaves_like 'does not reopen the milestone' end - - context 'when milestone is a group milestone' do - let(:group) { create(:group) } - let(:milestone) { create(:milestone, :closed, group: group) } - - it_behaves_like 'does not reopen the milestone' - end end end end -- GitLab From 2ee7121b0b99b81273ccbeb01c89a43f7f592ee0 Mon Sep 17 00:00:00 2001 From: Alex Kozenko <9867893@gmail.com> Date: Thu, 10 Jul 2025 15:03:48 +0300 Subject: [PATCH 04/10] Add info about group milestone_events to REST API --- doc/api/group_webhooks.md | 8 +++++++- ee/lib/api/group_hooks.rb | 1 + ee/lib/ee/api/entities/group_hook.rb | 1 + .../fixtures/api/schemas/public_api/v4/group_hook.json | 4 ++++ ee/spec/requests/api/group_hooks_spec.rb | 1 + spec/requests/api/project_hooks_spec.rb | 1 + 6 files changed, 15 insertions(+), 1 deletion(-) diff --git a/doc/api/group_webhooks.md b/doc/api/group_webhooks.md index f6ed1c89684560..46dfcca51c9a09 100644 --- a/doc/api/group_webhooks.md +++ b/doc/api/group_webhooks.md @@ -70,6 +70,7 @@ Example response: "deployment_events": false, "feature_flag_events": false, "releases_events": false, + "milestone_events": false, "subgroup_events": false, "emoji_events": false, "resource_access_token_events": false, @@ -129,6 +130,7 @@ Example response: "deployment_events": true, "feature_flag_events": false, "releases_events": true, + "milestone_events": false, "subgroup_events": true, "member_events": true, "enable_ssl_verification": true, @@ -498,6 +500,7 @@ Supported attributes: | `deployment_events` | boolean | no | Trigger hook on deployment events. | | `feature_flag_events` | boolean | no | Trigger hook on feature flag events. | | `releases_events` | boolean | no | Trigger hook on release events. | +| `milestone_events` | boolean | no | Trigger hook on milestone events. | | `subgroup_events` | boolean | no | Trigger hook on subgroup events. | | `member_events` | boolean | no | Trigger hook on member events. | | `enable_ssl_verification` | boolean | no | Do SSL verification when triggering the hook. | @@ -540,6 +543,7 @@ Example response: "deployment_events": true, "feature_flag_events": true, "releases_events": true, + "milestone_events": true, "subgroup_events": true, "member_events": true, "enable_ssl_verification": true, @@ -585,6 +589,7 @@ Supported attributes: | `deployment_events` | boolean | no | Trigger hook on deployment events. | | `feature_flag_events` | boolean | no | Trigger hook on feature flag events. | | `releases_events` | boolean | no | Trigger hook on release events. | +| `milestone_events` | boolean | no | Trigger hook on milestone events. | | `subgroup_events` | boolean | no | Trigger hook on subgroup events. | | `member_events` | boolean | no | Trigger hook on member events. | | `enable_ssl_verification` | boolean | no | Do SSL verification when triggering the hook. | @@ -628,6 +633,7 @@ Example response: "deployment_events": true, "feature_flag_events": true, "releases_events": true, + "milestone_events": true, "subgroup_events": true, "member_events": true, "enable_ssl_verification": true, @@ -693,7 +699,7 @@ POST /groups/:id/hooks/:hook_id/test/:trigger |-----------|-------------------|----------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | `hook_id` | integer | Yes | The ID of the group hook. | | `id` | integer or string | Yes | The ID or [URL-encoded path of the group](rest/_index.md#namespaced-paths). | -| `trigger` | string | Yes | One of `push_events`, `tag_push_events`, `issues_events`, `confidential_issues_events`, `note_events`, `merge_requests_events`, `job_events`, `pipeline_events`, `wiki_page_events`, `releases_events`, `emoji_events`, or `resource_access_token_events`. | +| `trigger` | string | Yes | One of `push_events`, `tag_push_events`, `issues_events`, `confidential_issues_events`, `note_events`, `merge_requests_events`, `job_events`, `pipeline_events`, `wiki_page_events`, `releases_events`, `milestone_events`, `emoji_events`, or `resource_access_token_events`. | Example request: diff --git a/ee/lib/api/group_hooks.rb b/ee/lib/api/group_hooks.rb index f52b4b4d5ca500..973c72f93da82d 100644 --- a/ee/lib/api/group_hooks.rb +++ b/ee/lib/api/group_hooks.rb @@ -39,6 +39,7 @@ def hook_scope optional :deployment_events, type: Boolean, desc: "Trigger hook on deployment events" optional :feature_flag_events, type: Boolean, desc: "Trigger hook on feature flag events" optional :releases_events, type: Boolean, desc: "Trigger hook on release events" + optional :milestone_events, type: Boolean, desc: "Trigger hook on milestone events" optional :subgroup_events, type: Boolean, desc: "Trigger hook on subgroup events" optional :emoji_events, type: Boolean, desc: "Trigger hook on emoji events" optional :resource_access_token_events, type: Boolean, desc: "Trigger hook on group access token expiry events" diff --git a/ee/lib/ee/api/entities/group_hook.rb b/ee/lib/ee/api/entities/group_hook.rb index 4c5a3e83359704..b966e60839dfba 100644 --- a/ee/lib/ee/api/entities/group_hook.rb +++ b/ee/lib/ee/api/entities/group_hook.rb @@ -15,6 +15,7 @@ class GroupHook < ::API::Entities::Hook expose :deployment_events, documentation: { type: 'boolean' } expose :feature_flag_events, documentation: { type: 'boolean' } expose :releases_events, documentation: { type: 'boolean' } + expose :milestone_events, documentation: { type: 'boolean' } expose :subgroup_events, documentation: { type: 'boolean' } expose :emoji_events, documentation: { type: 'boolean' } expose :resource_access_token_events, documentation: { type: 'boolean' } diff --git a/ee/spec/fixtures/api/schemas/public_api/v4/group_hook.json b/ee/spec/fixtures/api/schemas/public_api/v4/group_hook.json index ae32e83df72890..c271215ab3b4b6 100644 --- a/ee/spec/fixtures/api/schemas/public_api/v4/group_hook.json +++ b/ee/spec/fixtures/api/schemas/public_api/v4/group_hook.json @@ -20,6 +20,7 @@ "job_events", "deployment_events", "releases_events", + "milestone_events", "subgroup_events", "emoji_events", "feature_flag_events", @@ -115,6 +116,9 @@ "releases_events": { "type": "boolean" }, + "milestone_events": { + "type": "boolean" + }, "subgroup_events": { "type": "boolean" }, diff --git a/ee/spec/requests/api/group_hooks_spec.rb b/ee/spec/requests/api/group_hooks_spec.rb index 27cc8a65e2ee4d..e3420f6c8f0447 100644 --- a/ee/spec/requests/api/group_hooks_spec.rb +++ b/ee/spec/requests/api/group_hooks_spec.rb @@ -53,6 +53,7 @@ def event_names wiki_page_events deployment_events releases_events + milestone_events subgroup_events feature_flag_events emoji_events diff --git a/spec/requests/api/project_hooks_spec.rb b/spec/requests/api/project_hooks_spec.rb index 16f7e07ede920b..f055cdea268031 100644 --- a/spec/requests/api/project_hooks_spec.rb +++ b/spec/requests/api/project_hooks_spec.rb @@ -58,6 +58,7 @@ def event_names deployment_events feature_flag_events releases_events + milestone_events emoji_events resource_access_token_events ] -- GitLab From 12298562aec94194ff0497595f6924cebea8a506 Mon Sep 17 00:00:00 2001 From: Alex Kozenko <9867893@gmail.com> Date: Thu, 10 Jul 2025 17:53:55 +0300 Subject: [PATCH 05/10] Fix the spec for Gitlab::HookData::MilestoneBuilder --- spec/lib/gitlab/hook_data/milestone_builder_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/lib/gitlab/hook_data/milestone_builder_spec.rb b/spec/lib/gitlab/hook_data/milestone_builder_spec.rb index ef26dc9dd5d339..7043428a17c887 100644 --- a/spec/lib/gitlab/hook_data/milestone_builder_spec.rb +++ b/spec/lib/gitlab/hook_data/milestone_builder_spec.rb @@ -11,7 +11,7 @@ subject(:data) { builder.build } it 'includes safe attributes' do - expect(data.keys).to match_array(described_class::SAFE_HOOK_ATTRIBUTES.map(&:to_s)) + expect(data.keys).to match_array(described_class.safe_hook_attributes.map(&:to_s)) end it 'returns indifferent access hash' do -- GitLab From 3ff67b26bf07b2393c9233b4946efd61165f6ac0 Mon Sep 17 00:00:00 2001 From: Alex Kozenko <9867893@gmail.com> Date: Tue, 15 Jul 2025 13:24:11 +0300 Subject: [PATCH 06/10] Apply CR suggestions --- .../hook_data/milestone_builder_spec.rb | 31 +++++++++ .../services/milestones/close_service_spec.rb | 36 ++++++++++ .../milestones/create_service_spec.rb | 37 +++++++++++ .../milestones/destroy_service_spec.rb | 56 +++++++++++----- .../milestones/reopen_service_spec.rb | 36 ++++++++++ .../services/milestones/close_service_spec.rb | 41 ------------ .../milestones/create_service_spec.rb | 66 +++++-------------- .../milestones/destroy_service_spec.rb | 37 +---------- .../milestones/reopen_service_spec.rb | 41 ------------ .../services/milestones_shared_examples.rb | 60 +++++++++++++++++ 10 files changed, 261 insertions(+), 180 deletions(-) create mode 100644 ee/spec/lib/ee/gitlab/hook_data/milestone_builder_spec.rb create mode 100644 ee/spec/services/milestones/close_service_spec.rb create mode 100644 ee/spec/services/milestones/create_service_spec.rb create mode 100644 ee/spec/services/milestones/reopen_service_spec.rb create mode 100644 spec/support/shared_examples/services/milestones_shared_examples.rb diff --git a/ee/spec/lib/ee/gitlab/hook_data/milestone_builder_spec.rb b/ee/spec/lib/ee/gitlab/hook_data/milestone_builder_spec.rb new file mode 100644 index 00000000000000..794f30338b8645 --- /dev/null +++ b/ee/spec/lib/ee/gitlab/hook_data/milestone_builder_spec.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::HookData::MilestoneBuilder, feature_category: :webhooks do + let_it_be(:milestone) { create(:milestone) } + + let(:builder) { described_class.new(milestone) } + + describe '.safe_hook_attributes' do + subject(:safe_attribute_keys) { described_class.safe_hook_attributes } + + it 'includes safe attribute' do + expected_safe_attribute_keys = %i[ + id + iid + title + description + state + created_at + updated_at + due_date + start_date + project_id + group_id + ].freeze + + expect(safe_attribute_keys).to match_array(expected_safe_attribute_keys) + end + end +end diff --git a/ee/spec/services/milestones/close_service_spec.rb b/ee/spec/services/milestones/close_service_spec.rb new file mode 100644 index 00000000000000..71a73b14cfb2da --- /dev/null +++ b/ee/spec/services/milestones/close_service_spec.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Milestones::CloseService, feature_category: :team_planning do + let(:user) { create(:user) } + + subject(:service) { described_class.new(container, user, {}) } + + describe '#execute' do + context 'on group milestones' do + let(:container) { create(:group) } + let(:milestone) { create(:milestone, title: 'Milestone v1.0', group: container) } + + context 'when group webhooks are available' do + before do + stub_licensed_features(group_webhooks: true) + end + + context 'when group has active milestone hooks' do + let(:container) do + create(:group).tap do |group| + create(:group_hook, group: group, milestone_events: true) + end + end + + it_behaves_like 'closes the milestone', with_hooks: true, with_event: false + end + + context 'when group has no active milestone hooks' do + it_behaves_like 'closes the milestone', with_hooks: false, with_event: false + end + end + end + end +end diff --git a/ee/spec/services/milestones/create_service_spec.rb b/ee/spec/services/milestones/create_service_spec.rb new file mode 100644 index 00000000000000..2e7e658cb4ef16 --- /dev/null +++ b/ee/spec/services/milestones/create_service_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Milestones::CreateService, feature_category: :team_planning do + let_it_be(:params) { { title: 'New Milestone', description: 'Description' } } + + let(:user) { create(:user) } + + subject(:service) { described_class.new(container, user, params) } + + describe '#execute' do + context 'on group milestones' do + let(:container) { create(:group) } + + context 'when group webhooks are available' do + before do + stub_licensed_features(group_webhooks: true) + end + + context 'when group has active milestone hooks' do + let(:container) do + create(:group).tap do |group| + create(:group_hook, group: group, milestone_events: true) + end + end + + it_behaves_like 'creates the milestone', with_hooks: true, with_event: false + end + + context 'when group has no active milestone hooks' do + it_behaves_like 'creates the milestone', with_hooks: false, with_event: false + end + end + end + end +end diff --git a/ee/spec/services/milestones/destroy_service_spec.rb b/ee/spec/services/milestones/destroy_service_spec.rb index 2b93a9bcea6ce3..215940e447077c 100644 --- a/ee/spec/services/milestones/destroy_service_spec.rb +++ b/ee/spec/services/milestones/destroy_service_spec.rb @@ -4,26 +4,52 @@ RSpec.describe Milestones::DestroyService, feature_category: :team_planning do let(:user) { create(:user) } - let(:project) { create(:project, :repository) } - let(:milestone) { create(:milestone, title: 'Milestone v1.0', project: project) } - before do - project.add_maintainer(user) - end - - def service - described_class.new(project, user, {}) - end + subject(:service) { described_class.new(container, user, {}) } describe '#execute' do - context 'with an existing merge request' do - let!(:issue) { create(:issue, project: project, milestone: milestone) } - let!(:merge_request) { create(:merge_request, source_project: project, milestone: milestone) } + context 'on project milestones' do + let(:container) { create(:project, :repository) } + let(:milestone) { create(:milestone, title: 'Milestone v1.0', project: container) } + + before do + container.add_maintainer(user) + end + + context 'with an existing merge request' do + let!(:issue) { create(:issue, project: container, milestone: milestone) } + let!(:merge_request) { create(:merge_request, source_project: container, milestone: milestone) } + + it 'manually queues MergeRequests::SyncCodeOwnerApprovalRulesWorker jobs' do + expect(::MergeRequests::SyncCodeOwnerApprovalRulesWorker).to receive(:perform_async).with(merge_request.id) + + service.execute(milestone) + end + end + end + + context 'on group milestones' do + let(:container) { create(:group) } + let(:milestone) { create(:milestone, title: 'Milestone v1.0', group: container) } + + context 'when group webhooks are available' do + before do + stub_licensed_features(group_webhooks: true) + end + + context 'when group has active milestone hooks' do + let(:container) do + create(:group).tap do |group| + create(:group_hook, group: group, milestone_events: true) + end + end - it 'manually queues MergeRequests::SyncCodeOwnerApprovalRulesWorker jobs' do - expect(::MergeRequests::SyncCodeOwnerApprovalRulesWorker).to receive(:perform_async).with(merge_request.id) + it_behaves_like 'deletes group-milestone', with_hooks: true + end - service.execute(milestone) + context 'when group has no active milestone hooks' do + it_behaves_like 'deletes group-milestone', with_hooks: false + end end end end diff --git a/ee/spec/services/milestones/reopen_service_spec.rb b/ee/spec/services/milestones/reopen_service_spec.rb new file mode 100644 index 00000000000000..54b3663afed479 --- /dev/null +++ b/ee/spec/services/milestones/reopen_service_spec.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Milestones::ReopenService, feature_category: :team_planning do + let(:user) { create(:user) } + + subject(:service) { described_class.new(container, user, {}) } + + describe '#execute' do + context 'on group milestones' do + let(:container) { create(:group) } + let(:milestone) { create(:milestone, :closed, title: 'Milestone v1.0', group: container) } + + context 'when group webhooks are available' do + before do + stub_licensed_features(group_webhooks: true) + end + + context 'when group has active milestone hooks' do + let(:container) do + create(:group).tap do |group| + create(:group_hook, group: group, milestone_events: true) + end + end + + it_behaves_like 'reopens the milestone', with_hooks: true, with_event: false + end + + context 'when group has no active milestone hooks' do + it_behaves_like 'reopens the milestone', with_hooks: false, with_event: false + end + end + end + end +end diff --git a/spec/services/milestones/close_service_spec.rb b/spec/services/milestones/close_service_spec.rb index 385d45959ed623..39a0fc94b75011 100644 --- a/spec/services/milestones/close_service_spec.rb +++ b/spec/services/milestones/close_service_spec.rb @@ -31,25 +31,6 @@ end end - shared_examples 'closes the milestone' do |with_hooks:, with_event:| - it 'executes hooks with close action and creates new event' do - expect(service).to receive(:execute_hooks).with(milestone, 'close').and_call_original - expect(milestone.parent).to receive(:execute_hooks).with(kind_of(Hash), :milestone_hooks) if with_hooks - expect(Event).to receive(:new).exactly(1).time.and_call_original if with_event - - expect { service.execute(milestone) }.to change { milestone.state }.to('closed') - end - end - - shared_examples 'does not close the milestone' do - it 'does not execute hooks and does not create new event' do - expect(service).not_to receive(:execute_hooks) - expect(Event).not_to receive(:new) - - expect { service.execute(milestone) }.not_to change { milestone.state } - end - end - context 'when milestone is successfully closed' do context 'when milestone parent is a project' do context 'when project has active milestone hooks' do @@ -72,28 +53,6 @@ let(:milestone) { create(:milestone, group: group) } let(:service) { described_class.new(group, user, {}) } - context 'with Enterprise Edition', if: Gitlab.ee? do - context 'when group webhooks are available' do - before do - stub_licensed_features(group_webhooks: true) - end - - context 'when group has no active milestone hooks' do - it_behaves_like 'closes the milestone', with_hooks: false, with_event: false - end - - context 'when group has active milestone hooks' do - let(:group) do - create(:group).tap do |group| - create(:group_hook, group: group, milestone_events: true) - end - end - - it_behaves_like 'closes the milestone', with_hooks: true, with_event: false - end - end - end - context 'without Enterprise Edition', unless: Gitlab.ee? do it_behaves_like 'closes the milestone', with_hooks: false, with_event: false end diff --git a/spec/services/milestones/create_service_spec.rb b/spec/services/milestones/create_service_spec.rb index dd0d236d8c7b99..4f6e70ab486f64 100644 --- a/spec/services/milestones/create_service_spec.rb +++ b/spec/services/milestones/create_service_spec.rb @@ -4,15 +4,15 @@ RSpec.describe Milestones::CreateService, feature_category: :team_planning do let_it_be(:user) { create(:user) } - let_it_be(:project) { create(:project) } + let_it_be(:container) { create(:project) } let_it_be(:params) { { title: 'New Milestone', description: 'Description' } } - subject(:create_milestone) { described_class.new(project, user, params) } + subject(:service) { described_class.new(container, user, params) } describe '#execute' do context 'when milestone is saved successfully' do it 'creates a new milestone' do - expect { create_milestone.execute }.to change { Milestone.count }.by(1) + expect { service.execute }.to change { Milestone.count }.by(1) end it 'opens the milestone if it is a project milestone' do @@ -20,33 +20,23 @@ expect(instance).to receive(:open_milestone) end - create_milestone.execute + service.execute end it 'returns the created milestone' do - milestone = create_milestone.execute + milestone = service.execute expect(milestone).to be_a(Milestone) expect(milestone.title).to eq('New Milestone') expect(milestone.description).to eq('Description') end - shared_examples 'creates the milestone' do |with_hooks:, with_event:| - it 'executes hooks with create action and creates new event' do - expect(create_milestone).to receive(:execute_hooks).with(kind_of(Milestone), 'create').and_call_original - expect(milestone.parent).to receive(:execute_hooks).with(kind_of(Hash), :milestone_hooks) if with_hooks - expect(Event).to receive(:new).exactly(1).time.and_call_original if with_event - - expect { create_milestone.execute }.to change { Milestone.count }.by(1) - end - end - context 'when milestone is a project milestone' do - let(:milestone) { create(:milestone, project: project) } + let(:milestone) { create(:milestone, project: container) } context 'when project has active milestone hooks' do - let(:project) do - create(:project).tap do |project| - create(:project_hook, project: project, milestone_events: true) + let(:container) do + create(:project).tap do |container| + create(:project_hook, project: container, milestone_events: true) end end @@ -62,29 +52,7 @@ let(:group) { create(:group) } let(:milestone) { create(:milestone, group: group) } - subject(:create_milestone) { described_class.new(group, user, params) } - - context 'with Enterprise Edition', if: Gitlab.ee? do - context 'when group webhooks are available' do - before do - stub_licensed_features(group_webhooks: true) - end - - context 'when group has active milestone hooks' do - let(:group) do - create(:group).tap do |group| - create(:group_hook, group: group, milestone_events: true) - end - end - - it_behaves_like 'creates the milestone', with_hooks: true, with_event: false - end - - context 'when group has no active milestone hooks' do - it_behaves_like 'creates the milestone', with_hooks: false, with_event: false - end - end - end + subject(:service) { described_class.new(group, user, params) } context 'without Enterprise Edition', unless: Gitlab.ee? do it_behaves_like 'creates the milestone', with_hooks: false, with_event: false @@ -100,23 +68,23 @@ end it 'does not create a new milestone' do - expect { create_milestone.execute }.not_to change { Milestone.count } + expect { service.execute }.not_to change { Milestone.count } end it 'does not open the milestone' do expect(EventCreateService).not_to receive(:open_milestone) - create_milestone.execute + service.execute end it 'does not execute hooks and does not create new event' do - expect(create_milestone).not_to receive(:execute_hooks) + expect(service).not_to receive(:execute_hooks) - expect { create_milestone.execute }.not_to change { Event.count } + expect { service.execute }.not_to change { Event.count } end it 'returns the unsaved milestone' do - milestone = create_milestone.execute + milestone = service.execute expect(milestone).to be_a(Milestone) expect(milestone.title).to eq('New Milestone') expect(milestone.persisted?).to be_falsey @@ -124,8 +92,8 @@ end it 'calls before_create method' do - expect(create_milestone).to receive(:before_create) - create_milestone.execute + expect(service).to receive(:before_create) + service.execute end end diff --git a/spec/services/milestones/destroy_service_spec.rb b/spec/services/milestones/destroy_service_spec.rb index eca8c8a906e9cd..95fab0a0d3499a 100644 --- a/spec/services/milestones/destroy_service_spec.rb +++ b/spec/services/milestones/destroy_service_spec.rb @@ -27,6 +27,8 @@ updated_attributes: %w[milestone_id] ) + expect { milestone.reload }.to raise_error ActiveRecord::RecordNotFound + issues.each do |issue| expect(issue.reload.milestone).to be_nil end @@ -35,7 +37,7 @@ end context 'on project milestones' do - let_it_be_with_reload(:milestone) { create(:milestone, title: 'Milestone v1.0', project: project) } + let(:milestone) { create(:milestone, title: 'Milestone v1.0', project: project) } let(:container) { project } @@ -72,17 +74,6 @@ end end - shared_examples 'deletes group-milestone' do |with_hooks:| - it 'conditionally executes webhooks and does not create a new event' do - expect(service).to receive(:execute_hooks).with(milestone, 'delete').and_call_original - expect(milestone.parent).to receive(:execute_hooks).with(kind_of(Hash), :milestone_hooks) if with_hooks - expect(Event).not_to receive(:new) - - service.execute(milestone) - expect { milestone.reload }.to raise_error ActiveRecord::RecordNotFound - end - end - context 'on group milestones' do let(:milestone) { create(:milestone, group: group) } @@ -90,28 +81,6 @@ it_behaves_like 'deletes milestone id from issuables' - context 'with Enterprise Edition', if: Gitlab.ee? do - context 'when group webhooks are available' do - before do - stub_licensed_features(group_webhooks: true) - end - - context 'when group has active milestone hooks' do - let(:group) do - create(:group).tap do |group| - create(:group_hook, group: group, milestone_events: true) - end - end - - it_behaves_like 'deletes group-milestone', with_hooks: true - end - - context 'when group has no active milestone hooks' do - it_behaves_like 'deletes group-milestone', with_hooks: false - end - end - end - context 'without Enterprise Edition', unless: Gitlab.ee? do it_behaves_like 'deletes group-milestone', with_hooks: false end diff --git a/spec/services/milestones/reopen_service_spec.rb b/spec/services/milestones/reopen_service_spec.rb index a473970eab33d3..4eeeee80466703 100644 --- a/spec/services/milestones/reopen_service_spec.rb +++ b/spec/services/milestones/reopen_service_spec.rb @@ -32,25 +32,6 @@ end end - shared_examples 'reopens the milestone' do |with_hooks:, with_event:| - it 'executes hooks with reopen action and creates new event' do - expect(service).to receive(:execute_hooks).with(milestone, 'reopen').and_call_original - expect(milestone.parent).to receive(:execute_hooks).with(kind_of(Hash), :milestone_hooks) if with_hooks - expect(Event).to receive(:new).exactly(1).time.and_call_original if with_event - - expect { service.execute(milestone) }.to change { milestone.state }.to('active') - end - end - - shared_examples 'does not reopen the milestone' do - it 'does not execute hooks and does not create new event' do - expect(service).not_to receive(:execute_hooks) - expect { service.execute(milestone) }.not_to change { Event.count } - - expect { service.execute(milestone) }.not_to change { milestone.state } - end - end - context 'when milestone is successfully reopened' do context 'when milestone is a project milestone' do let(:milestone) { create(:milestone, :closed, project: project) } @@ -75,28 +56,6 @@ let(:milestone) { create(:milestone, :closed, group: group) } let(:service) { described_class.new(group, user, {}) } - context 'with Enterprise Edition', if: Gitlab.ee? do - context 'when group webhooks are available' do - before do - stub_licensed_features(group_webhooks: true) - end - - context 'when group has active milestone hooks' do - let(:group) do - create(:group).tap do |group| - create(:group_hook, group: group, milestone_events: true) - end - end - - it_behaves_like 'reopens the milestone', with_hooks: true, with_event: false - end - - context 'when group has no active milestone hooks' do - it_behaves_like 'reopens the milestone', with_hooks: false, with_event: false - end - end - end - context 'without Enterprise Edition', unless: Gitlab.ee? do it_behaves_like 'reopens the milestone', with_hooks: false, with_event: false end diff --git a/spec/support/shared_examples/services/milestones_shared_examples.rb b/spec/support/shared_examples/services/milestones_shared_examples.rb new file mode 100644 index 00000000000000..0acae0a109462e --- /dev/null +++ b/spec/support/shared_examples/services/milestones_shared_examples.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'creates the milestone' do |with_hooks:, with_event:| + it 'executes hooks with create action and creates new event' do + expect(service).to receive(:execute_hooks).with(kind_of(Milestone), 'create').and_call_original + expect(container).to receive(:execute_hooks).with(kind_of(Hash), :milestone_hooks) if with_hooks + expect(Event).to receive(:new).exactly(1).time.and_call_original if with_event + + expect { service.execute }.to change { Milestone.count }.by(1) + end +end + +RSpec.shared_examples 'reopens the milestone' do |with_hooks:, with_event:| + it 'executes hooks with reopen action and creates new event' do + expect(service).to receive(:execute_hooks).with(milestone, 'reopen').and_call_original + expect(milestone.parent).to receive(:execute_hooks).with(kind_of(Hash), :milestone_hooks) if with_hooks + expect(Event).to receive(:new).exactly(1).time.and_call_original if with_event + + expect { service.execute(milestone) }.to change { milestone.state }.to('active') + end +end + +RSpec.shared_examples 'does not reopen the milestone' do + it 'does not execute hooks and does not create new event' do + expect(service).not_to receive(:execute_hooks) + expect { service.execute(milestone) }.not_to change { Event.count } + + expect { service.execute(milestone) }.not_to change { milestone.state } + end +end + +RSpec.shared_examples 'closes the milestone' do |with_hooks:, with_event:| + it 'executes hooks with close action and creates new event' do + expect(service).to receive(:execute_hooks).with(milestone, 'close').and_call_original + expect(milestone.parent).to receive(:execute_hooks).with(kind_of(Hash), :milestone_hooks) if with_hooks + expect(Event).to receive(:new).exactly(1).time.and_call_original if with_event + + expect { service.execute(milestone) }.to change { milestone.state }.to('closed') + end +end + +RSpec.shared_examples 'does not close the milestone' do + it 'does not execute hooks and does not create new event' do + expect(service).not_to receive(:execute_hooks) + expect(Event).not_to receive(:new) + + expect { service.execute(milestone) }.not_to change { milestone.state } + end +end + +RSpec.shared_examples 'deletes group-milestone' do |with_hooks:| + it 'conditionally executes webhooks and does not create a new event' do + expect(service).to receive(:execute_hooks).with(milestone, 'delete').and_call_original + expect(milestone.parent).to receive(:execute_hooks).with(kind_of(Hash), :milestone_hooks) if with_hooks + expect(Event).not_to receive(:new) + + service.execute(milestone) + expect { milestone.reload }.to raise_error ActiveRecord::RecordNotFound + end +end -- GitLab From 2d401b4e7462faf4fb197c9fd61681ea519685d1 Mon Sep 17 00:00:00 2001 From: Alex Kozenko <9867893@gmail.com> Date: Fri, 25 Jul 2025 17:11:15 +0300 Subject: [PATCH 07/10] Apply CR suggestions for specs --- ee/spec/models/ee/group_spec.rb | 120 +++++++++--------- .../services/milestones/close_service_spec.rb | 4 + .../milestones/create_service_spec.rb | 4 + .../milestones/destroy_service_spec.rb | 4 + .../milestones/reopen_service_spec.rb | 4 + spec/models/group_spec.rb | 14 +- .../services/milestones/close_service_spec.rb | 10 -- .../milestones/create_service_spec.rb | 11 -- .../milestones/destroy_service_spec.rb | 12 -- .../milestones/reopen_service_spec.rb | 8 -- .../services/milestones_shared_examples.rb | 6 +- 11 files changed, 85 insertions(+), 112 deletions(-) diff --git a/ee/spec/models/ee/group_spec.rb b/ee/spec/models/ee/group_spec.rb index 0dbd655330c5a1..f0c1709063638b 100644 --- a/ee/spec/models/ee/group_spec.rb +++ b/ee/spec/models/ee/group_spec.rb @@ -2979,91 +2979,91 @@ def webhook_headers end end - context 'when resource access token hooks for expiry notification' do - let(:group) { create(:group) } - let(:group_hook) { create(:group_hook, group: group, resource_access_token_events: true) } + describe '#has_active_hooks?' do + let_it_be_with_reload(:parent_group) { create(:group) } + let_it_be_with_reload(:group) { create(:group, parent: parent_group) } + let(:hooks_scope) { :push_hooks } - before do - stub_licensed_features(group_webhooks: true) - end - - context 'when interval is seven days' do - let(:data) { { interval: :seven_days } } + subject { group.has_active_hooks?(hooks_scope) } - it 'executes webhook' do - expect(WebHookService) - .to receive(:new) - .with(group_hook, data, 'resource_access_token_hooks', idempotency_key: anything) - .and_call_original - - group.execute_hooks(data, :resource_access_token_hooks) + context 'when group_webhooks feature is available' do + before do + stub_licensed_features(group_webhooks: true) end - end - describe '#has_active_hooks?' do - let_it_be_with_reload(:parent_group) { create(:group) } - let_it_be_with_reload(:group) { create(:group, parent: parent_group) } - let(:hooks_scope) { :push_hooks } + context 'when no hooks exist' do + it 'returns false' do + expect(subject).to be false + end + end - subject { group.has_active_hooks?(hooks_scope) } + context 'when hook exists for the group' do + let_it_be_with_reload(:group_hook) { create(:group_hook, group: group, push_events: true) } - context 'when group_webhooks feature is available' do - before do - stub_licensed_features(group_webhooks: true) + it 'returns true for push_hooks scope' do + expect(subject).to be true end - context 'when no hooks exist' do + context 'with different hook scope' do + let(:hooks_scope) { :issues_hooks } + it 'returns false' do expect(subject).to be false end end + end - context 'when hook exists for the group' do - let_it_be_with_reload(:group_hook) { create(:group_hook, group: group, push_events: true) } - - it 'returns true for push_hooks scope' do - expect(subject).to be true - end - - context 'with different hook scope' do - let(:hooks_scope) { :issues_hooks } + context 'when hook exists for the ancestor group' do + let!(:parent_hook) { create(:group_hook, group: parent_group, push_events: true) } - it 'returns false' do - expect(subject).to be false - end - end + it 'returns true when checking group that inherits hooks from ancestor' do + expect(subject).to be true end + end - context 'when hook exists for the ancestor group' do - let!(:parent_hook) { create(:group_hook, group: parent_group, push_events: true) } + context 'when hook exists for unrelated group' do + let!(:unrelated_group) { create(:group) } + let!(:unrelated_group_hook) { create(:group_hook, group: unrelated_group, push_events: true) } - it 'returns true when checking group that inherits hooks from ancestor' do - expect(subject).to be true - end + it 'returns false' do + expect(subject).to be false end + end + end - context 'when hook exists for unrelated group' do - let!(:unrelated_group) { create(:group) } - let!(:unrelated_group_hook) { create(:group_hook, group: unrelated_group, push_events: true) } + context 'when group_webhooks feature is not available' do + before do + stub_licensed_features(group_webhooks: false) + end - it 'returns false' do - expect(subject).to be false - end + context 'even when hook exists' do + let!(:group_hook) { create(:group_hook, group: group, push_events: true) } + + it 'returns false' do + expect(subject).to be false end end + end + end - context 'when group_webhooks feature is not available' do - before do - stub_licensed_features(group_webhooks: false) - end + context 'when resource access token hooks for expiry notification' do + let(:group) { create(:group) } + let(:group_hook) { create(:group_hook, group: group, resource_access_token_events: true) } - context 'even when hook exists' do - let!(:group_hook) { create(:group_hook, group: group, push_events: true) } + before do + stub_licensed_features(group_webhooks: true) + end - it 'returns false' do - expect(subject).to be false - end - end + context 'when interval is seven days' do + let(:data) { { interval: :seven_days } } + + it 'executes webhook' do + expect(WebHookService) + .to receive(:new) + .with(group_hook, data, 'resource_access_token_hooks', idempotency_key: anything) + .and_call_original + + group.execute_hooks(data, :resource_access_token_hooks) end end diff --git a/ee/spec/services/milestones/close_service_spec.rb b/ee/spec/services/milestones/close_service_spec.rb index 71a73b14cfb2da..c38b75356988d8 100644 --- a/ee/spec/services/milestones/close_service_spec.rb +++ b/ee/spec/services/milestones/close_service_spec.rb @@ -31,6 +31,10 @@ it_behaves_like 'closes the milestone', with_hooks: false, with_event: false end end + + context 'when group webhooks are not available' do + it_behaves_like 'closes the milestone', with_hooks: false, with_event: false + end end end end diff --git a/ee/spec/services/milestones/create_service_spec.rb b/ee/spec/services/milestones/create_service_spec.rb index 2e7e658cb4ef16..243a1697c388c0 100644 --- a/ee/spec/services/milestones/create_service_spec.rb +++ b/ee/spec/services/milestones/create_service_spec.rb @@ -32,6 +32,10 @@ it_behaves_like 'creates the milestone', with_hooks: false, with_event: false end end + + context 'when group webhooks are not available' do + it_behaves_like 'creates the milestone', with_hooks: false, with_event: false + end end end end diff --git a/ee/spec/services/milestones/destroy_service_spec.rb b/ee/spec/services/milestones/destroy_service_spec.rb index 215940e447077c..8f1331441e0238 100644 --- a/ee/spec/services/milestones/destroy_service_spec.rb +++ b/ee/spec/services/milestones/destroy_service_spec.rb @@ -51,6 +51,10 @@ it_behaves_like 'deletes group-milestone', with_hooks: false end end + + context 'when group webhooks are not available' do + it_behaves_like 'deletes group-milestone', with_hooks: false + end end end end diff --git a/ee/spec/services/milestones/reopen_service_spec.rb b/ee/spec/services/milestones/reopen_service_spec.rb index 54b3663afed479..3348111db0fd18 100644 --- a/ee/spec/services/milestones/reopen_service_spec.rb +++ b/ee/spec/services/milestones/reopen_service_spec.rb @@ -31,6 +31,10 @@ it_behaves_like 'reopens the milestone', with_hooks: false, with_event: false end end + + context 'when group webhooks are not available' do + it_behaves_like 'reopens the milestone', with_hooks: false, with_event: false + end end end end diff --git a/spec/models/group_spec.rb b/spec/models/group_spec.rb index 503b0345250b17..92c32aa5778158 100644 --- a/spec/models/group_spec.rb +++ b/spec/models/group_spec.rb @@ -4352,10 +4352,8 @@ def define_cache_expectations(cache_key) subject { group.supports_group_work_items? } - context 'when CE edition', unless: Gitlab.ee? do - it 'returns false' do - expect(subject).to be false - end + it 'returns false' do + expect(subject).to be false end end @@ -4364,10 +4362,10 @@ def define_cache_expectations(cache_key) subject { group.has_active_hooks? } - context 'when CE edition', unless: Gitlab.ee? do - it 'returns false' do - expect(subject).to be false - end + # Would work in plain CE-version since it's implementation always returns false. + # Would work in EE-version since licensed features are disabled for specs in specs/ by deafult. + it 'returns false' do + expect(subject).to be false end end diff --git a/spec/services/milestones/close_service_spec.rb b/spec/services/milestones/close_service_spec.rb index 39a0fc94b75011..ac6b092c0f7bc0 100644 --- a/spec/services/milestones/close_service_spec.rb +++ b/spec/services/milestones/close_service_spec.rb @@ -47,16 +47,6 @@ it_behaves_like 'closes the milestone', with_hooks: false, with_event: true end end - - context 'when milestone parent is a group' do - let(:group) { create(:group) } - let(:milestone) { create(:milestone, group: group) } - let(:service) { described_class.new(group, user, {}) } - - context 'without Enterprise Edition', unless: Gitlab.ee? do - it_behaves_like 'closes the milestone', with_hooks: false, with_event: false - end - end end context 'when milestone fails to close' do diff --git a/spec/services/milestones/create_service_spec.rb b/spec/services/milestones/create_service_spec.rb index 4f6e70ab486f64..9678dae51196fb 100644 --- a/spec/services/milestones/create_service_spec.rb +++ b/spec/services/milestones/create_service_spec.rb @@ -47,17 +47,6 @@ it_behaves_like 'creates the milestone', with_hooks: false, with_event: true end end - - context 'when milestone is a group milestone' do - let(:group) { create(:group) } - let(:milestone) { create(:milestone, group: group) } - - subject(:service) { described_class.new(group, user, params) } - - context 'without Enterprise Edition', unless: Gitlab.ee? do - it_behaves_like 'creates the milestone', with_hooks: false, with_event: false - end - end end context 'when milestone fails to save' do diff --git a/spec/services/milestones/destroy_service_spec.rb b/spec/services/milestones/destroy_service_spec.rb index 95fab0a0d3499a..617cd487b5c60f 100644 --- a/spec/services/milestones/destroy_service_spec.rb +++ b/spec/services/milestones/destroy_service_spec.rb @@ -73,17 +73,5 @@ end end end - - context 'on group milestones' do - let(:milestone) { create(:milestone, group: group) } - - let(:container) { group } - - it_behaves_like 'deletes milestone id from issuables' - - context 'without Enterprise Edition', unless: Gitlab.ee? do - it_behaves_like 'deletes group-milestone', with_hooks: false - end - end end end diff --git a/spec/services/milestones/reopen_service_spec.rb b/spec/services/milestones/reopen_service_spec.rb index 4eeeee80466703..aadabaa0c969cf 100644 --- a/spec/services/milestones/reopen_service_spec.rb +++ b/spec/services/milestones/reopen_service_spec.rb @@ -61,13 +61,5 @@ end end end - - context 'when milestone fails to reopen' do - context 'when milestone is already active' do - let(:milestone) { create(:milestone, project: project) } - - it_behaves_like 'does not reopen the milestone' - end - end end end diff --git a/spec/support/shared_examples/services/milestones_shared_examples.rb b/spec/support/shared_examples/services/milestones_shared_examples.rb index 0acae0a109462e..e0378781d0561c 100644 --- a/spec/support/shared_examples/services/milestones_shared_examples.rb +++ b/spec/support/shared_examples/services/milestones_shared_examples.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true RSpec.shared_examples 'creates the milestone' do |with_hooks:, with_event:| - it 'executes hooks with create action and creates new event' do + it 'conditionally executes hooks with create action and creates new event' do expect(service).to receive(:execute_hooks).with(kind_of(Milestone), 'create').and_call_original expect(container).to receive(:execute_hooks).with(kind_of(Hash), :milestone_hooks) if with_hooks expect(Event).to receive(:new).exactly(1).time.and_call_original if with_event @@ -11,7 +11,7 @@ end RSpec.shared_examples 'reopens the milestone' do |with_hooks:, with_event:| - it 'executes hooks with reopen action and creates new event' do + it 'conditionally executes hooks with reopen action and creates new event' do expect(service).to receive(:execute_hooks).with(milestone, 'reopen').and_call_original expect(milestone.parent).to receive(:execute_hooks).with(kind_of(Hash), :milestone_hooks) if with_hooks expect(Event).to receive(:new).exactly(1).time.and_call_original if with_event @@ -30,7 +30,7 @@ end RSpec.shared_examples 'closes the milestone' do |with_hooks:, with_event:| - it 'executes hooks with close action and creates new event' do + it 'conditionally executes hooks with close action and creates new event' do expect(service).to receive(:execute_hooks).with(milestone, 'close').and_call_original expect(milestone.parent).to receive(:execute_hooks).with(kind_of(Hash), :milestone_hooks) if with_hooks expect(Event).to receive(:new).exactly(1).time.and_call_original if with_event -- GitLab From 56075108000400951d253e81b94dbb334c9894a8 Mon Sep 17 00:00:00 2001 From: Alex Kozenko <9867893@gmail.com> Date: Mon, 28 Jul 2025 09:13:28 +0000 Subject: [PATCH 08/10] Remove redundunt spec --- spec/services/milestones/reopen_service_spec.rb | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/spec/services/milestones/reopen_service_spec.rb b/spec/services/milestones/reopen_service_spec.rb index aadabaa0c969cf..e592967b4575de 100644 --- a/spec/services/milestones/reopen_service_spec.rb +++ b/spec/services/milestones/reopen_service_spec.rb @@ -50,16 +50,6 @@ it_behaves_like 'reopens the milestone', with_hooks: false, with_event: true end end - - context 'when milestone is a group milestone' do - let(:group) { create(:group) } - let(:milestone) { create(:milestone, :closed, group: group) } - let(:service) { described_class.new(group, user, {}) } - - context 'without Enterprise Edition', unless: Gitlab.ee? do - it_behaves_like 'reopens the milestone', with_hooks: false, with_event: false - end - end end end end -- GitLab From 82765558d73554be04579e24a220cecc721847ee Mon Sep 17 00:00:00 2001 From: Alex Kozenko <9867893@gmail.com> Date: Wed, 30 Jul 2025 11:29:05 +0300 Subject: [PATCH 09/10] Apply CR suggestions for specs - vol2 --- ee/spec/models/ee/group_spec.rb | 8 ++- .../services/milestones/close_service_spec.rb | 11 ++-- .../milestones/create_service_spec.rb | 11 ++-- .../milestones/destroy_service_spec.rb | 32 ++++++++---- .../milestones/reopen_service_spec.rb | 11 ++-- .../services/milestones/close_service_spec.rb | 45 +++++++++-------- .../milestones/create_service_spec.rb | 23 ++++----- .../milestones/destroy_service_spec.rb | 17 +++++++ .../milestones/reopen_service_spec.rb | 24 +++++++-- .../services/milestones_shared_examples.rb | 50 ++++++------------- 10 files changed, 124 insertions(+), 108 deletions(-) diff --git a/ee/spec/models/ee/group_spec.rb b/ee/spec/models/ee/group_spec.rb index f0c1709063638b..d45fc2297eabef 100644 --- a/ee/spec/models/ee/group_spec.rb +++ b/ee/spec/models/ee/group_spec.rb @@ -3036,12 +3036,10 @@ def webhook_headers stub_licensed_features(group_webhooks: false) end - context 'even when hook exists' do - let!(:group_hook) { create(:group_hook, group: group, push_events: true) } + let!(:group_hook) { create(:group_hook, group: group, push_events: true) } - it 'returns false' do - expect(subject).to be false - end + it 'returns false even when hook exists' do + expect(subject).to be false end end end diff --git a/ee/spec/services/milestones/close_service_spec.rb b/ee/spec/services/milestones/close_service_spec.rb index c38b75356988d8..32ab99621ab826 100644 --- a/ee/spec/services/milestones/close_service_spec.rb +++ b/ee/spec/services/milestones/close_service_spec.rb @@ -3,13 +3,14 @@ require 'spec_helper' RSpec.describe Milestones::CloseService, feature_category: :team_planning do - let(:user) { create(:user) } + let_it_be(:user) { create(:user) } subject(:service) { described_class.new(container, user, {}) } describe '#execute' do context 'on group milestones' do - let(:container) { create(:group) } + let_it_be(:container) { create(:group, maintainers: user) } + let(:milestone) { create(:milestone, title: 'Milestone v1.0', group: container) } context 'when group webhooks are available' do @@ -18,10 +19,8 @@ end context 'when group has active milestone hooks' do - let(:container) do - create(:group).tap do |group| - create(:group_hook, group: group, milestone_events: true) - end + before do + create(:group_hook, group: container, milestone_events: true) end it_behaves_like 'closes the milestone', with_hooks: true, with_event: false diff --git a/ee/spec/services/milestones/create_service_spec.rb b/ee/spec/services/milestones/create_service_spec.rb index 243a1697c388c0..85e2319fb604f6 100644 --- a/ee/spec/services/milestones/create_service_spec.rb +++ b/ee/spec/services/milestones/create_service_spec.rb @@ -4,14 +4,13 @@ RSpec.describe Milestones::CreateService, feature_category: :team_planning do let_it_be(:params) { { title: 'New Milestone', description: 'Description' } } - - let(:user) { create(:user) } + let_it_be(:user) { create(:user) } subject(:service) { described_class.new(container, user, params) } describe '#execute' do context 'on group milestones' do - let(:container) { create(:group) } + let_it_be(:container) { create(:group, maintainers: user) } context 'when group webhooks are available' do before do @@ -19,10 +18,8 @@ end context 'when group has active milestone hooks' do - let(:container) do - create(:group).tap do |group| - create(:group_hook, group: group, milestone_events: true) - end + before do + create(:group_hook, group: container, milestone_events: true) end it_behaves_like 'creates the milestone', with_hooks: true, with_event: false diff --git a/ee/spec/services/milestones/destroy_service_spec.rb b/ee/spec/services/milestones/destroy_service_spec.rb index 8f1331441e0238..3f1fc8df10e04e 100644 --- a/ee/spec/services/milestones/destroy_service_spec.rb +++ b/ee/spec/services/milestones/destroy_service_spec.rb @@ -3,18 +3,15 @@ require 'spec_helper' RSpec.describe Milestones::DestroyService, feature_category: :team_planning do - let(:user) { create(:user) } + let_it_be(:user) { create(:user) } subject(:service) { described_class.new(container, user, {}) } describe '#execute' do context 'on project milestones' do - let(:container) { create(:project, :repository) } - let(:milestone) { create(:milestone, title: 'Milestone v1.0', project: container) } + let_it_be(:container) { create(:project, :repository, maintainers: user) } - before do - container.add_maintainer(user) - end + let(:milestone) { create(:milestone, title: 'Milestone v1.0', project: container) } context 'with an existing merge request' do let!(:issue) { create(:issue, project: container, milestone: milestone) } @@ -29,19 +26,32 @@ end context 'on group milestones' do - let(:container) { create(:group) } + let_it_be(:container) { create(:group, maintainers: user) } + let(:milestone) { create(:milestone, title: 'Milestone v1.0', group: container) } + shared_examples 'deletes group-milestone' do |with_hooks:| + it 'conditionally executes webhooks and does not create a new event' do + if with_hooks + expect(milestone.parent).to receive(:execute_hooks).with(a_hash_including(action: 'delete'), + :milestone_hooks) + end + + expect(Event).not_to receive(:new) + + service.execute(milestone) + expect { milestone.reload }.to raise_error ActiveRecord::RecordNotFound + end + end + context 'when group webhooks are available' do before do stub_licensed_features(group_webhooks: true) end context 'when group has active milestone hooks' do - let(:container) do - create(:group).tap do |group| - create(:group_hook, group: group, milestone_events: true) - end + before do + create(:group_hook, group: container, milestone_events: true) end it_behaves_like 'deletes group-milestone', with_hooks: true diff --git a/ee/spec/services/milestones/reopen_service_spec.rb b/ee/spec/services/milestones/reopen_service_spec.rb index 3348111db0fd18..5c4c65875bc47b 100644 --- a/ee/spec/services/milestones/reopen_service_spec.rb +++ b/ee/spec/services/milestones/reopen_service_spec.rb @@ -3,13 +3,14 @@ require 'spec_helper' RSpec.describe Milestones::ReopenService, feature_category: :team_planning do - let(:user) { create(:user) } + let_it_be(:user) { create(:user) } subject(:service) { described_class.new(container, user, {}) } describe '#execute' do context 'on group milestones' do - let(:container) { create(:group) } + let_it_be(:container) { create(:group, maintainers: user) } + let(:milestone) { create(:milestone, :closed, title: 'Milestone v1.0', group: container) } context 'when group webhooks are available' do @@ -18,10 +19,8 @@ end context 'when group has active milestone hooks' do - let(:container) do - create(:group).tap do |group| - create(:group_hook, group: group, milestone_events: true) - end + before do + create(:group_hook, group: container, milestone_events: true) end it_behaves_like 'reopens the milestone', with_hooks: true, with_event: false diff --git a/spec/services/milestones/close_service_spec.rb b/spec/services/milestones/close_service_spec.rb index ac6b092c0f7bc0..d8083329b68474 100644 --- a/spec/services/milestones/close_service_spec.rb +++ b/spec/services/milestones/close_service_spec.rb @@ -3,16 +3,13 @@ require 'spec_helper' RSpec.describe Milestones::CloseService, feature_category: :team_planning do - let(:user) { create(:user) } - let(:project) { create(:project) } - let(:milestone) { create(:milestone, title: "Milestone v1.2", project: project) } + describe '#execute' do + let_it_be(:user) { create(:user) } + let_it_be_with_reload(:project) { create(:project, maintainers: user) } - before do - project.add_maintainer(user) - end + let(:milestone) { create(:milestone, title: 'Milestone v1.2', project: project) } - describe '#execute' do - let(:service) { described_class.new(project, user, {}) } + subject(:service) { described_class.new(project, user, {}) } context 'when service is called before test suite' do before do @@ -32,20 +29,21 @@ end context 'when milestone is successfully closed' do - context 'when milestone parent is a project' do - context 'when project has active milestone hooks' do - let(:project) do - create(:project).tap do |project| - create(:project_hook, project: project, milestone_events: true) - end - end - - it_behaves_like 'closes the milestone', with_hooks: true, with_event: true - end + before do + # To avoid memoization issues with milestone.parent.has_active_hooks? + milestone.reset + end - context 'when project has no active milestone hooks' do - it_behaves_like 'closes the milestone', with_hooks: false, with_event: true + context 'when project has active milestone hooks' do + before do + create(:project_hook, project: project, milestone_events: true) end + + it_behaves_like 'closes the milestone', with_hooks: true, with_event: true + end + + context 'when project has no active milestone hooks' do + it_behaves_like 'closes the milestone', with_hooks: false, with_event: true end end @@ -53,7 +51,12 @@ context 'when milestone is already closed' do let(:milestone) { create(:milestone, :closed, project: project) } - it_behaves_like 'does not close the milestone' + it 'does not execute hooks and does not create new event' do + expect(service).not_to receive(:execute_hooks) + expect(Event).not_to receive(:new) + + expect { service.execute(milestone) }.not_to change { milestone.state } + end end end end diff --git a/spec/services/milestones/create_service_spec.rb b/spec/services/milestones/create_service_spec.rb index 9678dae51196fb..38c501e2a371b7 100644 --- a/spec/services/milestones/create_service_spec.rb +++ b/spec/services/milestones/create_service_spec.rb @@ -30,22 +30,19 @@ expect(milestone.description).to eq('Description') end - context 'when milestone is a project milestone' do - let(:milestone) { create(:milestone, project: container) } + context 'when project has active milestone hooks' do + before do + # To avoid memoization issues with milestone.parent.has_active_hooks? + container.instance_variable_set(:@has_active_hooks, nil) - context 'when project has active milestone hooks' do - let(:container) do - create(:project).tap do |container| - create(:project_hook, project: container, milestone_events: true) - end - end - - it_behaves_like 'creates the milestone', with_hooks: true, with_event: true + create(:project_hook, project: container, milestone_events: true) end - context 'when project has no active milestone hooks' do - it_behaves_like 'creates the milestone', with_hooks: false, with_event: true - end + it_behaves_like 'creates the milestone', with_hooks: true, with_event: true + end + + context 'when project has no active milestone hooks' do + it_behaves_like 'creates the milestone', with_hooks: false, with_event: true end end diff --git a/spec/services/milestones/destroy_service_spec.rb b/spec/services/milestones/destroy_service_spec.rb index 617cd487b5c60f..1e83fa4c5701ed 100644 --- a/spec/services/milestones/destroy_service_spec.rb +++ b/spec/services/milestones/destroy_service_spec.rb @@ -73,5 +73,22 @@ end end end + + context 'on group milestones' do + let(:milestone) { create(:milestone, group: group) } + let(:container) { group } + + it 'deletes milestone' do + service.execute(milestone) + + expect { milestone.reload }.to raise_error ActiveRecord::RecordNotFound + end + + it_behaves_like 'deletes milestone id from issuables' + + it 'does not log destroy event and does not run on-delete webhook' do + expect { service.execute(milestone) }.not_to change { Event.count } + end + end end end diff --git a/spec/services/milestones/reopen_service_spec.rb b/spec/services/milestones/reopen_service_spec.rb index e592967b4575de..834811fca015e7 100644 --- a/spec/services/milestones/reopen_service_spec.rb +++ b/spec/services/milestones/reopen_service_spec.rb @@ -36,11 +36,14 @@ context 'when milestone is a project milestone' do let(:milestone) { create(:milestone, :closed, project: project) } + before do + # To avoid memoization issues with milestone.parent.has_active_hooks? + milestone.reset + end + context 'when project has active milestone hooks' do - let(:project) do - create(:project).tap do |project| - create(:project_hook, project: project, milestone_events: true) - end + before do + create(:project_hook, project: project, milestone_events: true) end it_behaves_like 'reopens the milestone', with_hooks: true, with_event: true @@ -49,6 +52,19 @@ context 'when project has no active milestone hooks' do it_behaves_like 'reopens the milestone', with_hooks: false, with_event: true end + + context 'when milestone fails to reopen' do + context 'when milestone is already active' do + let(:milestone) { create(:milestone, project: project) } + + it 'does not execute hooks and does not create new event' do + expect(service).not_to receive(:execute_hooks) + expect { service.execute(milestone) }.not_to change { Event.count } + + expect { service.execute(milestone) }.not_to change { milestone.state } + end + end + end end end end diff --git a/spec/support/shared_examples/services/milestones_shared_examples.rb b/spec/support/shared_examples/services/milestones_shared_examples.rb index e0378781d0561c..41403d132be7b0 100644 --- a/spec/support/shared_examples/services/milestones_shared_examples.rb +++ b/spec/support/shared_examples/services/milestones_shared_examples.rb @@ -2,8 +2,11 @@ RSpec.shared_examples 'creates the milestone' do |with_hooks:, with_event:| it 'conditionally executes hooks with create action and creates new event' do - expect(service).to receive(:execute_hooks).with(kind_of(Milestone), 'create').and_call_original - expect(container).to receive(:execute_hooks).with(kind_of(Hash), :milestone_hooks) if with_hooks + if with_hooks + expect(container).to receive(:execute_hooks).with(a_hash_including(action: 'create'), + :milestone_hooks) + end + expect(Event).to receive(:new).exactly(1).time.and_call_original if with_event expect { service.execute }.to change { Milestone.count }.by(1) @@ -12,49 +15,26 @@ RSpec.shared_examples 'reopens the milestone' do |with_hooks:, with_event:| it 'conditionally executes hooks with reopen action and creates new event' do - expect(service).to receive(:execute_hooks).with(milestone, 'reopen').and_call_original - expect(milestone.parent).to receive(:execute_hooks).with(kind_of(Hash), :milestone_hooks) if with_hooks + if with_hooks + expect(milestone.parent).to receive(:execute_hooks).with(a_hash_including(action: 'reopen'), + :milestone_hooks) + end + expect(Event).to receive(:new).exactly(1).time.and_call_original if with_event expect { service.execute(milestone) }.to change { milestone.state }.to('active') end end -RSpec.shared_examples 'does not reopen the milestone' do - it 'does not execute hooks and does not create new event' do - expect(service).not_to receive(:execute_hooks) - expect { service.execute(milestone) }.not_to change { Event.count } - - expect { service.execute(milestone) }.not_to change { milestone.state } - end -end - RSpec.shared_examples 'closes the milestone' do |with_hooks:, with_event:| it 'conditionally executes hooks with close action and creates new event' do - expect(service).to receive(:execute_hooks).with(milestone, 'close').and_call_original - expect(milestone.parent).to receive(:execute_hooks).with(kind_of(Hash), :milestone_hooks) if with_hooks + if with_hooks + expect(milestone.parent).to receive(:execute_hooks).with(a_hash_including(action: 'close'), + :milestone_hooks) + end + expect(Event).to receive(:new).exactly(1).time.and_call_original if with_event expect { service.execute(milestone) }.to change { milestone.state }.to('closed') end end - -RSpec.shared_examples 'does not close the milestone' do - it 'does not execute hooks and does not create new event' do - expect(service).not_to receive(:execute_hooks) - expect(Event).not_to receive(:new) - - expect { service.execute(milestone) }.not_to change { milestone.state } - end -end - -RSpec.shared_examples 'deletes group-milestone' do |with_hooks:| - it 'conditionally executes webhooks and does not create a new event' do - expect(service).to receive(:execute_hooks).with(milestone, 'delete').and_call_original - expect(milestone.parent).to receive(:execute_hooks).with(kind_of(Hash), :milestone_hooks) if with_hooks - expect(Event).not_to receive(:new) - - service.execute(milestone) - expect { milestone.reload }.to raise_error ActiveRecord::RecordNotFound - end -end -- GitLab From 1ccfe48234a9b9ea5ff60fe139b85f9deacb8bf3 Mon Sep 17 00:00:00 2001 From: Alex Kozenko <9867893@gmail.com> Date: Fri, 1 Aug 2025 08:11:04 +0300 Subject: [PATCH 10/10] Apply CR suggestions - polishing specs --- ee/spec/services/milestones/close_service_spec.rb | 2 +- ee/spec/services/milestones/create_service_spec.rb | 2 +- ee/spec/services/milestones/destroy_service_spec.rb | 2 +- ee/spec/services/milestones/reopen_service_spec.rb | 2 +- spec/services/milestones/close_service_spec.rb | 7 +------ spec/services/milestones/create_service_spec.rb | 5 +---- spec/services/milestones/reopen_service_spec.rb | 7 +------ 7 files changed, 7 insertions(+), 20 deletions(-) diff --git a/ee/spec/services/milestones/close_service_spec.rb b/ee/spec/services/milestones/close_service_spec.rb index 32ab99621ab826..d423ddad6090e2 100644 --- a/ee/spec/services/milestones/close_service_spec.rb +++ b/ee/spec/services/milestones/close_service_spec.rb @@ -20,7 +20,7 @@ context 'when group has active milestone hooks' do before do - create(:group_hook, group: container, milestone_events: true) + allow(container).to receive(:has_active_hooks?).with(:milestone_hooks).and_return(true) end it_behaves_like 'closes the milestone', with_hooks: true, with_event: false diff --git a/ee/spec/services/milestones/create_service_spec.rb b/ee/spec/services/milestones/create_service_spec.rb index 85e2319fb604f6..449d3cf23c68f4 100644 --- a/ee/spec/services/milestones/create_service_spec.rb +++ b/ee/spec/services/milestones/create_service_spec.rb @@ -19,7 +19,7 @@ context 'when group has active milestone hooks' do before do - create(:group_hook, group: container, milestone_events: true) + allow(container).to receive(:has_active_hooks?).with(:milestone_hooks).and_return(true) end it_behaves_like 'creates the milestone', with_hooks: true, with_event: false diff --git a/ee/spec/services/milestones/destroy_service_spec.rb b/ee/spec/services/milestones/destroy_service_spec.rb index 3f1fc8df10e04e..c31b181513fab2 100644 --- a/ee/spec/services/milestones/destroy_service_spec.rb +++ b/ee/spec/services/milestones/destroy_service_spec.rb @@ -51,7 +51,7 @@ context 'when group has active milestone hooks' do before do - create(:group_hook, group: container, milestone_events: true) + allow(container).to receive(:has_active_hooks?).with(:milestone_hooks).and_return(true) end it_behaves_like 'deletes group-milestone', with_hooks: true diff --git a/ee/spec/services/milestones/reopen_service_spec.rb b/ee/spec/services/milestones/reopen_service_spec.rb index 5c4c65875bc47b..184e3cc8571c2b 100644 --- a/ee/spec/services/milestones/reopen_service_spec.rb +++ b/ee/spec/services/milestones/reopen_service_spec.rb @@ -20,7 +20,7 @@ context 'when group has active milestone hooks' do before do - create(:group_hook, group: container, milestone_events: true) + allow(container).to receive(:has_active_hooks?).with(:milestone_hooks).and_return(true) end it_behaves_like 'reopens the milestone', with_hooks: true, with_event: false diff --git a/spec/services/milestones/close_service_spec.rb b/spec/services/milestones/close_service_spec.rb index d8083329b68474..0eee799f09f14e 100644 --- a/spec/services/milestones/close_service_spec.rb +++ b/spec/services/milestones/close_service_spec.rb @@ -29,14 +29,9 @@ end context 'when milestone is successfully closed' do - before do - # To avoid memoization issues with milestone.parent.has_active_hooks? - milestone.reset - end - context 'when project has active milestone hooks' do before do - create(:project_hook, project: project, milestone_events: true) + allow(project).to receive(:has_active_hooks?).with(:milestone_hooks).and_return(true) end it_behaves_like 'closes the milestone', with_hooks: true, with_event: true diff --git a/spec/services/milestones/create_service_spec.rb b/spec/services/milestones/create_service_spec.rb index 38c501e2a371b7..c871433384ff05 100644 --- a/spec/services/milestones/create_service_spec.rb +++ b/spec/services/milestones/create_service_spec.rb @@ -32,10 +32,7 @@ context 'when project has active milestone hooks' do before do - # To avoid memoization issues with milestone.parent.has_active_hooks? - container.instance_variable_set(:@has_active_hooks, nil) - - create(:project_hook, project: container, milestone_events: true) + allow(container).to receive(:has_active_hooks?).with(:milestone_hooks).and_return(true) end it_behaves_like 'creates the milestone', with_hooks: true, with_event: true diff --git a/spec/services/milestones/reopen_service_spec.rb b/spec/services/milestones/reopen_service_spec.rb index 834811fca015e7..2dafd66eb2e1a0 100644 --- a/spec/services/milestones/reopen_service_spec.rb +++ b/spec/services/milestones/reopen_service_spec.rb @@ -36,14 +36,9 @@ context 'when milestone is a project milestone' do let(:milestone) { create(:milestone, :closed, project: project) } - before do - # To avoid memoization issues with milestone.parent.has_active_hooks? - milestone.reset - end - context 'when project has active milestone hooks' do before do - create(:project_hook, project: project, milestone_events: true) + allow(project).to receive(:has_active_hooks?).with(:milestone_hooks).and_return(true) end it_behaves_like 'reopens the milestone', with_hooks: true, with_event: true -- GitLab