diff --git a/.rubocop_todo/gitlab/bounded_contexts.yml b/.rubocop_todo/gitlab/bounded_contexts.yml
index 678dca94adf..5a696cc9ec3 100644
--- a/.rubocop_todo/gitlab/bounded_contexts.yml
+++ b/.rubocop_todo/gitlab/bounded_contexts.yml
@@ -141,7 +141,6 @@ Gitlab/BoundedContexts:
- 'app/graphql/mutations/alert_management/http_integration/reset_token.rb'
- 'app/graphql/mutations/alert_management/http_integration/update.rb'
- 'app/graphql/mutations/alert_management/prometheus_integration/create.rb'
- - 'app/graphql/mutations/alert_management/prometheus_integration/prometheus_integration_base.rb'
- 'app/graphql/mutations/alert_management/prometheus_integration/reset_token.rb'
- 'app/graphql/mutations/alert_management/prometheus_integration/update.rb'
- 'app/graphql/mutations/alert_management/update_alert_status.rb'
diff --git a/.rubocop_todo/graphql/graphql_name.yml b/.rubocop_todo/graphql/graphql_name.yml
index b03e1202663..b9eabed7f56 100644
--- a/.rubocop_todo/graphql/graphql_name.yml
+++ b/.rubocop_todo/graphql/graphql_name.yml
@@ -3,7 +3,6 @@ GraphQL/GraphqlName:
Exclude:
- 'app/graphql/mutations/alert_management/base.rb'
- 'app/graphql/mutations/alert_management/http_integration/http_integration_base.rb'
- - 'app/graphql/mutations/alert_management/prometheus_integration/prometheus_integration_base.rb'
- 'app/graphql/mutations/award_emojis/base.rb'
- 'app/graphql/mutations/base_mutation.rb'
- 'app/graphql/mutations/boards/lists/base_create.rb'
diff --git a/Gemfile b/Gemfile
index 53ea06ee467..551ee1a3a9b 100644
--- a/Gemfile
+++ b/Gemfile
@@ -565,7 +565,7 @@ group :development, :test, :coverage do
gem 'simplecov', '~> 0.22', require: false, feature_category: :tooling
gem 'simplecov-lcov', '~> 0.8.0', require: false, feature_category: :tooling
gem 'simplecov-cobertura', '~> 2.1.0', require: false, feature_category: :tooling
- gem 'undercover', '~> 0.6.0', require: false, feature_category: :tooling
+ gem 'undercover', '~> 0.7.0', require: false, feature_category: :tooling
end
# Gems required in omnibus-gitlab pipeline
diff --git a/Gemfile.checksum b/Gemfile.checksum
index 669907a8df6..0c4f06de117 100644
--- a/Gemfile.checksum
+++ b/Gemfile.checksum
@@ -773,7 +773,7 @@
{"name":"typhoeus","version":"1.4.1","platform":"ruby","checksum":"1c17db8364bd45ab302dc61e460173c3e69835896be88a3df07c206d5c55ef7c"},
{"name":"tzinfo","version":"2.0.6","platform":"ruby","checksum":"8daf828cc77bcf7d63b0e3bdb6caa47e2272dcfaf4fbfe46f8c3a9df087a829b"},
{"name":"uber","version":"0.1.0","platform":"ruby","checksum":"5beeb407ff807b5db994f82fa9ee07cfceaa561dad8af20be880bc67eba935dc"},
-{"name":"undercover","version":"0.6.4","platform":"ruby","checksum":"3c34fcf129b52a4993065c52612a65e5e05e77f0cac3f4f8f388114fb129ec1a"},
+{"name":"undercover","version":"0.7.0","platform":"ruby","checksum":"1aa906581bd4d91b093a313496b8ef522e41a50b6c6689e228fd2587d3cca74d"},
{"name":"unf","version":"0.1.4","platform":"java","checksum":"49a5972ec0b3d091d3b0b2e00113f2f342b9b212f0db855eb30a629637f6d302"},
{"name":"unf","version":"0.1.4","platform":"ruby","checksum":"4999517a531f2a955750f8831941891f6158498ec9b6cb1c81ce89388e63022e"},
{"name":"unf_ext","version":"0.0.8.2","platform":"ruby","checksum":"90b9623ee359cc4878461c5d2eab7d3d3ce5801a680a9e7ac83b8040c5b742fa"},
diff --git a/Gemfile.lock b/Gemfile.lock
index 2ec3ebab72b..d162bd76dc9 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -1968,12 +1968,14 @@ GEM
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
uber (0.1.0)
- undercover (0.6.4)
+ undercover (0.7.0)
base64
bigdecimal
imagen (>= 0.2.0)
rainbow (>= 2.1, < 4.0)
rugged (>= 0.27, < 1.10)
+ simplecov
+ simplecov_json_formatter
unf (0.1.4)
unf_ext
unf_ext (0.0.8.2)
@@ -2412,7 +2414,7 @@ DEPENDENCIES
truncato (~> 0.7.13)
tty-prompt (~> 0.23)
typhoeus (~> 1.4.0)
- undercover (~> 0.6.0)
+ undercover (~> 0.7.0)
unicode-emoji (~> 4.0)
unleash (~> 3.2.2)
uri (= 0.13.2)
diff --git a/Gemfile.next.checksum b/Gemfile.next.checksum
index 276671ed136..8c10e3bbf47 100644
--- a/Gemfile.next.checksum
+++ b/Gemfile.next.checksum
@@ -773,7 +773,7 @@
{"name":"typhoeus","version":"1.4.1","platform":"ruby","checksum":"1c17db8364bd45ab302dc61e460173c3e69835896be88a3df07c206d5c55ef7c"},
{"name":"tzinfo","version":"2.0.6","platform":"ruby","checksum":"8daf828cc77bcf7d63b0e3bdb6caa47e2272dcfaf4fbfe46f8c3a9df087a829b"},
{"name":"uber","version":"0.1.0","platform":"ruby","checksum":"5beeb407ff807b5db994f82fa9ee07cfceaa561dad8af20be880bc67eba935dc"},
-{"name":"undercover","version":"0.6.4","platform":"ruby","checksum":"3c34fcf129b52a4993065c52612a65e5e05e77f0cac3f4f8f388114fb129ec1a"},
+{"name":"undercover","version":"0.7.0","platform":"ruby","checksum":"1aa906581bd4d91b093a313496b8ef522e41a50b6c6689e228fd2587d3cca74d"},
{"name":"unf","version":"0.1.4","platform":"java","checksum":"49a5972ec0b3d091d3b0b2e00113f2f342b9b212f0db855eb30a629637f6d302"},
{"name":"unf","version":"0.1.4","platform":"ruby","checksum":"4999517a531f2a955750f8831941891f6158498ec9b6cb1c81ce89388e63022e"},
{"name":"unf_ext","version":"0.0.8.2","platform":"ruby","checksum":"90b9623ee359cc4878461c5d2eab7d3d3ce5801a680a9e7ac83b8040c5b742fa"},
diff --git a/Gemfile.next.lock b/Gemfile.next.lock
index 21674891afe..8ed8113c68e 100644
--- a/Gemfile.next.lock
+++ b/Gemfile.next.lock
@@ -1962,12 +1962,14 @@ GEM
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
uber (0.1.0)
- undercover (0.6.4)
+ undercover (0.7.0)
base64
bigdecimal
imagen (>= 0.2.0)
rainbow (>= 2.1, < 4.0)
rugged (>= 0.27, < 1.10)
+ simplecov
+ simplecov_json_formatter
unf (0.1.4)
unf_ext
unf_ext (0.0.8.2)
@@ -2407,7 +2409,7 @@ DEPENDENCIES
truncato (~> 0.7.13)
tty-prompt (~> 0.23)
typhoeus (~> 1.4.0)
- undercover (~> 0.6.0)
+ undercover (~> 0.7.0)
unicode-emoji (~> 4.0)
unleash (~> 3.2.2)
uri (= 0.13.2)
diff --git a/app/graphql/mutations/alert_management/prometheus_integration/create.rb b/app/graphql/mutations/alert_management/prometheus_integration/create.rb
index 665ce96f0f9..943929da396 100644
--- a/app/graphql/mutations/alert_management/prometheus_integration/create.rb
+++ b/app/graphql/mutations/alert_management/prometheus_integration/create.rb
@@ -1,44 +1,30 @@
# frozen_string_literal: true
+# Deprecated:
+# Remove from MutationType during any major release.
module Mutations
module AlertManagement
module PrometheusIntegration
- class Create < PrometheusIntegrationBase
+ class Create < HttpIntegration::Create
graphql_name 'PrometheusIntegrationCreate'
- include FindsProject
+ field :integration,
+ Types::AlertManagement::PrometheusIntegrationType,
+ null: true,
+ description: "Newly created integration."
- argument :project_path, GraphQL::Types::ID,
- required: true,
- description: 'Project to create the integration in.'
-
- argument :active, GraphQL::Types::Boolean,
- required: true,
- description: 'Whether the integration is receiving alerts.'
+ argument :name, GraphQL::Types::String,
+ required: false,
+ description: 'Name of the integration.',
+ default_value: 'Prometheus'
argument :api_url, GraphQL::Types::String,
required: false,
- description: 'Endpoint at which Prometheus can be queried.'
+ description: 'Endpoint at which Prometheus can be queried.',
+ deprecated: { reason: 'Feature removed in 16.0', milestone: '18.2' }
def resolve(args)
- project = authorized_find!(args[:project_path])
-
- return integration_exists if project.prometheus_integration
-
- result = ::Projects::Operations::UpdateService.new(
- project,
- current_user,
- **integration_attributes(args),
- **token_attributes
- ).execute
-
- response(project.prometheus_integration, result)
- end
-
- private
-
- def integration_exists
- response(nil, message: _('Multiple Prometheus integrations are not supported'))
+ super(args.merge(name: 'Prometheus', type_identifier: :prometheus))
end
end
end
diff --git a/app/graphql/mutations/alert_management/prometheus_integration/prometheus_integration_base.rb b/app/graphql/mutations/alert_management/prometheus_integration/prometheus_integration_base.rb
deleted file mode 100644
index 19fb514d3a5..00000000000
--- a/app/graphql/mutations/alert_management/prometheus_integration/prometheus_integration_base.rb
+++ /dev/null
@@ -1,38 +0,0 @@
-# frozen_string_literal: true
-
-module Mutations
- module AlertManagement
- module PrometheusIntegration
- class PrometheusIntegrationBase < BaseMutation
- field :integration,
- Types::AlertManagement::PrometheusIntegrationType,
- null: true,
- description: "Newly created integration."
-
- authorize :admin_project
-
- private
-
- def response(integration, result)
- {
- integration: integration,
- errors: Array(result[:message])
- }
- end
-
- def integration_attributes(args)
- {
- prometheus_integration_attributes: {
- manual_configuration: args[:active],
- api_url: args[:api_url]
- }.compact
- }
- end
-
- def token_attributes
- { alerting_setting_attributes: { regenerate_token: true } }
- end
- end
- end
- end
-end
diff --git a/app/graphql/mutations/alert_management/prometheus_integration/reset_token.rb b/app/graphql/mutations/alert_management/prometheus_integration/reset_token.rb
index 15e6763b1ee..73f045791ff 100644
--- a/app/graphql/mutations/alert_management/prometheus_integration/reset_token.rb
+++ b/app/graphql/mutations/alert_management/prometheus_integration/reset_token.rb
@@ -1,25 +1,29 @@
# frozen_string_literal: true
+# Deprecated:
+# Remove from MutationType during any major release.
module Mutations
module AlertManagement
module PrometheusIntegration
- class ResetToken < PrometheusIntegrationBase
+ class ResetToken < HttpIntegration::ResetToken
graphql_name 'PrometheusIntegrationResetToken'
+ field :integration,
+ Types::AlertManagement::PrometheusIntegrationType,
+ null: true,
+ description: "Updated integration."
+
argument :id, Types::GlobalIDType[::Integrations::Prometheus],
required: true,
description: "ID of the integration to mutate."
- def resolve(id:)
- integration = authorized_find!(id: id)
+ def authorized_find!(**)
+ integration = super&.project
+ &.alert_management_http_integrations
+ &.for_endpoint_identifier('legacy-prometheus')
+ &.take
- result = ::Projects::Operations::UpdateService.new(
- integration.project,
- current_user,
- token_attributes
- ).execute
-
- response integration, result
+ integration || raise_resource_not_available_error!
end
end
end
diff --git a/app/graphql/mutations/alert_management/prometheus_integration/update.rb b/app/graphql/mutations/alert_management/prometheus_integration/update.rb
index 593624aaafd..740ef58862f 100644
--- a/app/graphql/mutations/alert_management/prometheus_integration/update.rb
+++ b/app/graphql/mutations/alert_management/prometheus_integration/update.rb
@@ -1,33 +1,38 @@
# frozen_string_literal: true
+# Deprecated:
+# Remove from MutationType during any major release.
module Mutations
module AlertManagement
module PrometheusIntegration
- class Update < PrometheusIntegrationBase
+ class Update < HttpIntegration::Update
graphql_name 'PrometheusIntegrationUpdate'
+ field :integration,
+ Types::AlertManagement::PrometheusIntegrationType,
+ null: true,
+ description: "Updated integration."
+
argument :id, Types::GlobalIDType[::Integrations::Prometheus],
required: true,
description: "ID of the integration to mutate."
- argument :active, GraphQL::Types::Boolean,
- required: false,
- description: "Whether the integration is receiving alerts."
-
argument :api_url, GraphQL::Types::String,
required: false,
- description: "Endpoint at which Prometheus can be queried."
+ description: "Endpoint at which Prometheus can be queried.",
+ deprecated: { reason: 'Feature removed in 16.0', milestone: '18.2' }
def resolve(args)
- integration = authorized_find!(id: args[:id])
+ super(args.except(:name))
+ end
- result = ::Projects::Operations::UpdateService.new(
- integration.project,
- current_user,
- integration_attributes(args)
- ).execute
+ def authorized_find!(**)
+ integration = super&.project
+ &.alert_management_http_integrations
+ &.for_endpoint_identifier('legacy-prometheus')
+ &.take
- response integration.reset, result
+ integration || raise_resource_not_available_error!
end
end
end
diff --git a/app/graphql/resolvers/alert_management/integrations_resolver.rb b/app/graphql/resolvers/alert_management/integrations_resolver.rb
index 44effb17072..992d387d454 100644
--- a/app/graphql/resolvers/alert_management/integrations_resolver.rb
+++ b/app/graphql/resolvers/alert_management/integrations_resolver.rb
@@ -14,48 +14,37 @@ module Resolvers
type Types::AlertManagement::IntegrationType.connection_type, null: true
def resolve(id: nil)
+ return [] unless Ability.allowed?(current_user, :admin_operations, project)
+
if id
integrations_by(gid: id)
else
- http_integrations + prometheus_integrations
+ http_integrations
end
end
private
def integrations_by(gid:)
- object = GitlabSchema.object_from_id(gid, expected_type: expected_integration_types)
+ object = GitlabSchema.object_from_id(gid, expected_type: [
+ ::AlertManagement::HttpIntegration,
+ ::Integrations::Prometheus
+ ])
+
defer { object }.then do |integration|
- ret = integration if project == integration&.project
- Array.wrap(ret)
+ next [] unless integration&.project == project
+
+ if integration.is_a?(::Integrations::Prometheus)
+ project.alert_management_http_integrations
+ .for_endpoint_identifier('legacy-prometheus').to_a
+ else
+ [integration]
+ end
end
end
- def prometheus_integrations
- return [] unless prometheus_integrations_allowed?
-
- Array(project.prometheus_integration)
- end
-
def http_integrations
- return [] unless http_integrations_allowed?
-
- ::AlertManagement::HttpIntegrationsFinder.new(project, { type_identifier: :http }).execute
- end
-
- def prometheus_integrations_allowed?
- Ability.allowed?(current_user, :admin_project, project)
- end
-
- def http_integrations_allowed?
- Ability.allowed?(current_user, :admin_operations, project)
- end
-
- def expected_integration_types
- [].tap do |types|
- types << ::AlertManagement::HttpIntegration if http_integrations_allowed?
- types << ::Integrations::Prometheus if prometheus_integrations_allowed?
- end
+ ::AlertManagement::HttpIntegrationsFinder.new(project).execute
end
end
end
diff --git a/app/graphql/types/alert_management/http_integration_type.rb b/app/graphql/types/alert_management/http_integration_type.rb
index e14a0f0f0d5..b05f3b49d73 100644
--- a/app/graphql/types/alert_management/http_integration_type.rb
+++ b/app/graphql/types/alert_management/http_integration_type.rb
@@ -9,14 +9,6 @@ module Types
implements Types::AlertManagement::IntegrationType
authorize :admin_operations
-
- def type
- object.type_identifier.to_sym
- end
-
- def api_url
- nil
- end
end
end
end
diff --git a/app/graphql/types/alert_management/integration_type.rb b/app/graphql/types/alert_management/integration_type.rb
index 6cc287a59ba..0b21207f3f5 100644
--- a/app/graphql/types/alert_management/integration_type.rb
+++ b/app/graphql/types/alert_management/integration_type.rb
@@ -39,11 +39,20 @@ module Types
field :api_url,
GraphQL::Types::String,
null: true,
- description: 'URL at which Prometheus metrics can be queried to populate the metrics dashboard.'
+ description: 'URL at which Prometheus metrics can be queried to populate the metrics dashboard.',
+ deprecated: { reason: 'Feature removed in 16.0', milestone: '18.2' }
+
+ def type
+ object.type_identifier.to_sym
+ end
+
+ def api_url
+ nil
+ end
definition_methods do
def resolve_type(object, context)
- if object.is_a?(::Integrations::Prometheus)
+ if object.type_identifier == 'prometheus'
Types::AlertManagement::PrometheusIntegrationType
else
Types::AlertManagement::HttpIntegrationType
diff --git a/app/graphql/types/alert_management/prometheus_integration_type.rb b/app/graphql/types/alert_management/prometheus_integration_type.rb
index 0f61eeaa177..a47a7f79eb4 100644
--- a/app/graphql/types/alert_management/prometheus_integration_type.rb
+++ b/app/graphql/types/alert_management/prometheus_integration_type.rb
@@ -1,38 +1,16 @@
# frozen_string_literal: true
+# Deprecated:
+# Remove with PrometheusIntegration mutations during any major release.
module Types
module AlertManagement
class PrometheusIntegrationType < ::Types::BaseObject
graphql_name 'AlertManagementPrometheusIntegration'
- description 'An endpoint and credentials used to accept Prometheus alerts for a project'
-
- include ::Gitlab::Routing
+ description '**DEPRECATED - Use AlertManagementHttpIntegration directly** An endpoint and credentials used to accept Prometheus alerts for a project'
implements Types::AlertManagement::IntegrationType
- authorize :admin_project
-
- alias_method :prometheus_integration, :object
-
- def name
- prometheus_integration.title
- end
-
- def type
- :prometheus
- end
-
- def token
- prometheus_integration.project&.alerting_setting&.token
- end
-
- def url
- prometheus_integration.project && notify_project_prometheus_alerts_url(prometheus_integration.project, format: :json)
- end
-
- def active
- prometheus_integration.manual_configuration?
- end
+ authorize :admin_operations
end
end
end
diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb
index c21783bc4d0..76d09fa958d 100644
--- a/app/graphql/types/mutation_type.rb
+++ b/app/graphql/types/mutation_type.rb
@@ -26,9 +26,15 @@ module Types
mount_mutation Mutations::Security::CiConfiguration::ConfigureSast
mount_mutation Mutations::Security::CiConfiguration::ConfigureSastIac
mount_mutation Mutations::Security::CiConfiguration::ConfigureSecretDetection
- mount_mutation Mutations::AlertManagement::PrometheusIntegration::Create
- mount_mutation Mutations::AlertManagement::PrometheusIntegration::Update
- mount_mutation Mutations::AlertManagement::PrometheusIntegration::ResetToken
+ mount_mutation Mutations::AlertManagement::PrometheusIntegration::Create, deprecated: {
+ reason: 'Use HttpIntegrationCreate', milestone: '18.2'
+ }
+ mount_mutation Mutations::AlertManagement::PrometheusIntegration::Update, deprecated: {
+ reason: 'Use HttpIntegrationUpdate', milestone: '18.2'
+ }
+ mount_mutation Mutations::AlertManagement::PrometheusIntegration::ResetToken, deprecated: {
+ reason: 'Use HttpIntegrationResetToken', milestone: '18.2'
+ }
mount_mutation Mutations::AwardEmojis::Add
mount_mutation Mutations::AwardEmojis::Remove
mount_mutation Mutations::AwardEmojis::Toggle
diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb
index e3e690e672d..e1b6730429e 100644
--- a/app/graphql/types/project_type.rb
+++ b/app/graphql/types/project_type.rb
@@ -537,7 +537,8 @@ module Types
field :alert_management_integrations, Types::AlertManagement::IntegrationType.connection_type,
null: true,
description: 'Integrations which can receive alerts for the project.',
- resolver: Resolvers::AlertManagement::IntegrationsResolver
+ resolver: Resolvers::AlertManagement::IntegrationsResolver,
+ deprecated: { reason: 'Use `alertManagementHttpIntegrations`', milestone: '18.2' }
field :alert_management_http_integrations, Types::AlertManagement::HttpIntegrationType.connection_type,
null: true,
diff --git a/app/models/concerns/triggerable_hooks.rb b/app/models/concerns/triggerable_hooks.rb
index 64e4a3311dc..a892593946b 100644
--- a/app/models/concerns/triggerable_hooks.rb
+++ b/app/models/concerns/triggerable_hooks.rb
@@ -32,6 +32,7 @@ module TriggerableHooks
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 1226a76433b..c15482d071e 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 358b4794764..7bdbdf49f7c 100644
--- a/app/models/milestone.rb
+++ b/app/models/milestone.rb
@@ -99,6 +99,10 @@ class Milestone < ApplicationRecord
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 185b57a1384..f71cf87832c 100644
--- a/app/services/concerns/integrations/project_test_data.rb
+++ b/app/services/concerns/integrations/project_test_data.rb
@@ -82,6 +82,10 @@ module Integrations
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 1e077d49e5a..3334b241ddd 100644
--- a/app/services/integrations/test/project_service.rb
+++ b/app/services/integrations/test/project_service.rb
@@ -14,7 +14,7 @@ module Integrations
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 @@ module Integrations
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 0d7d855bf5e..ecfe7db325e 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 a252f5c144e..616c6d85b7c 100644
--- a/app/services/milestones/close_service.rb
+++ b/app/services/milestones/close_service.rb
@@ -5,6 +5,7 @@ module Milestones
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 e8a14adc10d..d8a9be6aafe 100644
--- a/app/services/milestones/create_service.rb
+++ b/app/services/milestones/create_service.rb
@@ -9,6 +9,7 @@ module Milestones
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 6966764634f..dc605472e37 100644
--- a/app/services/milestones/destroy_service.rb
+++ b/app/services/milestones/destroy_service.rb
@@ -14,6 +14,7 @@ module Milestones
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 125a3ec1367..a4a9ccfd401 100644
--- a/app/services/milestones/reopen_service.rb
+++ b/app/services/milestones/reopen_service.rb
@@ -5,6 +5,7 @@ module Milestones
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 b183210edb3..25c81c3a498 100644
--- a/app/services/test_hooks/project_service.rb
+++ b/app/services/test_hooks/project_service.rb
@@ -35,6 +35,8 @@ module TestHooks
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 a3c503c9efc..3be7461410d 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/config/feature_flags/beta/validate_lfs_object_access.yml b/config/feature_flags/beta/validate_lfs_object_access.yml
index ef4ce8c5f2c..6f61a46acf4 100644
--- a/config/feature_flags/beta/validate_lfs_object_access.yml
+++ b/config/feature_flags/beta/validate_lfs_object_access.yml
@@ -6,4 +6,4 @@ rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/547584
milestone: '18.1'
group: group::source code
type: beta
-default_enabled: false
\ No newline at end of file
+default_enabled: true
\ No newline at end of file
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 00000000000..0357de580ff
--- /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 00000000000..16d68416d52
--- /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 f1a97c0fc05..f8002cd2c90 100644
--- a/db/structure.sql
+++ b/db/structure.sql
@@ -26012,6 +26012,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/administration/raketasks/maintenance.md b/doc/administration/raketasks/maintenance.md
index 7232876d85e..de15f5a29c1 100644
--- a/doc/administration/raketasks/maintenance.md
+++ b/doc/administration/raketasks/maintenance.md
@@ -552,7 +552,8 @@ gitlab-rake gitlab:db:schema_checker:run
{{< /history >}}
The `gitlab:db:sos` command gathers configuration, performance, and diagnostic data about your GitLab
-database to help you troubleshoot issues. Where you run this command depends on your configuration:
+database to help you troubleshoot issues. Where you run this command depends on your configuration. Make sure
+to run this command relative to where GitLab is installed `(/gitlab)`.
- **Scaled GitLab**: on your Puma or Sidekiq server.
- **Cloud native install**: on the toolbox pod.
@@ -561,9 +562,9 @@ database to help you troubleshoot issues. Where you run this command depends on
Modify the command as needed:
- **Default path** - To run the command with the default file path (`/var/opt/gitlab/gitlab-rails/tmp/sos.zip`), run `gitlab-rake gitlab:db:sos`.
-- **Custom path** - To change the file path, run `gitlab-rake gitlab:db:sos["custom/path/to/file.zip"]`.
+- **Custom path** - To change the file path, run `gitlab-rake gitlab:db:sos["/absolute/custom/path/to/file.zip"]`.
- **Zsh users** - If you have not modified your Zsh configuration, you must add quotation marks
- around the entire command, like this: `gitlab-rake "gitlab:db:sos[custom/path/to/file.zip]"`
+ around the entire command, like this: `gitlab-rake "gitlab:db:sos[/absolute/custom/path/to/file.zip]"`
The Rake task runs for five minutes. It creates a compressed folder in the path you specify.
The compressed folder contains a large number of files.
diff --git a/doc/api/graphql/reference/_index.md b/doc/api/graphql/reference/_index.md
index a029f685afb..f8e3708207f 100644
--- a/doc/api/graphql/reference/_index.md
+++ b/doc/api/graphql/reference/_index.md
@@ -2614,6 +2614,7 @@ Input type: `AuditEventsAmazonS3ConfigurationUpdateInput`
| Name | Type | Description |
| ---- | ---- | ----------- |
| `accessKeyXid` | [`String`](#string) | Access key ID of the Amazon S3 account. |
+| `active` | [`Boolean`](#boolean) | Active status of the destination. |
| `awsRegion` | [`String`](#string) | AWS region where the bucket is created. |
| `bucketName` | [`String`](#string) | Name of the bucket where the audit events would be logged. |
| `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
@@ -2776,6 +2777,7 @@ Input type: `AuditEventsInstanceAmazonS3ConfigurationUpdateInput`
| Name | Type | Description |
| ---- | ---- | ----------- |
| `accessKeyXid` | [`String`](#string) | Access key ID of the Amazon S3 account. |
+| `active` | [`Boolean`](#boolean) | Active status of the destination. |
| `awsRegion` | [`String`](#string) | AWS region where the bucket is created. |
| `bucketName` | [`String`](#string) | Name of the bucket where the audit events would be logged. |
| `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
@@ -10014,6 +10016,11 @@ Input type: `ProjectUpdateComplianceFrameworksInput`
### `Mutation.prometheusIntegrationCreate`
+{{< details >}}
+**Deprecated** in GitLab 18.2.
+Use HttpIntegrationCreate.
+{{< /details >}}
+
Input type: `PrometheusIntegrationCreateInput`
#### Arguments
@@ -10021,9 +10028,13 @@ Input type: `PrometheusIntegrationCreateInput`
| Name | Type | Description |
| ---- | ---- | ----------- |
| `active` | [`Boolean!`](#boolean) | Whether the integration is receiving alerts. |
-| `apiUrl` | [`String`](#string) | Endpoint at which Prometheus can be queried. |
+| `apiUrl` {{< icon name="warning-solid" >}} | [`String`](#string) | **Deprecated**: Feature removed in 16.0. Deprecated in GitLab 18.2. |
| `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
+| `name` | [`String`](#string) | Name of the integration. |
+| `payloadAttributeMappings` | [`[AlertManagementPayloadAlertFieldInput!]`](#alertmanagementpayloadalertfieldinput) | Custom mapping of GitLab alert attributes to fields from the payload example. |
+| `payloadExample` | [`JsonString`](#jsonstring) | Example of an alert payload. |
| `projectPath` | [`ID!`](#id) | Project to create the integration in. |
+| `type` | [`AlertManagementIntegrationType`](#alertmanagementintegrationtype) | Type of integration to create. Cannot be changed after creation. |
#### Fields
@@ -10035,6 +10046,11 @@ Input type: `PrometheusIntegrationCreateInput`
### `Mutation.prometheusIntegrationResetToken`
+{{< details >}}
+**Deprecated** in GitLab 18.2.
+Use HttpIntegrationResetToken.
+{{< /details >}}
+
Input type: `PrometheusIntegrationResetTokenInput`
#### Arguments
@@ -10050,10 +10066,15 @@ Input type: `PrometheusIntegrationResetTokenInput`
| ---- | ---- | ----------- |
| `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
| `errors` | [`[String!]!`](#string) | Errors encountered during the mutation. |
-| `integration` | [`AlertManagementPrometheusIntegration`](#alertmanagementprometheusintegration) | Newly created integration. |
+| `integration` | [`AlertManagementPrometheusIntegration`](#alertmanagementprometheusintegration) | Updated integration. |
### `Mutation.prometheusIntegrationUpdate`
+{{< details >}}
+**Deprecated** in GitLab 18.2.
+Use HttpIntegrationUpdate.
+{{< /details >}}
+
Input type: `PrometheusIntegrationUpdateInput`
#### Arguments
@@ -10061,9 +10082,12 @@ Input type: `PrometheusIntegrationUpdateInput`
| Name | Type | Description |
| ---- | ---- | ----------- |
| `active` | [`Boolean`](#boolean) | Whether the integration is receiving alerts. |
-| `apiUrl` | [`String`](#string) | Endpoint at which Prometheus can be queried. |
+| `apiUrl` {{< icon name="warning-solid" >}} | [`String`](#string) | **Deprecated**: Feature removed in 16.0. Deprecated in GitLab 18.2. |
| `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
| `id` | [`IntegrationsPrometheusID!`](#integrationsprometheusid) | ID of the integration to mutate. |
+| `name` | [`String`](#string) | Name of the integration. |
+| `payloadAttributeMappings` | [`[AlertManagementPayloadAlertFieldInput!]`](#alertmanagementpayloadalertfieldinput) | Custom mapping of GitLab alert attributes to fields from the payload example. |
+| `payloadExample` | [`JsonString`](#jsonstring) | Example of an alert payload. |
#### Fields
@@ -10071,7 +10095,7 @@ Input type: `PrometheusIntegrationUpdateInput`
| ---- | ---- | ----------- |
| `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
| `errors` | [`[String!]!`](#string) | Errors encountered during the mutation. |
-| `integration` | [`AlertManagementPrometheusIntegration`](#alertmanagementprometheusintegration) | Newly created integration. |
+| `integration` | [`AlertManagementPrometheusIntegration`](#alertmanagementprometheusintegration) | Updated integration. |
### `Mutation.promoteToEpic`
@@ -22006,7 +22030,7 @@ An endpoint and credentials used to accept alerts for a project.
| Name | Type | Description |
| ---- | ---- | ----------- |
| `active` | [`Boolean`](#boolean) | Whether the endpoint is currently accepting alerts. |
-| `apiUrl` | [`String`](#string) | URL at which Prometheus metrics can be queried to populate the metrics dashboard. |
+| `apiUrl` {{< icon name="warning-solid" >}} | [`String`](#string) | **Deprecated** in GitLab 18.2. Feature removed in 16.0. |
| `id` | [`ID!`](#id) | ID of the integration. |
| `name` | [`String`](#string) | Name of the integration. |
| `payloadAlertFields` | [`[AlertManagementPayloadAlertField!]`](#alertmanagementpayloadalertfield) | Extract alert fields from payload example for custom mapping. |
@@ -22043,14 +22067,14 @@ Parsed field (with its name) from an alert used for custom mappings.
### `AlertManagementPrometheusIntegration`
-An endpoint and credentials used to accept Prometheus alerts for a project.
+**DEPRECATED - Use AlertManagementHttpIntegration directly** An endpoint and credentials used to accept Prometheus alerts for a project.
#### Fields
| Name | Type | Description |
| ---- | ---- | ----------- |
| `active` | [`Boolean`](#boolean) | Whether the endpoint is currently accepting alerts. |
-| `apiUrl` | [`String`](#string) | URL at which Prometheus metrics can be queried to populate the metrics dashboard. |
+| `apiUrl` {{< icon name="warning-solid" >}} | [`String`](#string) | **Deprecated** in GitLab 18.2. Feature removed in 16.0. |
| `id` | [`ID!`](#id) | ID of the integration. |
| `name` | [`String`](#string) | Name of the integration. |
| `token` | [`String`](#string) | Token used to authenticate alert notification requests. |
@@ -22066,6 +22090,7 @@ Stores Amazon S3 configurations for audit event streaming.
| Name | Type | Description |
| ---- | ---- | ----------- |
| `accessKeyXid` | [`String!`](#string) | Access key ID of the Amazon S3 account. |
+| `active` | [`Boolean!`](#boolean) | Active status of the destination. |
| `awsRegion` | [`String!`](#string) | AWS region where the bucket is created. |
| `bucketName` | [`String!`](#string) | Name of the bucket where the audit events would be logged. |
| `group` | [`Group!`](#group) | Group the configuration belongs to. |
@@ -31021,6 +31046,7 @@ Stores instance level Amazon S3 configurations for audit event streaming.
| Name | Type | Description |
| ---- | ---- | ----------- |
| `accessKeyXid` | [`String!`](#string) | Access key ID of the Amazon S3 account. |
+| `active` | [`Boolean!`](#boolean) | Active status of the destination. |
| `awsRegion` | [`String!`](#string) | AWS region where the bucket is created. |
| `bucketName` | [`String!`](#string) | Name of the bucket where the audit events would be logged. |
| `id` | [`ID!`](#id) | ID of the configuration. |
@@ -36584,6 +36610,11 @@ four standard [pagination arguments](#pagination-arguments):
Integrations which can receive alerts for the project.
+{{< details >}}
+**Deprecated** in GitLab 18.2.
+Use `alertManagementHttpIntegrations`.
+{{< /details >}}
+
Returns [`AlertManagementIntegrationConnection`](#alertmanagementintegrationconnection).
This field returns a [connection](#connections). It accepts the
@@ -49567,7 +49598,7 @@ Implementations:
| Name | Type | Description |
| ---- | ---- | ----------- |
| `active` | [`Boolean`](#boolean) | Whether the endpoint is currently accepting alerts. |
-| `apiUrl` | [`String`](#string) | URL at which Prometheus metrics can be queried to populate the metrics dashboard. |
+| `apiUrl` {{< icon name="warning-solid" >}} | [`String`](#string) | **Deprecated** in GitLab 18.2. Feature removed in 16.0. |
| `id` | [`ID!`](#id) | ID of the integration. |
| `name` | [`String`](#string) | Name of the integration. |
| `token` | [`String`](#string) | Token used to authenticate alert notification requests. |
@@ -49586,6 +49617,7 @@ Implementations:
| Name | Type | Description |
| ---- | ---- | ----------- |
| `accessKeyXid` | [`String!`](#string) | Access key ID of the Amazon S3 account. |
+| `active` | [`Boolean!`](#boolean) | Active status of the destination. |
| `awsRegion` | [`String!`](#string) | AWS region where the bucket is created. |
| `bucketName` | [`String!`](#string) | Name of the bucket where the audit events would be logged. |
| `id` | [`ID!`](#id) | ID of the configuration. |
diff --git a/doc/api/merge_requests.md b/doc/api/merge_requests.md
index 4f538b3b3a8..657760c69b6 100644
--- a/doc/api/merge_requests.md
+++ b/doc/api/merge_requests.md
@@ -1386,7 +1386,7 @@ Supported attributes:
|---------------------|----------------|----------|-------------|
| `id` | integer or string | Yes | The ID or [URL-encoded path of the project](rest/_index.md#namespaced-paths) owned by the authenticated user. |
| `merge_request_iid` | integer | Yes | The internal ID of the merge request. |
-| `block_id` | integer | Yes | The internal ID of the block. |
+| `block_id` | integer | Yes | The ID of the block. |
Example request:
diff --git a/doc/api/openapi/openapi_v2.yaml b/doc/api/openapi/openapi_v2.yaml
index d9675e4d8da..fbf92bfa947 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 0918a17b462..52279498140 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 f8b86b3ca97..c5efc1b0621 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 a1ede6fe277..b81720fa28f 100644
--- a/lib/api/entities/project_hook.rb
+++ b/lib/api/entities/project_hook.rb
@@ -14,6 +14,7 @@ module API
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 410ff2e441e..bd0d4fec7d6 100644
--- a/lib/api/project_hooks.rb
+++ b/lib/api/project_hooks.rb
@@ -38,6 +38,7 @@ module API
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 00000000000..6f6e269c082
--- /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 00000000000..f2022a956eb
--- /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 2a88fdf2aef..2752406a9ea 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -2433,9 +2433,6 @@ msgstr ""
msgid "AICatalog|Provide default instructions or context that will be included with every user interaction."
msgstr ""
-msgid "AICatalog|Released %{fullDate}"
-msgstr ""
-
msgid "AICatalog|Run"
msgstr ""
@@ -17002,21 +16999,33 @@ msgstr ""
msgid "ComplianceViolation|Audit event captured"
msgstr ""
+msgid "ComplianceViolation|Control"
+msgstr ""
+
msgid "ComplianceViolation|Detected"
msgstr ""
msgid "ComplianceViolation|Dismissed"
msgstr ""
+msgid "ComplianceViolation|Framework"
+msgstr ""
+
msgid "ComplianceViolation|In review"
msgstr ""
msgid "ComplianceViolation|Registered event IP"
msgstr ""
+msgid "ComplianceViolation|Requirement"
+msgstr ""
+
msgid "ComplianceViolation|Resolved"
msgstr ""
+msgid "ComplianceViolation|Violation created based on associated framework"
+msgstr ""
+
msgid "Compliance|Framework deleted successfully"
msgstr ""
@@ -28163,6 +28172,9 @@ msgstr ""
msgid "Geo|Replication Details"
msgstr ""
+msgid "Geo|Replication failure"
+msgstr ""
+
msgid "Geo|Replication information"
msgstr ""
@@ -28382,6 +28394,9 @@ msgstr ""
msgid "Geo|Verification concurrency limit"
msgstr ""
+msgid "Geo|Verification failure"
+msgstr ""
+
msgid "Geo|Verification information"
msgstr ""
@@ -31536,6 +31551,9 @@ msgstr ""
msgid "Hide details"
msgstr ""
+msgid "Hide errors"
+msgstr ""
+
msgid "Hide file browser"
msgstr ""
@@ -40370,9 +40388,6 @@ msgstr ""
msgid "Multiple IP address ranges are supported. Does not affect access to the group's settings."
msgstr ""
-msgid "Multiple Prometheus integrations are not supported"
-msgstr ""
-
msgid "Multiple components '%{name}' have '%{attribute}' attribute"
msgstr ""
@@ -59015,6 +59030,9 @@ msgstr ""
msgid "Show epics in global search results"
msgstr ""
+msgid "Show errors"
+msgstr ""
+
msgid "Show file browser"
msgstr ""
@@ -69486,6 +69504,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 ""
@@ -73049,6 +73070,9 @@ msgstr ""
msgid "added a Zoom call to this issue"
msgstr ""
+msgid "agent"
+msgstr ""
+
msgid "agents"
msgstr ""
diff --git a/spec/factories/alert_management/http_integrations.rb b/spec/factories/alert_management/http_integrations.rb
index 1a46215de47..f6cdfc788b4 100644
--- a/spec/factories/alert_management/http_integrations.rb
+++ b/spec/factories/alert_management/http_integrations.rb
@@ -21,8 +21,15 @@ FactoryBot.define do
initialize_with { new(**attributes) }
+ trait :prometheus do
+ type_identifier { :prometheus }
+ end
+
factory :alert_management_prometheus_integration, traits: [:prometheus] do
+ type_identifier { :prometheus }
+
trait :legacy do
+ name { 'Prometheus' }
endpoint_identifier { 'legacy-prometheus' }
end
end
diff --git a/spec/factories/container_registry/protection/tag_rules.rb b/spec/factories/container_registry/protection/tag_rules.rb
index c7b5ae73ba8..73342bbaf1b 100644
--- a/spec/factories/container_registry/protection/tag_rules.rb
+++ b/spec/factories/container_registry/protection/tag_rules.rb
@@ -7,9 +7,4 @@ FactoryBot.define do
minimum_access_level_for_delete { :maintainer }
minimum_access_level_for_push { :maintainer }
end
-
- trait :immutable do
- minimum_access_level_for_delete { nil }
- minimum_access_level_for_push { nil }
- end
end
diff --git a/spec/factories/project_hooks.rb b/spec/factories/project_hooks.rb
index 482cec1195d..56959e051f8 100644
--- a/spec/factories/project_hooks.rb
+++ b/spec/factories/project_hooks.rb
@@ -30,6 +30,7 @@ FactoryBot.define do
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 062bcf90796..f1f824a8d65 100644
--- a/spec/features/projects/settings/webhooks_settings_spec.rb
+++ b/spec/features/projects/settings/webhooks_settings_spec.rb
@@ -43,6 +43,7 @@ RSpec.describe 'Projects > Settings > Webhook Settings', feature_category: :webh
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 238f5839f77..fb9cc717288 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/graphql/mutations/alert_management/prometheus_integration/create_spec.rb b/spec/graphql/mutations/alert_management/prometheus_integration/create_spec.rb
index 13575b7676b..043f57291d7 100644
--- a/spec/graphql/mutations/alert_management/prometheus_integration/create_spec.rb
+++ b/spec/graphql/mutations/alert_management/prometheus_integration/create_spec.rb
@@ -4,13 +4,13 @@ require 'spec_helper'
RSpec.describe Mutations::AlertManagement::PrometheusIntegration::Create, feature_category: :api do
include GraphqlHelpers
+
let_it_be(:current_user) { create(:user) }
let_it_be(:project) { create(:project) }
- let(:api_url) { 'http://prometheus.com/' }
- let(:args) { { project_path: project.full_path, active: true, api_url: api_url } }
+ let(:args) { { project_path: project.full_path, active: true, api_url: 'http://prometheus.com/' } }
- specify { expect(described_class).to require_graphql_authorizations(:admin_project) }
+ specify { expect(described_class).to require_graphql_authorizations(:admin_operations) }
describe '#resolve' do
subject(:resolve) { mutation_for(project, current_user).resolve(args) }
@@ -20,49 +20,31 @@ RSpec.describe Mutations::AlertManagement::PrometheusIntegration::Create, featur
project.add_maintainer(current_user)
end
- context 'when Prometheus Integration already exists' do
- let_it_be(:existing_integration) { create(:prometheus_integration, project: project) }
-
- it 'returns errors' do
- expect(resolve).to eq(
- integration: nil,
- errors: ['Multiple Prometheus integrations are not supported']
- )
- end
- end
-
- context 'when api_url is nil' do
- let(:api_url) { nil }
-
- it 'creates the integration' do
- expect { resolve }.to change(::Alerting::ProjectAlertingSetting, :count).by(1)
- end
- end
-
- context 'when UpdateService responds with success' do
+ context 'when HttpIntegrations::CreateService responds with success' do
it 'returns the integration with no errors' do
expect(resolve).to eq(
- integration: ::Integrations::Prometheus.last!,
+ integration: ::AlertManagement::HttpIntegration.last!,
errors: []
)
- end
-
- it 'creates a corresponding token' do
- expect { resolve }.to change(::Integrations::Prometheus, :count).by(1)
+ expect(resolve[:integration]).to have_attributes(
+ active: true,
+ name: 'Prometheus',
+ type_identifier: 'prometheus'
+ )
end
end
- context 'when UpdateService responds with an error' do
+ context 'when HttpIntegrations::CreateService responds with an error' do
before do
- allow_any_instance_of(::Projects::Operations::UpdateService)
+ allow_any_instance_of(::AlertManagement::HttpIntegrations::CreateService)
.to receive(:execute)
- .and_return({ status: :error, message: 'An error occurred' })
+ .and_return(ServiceResponse.error(payload: { integration: nil }, message: 'An integration already exists'))
end
it 'returns errors' do
expect(resolve).to eq(
integration: nil,
- errors: ['An error occurred']
+ errors: ['An integration already exists']
)
end
end
diff --git a/spec/graphql/mutations/alert_management/prometheus_integration/reset_token_spec.rb b/spec/graphql/mutations/alert_management/prometheus_integration/reset_token_spec.rb
index 1da4e644c31..c7bb0cfb96e 100644
--- a/spec/graphql/mutations/alert_management/prometheus_integration/reset_token_spec.rb
+++ b/spec/graphql/mutations/alert_management/prometheus_integration/reset_token_spec.rb
@@ -7,11 +7,12 @@ RSpec.describe Mutations::AlertManagement::PrometheusIntegration::ResetToken, fe
let_it_be(:current_user) { create(:user) }
let_it_be(:project) { create(:project) }
- let_it_be(:integration) { create(:prometheus_integration, project: project) }
+ let_it_be_with_reload(:integration) { create(:alert_management_prometheus_integration, :legacy, project: project) }
+ let_it_be_with_reload(:old_integration) { create(:prometheus_integration, project: project) }
- let(:args) { { id: GitlabSchema.id_from_object(integration) } }
+ let(:args) { { id: GitlabSchema.id_from_object(old_integration) } }
- specify { expect(described_class).to require_graphql_authorizations(:admin_project) }
+ specify { expect(described_class).to require_graphql_authorizations(:admin_operations) }
describe '#resolve' do
subject(:resolve) { mutation_for(project, current_user).resolve(**args) }
@@ -21,8 +22,10 @@ RSpec.describe Mutations::AlertManagement::PrometheusIntegration::ResetToken, fe
project.add_maintainer(current_user)
end
- context 'when ::Projects::Operations::UpdateService responds with success' do
+ context 'when ::AlertManagement::HttpIntegrations::UpdateService responds with success' do
it 'returns the integration with no errors' do
+ expect { resolve }.to change { integration.reload.token }
+
expect(resolve).to eq(
integration: integration,
errors: []
@@ -30,20 +33,42 @@ RSpec.describe Mutations::AlertManagement::PrometheusIntegration::ResetToken, fe
end
end
- context 'when ::Projects::Operations::UpdateService responds with an error' do
+ context 'when ::AlertManagement::HttpIntegrations::UpdateService responds with an error' do
before do
- allow_any_instance_of(::Projects::Operations::UpdateService)
+ allow_any_instance_of(::AlertManagement::HttpIntegrations::UpdateService)
.to receive(:execute)
- .and_return({ status: :error, message: 'An error occurred' })
+ .and_return(ServiceResponse.error(payload: { integration: nil }, message: 'An error occurred'))
end
it 'returns errors' do
expect(resolve).to eq(
- integration: integration,
+ integration: nil,
errors: ['An error occurred']
)
end
end
+
+ context 'when prometheus_integration does not exist' do
+ before do
+ old_integration.destroy!
+ end
+
+ it 'raises an error if the resource is not accessible to the user' do
+ expect(args[:id]).to be_present
+
+ expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
+ end
+ end
+
+ context 'when prometheus_integration does not have corresponding AlertManagement::HttpIntegration' do
+ before do
+ integration.destroy!
+ end
+
+ it 'raises an error if the resource is not accessible to the user' do
+ expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
+ end
+ end
end
context 'when resource is not accessible to the user' do
diff --git a/spec/graphql/mutations/alert_management/prometheus_integration/update_spec.rb b/spec/graphql/mutations/alert_management/prometheus_integration/update_spec.rb
index 3bba889b9a0..802ebe005fe 100644
--- a/spec/graphql/mutations/alert_management/prometheus_integration/update_spec.rb
+++ b/spec/graphql/mutations/alert_management/prometheus_integration/update_spec.rb
@@ -7,11 +7,12 @@ RSpec.describe Mutations::AlertManagement::PrometheusIntegration::Update, featur
let_it_be(:current_user) { create(:user) }
let_it_be(:project) { create(:project) }
- let_it_be(:integration) { create(:prometheus_integration, project: project) }
+ let_it_be_with_reload(:old_integration) { create(:prometheus_integration, project: project) }
+ let_it_be_with_reload(:integration) { create(:alert_management_prometheus_integration, :legacy, project: project) }
- let(:args) { { id: GitlabSchema.id_from_object(integration), active: false, api_url: 'http://new-url.com' } }
+ let(:args) { { id: GitlabSchema.id_from_object(old_integration), active: false, api_url: 'http://new-url.com' } }
- specify { expect(described_class).to require_graphql_authorizations(:admin_project) }
+ specify { expect(described_class).to require_graphql_authorizations(:admin_operations) }
describe '#resolve' do
subject(:resolve) { mutation_for(project, current_user).resolve(args) }
@@ -21,29 +22,52 @@ RSpec.describe Mutations::AlertManagement::PrometheusIntegration::Update, featur
project.add_maintainer(current_user)
end
- context 'when ::Projects::Operations::UpdateService responds with success' do
+ context 'when ::AlertManagement::HttpIntegrations::UpdateService responds with success' do
it 'returns the integration with no errors' do
expect(resolve).to eq(
integration: integration,
errors: []
)
+ expect(integration.reload.active?).to be(false)
end
end
- context 'when ::Projects::Operations::UpdateService responds with an error' do
+ context 'when ::AlertManagement::HttpIntegrations::UpdateService responds with an error' do
before do
- allow_any_instance_of(::Projects::Operations::UpdateService)
+ allow_any_instance_of(::AlertManagement::HttpIntegrations::UpdateService)
.to receive(:execute)
- .and_return({ status: :error, message: 'An error occurred' })
+ .and_return(ServiceResponse.error(payload: { integration: nil }, message: 'An error occurred'))
end
it 'returns errors' do
expect(resolve).to eq(
- integration: integration,
+ integration: nil,
errors: ['An error occurred']
)
end
end
+
+ context 'when prometheus_integration does not exist' do
+ before do
+ old_integration.destroy!
+ end
+
+ it 'raises an error if the resource is not accessible to the user' do
+ expect(args[:id]).to be_present
+
+ expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
+ end
+ end
+
+ context 'when prometheus_integration does not have corresponding AlertManagement::HttpIntegration' do
+ before do
+ integration.destroy!
+ end
+
+ it 'raises an error if the resource is not accessible to the user' do
+ expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
+ end
+ end
end
context 'when resource is not accessible to the user' do
diff --git a/spec/graphql/resolvers/alert_management/integrations_resolver_spec.rb b/spec/graphql/resolvers/alert_management/integrations_resolver_spec.rb
index ed2e7d35ee3..638d72508d8 100644
--- a/spec/graphql/resolvers/alert_management/integrations_resolver_spec.rb
+++ b/spec/graphql/resolvers/alert_management/integrations_resolver_spec.rb
@@ -8,13 +8,17 @@ RSpec.describe Resolvers::AlertManagement::IntegrationsResolver, feature_categor
let_it_be(:current_user) { create(:user) }
let_it_be(:project) { create(:project) }
let_it_be(:project2) { create(:project) }
- let_it_be(:prometheus_integration) { create(:prometheus_integration, project: project) }
+
let_it_be(:active_http_integration) { create(:alert_management_http_integration, project: project) }
let_it_be(:inactive_http_integration) { create(:alert_management_http_integration, :inactive, project: project) }
let_it_be(:other_proj_integration) { create(:alert_management_http_integration, project: project2) }
- let_it_be(:other_proj_prometheus_integration) { create(:prometheus_integration, project: project2) }
+
+ let_it_be(:prometheus_integration) { create(:prometheus_integration, project: project) }
let_it_be(:migrated_integration) { create(:alert_management_prometheus_integration, :legacy, project: project) }
+ let_it_be(:alt_prometheus_integration) { create(:prometheus_integration, project: project2) }
+ let_it_be(:alt_migrated_integration) { create(:alert_management_prometheus_integration, :legacy, project: project2) }
+
let(:params) { {} }
subject { sync(resolve_http_integrations(params)) }
@@ -33,7 +37,7 @@ RSpec.describe Resolvers::AlertManagement::IntegrationsResolver, feature_categor
project2.add_maintainer(current_user)
end
- it { is_expected.to contain_exactly(active_http_integration, prometheus_integration) }
+ it { is_expected.to contain_exactly(active_http_integration, migrated_integration) }
context 'when HTTP Integration ID is given' do
context 'when integration is from the current project' do
@@ -53,11 +57,11 @@ RSpec.describe Resolvers::AlertManagement::IntegrationsResolver, feature_categor
context 'when integration is from the current project' do
let(:params) { { id: global_id_of(prometheus_integration) } }
- it { is_expected.to contain_exactly(prometheus_integration) }
+ it { is_expected.to contain_exactly(migrated_integration) }
end
context 'when integration is from other project' do
- let(:params) { { id: global_id_of(other_proj_prometheus_integration) } }
+ let(:params) { { id: global_id_of(alt_prometheus_integration) } }
it { is_expected.to be_empty }
end
diff --git a/spec/graphql/types/alert_management/prometheus_integration_type_spec.rb b/spec/graphql/types/alert_management/prometheus_integration_type_spec.rb
index 8c0bb96488f..7ccc70528c8 100644
--- a/spec/graphql/types/alert_management/prometheus_integration_type_spec.rb
+++ b/spec/graphql/types/alert_management/prometheus_integration_type_spec.rb
@@ -2,11 +2,11 @@
require 'spec_helper'
-RSpec.describe GitlabSchema.types['AlertManagementPrometheusIntegration'] do
+RSpec.describe GitlabSchema.types['AlertManagementPrometheusIntegration'], feature_category: :incident_management do
include GraphqlHelpers
specify { expect(described_class.graphql_name).to eq('AlertManagementPrometheusIntegration') }
- specify { expect(described_class).to require_graphql_authorizations(:admin_project) }
+ specify { expect(described_class).to require_graphql_authorizations(:admin_operations) }
describe 'resolvers' do
shared_examples_for 'has field with value' do |field_name|
@@ -17,11 +17,11 @@ RSpec.describe GitlabSchema.types['AlertManagementPrometheusIntegration'] do
end
end
- let_it_be_with_reload(:integration) { create(:prometheus_integration) }
+ let_it_be_with_reload(:integration) { create(:alert_management_prometheus_integration, :legacy) }
let_it_be(:user) { create(:user, maintainer_of: integration.project) }
it_behaves_like 'has field with value', 'name' do
- let(:value) { integration.title }
+ let(:value) { integration.name }
end
it_behaves_like 'has field with value', 'type' do
@@ -29,7 +29,7 @@ RSpec.describe GitlabSchema.types['AlertManagementPrometheusIntegration'] do
end
it_behaves_like 'has field with value', 'token' do
- let(:value) { nil }
+ let(:value) { integration.token }
end
it_behaves_like 'has field with value', 'url' do
@@ -37,33 +37,7 @@ RSpec.describe GitlabSchema.types['AlertManagementPrometheusIntegration'] do
end
it_behaves_like 'has field with value', 'active' do
- let(:value) { integration.manual_configuration? }
- end
-
- context 'with alerting setting' do
- let_it_be(:alerting_setting) { create(:project_alerting_setting, project: integration.project) }
-
- it_behaves_like 'has field with value', 'token' do
- let(:value) { alerting_setting.token }
- end
- end
-
- describe 'a group integration' do
- let_it_be(:group) { create(:group) }
- let_it_be(:integration) { create(:prometheus_integration, :group, group: group) }
-
- # Since it is impossible to authorize the parent here, given that the
- # project is nil, all fields should be redacted:
-
- described_class.fields.each_key do |field_name|
- context "field: #{field_name}" do
- it 'is redacted' do
- expect do
- resolve_field(field_name, integration, current_user: user)
- end.to raise_error(GraphqlHelpers::UnauthorizedObject)
- end
- end
- end
+ let(:value) { integration.active }
end
end
end
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 00000000000..8eb13c8ac42
--- /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 00000000000..ef26dc9dd5d
--- /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 f898b18e200..439fedd3243 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 @@ RSpec.describe Gitlab::ImportExport::Project::RelationFactory, :use_clean_rails_
'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 516c94336f4..a85a4805f0c 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/requests/api/graphql/mutations/alert_management/prometheus_integration/create_spec.rb b/spec/requests/api/graphql/mutations/alert_management/prometheus_integration/create_spec.rb
index 0ab87941e6b..cc42c764f98 100644
--- a/spec/requests/api/graphql/mutations/alert_management/prometheus_integration/create_spec.rb
+++ b/spec/requests/api/graphql/mutations/alert_management/prometheus_integration/create_spec.rb
@@ -38,21 +38,7 @@ RSpec.describe 'Creating a new Prometheus Integration', feature_category: :incid
let(:mutation_response) { graphql_mutation_response(:prometheus_integration_create) }
- it 'creates a new integration' do
- post_graphql_mutation(mutation, current_user: current_user)
-
- new_integration = ::Integrations::Prometheus.last!
- integration_response = mutation_response['integration']
-
- expect(response).to have_gitlab_http_status(:success)
- expect(integration_response['id']).to eq(GitlabSchema.id_from_object(new_integration).to_s)
- expect(integration_response['type']).to eq('PROMETHEUS')
- expect(integration_response['name']).to eq(new_integration.title)
- expect(integration_response['active']).to eq(new_integration.manual_configuration?)
- expect(integration_response['token']).to eq(new_integration.project.alerting_setting.token)
- expect(integration_response['url']).to eq("http://localhost/#{project.full_path}/prometheus/alerts/notify.json")
- expect(integration_response['apiUrl']).to eq(new_integration.api_url)
- end
+ it_behaves_like 'creating a new HTTP integration', 'PROMETHEUS'
context 'without api url' do
let(:api_url) { nil }
diff --git a/spec/requests/api/graphql/mutations/alert_management/prometheus_integration/reset_token_spec.rb b/spec/requests/api/graphql/mutations/alert_management/prometheus_integration/reset_token_spec.rb
index 7b00c9e3802..b77d8c34e11 100644
--- a/spec/requests/api/graphql/mutations/alert_management/prometheus_integration/reset_token_spec.rb
+++ b/spec/requests/api/graphql/mutations/alert_management/prometheus_integration/reset_token_spec.rb
@@ -7,11 +7,12 @@ RSpec.describe 'Resetting a token on an existing Prometheus Integration', featur
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project, maintainers: user) }
- let_it_be(:integration) { create(:prometheus_integration, project: project) }
+ let_it_be(:old_integration) { create(:prometheus_integration, project: project) }
+ let_it_be(:integration) { create(:alert_management_prometheus_integration, :legacy, project: project) }
let(:mutation) do
variables = {
- id: GitlabSchema.id_from_object(integration).to_s
+ id: GitlabSchema.id_from_object(old_integration).to_s
}
graphql_mutation(:prometheus_integration_reset_token, variables) do
<<~QL
@@ -27,29 +28,15 @@ RSpec.describe 'Resetting a token on an existing Prometheus Integration', featur
let(:mutation_response) { graphql_mutation_response(:prometheus_integration_reset_token) }
- it 'creates a token' do
- post_graphql_mutation(mutation, current_user: user)
+ it 'updates the token' do
+ expect { post_graphql_mutation(mutation, current_user: user) }
+ .to change { integration.reload.token }
+
integration_response = mutation_response['integration']
expect(response).to have_gitlab_http_status(:success)
expect(integration_response['id']).to eq(GitlabSchema.id_from_object(integration).to_s)
expect(integration_response['token']).not_to be_nil
- expect(integration_response['token']).to eq(project.alerting_setting.token)
- end
-
- context 'with an existing alerting setting' do
- let_it_be(:alerting_setting) { create(:project_alerting_setting, project: project) }
-
- it 'updates the token' do
- previous_token = alerting_setting.token
-
- post_graphql_mutation(mutation, current_user: user)
- integration_response = mutation_response['integration']
-
- expect(response).to have_gitlab_http_status(:success)
- expect(integration_response['id']).to eq(GitlabSchema.id_from_object(integration).to_s)
- expect(integration_response['token']).not_to eq(previous_token)
- expect(integration_response['token']).to eq(alerting_setting.reload.token)
- end
+ expect(integration_response['token']).to eq(integration.token)
end
end
diff --git a/spec/requests/api/graphql/mutations/alert_management/prometheus_integration/update_spec.rb b/spec/requests/api/graphql/mutations/alert_management/prometheus_integration/update_spec.rb
index 3219127fb5a..125ce0f31d6 100644
--- a/spec/requests/api/graphql/mutations/alert_management/prometheus_integration/update_spec.rb
+++ b/spec/requests/api/graphql/mutations/alert_management/prometheus_integration/update_spec.rb
@@ -7,13 +7,14 @@ RSpec.describe 'Updating an existing Prometheus Integration', feature_category:
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project, maintainers: user) }
- let_it_be(:integration) { create(:prometheus_integration, project: project) }
+ let_it_be(:old_integration) { create(:prometheus_integration, project: project) }
+ let_it_be(:integration) { create(:alert_management_prometheus_integration, :legacy, project: project) }
let(:mutation) do
variables = {
- id: GitlabSchema.id_from_object(integration).to_s,
+ id: GitlabSchema.id_from_object(old_integration).to_s,
api_url: 'http://modified-url.com',
- active: true
+ active: false
}
graphql_mutation(:prometheus_integration_update, variables) do
<<~QL
@@ -37,7 +38,7 @@ RSpec.describe 'Updating an existing Prometheus Integration', feature_category:
expect(response).to have_gitlab_http_status(:success)
expect(integration_response['id']).to eq(GitlabSchema.id_from_object(integration).to_s)
- expect(integration_response['apiUrl']).to eq('http://modified-url.com')
- expect(integration_response['active']).to be_truthy
+ expect(integration_response['apiUrl']).to be_nil
+ expect(integration_response['active']).to be_falsey
end
end
diff --git a/spec/requests/api/graphql/project/alert_management/integrations_spec.rb b/spec/requests/api/graphql/project/alert_management/integrations_spec.rb
index c4d3a217027..5266a7a4748 100644
--- a/spec/requests/api/graphql/project/alert_management/integrations_spec.rb
+++ b/spec/requests/api/graphql/project/alert_management/integrations_spec.rb
@@ -7,8 +7,8 @@ RSpec.describe 'getting Alert Management Integrations', feature_category: :incid
let_it_be(:project) { create(:project, :repository) }
let_it_be(:current_user) { create(:user) }
- let_it_be(:prometheus_integration) { create(:prometheus_integration, project: project) }
- let_it_be(:project_alerting_setting) { create(:project_alerting_setting, project: project) }
+ let_it_be(:old_prometheus_integration) { create(:prometheus_integration, project: project) }
+ let_it_be(:prometheus_integration) { create(:alert_management_prometheus_integration, :legacy, project: project) }
let_it_be(:active_http_integration) { create(:alert_management_http_integration, project: project) }
let_it_be(:inactive_http_integration) { create(:alert_management_http_integration, :inactive, project: project) }
let_it_be(:other_project_http_integration) { create(:alert_management_http_integration) }
@@ -63,12 +63,7 @@ RSpec.describe 'getting Alert Management Integrations', feature_category: :incid
),
a_graphql_entity_for(
prometheus_integration,
- 'type' => 'PROMETHEUS',
- 'name' => 'Prometheus',
- 'active' => prometheus_integration.manual_configuration?,
- 'token' => project_alerting_setting.token,
- 'url' => "http://#{Gitlab.config.gitlab.host}/#{project.full_path}/prometheus/alerts/notify.json",
- 'apiUrl' => prometheus_integration.api_url
+ :name, :active, :token, :url, type: 'PROMETHEUS', api_url: nil
)
]
end
@@ -87,19 +82,14 @@ RSpec.describe 'getting Alert Management Integrations', feature_category: :incid
end
context 'when Prometheus Integration ID is given' do
- let(:params) { { id: global_id_of(prometheus_integration) } }
+ let(:params) { { id: global_id_of(old_prometheus_integration) } }
it_behaves_like 'a working graphql query'
it 'returns the correct properties of the Prometheus Integration' do
expect(integrations).to contain_exactly a_graphql_entity_for(
prometheus_integration,
- 'type' => 'PROMETHEUS',
- 'name' => 'Prometheus',
- 'active' => prometheus_integration.manual_configuration?,
- 'token' => project_alerting_setting.token,
- 'url' => "http://localhost/#{project.full_path}/prometheus/alerts/notify.json",
- 'apiUrl' => prometheus_integration.api_url
+ :name, :active, :token, :url, type: 'PROMETHEUS', api_url: nil
)
end
end
diff --git a/spec/services/integrations/test/project_service_spec.rb b/spec/services/integrations/test/project_service_spec.rb
index 4f8f932fb45..cc88a815f65 100644
--- a/spec/services/integrations/test/project_service_spec.rb
+++ b/spec/services/integrations/test/project_service_spec.rb
@@ -41,6 +41,24 @@ RSpec.describe Integrations::Test::ProjectService, feature_category: :integratio
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 f362c8da642..2c0caa38bb6 100644
--- a/spec/services/milestones/close_service_spec.rb
+++ b/spec/services/milestones/close_service_spec.rb
@@ -12,19 +12,71 @@ RSpec.describe Milestones::CloseService, feature_category: :team_planning do
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
- describe 'event' do
- let(:event) { Event.recent.first }
+ expect { service.execute(milestone) }.to change { Event.count }.by(1)
+ end
+ end
- it { expect(event.milestone).to be_truthy }
- it { expect(event.target).to eq(milestone) }
- it { expect(event.action_name).to eq('closed') }
+ 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
+
+ context 'when milestone is a group milestone' do
+ let(:group) { create(:group) }
+ let(:milestone) { create(:milestone, group: group) }
+
+ it_behaves_like 'does not close the milestone'
+ end
end
end
end
diff --git a/spec/services/milestones/create_service_spec.rb b/spec/services/milestones/create_service_spec.rb
index 70010d88fbd..1999b1d4039 100644
--- a/spec/services/milestones/create_service_spec.rb
+++ b/spec/services/milestones/create_service_spec.rb
@@ -29,6 +29,29 @@ RSpec.describe Milestones::CreateService, feature_category: :team_planning do
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 @@ RSpec.describe Milestones::CreateService, feature_category: :team_planning do
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 @@ RSpec.describe Milestones::CreateService, feature_category: :team_planning do
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 fd276a54e10..c76edd2648c 100644
--- a/spec/services/milestones/destroy_service_spec.rb
+++ b/spec/services/milestones/destroy_service_spec.rb
@@ -48,7 +48,9 @@ RSpec.describe Milestones::DestroyService, feature_category: :team_planning do
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 @@ RSpec.describe Milestones::DestroyService, feature_category: :team_planning do
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 00000000000..fc7874cbc84
--- /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 1e06911f8f7..cd2bc14c0ed 100644
--- a/spec/services/test_hooks/project_service_spec.rb
+++ b/spec/services/test_hooks/project_service_spec.rb
@@ -211,6 +211,18 @@ RSpec.describe TestHooks::ProjectService, feature_category: :code_testing do
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 }
diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb
index 5a9fa87a223..1075e20e5ef 100644
--- a/spec/spec_helper.rb
+++ b/spec/spec_helper.rb
@@ -223,6 +223,8 @@ RSpec.configure do |config|
config.include_context 'when rendered has no HTML escapes', type: :view
config.include_context 'with STI disabled', type: :model
+ # Validate JSONB columns only in EE to avoid false positives in FOSS.
+ config.include_context 'with JSONB validated columns', type: :model if Gitlab.ee?
include StubCurrentOrganization
include StubFeatureFlags
diff --git a/spec/support/shared_examples/models/jsonb_column_validation_shared_example.rb b/spec/support/shared_examples/models/jsonb_column_validation_shared_example.rb
new file mode 100644
index 00000000000..f6c5ac1c26b
--- /dev/null
+++ b/spec/support/shared_examples/models/jsonb_column_validation_shared_example.rb
@@ -0,0 +1,60 @@
+# frozen_string_literal: true
+
+require 'yaml'
+
+module Support
+ module JsonbColumnValidation
+ TODO_YAML = File.join(__dir__, 'jsonb_column_validation_todo.yml')
+
+ module_function
+
+ def todo?(model, column)
+ @todo ||= YAML.load_file(TODO_YAML).to_set # rubocop:disable Gitlab/PredicateMemoization -- @todo is never `nil` or `false`.
+ @todo.include?("#{model.name}##{column}")
+ end
+ end
+end
+
+# Checks whether JSONB columns are validated via JsonSchemaValidator.
+#
+# These checks are skipped in FOSS specs because they produce false positives,
+# as the models are missing their EE extensions, including their validations.
+# See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/195456#note_2601197778
+#
+# See https://docs.gitlab.com/development/migration_style_guide/#storing-json-in-database
+#
+# Parameter:
+# - model: Model class
+# - jsonb_column: List of JSONB columns
+RSpec.shared_examples 'Model validates JSONB columns' do |model, jsonb_columns|
+ jsonb_columns.each do |column|
+ context "with JSONB column #{column}" do
+ let(:json_schema_validator) do
+ model.validators_on(column).find { |validator| validator.is_a?(JsonSchemaValidator) }
+ end
+
+ it 'validates via JsonSchemaValidator' do
+ pending 'Still a TODO' if Support::JsonbColumnValidation.todo?(model, column)
+
+ docs_reference = 'See https://docs.gitlab.com/development/migration_style_guide/#storing-json-in-database.'
+ expect(json_schema_validator).to be_present,
+ "This JSONB column is missing schema validation. #{docs_reference}"
+ end
+ end
+ end
+end
+
+RSpec.shared_context 'with JSONB validated columns' do # rubocop:disable RSpec/SharedContext -- We cannot include `shared_examples` conditionally based on `type: :model`
+ model = described_class
+ jsonb_columns = \
+ model &&
+ model < ApplicationRecord &&
+ model.name && # skip unnamed/anonymous models
+ !model.abstract_class? &&
+ !model.table_name&.start_with?('_test') && # skip test models that define the tables in specs
+ model.columns.select { |c| c.type == :jsonb }.map(&:name).map(&:to_sym)
+
+ if jsonb_columns && jsonb_columns.any?
+ include_examples 'Model validates JSONB columns', described_class, jsonb_columns
+ end
+end
diff --git a/spec/support/shared_examples/models/jsonb_column_validation_todo.yml b/spec/support/shared_examples/models/jsonb_column_validation_todo.yml
new file mode 100644
index 00000000000..ad9facc2562
--- /dev/null
+++ b/spec/support/shared_examples/models/jsonb_column_validation_todo.yml
@@ -0,0 +1,47 @@
+# TODO list for `spec/support/shared_examples/models/jsonb_column_validation_shared_example.rb`
+# Ideally, this list should only shrink and never grow.
+- Ai::ActiveContext::Connection#options
+- Ai::ActiveContext::Migration#metadata
+- Ai::Conversation::Message#error_details
+- Ai::DuoWorkflows::Checkpoint#checkpoint
+- Ai::DuoWorkflows::Checkpoint#metadata
+- AlertManagement::Alert#payload
+- AlertManagement::HttpIntegration#payload_example
+- ApplicationSetting#ci_cd_settings
+- ApplicationSetting#oauth_provider
+- ApplicationSetting#rate_limits_unauthenticated_git_http
+- ApplicationSetting#repository_storages_weighted
+- ApplicationSetting#tmp_asset_proxy_secret_key
+- ApplicationSetting#token_prefixes
+- Ci::BuildMetadata#config_options
+- Ci::BuildMetadata#config_variables
+- Ci::PipelineScheduleInput#value
+- CloudConnector::Keys#secret_key
+- DependencyProxy::GroupSetting#identity
+- DependencyProxy::GroupSetting#secret
+- GeoNodeStatus#status
+- Geo::Event#payload
+- Geo::SecondaryUsageData#payload
+- GitlabSubscriptions::UserAddOnAssignmentVersion#object
+- MergeRequest::CommitsMetadata#trailers
+- Operations::FeatureFlags::Strategy#parameters
+- Packages::Composer::Metadatum#composer_json
+- RawUsageData#payload
+- Releases::Evidence#summary
+- RemoteDevelopment::WorkspaceAgentkState#desired_config
+- RemoteDevelopment::WorkspacesAgentConfig#annotations
+- RemoteDevelopment::WorkspacesAgentConfig#labels
+- RemoteDevelopment::WorkspacesAgentConfigVersion#object
+- RemoteDevelopment::WorkspacesAgentConfigVersion#object_changes
+- Sbom::Occurrence#ancestors
+- Security::ApprovalPolicyRule#content
+- Security::Policy#metadata
+- Security::ScanExecutionPolicyRule#content
+- Security::VulnerabilityManagementPolicyRule#content
+- ServicePing::NonSqlServicePing#metadata
+- ServicePing::NonSqlServicePing#payload
+- ServicePing::QueriesServicePing#payload
+- VirtualRegistries::Packages::Maven::Upstream#password
+- VirtualRegistries::Packages::Maven::Upstream#username
+- Vulnerabilities::Finding::Evidence#data
+- Vulnerabilities::Finding#location