diff --git a/app/models/concerns/triggerable_hooks.rb b/app/models/concerns/triggerable_hooks.rb index 64e4a3311dc91c7689da20ab436273d30dafcc95..a892593946b640ee6373842e75c47ad16ffc3163 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 1226a76433bfafd9cd8a51f1733589f27002fd62..c15482d071eaae339b4d1f00d863f06450f80aec 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 358b479476489fa4d100e2d5205c5f695370de4c..7bdbdf49f7c59a0b4bfa7f32527e7663e4314de0 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 185b57a1384555d0d303fd7c2ed64a9596e57d02..f71cf87832ca43cabb72b9a9da6d4eab8842eb64 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 1e077d49e5a0cefaf5476d4c3c223f3d95b81788..3334b241dddae61f5446d0641ae937e56b517b60 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,6 +35,8 @@ def data deployment_events_data when 'release' releases_events_data + when 'milestone' + milestone_events_data when 'award_emoji' emoji_events_data when 'current_user' diff --git a/app/services/milestones/base_service.rb b/app/services/milestones/base_service.rb index 0d7d855bf5ed3bf3f73b0b831b909514ed45bca9..ecfe7db325e51436c3c0b570de8f195ac40a4155 100644 --- a/app/services/milestones/base_service.rb +++ b/app/services/milestones/base_service.rb @@ -2,14 +2,26 @@ 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 can either a group or a project @parent = parent @current_user = user @params = params.dup + super end + + 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) + 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 a252f5c144ea142896ea1de1aeda811e8c78644b..616c6d85b7c55a1cbebaa7ec5cb423933a02e5f7 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 e8a14adc10dd2dc4322a64ea8488f99d0c74f941..d8a9be6aafebd451d58d26b0e4dac489caba9118 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/destroy_service.rb b/app/services/milestones/destroy_service.rb index 6966764634f1ecaea43c2258fcd2c356ffe13d9a..dc605472e37bf0b10b820376c31c9baef1a6a0fe 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/services/milestones/reopen_service.rb b/app/services/milestones/reopen_service.rb index 125a3ec1367654c174f5f1c8d0b810653cb1f2c8..a4a9ccfd4011dc5371cea76c465bbabd59f6cebb 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 b183210edb3b547c75716d32d0004769330eb4d5..25c81c3a49890775975ea787ce9cc5256dc2ac5e 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 a3c503c9efcdc4e5c27f1b57a3f5046c7a9c8dca..3be7461410d8bbb49eedb28f255d4290255f872b 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, 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/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 0000000000000000000000000000000000000000..0357de580ff5185decdac8faef3f76a53403bb57 --- /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.2' + + 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 0000000000000000000000000000000000000000..16d68416d52b0133a6261f914aa49be9456c5847 --- /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 02d98b13a2c0226ca680234760e5f266a0324fd5..86bb2cbe6f4c9c628522d3d550b4ed1f2b0ec56f 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -26011,6 +26011,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)) diff --git a/doc/api/openapi/openapi_v2.yaml b/doc/api/openapi/openapi_v2.yaml index d9675e4d8daaef41e4fbb89aa3011ef35370c40f..fbf92bfa947b448c12fcca8cc56fe378d778bb08 100644 --- a/doc/api/openapi/openapi_v2.yaml +++ b/doc/api/openapi/openapi_v2.yaml @@ -29125,6 +29125,7 @@ paths: - issues_events - job_events - merge_requests_events + - milestone_events - note_events - pipeline_events - push_events @@ -59246,6 +59247,8 @@ definitions: type: boolean releases_events: type: boolean + milestone_events: + type: boolean emoji_events: type: boolean resource_access_token_events: @@ -59304,6 +59307,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 @@ -59421,6 +59427,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 diff --git a/doc/api/project_webhooks.md b/doc/api/project_webhooks.md index 0918a17b4620176fb20172764ce0d7de07d7c730..52279498140225aa923ead774beb6a59ba3e60df 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 f8b86b3ca97dd9afb0648af1935a29f7c62b3506..c5efc1b0621521e1a4e97095e14d644b16174281 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, 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. @@ -2137,6 +2138,69 @@ 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: + +- `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 >}} diff --git a/lib/api/entities/project_hook.rb b/lib/api/entities/project_hook.rb index a1ede6fe277176cc5cbc1d5dbf16029ec2212dee..b81720fa28f7fb38964332cda75c41ad423618b1 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 ecd32e9155f84925e4baf47e0345de1b75980969..ceffa33a63dcc32093d2104e81267bd04224ad44 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/lib/gitlab/data_builder/milestone.rb b/lib/gitlab/data_builder/milestone.rb new file mode 100644 index 0000000000000000000000000000000000000000..6f6e269c082513e6703b85d72261c9b2827ab0c2 --- /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 0000000000000000000000000000000000000000..f2022a956eb25b685cf5a794ed87c340b7c5dda2 --- /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/locale/gitlab.pot b/locale/gitlab.pot index d98c291e85dd788d6b31a5abae93f614c26fdd91..b1c1504ee5bea6274e2f3c41f2cb66b4082713b5 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -69501,6 +69501,9 @@ msgstr "" msgid "Webhooks|A merge request is created, updated, or merged." msgstr "" +msgid "Webhooks|A milestone is created, closed, reopened, or deleted." +msgstr "" + msgid "Webhooks|A new tag is pushed to the repository." msgstr "" diff --git a/spec/factories/project_hooks.rb b/spec/factories/project_hooks.rb index 482cec1195d5cefc576907d38f773bfe66578adb..56959e051f828d81369062a07655e6ab621f77b5 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 062bcf907960c7e2cd4b4d917d769242d3b3678f..f1f824a8d657e4656f75bc154f944a38a8074623 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/fixtures/api/schemas/public_api/v4/project_hook.json b/spec/fixtures/api/schemas/public_api/v4/project_hook.json index 238f5839f77dfbbc46cec36b19723c4d4f27227d..fb9cc71728868576a5c514da83274b814159d04b 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/data_builder/milestone_spec.rb b/spec/lib/gitlab/data_builder/milestone_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..8eb13c8ac42811e692d15e5ebda2f4f673253037 --- /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_it_be(: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_it_be(:clean_project) { create(:project) } + + 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 0000000000000000000000000000000000000000..ef26dc9dd5d339bd58a03c59b67bab931298da52 --- /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/lib/gitlab/import_export/project/relation_factory_spec.rb b/spec/lib/gitlab/import_export/project/relation_factory_spec.rb index f898b18e20035dbb6d3aeb87294fe92e55ec8f59..439fedd3243226ffe683122cd393370f9bab2532 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 516c94336f4ed4563cc8f80fa32888d6f6f29a05..a85a4805f0c27c72b271b9b56768cb9505fe4736 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: diff --git a/spec/services/integrations/test/project_service_spec.rb b/spec/services/integrations/test/project_service_spec.rb index 4f8f932fb45c76e8f395e651d301342f023a4bbb..cc88a815f65b45349d8fa2d7d297977c9bc66f25 100644 --- a/spec/services/integrations/test/project_service_spec.rb +++ b/spec/services/integrations/test/project_service_spec.rb @@ -41,6 +41,24 @@ end end + context 'milestone' do + let(:event) { 'milestone' } + + 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 + context 'push' do let(:event) { 'push' } diff --git a/spec/services/milestones/close_service_spec.rb b/spec/services/milestones/close_service_spec.rb index f362c8da6423d8a8dd28bba579809fcf4713e2c2..2c0caa38bb6ec3436612cffb0af59fd218f654da 100644 --- a/spec/services/milestones/close_service_spec.rb +++ b/spec/services/milestones/close_service_spec.rb @@ -12,19 +12,71 @@ end describe '#execute' do - before do - described_class.new(project, user, {}).execute(milestone) + let(:service) { described_class.new(project, user, {}) } + + 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 } + + 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') } + end end - it { expect(milestone).to be_valid } - it { expect(milestone).to be_closed } + shared_examples 'closes the milestone' do |with_project_hooks:| + 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 { service.execute(milestone) }.to change { Event.count }.by(1) + 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 { service.execute(milestone) }.not_to change { Event.count } + 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) + end + end + + it_behaves_like 'closes the milestone', with_project_hooks: true + end + + context 'when project has no active milestone hooks' do + it_behaves_like 'closes the milestone', with_project_hooks: false + end + end + + context 'when milestone fails to close' do + context 'when milestone is already closed' do + let(:milestone) { create(:milestone, :closed, project: project) } + + it_behaves_like 'does not close the milestone' + end - describe 'event' do - let(:event) { Event.recent.first } + context 'when milestone is a group milestone' do + let(:group) { create(:group) } + let(:milestone) { create(:milestone, group: group) } - it { expect(event.milestone).to be_truthy } - it { expect(event.target).to eq(milestone) } - it { expect(event.action_name).to eq('closed') } + 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 70010d88fbd3b319bb9b91b7d5853f9daefd9bc3..1999b1d40392bb7befdea7614f188a0194150a80 100644 --- a/spec/services/milestones/create_service_spec.rb +++ b/spec/services/milestones/create_service_spec.rb @@ -29,6 +29,29 @@ expect(milestone.title).to eq('New Milestone') expect(milestone.description).to eq('Description') end + + 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) + 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 context 'when milestone fails to save' do @@ -48,6 +71,12 @@ create_milestone.execute end + it 'does not execute hooks and does not create new event' do + expect(create_milestone).not_to receive(:execute_hooks) + + expect { create_milestone.execute }.not_to change { Event.count } + end + it 'returns the unsaved milestone' do milestone = create_milestone.execute expect(milestone).to be_a(Milestone) @@ -56,6 +85,21 @@ 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 fd276a54e10111dbea5b79ae41f566a723419317..c76edd2648c361276265637c8326268b6233e839 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 diff --git a/spec/services/milestones/reopen_service_spec.rb b/spec/services/milestones/reopen_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..fc7874cbc84fb5a74965806bc0821a6dd65faec4 --- /dev/null +++ b/spec/services/milestones/reopen_service_spec.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Milestones::ReopenService, feature_category: :team_planning do + let_it_be(:project) { create(:project) } + let_it_be(:user) { create(:user) } + + let(:milestone) { create(:milestone, :closed, title: "Milestone v1.2", project: project) } + + before_all do + project.add_maintainer(user) + end + + describe '#execute' do + let(:service) { described_class.new(project, user, {}) } + + 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 } + + 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 + end + + shared_examples 'reopens the milestone' do |with_project_hooks:| + 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 { service.execute(milestone) }.to change { Event.count }.by(1) + 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 } + 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) + end + end + + it_behaves_like 'reopens the milestone', with_project_hooks: true + end + + context 'when project has no active milestone hooks' do + it_behaves_like 'reopens the milestone', with_project_hooks: false + 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 + + 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 diff --git a/spec/services/test_hooks/project_service_spec.rb b/spec/services/test_hooks/project_service_spec.rb index 1e06911f8f799cb2dd1613fc5519420e9ccd2b66..cd2bc14c0edeca1be9ef37e29d92e5fd48433161 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 }