From c21418e95cd9e4e2884f00594c39632d98392618 Mon Sep 17 00:00:00 2001 From: Alex Kozenko <9867893@gmail.com> Date: Mon, 16 Jun 2025 17:53:39 +0300 Subject: [PATCH 01/15] Add milestone_events column to web_hooks table --- .../20250613162310_add_milestone_events_to_web_hooks.rb | 9 +++++++++ db/schema_migrations/20250613162310 | 1 + db/structure.sql | 1 + 3 files changed, 11 insertions(+) create mode 100644 db/migrate/20250613162310_add_milestone_events_to_web_hooks.rb create mode 100644 db/schema_migrations/20250613162310 diff --git a/db/migrate/20250613162310_add_milestone_events_to_web_hooks.rb b/db/migrate/20250613162310_add_milestone_events_to_web_hooks.rb new file mode 100644 index 00000000000000..bf8b24e3e399f7 --- /dev/null +++ b/db/migrate/20250613162310_add_milestone_events_to_web_hooks.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddMilestoneEventsToWebHooks < Gitlab::Database::Migration[2.3] + milestone '18.1' + + def change + add_column :web_hooks, :milestone_events, :boolean, null: false, default: false + end +end diff --git a/db/schema_migrations/20250613162310 b/db/schema_migrations/20250613162310 new file mode 100644 index 00000000000000..16d68416d52b01 --- /dev/null +++ b/db/schema_migrations/20250613162310 @@ -0,0 +1 @@ +ecd7624a83f30d66eb361cdfa56d857162c5ff00a2f9ae3ce256013f003f4d1b \ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index 7c5297872d2dc2..8db6a3ab81bf9f 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -25649,6 +25649,7 @@ CREATE TABLE web_hooks ( project_events boolean DEFAULT false NOT NULL, vulnerability_events boolean DEFAULT false NOT NULL, member_approval_events boolean DEFAULT false NOT NULL, + milestone_events boolean DEFAULT false NOT NULL, CONSTRAINT check_1e4d5cbdc5 CHECK ((char_length(name) <= 255)), CONSTRAINT check_23a96ad211 CHECK ((char_length(description) <= 2048)), CONSTRAINT check_69ef76ee0c CHECK ((char_length(custom_webhook_template) <= 4096)) -- GitLab From 4cd18e72040ab26a82463650c9335ebe3cf13110 Mon Sep 17 00:00:00 2001 From: Alex Kozenko <9867893@gmail.com> Date: Mon, 16 Jun 2025 21:57:04 +0300 Subject: [PATCH 02/15] Add webhooks for project-milestones --- app/models/concerns/triggerable_hooks.rb | 1 + app/models/hooks/project_hook.rb | 1 + app/models/milestone.rb | 4 + .../integrations/project_test_data.rb | 4 + .../integrations/test/project_service.rb | 6 +- app/services/milestones/base_service.rb | 12 +- app/services/milestones/close_service.rb | 1 + app/services/milestones/create_service.rb | 1 + app/services/milestones/reopen_service.rb | 1 + app/services/test_hooks/project_service.rb | 2 + app/views/shared/web_hooks/_form.html.haml | 4 + lib/gitlab/data_builder/milestone.rb | 36 +++++ lib/gitlab/hook_data/milestone_builder.rb | 29 ++++ spec/factories/project_hooks.rb | 1 + .../settings/webhooks_settings_spec.rb | 1 + .../lib/gitlab/data_builder/milestone_spec.rb | 130 ++++++++++++++++++ .../hook_data/milestone_builder_spec.rb | 29 ++++ .../services/milestones/close_service_spec.rb | 70 +++++++++- .../milestones/create_service_spec.rb | 66 +++++++++ .../milestones/reopen_service_spec.rb | 105 ++++++++++++++ .../test_hooks/project_service_spec.rb | 12 ++ 21 files changed, 512 insertions(+), 4 deletions(-) create mode 100644 lib/gitlab/data_builder/milestone.rb create mode 100644 lib/gitlab/hook_data/milestone_builder.rb create mode 100644 spec/lib/gitlab/data_builder/milestone_spec.rb create mode 100644 spec/lib/gitlab/hook_data/milestone_builder_spec.rb create mode 100644 spec/services/milestones/reopen_service_spec.rb diff --git a/app/models/concerns/triggerable_hooks.rb b/app/models/concerns/triggerable_hooks.rb index 64e4a3311dc91c..a892593946b640 100644 --- a/app/models/concerns/triggerable_hooks.rb +++ b/app/models/concerns/triggerable_hooks.rb @@ -32,6 +32,7 @@ def available_triggers job_hooks: :job_events, member_hooks: :member_events, merge_request_hooks: :merge_requests_events, + milestone_hooks: :milestone_events, note_hooks: :note_events, pipeline_hooks: :pipeline_events, project_hooks: :project_events, diff --git a/app/models/hooks/project_hook.rb b/app/models/hooks/project_hook.rb index 1226a76433bfaf..c15482d071eaae 100644 --- a/app/models/hooks/project_hook.rb +++ b/app/models/hooks/project_hook.rb @@ -15,6 +15,7 @@ class ProjectHook < WebHook :issue_hooks, :job_hooks, :merge_request_hooks, + :milestone_hooks, :note_hooks, :pipeline_hooks, :push_hooks, diff --git a/app/models/milestone.rb b/app/models/milestone.rb index 358b479476489f..7bdbdf49f7c59a 100644 --- a/app/models/milestone.rb +++ b/app/models/milestone.rb @@ -99,6 +99,10 @@ class Predefined state :active end + def hook_attrs + Gitlab::HookData::MilestoneBuilder.new(self).build + end + # Searches for timeboxes with a matching title. # # This method uses ILIKE on PostgreSQL diff --git a/app/services/concerns/integrations/project_test_data.rb b/app/services/concerns/integrations/project_test_data.rb index 185b57a1384555..f71cf87832ca43 100644 --- a/app/services/concerns/integrations/project_test_data.rb +++ b/app/services/concerns/integrations/project_test_data.rb @@ -82,6 +82,10 @@ def releases_events_data release.to_hook_data('create') end + def milestone_events_data + Gitlab::DataBuilder::Milestone.build_sample(project) + end + def emoji_events_data no_data_error(s_('TestHooks|Ensure the project has notes.')) unless project.notes.any? diff --git a/app/services/integrations/test/project_service.rb b/app/services/integrations/test/project_service.rb index 1e077d49e5a0ce..ddba08e388231d 100644 --- a/app/services/integrations/test/project_service.rb +++ b/app/services/integrations/test/project_service.rb @@ -14,7 +14,7 @@ def project private - def data + def data # rubocop:disable Metrics/CyclomaticComplexity -- despite a high count of cases, this isn't that complex strong_memoize(:data) do case event || integration.default_test_event when 'push', 'tag_push' @@ -35,13 +35,15 @@ def data deployment_events_data when 'release' releases_events_data + when 'milestone' + milestone_events_data when 'award_emoji' emoji_events_data when 'current_user' current_user_events_data end end - end + end # rubocop:enable Metrics/CyclomaticComplexity end end end diff --git a/app/services/milestones/base_service.rb b/app/services/milestones/base_service.rb index 0d7d855bf5ed3b..5be51043f5b297 100644 --- a/app/services/milestones/base_service.rb +++ b/app/services/milestones/base_service.rb @@ -2,14 +2,24 @@ module Milestones class BaseService < ::BaseService - # Parent can either a group or a project attr_accessor :parent, :current_user, :params def initialize(parent, user, params = {}) @parent = parent @current_user = user @params = params.dup + super end + + private + + def execute_hooks(milestone, action) + return unless milestone.project_milestone? + return unless milestone.parent.has_active_hooks?(:milestone_hooks) + + payload = Gitlab::DataBuilder::Milestone.build(milestone, action) + milestone.parent.execute_hooks(payload, :milestone_hooks) + end end end diff --git a/app/services/milestones/close_service.rb b/app/services/milestones/close_service.rb index a252f5c144ea14..616c6d85b7c55a 100644 --- a/app/services/milestones/close_service.rb +++ b/app/services/milestones/close_service.rb @@ -5,6 +5,7 @@ class CloseService < Milestones::BaseService def execute(milestone) if milestone.close && milestone.project_milestone? event_service.close_milestone(milestone, current_user) + execute_hooks(milestone, 'close') end milestone diff --git a/app/services/milestones/create_service.rb b/app/services/milestones/create_service.rb index e8a14adc10dd2d..d8a9be6aafebd4 100644 --- a/app/services/milestones/create_service.rb +++ b/app/services/milestones/create_service.rb @@ -9,6 +9,7 @@ def execute if milestone.save && milestone.project_milestone? event_service.open_milestone(milestone, current_user) + execute_hooks(milestone, 'create') end milestone diff --git a/app/services/milestones/reopen_service.rb b/app/services/milestones/reopen_service.rb index 125a3ec1367654..a4a9ccfd4011dc 100644 --- a/app/services/milestones/reopen_service.rb +++ b/app/services/milestones/reopen_service.rb @@ -5,6 +5,7 @@ class ReopenService < Milestones::BaseService def execute(milestone) if milestone.activate && milestone.project_milestone? event_service.reopen_milestone(milestone, current_user) + execute_hooks(milestone, 'reopen') end milestone diff --git a/app/services/test_hooks/project_service.rb b/app/services/test_hooks/project_service.rb index b183210edb3b54..25c81c3a498907 100644 --- a/app/services/test_hooks/project_service.rb +++ b/app/services/test_hooks/project_service.rb @@ -35,6 +35,8 @@ def data wiki_page_events_data when 'releases_events' releases_events_data + when 'milestone_events' + milestone_events_data when 'emoji_events' emoji_events_data when 'resource_access_token_events' diff --git a/app/views/shared/web_hooks/_form.html.haml b/app/views/shared/web_hooks/_form.html.haml index a3c503c9efcdc4..7c7a60adcc829b 100644 --- a/app/views/shared/web_hooks/_form.html.haml +++ b/app/views/shared/web_hooks/_form.html.haml @@ -58,6 +58,10 @@ = form.gitlab_ui_checkbox_component :releases_events, integration_webhook_event_human_name(:releases_events), help_text: s_('Webhooks|A release is created, updated, or deleted.') + %li.gl-pb-3 + = form.gitlab_ui_checkbox_component :milestone_events, + integration_webhook_event_human_name(:milestone_events), + help_text: s_('Webhooks|A milestone is created, closed, or reopened.') %li.gl-pb-3 - emoji_help_link = link_to s_('Which emoji events trigger webhooks'), help_page_path('user/project/integrations/webhook_events.md', anchor: 'emoji-events') = form.gitlab_ui_checkbox_component :emoji_events, diff --git a/lib/gitlab/data_builder/milestone.rb b/lib/gitlab/data_builder/milestone.rb new file mode 100644 index 00000000000000..6f6e269c082513 --- /dev/null +++ b/lib/gitlab/data_builder/milestone.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module Gitlab + module DataBuilder + module Milestone + extend self + + SAMPLE_DATA = { + id: 1, + iid: 1, + title: 'Sample milestone', + description: 'Sample milestone description', + state: 'active', + created_at: Time.current, + updated_at: Time.current, + due_date: 1.week.from_now, + start_date: Time.current + }.freeze + + def build(milestone, action) + { + object_kind: 'milestone', + event_type: 'milestone', + project: milestone.project&.hook_attrs, + object_attributes: milestone.hook_attrs, + action: action + } + end + + def build_sample(project) + milestone = project.milestones.first || ::Milestone.new(SAMPLE_DATA.merge(project: project)) + build(milestone, 'create') + end + end + end +end diff --git a/lib/gitlab/hook_data/milestone_builder.rb b/lib/gitlab/hook_data/milestone_builder.rb new file mode 100644 index 00000000000000..f2022a956eb25b --- /dev/null +++ b/lib/gitlab/hook_data/milestone_builder.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +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 + + alias_method :milestone, :object + + def build + milestone + .attributes + .with_indifferent_access + .slice(*SAFE_HOOK_ATTRIBUTES) + end + end + end +end diff --git a/spec/factories/project_hooks.rb b/spec/factories/project_hooks.rb index 482cec1195d5ce..56959e051f828d 100644 --- a/spec/factories/project_hooks.rb +++ b/spec/factories/project_hooks.rb @@ -30,6 +30,7 @@ deployment_events { true } feature_flag_events { true } releases_events { true } + milestone_events { true } emoji_events { true } vulnerability_events { true } end diff --git a/spec/features/projects/settings/webhooks_settings_spec.rb b/spec/features/projects/settings/webhooks_settings_spec.rb index 062bcf907960c7..f1f824a8d657e4 100644 --- a/spec/features/projects/settings/webhooks_settings_spec.rb +++ b/spec/features/projects/settings/webhooks_settings_spec.rb @@ -43,6 +43,7 @@ expect(page).to have_content('Comment') expect(page).to have_content('Merge request events') expect(page).to have_content('Pipeline events') + expect(page).to have_content('Milestone events') expect(page).to have_content('Wiki page events') expect(page).to have_content('Releases events') expect(page).to have_content('Emoji events') diff --git a/spec/lib/gitlab/data_builder/milestone_spec.rb b/spec/lib/gitlab/data_builder/milestone_spec.rb new file mode 100644 index 00000000000000..ed750f74835477 --- /dev/null +++ b/spec/lib/gitlab/data_builder/milestone_spec.rb @@ -0,0 +1,130 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::DataBuilder::Milestone, feature_category: :team_planning do + let_it_be(:project) { create(:project) } + + shared_examples 'builds milestone hook data' do + it { expect(data).to be_a(Hash) } + + it 'includes the correct structure' do + expect(data[:object_kind]).to eq('milestone') + expect(data[:event_type]).to eq('milestone') + expect(data[:action]).to eq(action) + end + end + + describe '.build' do + let(:milestone) { create(:milestone, project: project) } + let(:action) { 'create' } + + subject(:data) { described_class.build(milestone, action) } + + it_behaves_like 'builds milestone hook data' + + it 'includes project data' do + expect(data[:project]).to eq(milestone.project.hook_attrs) + end + + it 'includes milestone attributes' do + object_attributes = data[:object_attributes] + + expect(object_attributes[:id]).to eq(milestone.id) + expect(object_attributes[:iid]).to eq(milestone.iid) + expect(object_attributes[:title]).to eq(milestone.title) + expect(object_attributes[:description]).to eq(milestone.description) + expect(object_attributes[:state]).to eq(milestone.state) + expect(object_attributes[:created_at]).to eq(milestone.created_at) + expect(object_attributes[:updated_at]).to eq(milestone.updated_at) + expect(object_attributes[:due_date]).to eq(milestone.due_date) + expect(object_attributes[:start_date]).to eq(milestone.start_date) + expect(object_attributes[:project_id]).to eq(milestone.project_id) + end + + context 'with different actions' do + %w[create close reopen].each do |test_action| + context "when action is #{test_action}" do + let(:action) { test_action } + + it "sets the action to #{test_action}" do + expect(data[:action]).to eq(test_action) + end + end + end + end + + context 'with milestone having dates' do + let(:milestone) { create(:milestone, project: project, due_date: 1.week.from_now, start_date: 1.day.ago) } + + it 'includes the date information' do + expect(data[:object_attributes][:due_date]).to eq(milestone.due_date) + expect(data[:object_attributes][:start_date]).to eq(milestone.start_date) + end + end + + context 'with milestone having no dates' do + let(:milestone) { create(:milestone, project: project, due_date: nil, start_date: nil) } + + it 'includes nil date information' do + expect(data[:object_attributes][:due_date]).to be_nil + expect(data[:object_attributes][:start_date]).to be_nil + end + end + + context 'with closed milestone' do + let(:milestone) { create(:milestone, :closed, project: project) } + + it 'includes the correct state' do + expect(data[:object_attributes][:state]).to eq('closed') + end + end + + include_examples 'project hook data' + end + + describe '.build_sample' do + let(:action) { 'create' } + + context 'when project has existing milestones' do + subject(:data) { described_class.build_sample(project) } + + let!(:existing_milestone) { create(:milestone, project: project) } + + it_behaves_like 'builds milestone hook data' + + it 'includes project data' do + expect(data[:project]).to eq(project.hook_attrs) + end + + it 'uses the first existing milestone' do + expect(data[:object_attributes][:id]).to eq(existing_milestone.id) + expect(data[:object_attributes][:title]).to eq(existing_milestone.title) + end + + include_examples 'project hook data' + end + + context 'when project has no milestones' do + subject(:data) { described_class.build_sample(clean_project) } + + let(:clean_project) { create(:project, :repository) } + + it_behaves_like 'builds milestone hook data' + + it 'includes project data' do + expect(data[:project]).to eq(clean_project.hook_attrs) + end + + it 'creates a sample milestone with predefined data' do + expect(data[:object_attributes][:title]).to eq('Sample milestone') + expect(data[:object_attributes][:description]).to eq('Sample milestone description') + expect(data[:object_attributes][:state]).to eq('active') + end + + include_examples 'project hook data' do + let(:project) { clean_project } + end + end + end +end diff --git a/spec/lib/gitlab/hook_data/milestone_builder_spec.rb b/spec/lib/gitlab/hook_data/milestone_builder_spec.rb new file mode 100644 index 00000000000000..ef26dc9dd5d339 --- /dev/null +++ b/spec/lib/gitlab/hook_data/milestone_builder_spec.rb @@ -0,0 +1,29 @@ +# 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 '#build' do + subject(:data) { builder.build } + + it 'includes safe attributes' do + expect(data.keys).to match_array(described_class::SAFE_HOOK_ATTRIBUTES.map(&:to_s)) + end + + it 'returns indifferent access hash' do + expect(data).to be_a(ActiveSupport::HashWithIndifferentAccess) + end + + it 'includes correct milestone data' do + expect(data['id']).to eq(milestone.id) + expect(data['iid']).to eq(milestone.iid) + expect(data['title']).to eq(milestone.title) + expect(data['state']).to eq(milestone.state) + expect(data['project_id']).to eq(milestone.project_id) + end + end +end diff --git a/spec/services/milestones/close_service_spec.rb b/spec/services/milestones/close_service_spec.rb index f362c8da6423d8..0cb302027dc0f4 100644 --- a/spec/services/milestones/close_service_spec.rb +++ b/spec/services/milestones/close_service_spec.rb @@ -12,8 +12,10 @@ end describe '#execute' do + let(:service) { described_class.new(project, user, {}) } + before do - described_class.new(project, user, {}).execute(milestone) + service.execute(milestone) end it { expect(milestone).to be_valid } @@ -26,5 +28,71 @@ it { expect(event.target).to eq(milestone) } it { expect(event.action_name).to eq('closed') } end + + context 'when milestone is successfully closed' do + it 'executes hooks with close action' do + new_milestone = create(:milestone, project: project) + + expect(service).to receive(:execute_hooks).with(new_milestone, 'close') + + service.execute(new_milestone) + end + end + + context 'when milestone fails to close' do + it 'does not execute hooks' do + closed_milestone = create(:milestone, :closed, project: project) + + expect(service).not_to receive(:execute_hooks) + + service.execute(closed_milestone) + end + end + end + + describe 'webhook execution' do + let(:service) { described_class.new(project, user, {}) } + + context 'when project has active milestone hooks' do + before do + create(:project_hook, project: project, milestone_events: true) + end + + it 'executes milestone hooks with correct payload when conditions are met' do + allow(milestone).to receive(:project_milestone?).and_return(true) + allow(project).to receive(:has_active_hooks?).with(:milestone_hooks).and_return(true) + + expected_payload = Gitlab::DataBuilder::Milestone.build(milestone, 'close') + + expect(project).to receive(:execute_hooks).with(expected_payload, :milestone_hooks) + + service.send(:execute_hooks, milestone, 'close') + end + end + + context 'when project has no active milestone hooks' do + it 'does not execute hooks when no active hooks' do + allow(milestone).to receive(:project_milestone?).and_return(true) + allow(project).to receive(:has_active_hooks?).with(:milestone_hooks).and_return(false) + + expect(project).not_to receive(:execute_hooks) + + service.send(:execute_hooks, milestone, 'close') + end + end + + context 'when milestone is a group milestone' do + let(:group) { create(:group) } + let(:group_milestone) { create(:milestone, group: group) } + let(:group_service) { described_class.new(group, user, {}) } + + it 'does not execute hooks for group milestones' do + allow(group_milestone).to receive(:project_milestone?).and_return(false) + + expect(group).not_to receive(:execute_hooks) + + group_service.send(:execute_hooks, group_milestone, 'close') + end + end end end diff --git a/spec/services/milestones/create_service_spec.rb b/spec/services/milestones/create_service_spec.rb index 70010d88fbd3b3..34f449a9135877 100644 --- a/spec/services/milestones/create_service_spec.rb +++ b/spec/services/milestones/create_service_spec.rb @@ -29,6 +29,12 @@ expect(milestone.title).to eq('New Milestone') expect(milestone.description).to eq('Description') end + + it 'executes hooks with create action' do + expect(create_milestone).to receive(:execute_hooks).with(kind_of(Milestone), 'create') + + create_milestone.execute + end end context 'when milestone fails to save' do @@ -48,6 +54,12 @@ create_milestone.execute end + it 'does not execute hooks' do + expect(create_milestone).not_to receive(:execute_hooks) + + create_milestone.execute + end + it 'returns the unsaved milestone' do milestone = create_milestone.execute expect(milestone).to be_a(Milestone) @@ -62,6 +74,60 @@ end end + describe 'webhook execution' do + let(:milestone) { create(:milestone, project: project) } + + context 'when project has active milestone hooks' do + before do + create(:project_hook, project: project, milestone_events: true) + end + + it 'executes milestone hooks with correct payload when conditions are met' do + allow(milestone).to receive(:project_milestone?).and_return(true) + allow(project).to receive(:has_active_hooks?).with(:milestone_hooks).and_return(true) + expected_payload = Gitlab::DataBuilder::Milestone.build(milestone, 'create') + + expect(project).to receive(:execute_hooks).with(expected_payload, :milestone_hooks) + + create_milestone.send(:execute_hooks, milestone, 'create') + end + + it 'only executes hooks for project milestones' do + allow(milestone).to receive(:project_milestone?).and_return(true) + + expect(project).to receive(:has_active_hooks?).with(:milestone_hooks).and_return(true) + expect(project).to receive(:execute_hooks) + + create_milestone.send(:execute_hooks, milestone, 'create') + end + end + + context 'when project has no active milestone hooks' do + it 'does not execute hooks when no active hooks' do + allow(milestone).to receive(:project_milestone?).and_return(true) + allow(project).to receive(:has_active_hooks?).with(:milestone_hooks).and_return(false) + + expect(project).not_to receive(:execute_hooks) + + create_milestone.send(:execute_hooks, milestone, 'create') + end + end + + context 'when milestone is a group milestone' do + let(:group) { create(:group) } + let(:group_milestone) { create(:milestone, group: group) } + let(:group_service) { described_class.new(group, user, params) } + + it 'does not execute hooks for group milestones' do + allow(group_milestone).to receive(:project_milestone?).and_return(false) + + expect(group).not_to receive(:execute_hooks) + + group_service.send(:execute_hooks, group_milestone, 'create') + end + end + end + describe '#before_create' do it 'checks for spam' do milestone = build(:milestone) diff --git a/spec/services/milestones/reopen_service_spec.rb b/spec/services/milestones/reopen_service_spec.rb new file mode 100644 index 00000000000000..c9db97a94a7279 --- /dev/null +++ b/spec/services/milestones/reopen_service_spec.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Milestones::ReopenService, feature_category: :team_planning do + let(:user) { create(:user) } + let(:project) { create(:project) } + let(:milestone) { create(:milestone, :closed, title: "Milestone v1.2", project: project) } + + before do + project.add_maintainer(user) + end + + describe '#execute' do + let(:service) { described_class.new(project, user, {}) } + + before do + service.execute(milestone) + end + + it { expect(milestone).to be_valid } + it { expect(milestone).to be_active } + + describe 'event' do + let(:event) { Event.recent.first } + + it { expect(event.milestone).to be_truthy } + it { expect(event.target).to eq(milestone) } + it { expect(event.action_name).to eq('opened') } + end + + context 'when milestone is successfully reopened' do + it 'executes hooks with reopen action' do + closed_milestone = create(:milestone, :closed, project: project) + + expect(service).to receive(:execute_hooks).with(closed_milestone, 'reopen') + + service.execute(closed_milestone) + end + end + + context 'when milestone fails to reopen' do + it 'does not execute hooks' do + active_milestone = create(:milestone, project: project) + + expect(service).not_to receive(:execute_hooks) + + service.execute(active_milestone) + end + end + end + + describe 'webhook execution' do + let(:service) { described_class.new(project, user, {}) } + + context 'when project has active milestone hooks' do + before do + create(:project_hook, project: project, milestone_events: true) + end + + it 'executes milestone hooks with correct payload when conditions are met' do + allow(milestone).to receive(:project_milestone?).and_return(true) + allow(project).to receive(:has_active_hooks?).with(:milestone_hooks).and_return(true) + expected_payload = Gitlab::DataBuilder::Milestone.build(milestone, 'reopen') + + expect(project).to receive(:execute_hooks).with(expected_payload, :milestone_hooks) + + service.send(:execute_hooks, milestone, 'reopen') + end + + it 'only executes hooks for project milestones' do + allow(milestone).to receive(:project_milestone?).and_return(true) + expect(project).to receive(:has_active_hooks?).with(:milestone_hooks).and_return(true) + expect(project).to receive(:execute_hooks) + + service.send(:execute_hooks, milestone, 'reopen') + end + end + + context 'when project has no active milestone hooks' do + it 'does not execute hooks when no active hooks' do + allow(milestone).to receive(:project_milestone?).and_return(true) + allow(project).to receive(:has_active_hooks?).with(:milestone_hooks).and_return(false) + + expect(project).not_to receive(:execute_hooks) + + service.send(:execute_hooks, milestone, 'reopen') + end + end + + context 'when milestone is a group milestone' do + let(:group) { create(:group) } + let(:group_milestone) { create(:milestone, :closed, group: group) } + let(:group_service) { described_class.new(group, user, {}) } + + it 'does not execute hooks for group milestones' do + allow(group_milestone).to receive(:project_milestone?).and_return(false) + + expect(group).not_to receive(:execute_hooks) + + group_service.send(:execute_hooks, group_milestone, 'reopen') + end + end + end +end diff --git a/spec/services/test_hooks/project_service_spec.rb b/spec/services/test_hooks/project_service_spec.rb index 1e06911f8f799c..cd2bc14c0edeca 100644 --- a/spec/services/test_hooks/project_service_spec.rb +++ b/spec/services/test_hooks/project_service_spec.rb @@ -211,6 +211,18 @@ end end + context 'milestone_events' do + let(:trigger) { 'milestone_events' } + let(:trigger_key) { :milestone_hooks } + + it 'executes hook' do + allow(Gitlab::DataBuilder::Milestone).to receive(:build_sample).and_return(sample_data) + + expect(hook).to receive(:execute).with(sample_data, trigger_key, force: true).and_return(success_result) + expect(service.execute).to include(success_result) + end + end + context 'emoji' do let(:trigger) { 'emoji_events' } let(:trigger_key) { :emoji_hooks } -- GitLab From 5b80bb2be1050fd1ba0410d1d0375012472911c4 Mon Sep 17 00:00:00 2001 From: Alex Kozenko <9867893@gmail.com> Date: Mon, 16 Jun 2025 22:03:28 +0300 Subject: [PATCH 03/15] Add info about milestone_events to REST API --- lib/api/entities/project_hook.rb | 1 + lib/api/project_hooks.rb | 1 + spec/fixtures/api/schemas/public_api/v4/project_hook.json | 3 +++ spec/lib/gitlab/import_export/project/relation_factory_spec.rb | 1 + spec/lib/gitlab/import_export/safe_model_attributes.yml | 1 + 5 files changed, 7 insertions(+) diff --git a/lib/api/entities/project_hook.rb b/lib/api/entities/project_hook.rb index a1ede6fe277176..b81720fa28f7fb 100644 --- a/lib/api/entities/project_hook.rb +++ b/lib/api/entities/project_hook.rb @@ -14,6 +14,7 @@ class ProjectHook < Hook expose :feature_flag_events, documentation: { type: 'boolean' } expose :job_events, documentation: { type: 'boolean' } expose :releases_events, documentation: { type: 'boolean' } + expose :milestone_events, documentation: { type: 'boolean' } expose :emoji_events, documentation: { type: 'boolean' } expose :resource_access_token_events, documentation: { type: 'boolean' } expose :vulnerability_events, documentation: { type: 'boolean' } diff --git a/lib/api/project_hooks.rb b/lib/api/project_hooks.rb index ecd32e9155f849..ceffa33a63dcc3 100644 --- a/lib/api/project_hooks.rb +++ b/lib/api/project_hooks.rb @@ -37,6 +37,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 :emoji_events, type: Boolean, desc: "Trigger hook on emoji events" optional :resource_access_token_events, type: Boolean, desc: "Trigger hook on project access token expiry events" optional :enable_ssl_verification, type: Boolean, desc: "Do SSL verification when triggering the hook" diff --git a/spec/fixtures/api/schemas/public_api/v4/project_hook.json b/spec/fixtures/api/schemas/public_api/v4/project_hook.json index 238f5839f77dfb..fb9cc717288685 100644 --- a/spec/fixtures/api/schemas/public_api/v4/project_hook.json +++ b/spec/fixtures/api/schemas/public_api/v4/project_hook.json @@ -117,6 +117,9 @@ "releases_events": { "type": "boolean" }, + "milestone_events": { + "type": "boolean" + }, "emoji_events": { "type": "boolean" }, diff --git a/spec/lib/gitlab/import_export/project/relation_factory_spec.rb b/spec/lib/gitlab/import_export/project/relation_factory_spec.rb index f898b18e20035d..439fedd3243226 100644 --- a/spec/lib/gitlab/import_export/project/relation_factory_spec.rb +++ b/spec/lib/gitlab/import_export/project/relation_factory_spec.rb @@ -67,6 +67,7 @@ def values 'job_events' => false, 'wiki_page_events' => true, 'releases_events' => false, + 'milestone_events' => false, 'emoji_events' => false, 'resource_access_token_events' => false, 'token' => token diff --git a/spec/lib/gitlab/import_export/safe_model_attributes.yml b/spec/lib/gitlab/import_export/safe_model_attributes.yml index 0de163942df879..1ef6ed465cd8f3 100644 --- a/spec/lib/gitlab/import_export/safe_model_attributes.yml +++ b/spec/lib/gitlab/import_export/safe_model_attributes.yml @@ -619,6 +619,7 @@ ProjectHook: - confidential_note_events - repository_update_events - releases_events +- milestone_events - emoji_events - resource_access_token_events ProtectedBranch: -- GitLab From a4ef74fc9c723c640d8765a1a2995fb4a1d0fb41 Mon Sep 17 00:00:00 2001 From: Alex Kozenko <9867893@gmail.com> Date: Mon, 16 Jun 2025 22:08:08 +0300 Subject: [PATCH 04/15] Add info about milestone webhooks to documentation --- doc/api/project_webhooks.md | 5 +- .../project/integrations/webhook_events.md | 58 +++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/doc/api/project_webhooks.md b/doc/api/project_webhooks.md index 612ff5516beb53..9d5173660cef8e 100644 --- a/doc/api/project_webhooks.md +++ b/doc/api/project_webhooks.md @@ -70,6 +70,7 @@ Example response: "wiki_page_events": true, "deployment_events": true, "releases_events": true, + "milestone_events": true, "feature_flag_events": true, "enable_ssl_verification": true, "repository_update_events": false, @@ -431,6 +432,7 @@ Supported attributes: | `branch_filter_strategy` | string | No | Filter push events by branch. Possible values are `wildcard` (default), `regex`, and `all_branches`. | | `push_events` | boolean | No | Trigger project webhook on push events. | | `releases_events` | boolean | No | Trigger project webhook on release events. | +| `milestone_events` | boolean | No | Trigger project webhook on milestone events. | | `tag_push_events` | boolean | No | Trigger project webhook on tag push events. | | `token` | string | No | Secret token to validate received payloads; the token isn't returned in the response. | | `wiki_page_events` | boolean | No | Trigger project webhook on wiki events. | @@ -475,6 +477,7 @@ Supported attributes: | `branch_filter_strategy` | string | No | Filter push events by branch. Possible values are `wildcard` (default), `regex`, and `all_branches`. | | `push_events` | boolean | No | Trigger project webhook on push events. | | `releases_events` | boolean | No | Trigger project webhook on release events. | +| `milestone_events` | boolean | No | Trigger project webhook on milestone events. | | `tag_push_events` | boolean | No | Trigger project webhook on tag push events. | | `token` | string | No | Secret token to validate received payloads. Not returned in the response. When you change the webhook URL, the secret token is reset and not retained. | | `wiki_page_events` | boolean | No | Trigger project webhook on wiki page events. | @@ -531,7 +534,7 @@ Supported attributes: |:----------|:------------------|:---------|:------------| | `hook_id` | integer | Yes | ID of the project webhook. | | `id` | integer or string | Yes | ID or [URL-encoded path of the project](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 response: diff --git a/doc/user/project/integrations/webhook_events.md b/doc/user/project/integrations/webhook_events.md index 6d36f967f5fcc5..a52a89081071e8 100644 --- a/doc/user/project/integrations/webhook_events.md +++ b/doc/user/project/integrations/webhook_events.md @@ -37,6 +37,7 @@ Event type | Trigger [Deployment event](#deployment-events) | A deployment starts, succeeds, fails, or is canceled. [Feature flag event](#feature-flag-events) | A feature flag is turned on or off. [Release event](#release-events) | A release is created, edited, or deleted. +[Milestone event](#milestone-events) | A milestone is created, closed, or reopened. [Emoji event](#emoji-events) | An emoji reaction is added or removed. [Project or group access token event](#project-and-group-access-token-events) | A project or group access token will expire in seven days. [Vulnerability event](#vulnerability-events) | A vulnerability is created or updated. @@ -2137,6 +2138,63 @@ Payload example: } ``` +## Milestone events + +Milestone events are triggered when a milestone is created, closed, or reopened. + +The available values for `object_attributes.action` in the payload are: + +- `create` +- `close` +- `reopen` + +Request header: + +```plaintext +X-Gitlab-Event: Milestone Hook +``` + +Payload example: + +```json +{ + "object_kind": "milestone", + "event_type": "milestone", + "project": { + "id": 1, + "name": "Gitlab Test", + "description": "Aut reprehenderit ut est.", + "web_url": "http://example.com/gitlabhq/gitlab-test", + "avatar_url": null, + "git_ssh_url": "git@example.com:gitlabhq/gitlab-test.git", + "git_http_url": "http://example.com/gitlabhq/gitlab-test.git", + "namespace": "GitlabHQ", + "visibility_level": 20, + "path_with_namespace": "gitlabhq/gitlab-test", + "default_branch": "master", + "ci_config_path": null, + "homepage": "http://example.com/gitlabhq/gitlab-test", + "url": "http://example.com/gitlabhq/gitlab-test.git", + "ssh_url": "git@example.com:gitlabhq/gitlab-test.git", + "http_url": "http://example.com/gitlabhq/gitlab-test.git" + }, + "object_attributes": { + "id": 61, + "iid": 10, + "title": "v1.0", + "description": "First stable release", + "state": "active", + "created_at": "2025-06-16 14:10:57 UTC", + "updated_at": "2025-06-16 14:10:57 UTC", + "due_date": "2025-06-30", + "start_date": "2025-06-16", + "group_id": null, + "project_id": 1 + }, + "action": "create" +} +``` + ## Emoji events {{< history >}} -- GitLab From 236889e70bd0bb3a373cf97536c750b4cc4aad5a Mon Sep 17 00:00:00 2001 From: Alex Kozenko <9867893@gmail.com> Date: Mon, 16 Jun 2025 22:14:56 +0300 Subject: [PATCH 05/15] Update gitlab.pot --- locale/gitlab.pot | 3 +++ 1 file changed, 3 insertions(+) diff --git a/locale/gitlab.pot b/locale/gitlab.pot index ef04cd43442775..952a65ae5a7f7d 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -69032,6 +69032,9 @@ msgstr "" msgid "Webhooks|A merge request is created, updated, or merged." msgstr "" +msgid "Webhooks|A milestone is created, closed, or reopened." +msgstr "" + msgid "Webhooks|A new tag is pushed to the repository." msgstr "" -- GitLab From b1ae109a721683579f289d7e476613614b3436b3 Mon Sep 17 00:00:00 2001 From: Alex Kozenko <9867893@gmail.com> Date: Tue, 17 Jun 2025 12:27:02 +0300 Subject: [PATCH 06/15] Update OpenAPI documentation --- doc/api/openapi/openapi_v2.yaml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/doc/api/openapi/openapi_v2.yaml b/doc/api/openapi/openapi_v2.yaml index a8206c01ef1985..9e8a143724190a 100644 --- a/doc/api/openapi/openapi_v2.yaml +++ b/doc/api/openapi/openapi_v2.yaml @@ -29119,6 +29119,7 @@ paths: - issues_events - job_events - merge_requests_events + - milestone_events - note_events - pipeline_events - push_events @@ -59191,6 +59192,8 @@ definitions: type: boolean releases_events: type: boolean + milestone_events: + type: boolean emoji_events: type: boolean resource_access_token_events: @@ -59249,6 +59252,9 @@ definitions: releases_events: type: boolean description: Trigger hook on release events + milestone_events: + type: boolean + description: Trigger hook on milestone events emoji_events: type: boolean description: Trigger hook on emoji events @@ -59366,6 +59372,9 @@ definitions: releases_events: type: boolean description: Trigger hook on release events + milestone_events: + type: boolean + description: Trigger hook on milestone events emoji_events: type: boolean description: Trigger hook on emoji events -- GitLab From 49a897a9c427cd6266c8d05e6fdea242943779de Mon Sep 17 00:00:00 2001 From: Alex Kozenko <9867893@gmail.com> Date: Tue, 17 Jun 2025 12:33:37 +0300 Subject: [PATCH 07/15] Polish up milestone services specs --- app/services/milestones/base_service.rb | 1 + spec/services/milestones/close_service_spec.rb | 8 ++++---- spec/services/milestones/create_service_spec.rb | 12 +++++------- spec/services/milestones/reopen_service_spec.rb | 8 ++++---- 4 files changed, 14 insertions(+), 15 deletions(-) diff --git a/app/services/milestones/base_service.rb b/app/services/milestones/base_service.rb index 5be51043f5b297..1b3aa1ef777243 100644 --- a/app/services/milestones/base_service.rb +++ b/app/services/milestones/base_service.rb @@ -5,6 +5,7 @@ class BaseService < ::BaseService attr_accessor :parent, :current_user, :params def initialize(parent, user, params = {}) + # Parent can either a group or a project @parent = parent @current_user = user @params = params.dup diff --git a/spec/services/milestones/close_service_spec.rb b/spec/services/milestones/close_service_spec.rb index 0cb302027dc0f4..6eb327d01ea2be 100644 --- a/spec/services/milestones/close_service_spec.rb +++ b/spec/services/milestones/close_service_spec.rb @@ -30,22 +30,22 @@ end context 'when milestone is successfully closed' do - it 'executes hooks with close action' do + it 'executes hooks with close action and creates new event' do new_milestone = create(:milestone, project: project) expect(service).to receive(:execute_hooks).with(new_milestone, 'close') - service.execute(new_milestone) + expect { service.execute(new_milestone) }.to change { Event.count }.by(1) end end context 'when milestone fails to close' do - it 'does not execute hooks' do + it 'does not execute hooks and does not create new event' do closed_milestone = create(:milestone, :closed, project: project) expect(service).not_to receive(:execute_hooks) - service.execute(closed_milestone) + expect { service.execute(closed_milestone) }.not_to change { Event.count } end end end diff --git a/spec/services/milestones/create_service_spec.rb b/spec/services/milestones/create_service_spec.rb index 34f449a9135877..2f8c1df09a587e 100644 --- a/spec/services/milestones/create_service_spec.rb +++ b/spec/services/milestones/create_service_spec.rb @@ -30,10 +30,10 @@ expect(milestone.description).to eq('Description') end - it 'executes hooks with create action' do + it 'executes hooks with create action and creates new event' do expect(create_milestone).to receive(:execute_hooks).with(kind_of(Milestone), 'create') - create_milestone.execute + expect { create_milestone.execute }.to change { Event.count }.by(1) end end @@ -54,10 +54,10 @@ create_milestone.execute end - it 'does not execute hooks' do + it 'does not execute hooks and does not create new event' do expect(create_milestone).not_to receive(:execute_hooks) - create_milestone.execute + expect { create_milestone.execute }.not_to change { Event.count } end it 'returns the unsaved milestone' do @@ -92,7 +92,7 @@ create_milestone.send(:execute_hooks, milestone, 'create') end - it 'only executes hooks for project milestones' do + it 'only executes active hooks for project milestones' do allow(milestone).to receive(:project_milestone?).and_return(true) expect(project).to receive(:has_active_hooks?).with(:milestone_hooks).and_return(true) @@ -119,8 +119,6 @@ let(:group_service) { described_class.new(group, user, params) } it 'does not execute hooks for group milestones' do - allow(group_milestone).to receive(:project_milestone?).and_return(false) - expect(group).not_to receive(:execute_hooks) group_service.send(:execute_hooks, group_milestone, 'create') diff --git a/spec/services/milestones/reopen_service_spec.rb b/spec/services/milestones/reopen_service_spec.rb index c9db97a94a7279..ceded7275fabf5 100644 --- a/spec/services/milestones/reopen_service_spec.rb +++ b/spec/services/milestones/reopen_service_spec.rb @@ -30,22 +30,22 @@ end context 'when milestone is successfully reopened' do - it 'executes hooks with reopen action' do + it 'executes hooks with reopen action and creates new event' do closed_milestone = create(:milestone, :closed, project: project) expect(service).to receive(:execute_hooks).with(closed_milestone, 'reopen') - service.execute(closed_milestone) + expect { service.execute(closed_milestone) }.to change { Event.count }.by(1) end end context 'when milestone fails to reopen' do - it 'does not execute hooks' do + it 'does not execute hooks and does not create new event' do active_milestone = create(:milestone, project: project) expect(service).not_to receive(:execute_hooks) - service.execute(active_milestone) + expect { service.execute(active_milestone) }.not_to change { Event.count } end end end -- GitLab From e0485eff11af1a435f4509971ddca6898c3e34d6 Mon Sep 17 00:00:00 2001 From: Alex Kozenko <9867893@gmail.com> Date: Tue, 17 Jun 2025 13:30:56 +0300 Subject: [PATCH 08/15] Apply Danger Bot suggestions --- spec/lib/gitlab/data_builder/milestone_spec.rb | 4 ++-- spec/services/milestones/reopen_service_spec.rb | 7 ++++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/spec/lib/gitlab/data_builder/milestone_spec.rb b/spec/lib/gitlab/data_builder/milestone_spec.rb index ed750f74835477..8eb13c8ac42811 100644 --- a/spec/lib/gitlab/data_builder/milestone_spec.rb +++ b/spec/lib/gitlab/data_builder/milestone_spec.rb @@ -89,7 +89,7 @@ context 'when project has existing milestones' do subject(:data) { described_class.build_sample(project) } - let!(:existing_milestone) { create(:milestone, project: project) } + let_it_be(:existing_milestone) { create(:milestone, project: project) } it_behaves_like 'builds milestone hook data' @@ -108,7 +108,7 @@ context 'when project has no milestones' do subject(:data) { described_class.build_sample(clean_project) } - let(:clean_project) { create(:project, :repository) } + let_it_be(:clean_project) { create(:project) } it_behaves_like 'builds milestone hook data' diff --git a/spec/services/milestones/reopen_service_spec.rb b/spec/services/milestones/reopen_service_spec.rb index ceded7275fabf5..199ad404bc7a8f 100644 --- a/spec/services/milestones/reopen_service_spec.rb +++ b/spec/services/milestones/reopen_service_spec.rb @@ -3,11 +3,12 @@ require 'spec_helper' RSpec.describe Milestones::ReopenService, feature_category: :team_planning do - let(:user) { create(:user) } - let(:project) { create(:project) } + let_it_be(:project) { create(:project) } + let_it_be(:user) { create(:user) } + let(:milestone) { create(:milestone, :closed, title: "Milestone v1.2", project: project) } - before do + before_all do project.add_maintainer(user) end -- GitLab From 73e8c861425473ae5523bb1e435b96bea3cab868 Mon Sep 17 00:00:00 2001 From: Alex Kozenko <9867893@gmail.com> Date: Tue, 17 Jun 2025 16:01:19 +0300 Subject: [PATCH 09/15] Apply GitLabDuo suggestion - add comment --- app/services/milestones/base_service.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/app/services/milestones/base_service.rb b/app/services/milestones/base_service.rb index 1b3aa1ef777243..ecfe7db325e514 100644 --- a/app/services/milestones/base_service.rb +++ b/app/services/milestones/base_service.rb @@ -16,6 +16,7 @@ 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) -- GitLab From c6b1b33172899896223e5452ec2563edb7121605 Mon Sep 17 00:00:00 2001 From: Alex Kozenko <9867893@gmail.com> Date: Fri, 20 Jun 2025 17:01:26 +0300 Subject: [PATCH 10/15] Apply CR suggestions for milestone webhooks --- ...62310_add_milestone_events_to_web_hooks.rb | 2 +- .../services/milestones/close_service_spec.rb | 90 ++++++++---------- .../milestones/create_service_spec.rb | 80 ++++++---------- .../milestones/reopen_service_spec.rb | 95 ++++++++----------- 4 files changed, 105 insertions(+), 162 deletions(-) diff --git a/db/migrate/20250613162310_add_milestone_events_to_web_hooks.rb b/db/migrate/20250613162310_add_milestone_events_to_web_hooks.rb index bf8b24e3e399f7..0357de580ff518 100644 --- a/db/migrate/20250613162310_add_milestone_events_to_web_hooks.rb +++ b/db/migrate/20250613162310_add_milestone_events_to_web_hooks.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class AddMilestoneEventsToWebHooks < Gitlab::Database::Migration[2.3] - milestone '18.1' + milestone '18.2' def change add_column :web_hooks, :milestone_events, :boolean, null: false, default: false diff --git a/spec/services/milestones/close_service_spec.rb b/spec/services/milestones/close_service_spec.rb index 6eb327d01ea2be..2c0caa38bb6ec3 100644 --- a/spec/services/milestones/close_service_spec.rb +++ b/spec/services/milestones/close_service_spec.rb @@ -14,84 +14,68 @@ describe '#execute' do let(:service) { described_class.new(project, user, {}) } - before do - service.execute(milestone) - end + context 'when service is called before test suite' do + before do + service.execute(milestone) + end - it { expect(milestone).to be_valid } - it { expect(milestone).to be_closed } + it { expect(milestone).to be_valid } + it { expect(milestone).to be_closed } - describe 'event' do - let(:event) { Event.recent.first } + describe 'event' do + let(:event) { Event.recent.first } - it { expect(event.milestone).to be_truthy } - it { expect(event.target).to eq(milestone) } - it { expect(event.action_name).to eq('closed') } + it { expect(event.milestone).to be_truthy } + it { expect(event.target).to eq(milestone) } + it { expect(event.action_name).to eq('closed') } + end end - context 'when milestone is successfully closed' do + shared_examples 'closes the milestone' do |with_project_hooks:| it 'executes hooks with close action and creates new event' do - new_milestone = create(:milestone, project: project) - - expect(service).to receive(:execute_hooks).with(new_milestone, 'close') + 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 { service.execute(new_milestone) }.to change { Event.count }.by(1) + expect { service.execute(milestone) }.to change { Event.count }.by(1) end end - context 'when milestone fails to close' do + shared_examples 'does not close the milestone' do it 'does not execute hooks and does not create new event' do - closed_milestone = create(:milestone, :closed, project: project) - expect(service).not_to receive(:execute_hooks) - expect { service.execute(closed_milestone) }.not_to change { Event.count } + expect { service.execute(milestone) }.not_to change { Event.count } end end - end - - describe 'webhook execution' do - let(:service) { described_class.new(project, user, {}) } - context 'when project has active milestone hooks' do - before do - create(:project_hook, project: project, milestone_events: true) + 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) + end + end + + it_behaves_like 'closes the milestone', with_project_hooks: true end - it 'executes milestone hooks with correct payload when conditions are met' do - allow(milestone).to receive(:project_milestone?).and_return(true) - allow(project).to receive(:has_active_hooks?).with(:milestone_hooks).and_return(true) - - expected_payload = Gitlab::DataBuilder::Milestone.build(milestone, 'close') - - expect(project).to receive(:execute_hooks).with(expected_payload, :milestone_hooks) - - service.send(:execute_hooks, milestone, 'close') + context 'when project has no active milestone hooks' do + it_behaves_like 'closes the milestone', with_project_hooks: false end end - context 'when project has no active milestone hooks' do - it 'does not execute hooks when no active hooks' do - allow(milestone).to receive(:project_milestone?).and_return(true) - allow(project).to receive(:has_active_hooks?).with(:milestone_hooks).and_return(false) - - expect(project).not_to receive(:execute_hooks) + context 'when milestone fails to close' do + context 'when milestone is already closed' do + let(:milestone) { create(:milestone, :closed, project: project) } - service.send(:execute_hooks, milestone, 'close') + it_behaves_like 'does not close the milestone' end - end - - context 'when milestone is a group milestone' do - let(:group) { create(:group) } - let(:group_milestone) { create(:milestone, group: group) } - let(:group_service) { described_class.new(group, user, {}) } - - it 'does not execute hooks for group milestones' do - allow(group_milestone).to receive(:project_milestone?).and_return(false) - expect(group).not_to receive(:execute_hooks) + context 'when milestone is a group milestone' do + let(:group) { create(:group) } + let(:milestone) { create(:milestone, group: group) } - group_service.send(:execute_hooks, group_milestone, 'close') + it_behaves_like 'does not close the milestone' end end end diff --git a/spec/services/milestones/create_service_spec.rb b/spec/services/milestones/create_service_spec.rb index 2f8c1df09a587e..1999b1d40392bb 100644 --- a/spec/services/milestones/create_service_spec.rb +++ b/spec/services/milestones/create_service_spec.rb @@ -30,10 +30,27 @@ expect(milestone.description).to eq('Description') end - it 'executes hooks with create action and creates new event' do - expect(create_milestone).to receive(:execute_hooks).with(kind_of(Milestone), 'create') + shared_examples 'creates the milestone' do |with_project_hooks:| + 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 { create_milestone.execute }.to change { Event.count }.by(1) + expect { create_milestone.execute }.to change { Event.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) + end + end + + it_behaves_like 'creates the milestone', with_project_hooks: true + end + + context 'when project has no active milestone hooks' do + it_behaves_like 'creates the milestone', with_project_hooks: false end end @@ -68,62 +85,25 @@ end end - it 'calls before_create method' do - expect(create_milestone).to receive(:before_create) - create_milestone.execute - end - end - - describe 'webhook execution' do - let(:milestone) { create(:milestone, project: project) } - - context 'when project has active milestone hooks' do - before do - create(:project_hook, project: project, milestone_events: true) - end - - it 'executes milestone hooks with correct payload when conditions are met' do - allow(milestone).to receive(:project_milestone?).and_return(true) - allow(project).to receive(:has_active_hooks?).with(:milestone_hooks).and_return(true) - expected_payload = Gitlab::DataBuilder::Milestone.build(milestone, 'create') - - expect(project).to receive(:execute_hooks).with(expected_payload, :milestone_hooks) - - create_milestone.send(:execute_hooks, milestone, 'create') - end - - it 'only executes active hooks for project milestones' do - allow(milestone).to receive(:project_milestone?).and_return(true) - - expect(project).to receive(:has_active_hooks?).with(:milestone_hooks).and_return(true) - expect(project).to receive(:execute_hooks) - - create_milestone.send(:execute_hooks, milestone, 'create') - end - end - - context 'when project has no active milestone hooks' do - it 'does not execute hooks when no active hooks' do - allow(milestone).to receive(:project_milestone?).and_return(true) - allow(project).to receive(:has_active_hooks?).with(:milestone_hooks).and_return(false) - - expect(project).not_to receive(:execute_hooks) - - create_milestone.send(:execute_hooks, milestone, 'create') - end - end - context 'when milestone is a group milestone' do let(:group) { create(:group) } - let(:group_milestone) { create(:milestone, group: 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.send(:execute_hooks, group_milestone, 'create') + group_service.execute end end + + it 'calls before_create method' do + expect(create_milestone).to receive(:before_create) + create_milestone.execute + end end describe '#before_create' do diff --git a/spec/services/milestones/reopen_service_spec.rb b/spec/services/milestones/reopen_service_spec.rb index 199ad404bc7a8f..fc7874cbc84fb5 100644 --- a/spec/services/milestones/reopen_service_spec.rb +++ b/spec/services/milestones/reopen_service_spec.rb @@ -15,91 +15,70 @@ describe '#execute' do let(:service) { described_class.new(project, user, {}) } - before do - service.execute(milestone) - end + context 'when service is called before test suite' do + before do + service.execute(milestone) + end - it { expect(milestone).to be_valid } - it { expect(milestone).to be_active } + it { expect(milestone).to be_valid } + it { expect(milestone).to be_active } - describe 'event' do - let(:event) { Event.recent.first } + describe 'event' do + let(:event) { Event.recent.first } - it { expect(event.milestone).to be_truthy } - it { expect(event.target).to eq(milestone) } - it { expect(event.action_name).to eq('opened') } + it { expect(event.milestone).to be_truthy } + it { expect(event.target).to eq(milestone) } + it { expect(event.action_name).to eq('opened') } + end end - context 'when milestone is successfully reopened' do + shared_examples 'reopens the milestone' do |with_project_hooks:| it 'executes hooks with reopen action and creates new event' do - closed_milestone = create(:milestone, :closed, project: project) - - expect(service).to receive(:execute_hooks).with(closed_milestone, 'reopen') + 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 { service.execute(closed_milestone) }.to change { Event.count }.by(1) + expect { service.execute(milestone) }.to change { Event.count }.by(1) end end - context 'when milestone fails to reopen' do + shared_examples 'does not reopen the milestone' do it 'does not execute hooks and does not create new event' do - active_milestone = create(:milestone, project: project) - expect(service).not_to receive(:execute_hooks) - expect { service.execute(active_milestone) }.not_to change { Event.count } + expect { service.execute(milestone) }.not_to change { Event.count } end end - end - - describe 'webhook execution' do - let(:service) { described_class.new(project, user, {}) } - context 'when project has active milestone hooks' do - before do - create(:project_hook, project: project, milestone_events: true) - end - - it 'executes milestone hooks with correct payload when conditions are met' do - allow(milestone).to receive(:project_milestone?).and_return(true) - allow(project).to receive(:has_active_hooks?).with(:milestone_hooks).and_return(true) - expected_payload = Gitlab::DataBuilder::Milestone.build(milestone, 'reopen') + context 'when milestone is successfully reopened' do + let(:milestone) { create(:milestone, :closed, project: project) } - expect(project).to receive(:execute_hooks).with(expected_payload, :milestone_hooks) + 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 - service.send(:execute_hooks, milestone, 'reopen') + it_behaves_like 'reopens the milestone', with_project_hooks: true end - it 'only executes hooks for project milestones' do - allow(milestone).to receive(:project_milestone?).and_return(true) - expect(project).to receive(:has_active_hooks?).with(:milestone_hooks).and_return(true) - expect(project).to receive(:execute_hooks) - - service.send(:execute_hooks, milestone, 'reopen') + context 'when project has no active milestone hooks' do + it_behaves_like 'reopens the milestone', with_project_hooks: false end end - context 'when project has no active milestone hooks' do - it 'does not execute hooks when no active hooks' do - allow(milestone).to receive(:project_milestone?).and_return(true) - allow(project).to receive(:has_active_hooks?).with(:milestone_hooks).and_return(false) - - expect(project).not_to receive(:execute_hooks) + context 'when milestone fails to reopen' do + context 'when milestone is already active' do + let(:milestone) { create(:milestone, project: project) } - service.send(:execute_hooks, milestone, 'reopen') + it_behaves_like 'does not reopen the milestone' end - end - - context 'when milestone is a group milestone' do - let(:group) { create(:group) } - let(:group_milestone) { create(:milestone, :closed, group: group) } - let(:group_service) { described_class.new(group, user, {}) } - - it 'does not execute hooks for group milestones' do - allow(group_milestone).to receive(:project_milestone?).and_return(false) - expect(group).not_to receive(:execute_hooks) + context 'when milestone is a group milestone' do + let(:group) { create(:group) } + let(:milestone) { create(:milestone, :closed, group: group) } - group_service.send(:execute_hooks, group_milestone, 'reopen') + it_behaves_like 'does not reopen the milestone' end end end -- GitLab From 7ed2e400a49a43f52b2cf94c4e8437d3d38dc32d Mon Sep 17 00:00:00 2001 From: Alex Kozenko <9867893@gmail.com> Date: Wed, 25 Jun 2025 12:41:07 +0300 Subject: [PATCH 11/15] Add on-delete milestone-webhook --- app/services/milestones/destroy_service.rb | 1 + app/views/shared/web_hooks/_form.html.haml | 2 +- doc/user/project/integrations/webhook_events.md | 4 ++-- locale/gitlab.pot | 2 +- spec/services/milestones/destroy_service_spec.rb | 8 ++++++-- 5 files changed, 11 insertions(+), 6 deletions(-) diff --git a/app/services/milestones/destroy_service.rb b/app/services/milestones/destroy_service.rb index 6966764634f1ec..dc605472e37bf0 100644 --- a/app/services/milestones/destroy_service.rb +++ b/app/services/milestones/destroy_service.rb @@ -14,6 +14,7 @@ def execute(milestone) return unless milestone.destroyed? + execute_hooks(milestone, 'delete') if milestone.project_milestone? milestone end diff --git a/app/views/shared/web_hooks/_form.html.haml b/app/views/shared/web_hooks/_form.html.haml index 7c7a60adcc829b..e8106aa9c6fb09 100644 --- a/app/views/shared/web_hooks/_form.html.haml +++ b/app/views/shared/web_hooks/_form.html.haml @@ -61,7 +61,7 @@ %li.gl-pb-3 = form.gitlab_ui_checkbox_component :milestone_events, integration_webhook_event_human_name(:milestone_events), - help_text: s_('Webhooks|A milestone is created, closed, or reopened.') + help_text: s_('Webhooks|A milestone is created, closed, reopened or deleted.') %li.gl-pb-3 - emoji_help_link = link_to s_('Which emoji events trigger webhooks'), help_page_path('user/project/integrations/webhook_events.md', anchor: 'emoji-events') = form.gitlab_ui_checkbox_component :emoji_events, diff --git a/doc/user/project/integrations/webhook_events.md b/doc/user/project/integrations/webhook_events.md index febda63c8a6e23..fe613dcefcd9f1 100644 --- a/doc/user/project/integrations/webhook_events.md +++ b/doc/user/project/integrations/webhook_events.md @@ -37,7 +37,7 @@ Event type | Trigger [Deployment event](#deployment-events) | A deployment starts, succeeds, fails, or is canceled. [Feature flag event](#feature-flag-events) | A feature flag is turned on or off. [Release event](#release-events) | A release is created, edited, or deleted. -[Milestone event](#milestone-events) | A milestone is created, closed, or reopened. +[Milestone event](#milestone-events) | A milestone is created, closed, reopened or deleted. [Emoji event](#emoji-events) | An emoji reaction is added or removed. [Project or group access token event](#project-and-group-access-token-events) | A project or group access token will expire in seven days. [Vulnerability event](#vulnerability-events) | A vulnerability is created or updated. @@ -2140,7 +2140,7 @@ Payload example: ## Milestone events -Milestone events are triggered when a milestone is created, closed, or reopened. +Milestone events are triggered when a milestone is created, closed, reopened, or deleted. The available values for `object_attributes.action` in the payload are: diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 80e837e30b18b5..620af8c2ba80f3 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -69166,7 +69166,7 @@ msgstr "" msgid "Webhooks|A merge request is created, updated, or merged." msgstr "" -msgid "Webhooks|A milestone is created, closed, or reopened." +msgid "Webhooks|A milestone is created, closed, reopened or deleted." msgstr "" msgid "Webhooks|A new tag is pushed to the repository." diff --git a/spec/services/milestones/destroy_service_spec.rb b/spec/services/milestones/destroy_service_spec.rb index fd276a54e10111..c76edd2648c361 100644 --- a/spec/services/milestones/destroy_service_spec.rb +++ b/spec/services/milestones/destroy_service_spec.rb @@ -48,7 +48,9 @@ it_behaves_like 'deletes milestone id from issuables' - it 'logs destroy event' do + it 'logs destroy event and runs on-delete webhook' do + expect(service).to receive(:execute_hooks).with(milestone, 'delete') + service.execute(milestone) event = Event.where(project_id: milestone.project_id, target_type: 'Milestone') @@ -84,7 +86,9 @@ it_behaves_like 'deletes milestone id from issuables' - it 'does not log destroy event' do + it 'does not log destroy event and does not run on-delete webhook' do + expect(service).not_to receive(:execute_hooks).with(milestone, 'delete') + expect { service.execute(milestone) }.not_to change { Event.count } end end -- GitLab From ab989770a1160ba1bf60989844212b6a067a7859 Mon Sep 17 00:00:00 2001 From: Alex Kozenko <9867893@gmail.com> Date: Thu, 26 Jun 2025 12:52:05 +0300 Subject: [PATCH 12/15] Add test case for milestone-event to the Integrations::Test::ProjectService specs --- spec/services/integrations/test/project_service_spec.rb | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/spec/services/integrations/test/project_service_spec.rb b/spec/services/integrations/test/project_service_spec.rb index 4f8f932fb45c76..b23e0671e49eb6 100644 --- a/spec/services/integrations/test/project_service_spec.rb +++ b/spec/services/integrations/test/project_service_spec.rb @@ -41,6 +41,15 @@ end end + context 'milestone' do + let(:event) { 'milestone' } + + it 'returns error message that testing is not available for this event' do + expect(integration).not_to receive(:test) + expect(subject).to include({ status: :error, message: 'Testing not available for this event' }) + end + end + context 'push' do let(:event) { 'push' } -- GitLab From 48f4472e8495ba220e60b69e493c94782667cfbf Mon Sep 17 00:00:00 2001 From: Alex Kozenko <9867893@gmail.com> Date: Fri, 27 Jun 2025 10:30:48 +0300 Subject: [PATCH 13/15] Update documentation for milestone webhook --- app/views/shared/web_hooks/_form.html.haml | 2 +- doc/user/project/integrations/webhook_events.md | 8 +++++++- locale/gitlab.pot | 2 +- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/app/views/shared/web_hooks/_form.html.haml b/app/views/shared/web_hooks/_form.html.haml index e8106aa9c6fb09..3be7461410d8bb 100644 --- a/app/views/shared/web_hooks/_form.html.haml +++ b/app/views/shared/web_hooks/_form.html.haml @@ -61,7 +61,7 @@ %li.gl-pb-3 = form.gitlab_ui_checkbox_component :milestone_events, integration_webhook_event_human_name(:milestone_events), - help_text: s_('Webhooks|A milestone is created, closed, reopened or deleted.') + help_text: s_('Webhooks|A milestone is created, closed, reopened, or deleted.') %li.gl-pb-3 - emoji_help_link = link_to s_('Which emoji events trigger webhooks'), help_page_path('user/project/integrations/webhook_events.md', anchor: 'emoji-events') = form.gitlab_ui_checkbox_component :emoji_events, diff --git a/doc/user/project/integrations/webhook_events.md b/doc/user/project/integrations/webhook_events.md index fe613dcefcd9f1..a32c9ed85a79eb 100644 --- a/doc/user/project/integrations/webhook_events.md +++ b/doc/user/project/integrations/webhook_events.md @@ -37,7 +37,7 @@ Event type | Trigger [Deployment event](#deployment-events) | A deployment starts, succeeds, fails, or is canceled. [Feature flag event](#feature-flag-events) | A feature flag is turned on or off. [Release event](#release-events) | A release is created, edited, or deleted. -[Milestone event](#milestone-events) | A milestone is created, closed, reopened or deleted. +[Milestone event](#milestone-events) | A milestone is created, closed, reopened, or deleted. [Emoji event](#emoji-events) | An emoji reaction is added or removed. [Project or group access token event](#project-and-group-access-token-events) | A project or group access token will expire in seven days. [Vulnerability event](#vulnerability-events) | A vulnerability is created or updated. @@ -2140,6 +2140,12 @@ Payload example: ## Milestone events +{{< history >}} + +- [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/14213) in GitLab 18.2. + +{{< /history >}} + Milestone events are triggered when a milestone is created, closed, reopened, or deleted. The available values for `object_attributes.action` in the payload are: diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 9aadd8084ee7f0..d4f32edb27034f 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -69269,7 +69269,7 @@ msgstr "" msgid "Webhooks|A merge request is created, updated, or merged." msgstr "" -msgid "Webhooks|A milestone is created, closed, reopened or deleted." +msgid "Webhooks|A milestone is created, closed, reopened, or deleted." msgstr "" msgid "Webhooks|A new tag is pushed to the repository." -- GitLab From 2f81dfe0a6197baaec7704b2f5a4eb3f0a9cd4db Mon Sep 17 00:00:00 2001 From: Alex Kozenko <9867893@gmail.com> Date: Mon, 30 Jun 2025 15:21:06 +0300 Subject: [PATCH 14/15] Modify spec for milestone-webhook integrations --- .../integrations/test/project_service_spec.rb | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/spec/services/integrations/test/project_service_spec.rb b/spec/services/integrations/test/project_service_spec.rb index b23e0671e49eb6..cc88a815f65b45 100644 --- a/spec/services/integrations/test/project_service_spec.rb +++ b/spec/services/integrations/test/project_service_spec.rb @@ -44,9 +44,18 @@ context 'milestone' do let(:event) { 'milestone' } - it 'returns error message that testing is not available for this event' do - expect(integration).not_to receive(:test) - expect(subject).to include({ status: :error, message: 'Testing not available for this event' }) + before do + # Mock the integration to support milestone events for testing + allow(integration).to receive(:supported_events).and_return(integration.supported_events + ['milestone']) + end + + it 'executes integration' do + milestone = create(:milestone, project: project) + allow(Gitlab::DataBuilder::Milestone).to receive(:build).and_return(sample_data) + allow_next(MilestonesFinder).to receive(:execute).and_return([milestone]) + + expect(integration).to receive(:test).with(sample_data).and_return(success_result) + expect(subject).to eq(success_result) end end -- GitLab From 0556f54017414514d15b4317d756e5e7b58776fb Mon Sep 17 00:00:00 2001 From: Alex Kozenko <9867893@gmail.com> Date: Tue, 1 Jul 2025 12:52:54 +0300 Subject: [PATCH 15/15] Remove redundunt rubocop:enable instruction --- app/services/integrations/test/project_service.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/services/integrations/test/project_service.rb b/app/services/integrations/test/project_service.rb index ddba08e388231d..3334b241dddae6 100644 --- a/app/services/integrations/test/project_service.rb +++ b/app/services/integrations/test/project_service.rb @@ -43,7 +43,7 @@ def data # rubocop:disable Metrics/CyclomaticComplexity -- despite a high count current_user_events_data end end - end # rubocop:enable Metrics/CyclomaticComplexity + end end end end -- GitLab