From 857547ae941db6dcfcb912c0c76afd268f958216 Mon Sep 17 00:00:00 2001
From: Alexander Dietrich <adietrich@gitlab.com>
Date: Wed, 12 Jul 2023 01:23:44 +0000
Subject: [PATCH] Add group mention Slack events behind feature flag

Adds group_mention and group_confidential_mention as group-level
integrations events/hooks and Slack notification triggers.

- https://gitlab.com/gitlab-com/gl-security/engineering-and-research/automation-team/automation/-/issues/346
- https://gitlab.com/gitlab-org/gitlab/-/issues/414856

Changelog: added
---
 .../concerns/integrations/params.rb           |   2 +
 app/helpers/integrations_helper.rb            |   8 +
 app/models/group.rb                           |   7 +-
 app/models/integration.rb                     |   4 +
 .../integrations/base_chat_notification.rb    |   4 +-
 .../integrations/base_slack_notification.rb   |  11 +-
 .../chat_message/group_mention_message.rb     | 102 +++++++++
 .../integrations/group_mention_service.rb     |  59 ++++++
 app/services/issues/base_service.rb           |  19 ++
 app/services/merge_requests/base_service.rb   |  19 ++
 app/services/notes/post_process_service.rb    |  11 +-
 app/workers/all_queues.yml                    |   9 +
 .../integrations/group_mention_worker.rb      |  42 ++++
 .../development/group_mentions.yml            |   8 +
 config/sidekiq_queues.yml                     |   2 +
 ...dd_group_mention_events_to_integrations.rb |   8 +
 db/schema_migrations/20230705155000           |   1 +
 db/structure.sql                              |   2 +
 .../integrations/gitlab_slack_application.md  |   2 +
 doc/user/project/integrations/slack.md        |   2 +
 locale/gitlab.pot                             |  12 ++
 spec/models/group_spec.rb                     |  22 ++
 .../group_mention_message_spec.rb             | 193 ++++++++++++++++++
 .../group_mention_service_spec.rb             | 143 +++++++++++++
 spec/services/issues/create_service_spec.rb   |   6 +
 spec/services/issues/reopen_service_spec.rb   |   6 +
 .../after_create_service_spec.rb              |   6 +
 .../merge_requests/reopen_service_spec.rb     |   4 +
 .../notes/post_process_service_spec.rb        |   3 +
 .../integrations/group_mention_worker_spec.rb |  65 ++++++
 30 files changed, 775 insertions(+), 7 deletions(-)
 create mode 100644 app/models/integrations/chat_message/group_mention_message.rb
 create mode 100644 app/services/integrations/group_mention_service.rb
 create mode 100644 app/workers/integrations/group_mention_worker.rb
 create mode 100644 config/feature_flags/development/group_mentions.yml
 create mode 100644 db/migrate/20230705155000_add_group_mention_events_to_integrations.rb
 create mode 100644 db/schema_migrations/20230705155000
 create mode 100644 spec/models/integrations/chat_message/group_mention_message_spec.rb
 create mode 100644 spec/services/integrations/group_mention_service_spec.rb
 create mode 100644 spec/workers/integrations/group_mention_worker_spec.rb

diff --git a/app/controllers/concerns/integrations/params.rb b/app/controllers/concerns/integrations/params.rb
index 19e458307a18c..53dd06ce6386a 100644
--- a/app/controllers/concerns/integrations/params.rb
+++ b/app/controllers/concerns/integrations/params.rb
@@ -43,6 +43,8 @@ module Params
       :external_wiki_url,
       :google_iap_service_account_json,
       :google_iap_audience_client_id,
+      :group_confidential_mention_events,
+      :group_mention_events,
       :incident_events,
       :inherit_from_id,
       # We're using `issues_events` and `merge_requests_events`
diff --git a/app/helpers/integrations_helper.rb b/app/helpers/integrations_helper.rb
index ffea23bf55d22..4b5fadf339748 100644
--- a/app/helpers/integrations_helper.rb
+++ b/app/helpers/integrations_helper.rb
@@ -30,6 +30,10 @@ def integration_event_title(event)
       _("Alert")
     when "incident"
       _("Incident")
+    when "group_mention"
+      _("Group mention in public")
+    when "group_confidential_mention"
+      _("Group mention in private")
     end
   end
   # rubocop:enable Metrics/CyclomaticComplexity
@@ -290,6 +294,10 @@ def default_integration_event_description(event)
       s_("ProjectService|Trigger event when a new, unique alert is recorded.")
     when "incident"
       s_("ProjectService|Trigger event when an incident is created.")
+    when "group_mention"
+      s_("ProjectService|Trigger event when a group is mentioned in a public context.")
+    when "group_confidential_mention"
+      s_("ProjectService|Trigger event when a group is mentioned in a confidential context.")
     end
   end
   # rubocop:enable Metrics/CyclomaticComplexity
diff --git a/app/models/group.rb b/app/models/group.rb
index 4e92bdab94ce7..c004d525fcde7 100644
--- a/app/models/group.rb
+++ b/app/models/group.rb
@@ -813,8 +813,11 @@ def execute_hooks(data, hooks_scope)
   end
 
   def execute_integrations(data, hooks_scope)
-    # NOOP
-    # TODO: group hooks https://gitlab.com/gitlab-org/gitlab/-/issues/216904
+    return unless Feature.enabled?(:group_mentions, self)
+
+    integrations.public_send(hooks_scope).each do |integration| # rubocop:disable GitlabSecurity/PublicSend
+      integration.async_execute(data)
+    end
   end
 
   def preload_shared_group_links
diff --git a/app/models/integration.rb b/app/models/integration.rb
index eae94f7cd5d0a..f823a38502255 100644
--- a/app/models/integration.rb
+++ b/app/models/integration.rb
@@ -90,6 +90,8 @@ def properties=(props)
   attribute :push_events, default: true
   attribute :tag_push_events, default: true
   attribute :wiki_page_events, default: true
+  attribute :group_mention_events, default: false
+  attribute :group_confidential_mention_events, default: false
 
   after_initialize :initialize_properties
 
@@ -137,6 +139,8 @@ def properties=(props)
   scope :alert_hooks, -> { where(alert_events: true, active: true) }
   scope :incident_hooks, -> { where(incident_events: true, active: true) }
   scope :deployment, -> { where(category: 'deployment') }
+  scope :group_mention_hooks, -> { where(group_mention_events: true, active: true) }
+  scope :group_confidential_mention_hooks, -> { where(group_confidential_mention_events: true, active: true) }
 
   class << self
     private
diff --git a/app/models/integrations/base_chat_notification.rb b/app/models/integrations/base_chat_notification.rb
index 4477f3d207f11..c9de4d2b3bbfb 100644
--- a/app/models/integrations/base_chat_notification.rb
+++ b/app/models/integrations/base_chat_notification.rb
@@ -262,11 +262,11 @@ def build_event_channels
     end
 
     def project_name
-      project.full_name
+      project.try(:full_name)
     end
 
     def project_url
-      project.web_url
+      project.try(:web_url)
     end
 
     def update?(data)
diff --git a/app/models/integrations/base_slack_notification.rb b/app/models/integrations/base_slack_notification.rb
index c83a559e0da61..29a20419809c3 100644
--- a/app/models/integrations/base_slack_notification.rb
+++ b/app/models/integrations/base_slack_notification.rb
@@ -7,6 +7,8 @@ class BaseSlackNotification < BaseChatNotification
     ].freeze
 
     prop_accessor EVENT_CHANNEL['alert']
+    prop_accessor EVENT_CHANNEL['group_mention']
+    prop_accessor EVENT_CHANNEL['group_confidential_mention']
 
     override :default_channel_placeholder
     def default_channel_placeholder
@@ -16,15 +18,20 @@ def default_channel_placeholder
     override :get_message
     def get_message(object_kind, data)
       return Integrations::ChatMessage::AlertMessage.new(data) if object_kind == 'alert'
+      return Integrations::ChatMessage::GroupMentionMessage.new(data) if object_kind == 'group_mention'
 
       super
     end
 
     override :supported_events
     def supported_events
-      additional = ['alert']
+      additional = %w[alert]
 
-      super + additional
+      if group_level? && Feature.enabled?(:group_mentions, group)
+        additional += %w[group_mention group_confidential_mention]
+      end
+
+      (super + additional).freeze
     end
 
     override :configurable_channels?
diff --git a/app/models/integrations/chat_message/group_mention_message.rb b/app/models/integrations/chat_message/group_mention_message.rb
new file mode 100644
index 0000000000000..a2bc00ddbd929
--- /dev/null
+++ b/app/models/integrations/chat_message/group_mention_message.rb
@@ -0,0 +1,102 @@
+# frozen_string_literal: true
+
+module Integrations
+  module ChatMessage
+    class GroupMentionMessage < BaseMessage
+      ISSUE_KIND = 'issue'
+      MR_KIND    = 'merge_request'
+      NOTE_KIND  = 'note'
+
+      KNOWN_KINDS = [ISSUE_KIND, MR_KIND, NOTE_KIND].freeze
+
+      def initialize(params)
+        super
+        params = HashWithIndifferentAccess.new(params)
+
+        @group_name, @group_url = params[:mentioned].values_at(:name, :url)
+        @detail = nil
+
+        obj_attr = params[:object_attributes]
+        obj_kind = obj_attr[:object_kind]
+        raise NotImplementedError unless KNOWN_KINDS.include?(obj_kind)
+
+        case obj_kind
+        when 'issue'
+          @source_name, @title = get_source_for_issue(obj_attr)
+          @detail = obj_attr[:description]
+        when 'merge_request'
+          @source_name, @title = get_source_for_merge_request(obj_attr)
+          @detail = obj_attr[:description]
+        when 'note'
+          if params[:commit]
+            @source_name, @title = get_source_for_commit(params[:commit])
+          elsif params[:issue]
+            @source_name, @title = get_source_for_issue(params[:issue])
+          elsif params[:merge_request]
+            @source_name, @title = get_source_for_merge_request(params[:merge_request])
+          else
+            raise NotImplementedError
+          end
+
+          @detail = obj_attr[:note]
+        end
+
+        @source_url = obj_attr[:url]
+      end
+
+      def attachments
+        if markdown
+          detail
+        else
+          [{ text: format(detail), color: attachment_color }]
+        end
+      end
+
+      def activity
+        {
+          title: "Group #{group_link} was mentioned in #{source_link}",
+          subtitle: "of #{project_link}",
+          text: strip_markup(formatted_title),
+          image: user_avatar
+        }
+      end
+
+      private
+
+      attr_reader :group_name, :group_url, :source_name, :source_url, :title, :detail
+
+      def get_source_for_commit(params)
+        commit_sha = Commit.truncate_sha(params[:id])
+        ["commit #{commit_sha}", params[:title]]
+      end
+
+      def get_source_for_issue(params)
+        ["issue ##{params[:iid]}", params[:title]]
+      end
+
+      def get_source_for_merge_request(params)
+        ["merge request !#{params[:iid]}", params[:title]]
+      end
+
+      def message
+        "Group #{group_link} was mentioned in #{source_link} of #{project_link}: *#{formatted_title}*"
+      end
+
+      def formatted_title
+        strip_markup(title.lines.first.chomp)
+      end
+
+      def group_link
+        link(group_name, group_url)
+      end
+
+      def source_link
+        link(source_name, source_url)
+      end
+
+      def project_link
+        link(project_name, project_url)
+      end
+    end
+  end
+end
diff --git a/app/services/integrations/group_mention_service.rb b/app/services/integrations/group_mention_service.rb
new file mode 100644
index 0000000000000..2389bf3343212
--- /dev/null
+++ b/app/services/integrations/group_mention_service.rb
@@ -0,0 +1,59 @@
+# frozen_string_literal: true
+
+# GroupMentionService class
+#
+# Used for sending group mention notifications
+#
+# Ex.
+#   Integrations::GroupMentionService.new(mentionable, hook_data: data, is_confidential: true).execute
+#
+module Integrations
+  class GroupMentionService
+    def initialize(mentionable, hook_data:, is_confidential:)
+      @mentionable = mentionable
+      @hook_data = hook_data
+      @is_confidential = is_confidential
+    end
+
+    def execute
+      return ServiceResponse.success if mentionable.nil? || hook_data.nil?
+
+      @hook_data = hook_data.clone
+      # Fake a "group_mention" object kind so integrations can handle this as a separate class of event
+      hook_data[:object_attributes][:object_kind] = hook_data[:object_kind]
+      hook_data[:object_kind] = 'group_mention'
+
+      if confidential?
+        hook_data[:event_type] = 'group_confidential_mention'
+        hook_scope = :group_confidential_mention_hooks
+      else
+        hook_data[:event_type] = 'group_mention'
+        hook_scope = :group_mention_hooks
+      end
+
+      groups = mentionable.referenced_groups(mentionable.author)
+      groups.each do |group|
+        group_hook_data = hook_data.merge(
+          mentioned: {
+            object_kind: 'group',
+            name: group.full_path,
+            url: group.web_url
+          }
+        )
+        group.execute_integrations(group_hook_data, hook_scope)
+      end
+
+      ServiceResponse.success
+    end
+
+    private
+
+    attr_reader :mentionable, :hook_data, :is_confidential
+
+    def confidential?
+      return is_confidential if is_confidential.present?
+
+      mentionable.project.visibility_level != Gitlab::VisibilityLevel::PUBLIC
+    end
+  end
+end
diff --git a/app/services/issues/base_service.rb b/app/services/issues/base_service.rb
index f982d66eb088e..b9b7cd08b686d 100644
--- a/app/services/issues/base_service.rb
+++ b/app/services/issues/base_service.rb
@@ -111,6 +111,10 @@ def execute_hooks(issue, action = 'open', old_associations: {})
       issue.namespace.execute_integrations(issue_data, hooks_scope)
 
       execute_incident_hooks(issue, issue_data) if issue.work_item_type&.incident?
+
+      return unless Feature.enabled?(:group_mentions, issue.project)
+
+      execute_group_mention_hooks(issue, issue_data) if action == 'open'
     end
 
     # We can remove this code after proposal in
@@ -121,6 +125,21 @@ def execute_incident_hooks(issue, issue_data)
       issue.namespace.execute_integrations(issue_data, :incident_hooks)
     end
 
+    def execute_group_mention_hooks(issue, issue_data)
+      return unless issue.instance_of?(Issue)
+
+      args = {
+        mentionable_type: 'Issue',
+        mentionable_id: issue.id,
+        hook_data: issue_data,
+        is_confidential: issue.confidential?
+      }
+
+      issue.run_after_commit_or_now do
+        Integrations::GroupMentionWorker.perform_async(args)
+      end
+    end
+
     def update_project_counter_caches?(issue)
       super || issue.confidential_changed?
     end
diff --git a/app/services/merge_requests/base_service.rb b/app/services/merge_requests/base_service.rb
index ec8a17162ca0e..aaa91548d198f 100644
--- a/app/services/merge_requests/base_service.rb
+++ b/app/services/merge_requests/base_service.rb
@@ -36,6 +36,10 @@ def execute_hooks(merge_request, action = 'open', old_rev: nil, old_associations
 
       execute_external_hooks(merge_request, merge_data)
 
+      if action == 'open' && Feature.enabled?(:group_mentions, merge_request.project)
+        execute_group_mention_hooks(merge_request, merge_data)
+      end
+
       enqueue_jira_connect_messages_for(merge_request)
     end
 
@@ -43,6 +47,21 @@ def execute_external_hooks(merge_request, merge_data)
       # Implemented in EE
     end
 
+    def execute_group_mention_hooks(merge_request, merge_data)
+      return unless merge_request.instance_of?(MergeRequest)
+
+      args = {
+        mentionable_type: 'MergeRequest',
+        mentionable_id: merge_request.id,
+        hook_data: merge_data,
+        is_confidential: false
+      }
+
+      merge_request.run_after_commit_or_now do
+        Integrations::GroupMentionWorker.perform_async(args)
+      end
+    end
+
     def handle_changes(merge_request, options)
       old_associations = options.fetch(:old_associations, {})
       old_assignees = old_associations.fetch(:assignees, [])
diff --git a/app/services/notes/post_process_service.rb b/app/services/notes/post_process_service.rb
index c9375fe14a1df..9465b5218b029 100644
--- a/app/services/notes/post_process_service.rb
+++ b/app/services/notes/post_process_service.rb
@@ -36,10 +36,19 @@ def execute_note_hooks
       return unless note.project
 
       note_data = hook_data
-      hooks_scope = note.confidential?(include_noteable: true) ? :confidential_note_hooks : :note_hooks
+      is_confidential = note.confidential?(include_noteable: true)
+      hooks_scope = is_confidential ? :confidential_note_hooks : :note_hooks
 
       note.project.execute_hooks(note_data, hooks_scope)
       note.project.execute_integrations(note_data, hooks_scope)
+
+      return unless Feature.enabled?(:group_mentions, note.project)
+
+      execute_group_mention_hooks(note, note_data, is_confidential)
+    end
+
+    def execute_group_mention_hooks(note, note_data, is_confidential)
+      Integrations::GroupMentionService.new(note, hook_data: note_data, is_confidential: is_confidential).execute
     end
   end
 end
diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml
index 316b11ad7910e..f4d9443284249 100644
--- a/app/workers/all_queues.yml
+++ b/app/workers/all_queues.yml
@@ -2847,6 +2847,15 @@
   :weight: 1
   :idempotent: false
   :tags: []
+- :name: integrations_group_mention
+  :worker_name: Integrations::GroupMentionWorker
+  :feature_category: :integrations
+  :has_external_dependencies: true
+  :urgency: :low
+  :resource_boundary: :unknown
+  :weight: 1
+  :idempotent: true
+  :tags: []
 - :name: integrations_irker
   :worker_name: Integrations::IrkerWorker
   :feature_category: :integrations
diff --git a/app/workers/integrations/group_mention_worker.rb b/app/workers/integrations/group_mention_worker.rb
new file mode 100644
index 0000000000000..6cde1657ccdad
--- /dev/null
+++ b/app/workers/integrations/group_mention_worker.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+module Integrations
+  class GroupMentionWorker
+    include ApplicationWorker
+
+    idempotent!
+    feature_category :integrations
+    deduplicate :until_executed
+    data_consistency :delayed
+    urgency :low
+
+    worker_has_external_dependencies!
+
+    def perform(args)
+      args = args.with_indifferent_access
+
+      mentionable_type = args[:mentionable_type]
+      mentionable_id = args[:mentionable_id]
+      hook_data = args[:hook_data]
+      is_confidential = args[:is_confidential]
+
+      mentionable = case mentionable_type
+                    when 'Issue'
+                      Issue.find(mentionable_id)
+                    when 'MergeRequest'
+                      MergeRequest.find(mentionable_id)
+                    end
+
+      if mentionable.nil?
+        Sidekiq.logger.error(
+          message: 'Integrations::GroupMentionWorker: mentionable not supported',
+          mentionable_type: mentionable_type,
+          mentionable_id: mentionable_id
+        )
+        return
+      end
+
+      Integrations::GroupMentionService.new(mentionable, hook_data: hook_data, is_confidential: is_confidential).execute
+    end
+  end
+end
diff --git a/config/feature_flags/development/group_mentions.yml b/config/feature_flags/development/group_mentions.yml
new file mode 100644
index 0000000000000..4f536b2b583e0
--- /dev/null
+++ b/config/feature_flags/development/group_mentions.yml
@@ -0,0 +1,8 @@
+---
+name: group_mentions
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/96684
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/414856
+milestone: '16.2'
+type: development
+group: group::import and integrate
+default_enabled: false
diff --git a/config/sidekiq_queues.yml b/config/sidekiq_queues.yml
index e4c45120ee8ac..bfb6f05b33579 100644
--- a/config/sidekiq_queues.yml
+++ b/config/sidekiq_queues.yml
@@ -303,6 +303,8 @@
   - 1
 - - integrations_execute
   - 1
+- - integrations_group_mention
+  - 1
 - - integrations_irker
   - 1
 - - integrations_slack_event
diff --git a/db/migrate/20230705155000_add_group_mention_events_to_integrations.rb b/db/migrate/20230705155000_add_group_mention_events_to_integrations.rb
new file mode 100644
index 0000000000000..82c2c7140222c
--- /dev/null
+++ b/db/migrate/20230705155000_add_group_mention_events_to_integrations.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+class AddGroupMentionEventsToIntegrations < Gitlab::Database::Migration[2.1]
+  def change
+    add_column :integrations, :group_mention_events, :boolean, null: false, default: false
+    add_column :integrations, :group_confidential_mention_events, :boolean, null: false, default: false
+  end
+end
diff --git a/db/schema_migrations/20230705155000 b/db/schema_migrations/20230705155000
new file mode 100644
index 0000000000000..e2f6d13e31e10
--- /dev/null
+++ b/db/schema_migrations/20230705155000
@@ -0,0 +1 @@
+4f3d64c52ac1b46bab194be78cadeaa36abf3faf26de1c7d7ca3c03cc64b876f
\ No newline at end of file
diff --git a/db/structure.sql b/db/structure.sql
index 5f3b85edba047..ce7825fc58091 100644
--- a/db/structure.sql
+++ b/db/structure.sql
@@ -17152,6 +17152,8 @@ CREATE TABLE integrations (
     encrypted_properties bytea,
     encrypted_properties_iv bytea,
     incident_events boolean DEFAULT false NOT NULL,
+    group_mention_events boolean DEFAULT false NOT NULL,
+    group_confidential_mention_events boolean DEFAULT false NOT NULL,
     CONSTRAINT check_a948a0aa7e CHECK ((char_length(type_new) <= 255))
 );
 
diff --git a/doc/user/project/integrations/gitlab_slack_application.md b/doc/user/project/integrations/gitlab_slack_application.md
index 77a08585e0832..1e4c9b36f710e 100644
--- a/doc/user/project/integrations/gitlab_slack_application.md
+++ b/doc/user/project/integrations/gitlab_slack_application.md
@@ -153,6 +153,8 @@ The following events are available for Slack notifications:
 | **Wiki page**                                                            | A wiki page is created or updated.                   |
 | **Deployment**                                                           | A deployment starts or finishes.                     |
 | **Alert**                                                                | A new, unique alert is recorded.                     |
+| **Group mention in public**                                              | A group is mentioned in a public context.            |
+| **Group mention in private**                                             | A group is mentioned in a confidential context.      |
 | [**Vulnerability**](../../application_security/vulnerabilities/index.md) | A new, unique vulnerability is recorded.             |
 
 ## Troubleshooting
diff --git a/doc/user/project/integrations/slack.md b/doc/user/project/integrations/slack.md
index 14183a47625a9..55000a0a99227 100644
--- a/doc/user/project/integrations/slack.md
+++ b/doc/user/project/integrations/slack.md
@@ -79,6 +79,8 @@ The following triggers are available for Slack notifications:
 | **Wiki page**                                                            | A wiki page is created or updated.                   |
 | **Deployment**                                                           | A deployment starts or finishes.                     |
 | **Alert**                                                                | A new, unique alert is recorded.                     |
+| **Group mention in public**                                              | A group is mentioned in a public context.            |
+| **Group mention in private**                                             | A group is mentioned in a confidential context.      |
 | [**Vulnerability**](../../application_security/vulnerabilities/index.md) | A new, unique vulnerability is recorded.             |
 
 ## Troubleshooting
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 0d4f83413454c..53f00d0066a80 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -21516,6 +21516,12 @@ msgstr ""
 msgid "Group membership expiration date removed"
 msgstr ""
 
+msgid "Group mention in private"
+msgstr ""
+
+msgid "Group mention in public"
+msgstr ""
+
 msgid "Group milestone"
 msgstr ""
 
@@ -35876,6 +35882,12 @@ msgstr ""
 msgid "ProjectService|Trigger event when a deployment starts or finishes."
 msgstr ""
 
+msgid "ProjectService|Trigger event when a group is mentioned in a confidential context."
+msgstr ""
+
+msgid "ProjectService|Trigger event when a group is mentioned in a public context."
+msgstr ""
+
 msgid "ProjectService|Trigger event when a merge request is created, updated, or merged."
 msgstr ""
 
diff --git a/spec/models/group_spec.rb b/spec/models/group_spec.rb
index 8385468756f25..61c64a822969f 100644
--- a/spec/models/group_spec.rb
+++ b/spec/models/group_spec.rb
@@ -852,6 +852,28 @@
     end
   end
 
+  describe '.execute_integrations' do
+    let(:integration) { create(:integrations_slack, :group, group: group) }
+    let(:test_data) { { 'foo' => 'bar' } }
+
+    before do
+      allow(group.integrations).to receive(:public_send).and_return([])
+      allow(group.integrations).to receive(:public_send).with(:push_hooks).and_return([integration])
+    end
+
+    it 'executes integrations with a matching scope' do
+      expect(integration).to receive(:async_execute).with(test_data)
+
+      group.execute_integrations(test_data, :push_hooks)
+    end
+
+    it 'ignores integrations without a matching scope' do
+      expect(integration).not_to receive(:async_execute).with(test_data)
+
+      group.execute_integrations(test_data, :note_hooks)
+    end
+  end
+
   describe '.public_or_visible_to_user' do
     let!(:private_group) { create(:group, :private) }
     let!(:private_subgroup) { create(:group, :private, parent: private_group) }
diff --git a/spec/models/integrations/chat_message/group_mention_message_spec.rb b/spec/models/integrations/chat_message/group_mention_message_spec.rb
new file mode 100644
index 0000000000000..6fa486f3dc310
--- /dev/null
+++ b/spec/models/integrations/chat_message/group_mention_message_spec.rb
@@ -0,0 +1,193 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Integrations::ChatMessage::GroupMentionMessage, feature_category: :integrations do
+  subject { described_class.new(args) }
+
+  let(:color) { '#345' }
+  let(:args) do
+    {
+      object_kind: 'group_mention',
+      mentioned: {
+        object_kind: 'group',
+        name: 'test/group',
+        url: 'http://test/group'
+      },
+      user: {
+        name: 'Test User',
+        username: 'test.user',
+        avatar_url: 'http://avatar'
+      },
+      project_name: 'Test Project',
+      project_url: 'http://project'
+    }
+  end
+
+  context 'for issue descriptions' do
+    let(:attachments) { [{ text: "Issue\ndescription\n123", color: color }] }
+
+    before do
+      args[:object_attributes] = {
+        object_kind: 'issue',
+        iid: '42',
+        title: 'Test Issue',
+        description: "Issue\ndescription\n123",
+        url: 'http://issue'
+      }
+    end
+
+    it 'returns the appropriate message' do
+      expect(subject.pretext).to eq(
+        'Group <http://test/group|test/group> was mentioned ' \
+        'in <http://issue|issue #42> ' \
+        'of <http://project|Test Project>: ' \
+        '*Test Issue*'
+      )
+      expect(subject.attachments).to eq(attachments)
+    end
+
+    context 'with markdown' do
+      before do
+        args[:markdown] = true
+      end
+
+      it 'returns the appropriate message' do
+        expect(subject.pretext).to eq(
+          'Group [test/group](http://test/group) was mentioned ' \
+          'in [issue #42](http://issue) ' \
+          'of [Test Project](http://project): ' \
+          '*Test Issue*'
+        )
+        expect(subject.attachments).to eq("Issue\ndescription\n123")
+        expect(subject.activity).to eq(
+          {
+            title: 'Group [test/group](http://test/group) was mentioned in [issue #42](http://issue)',
+            subtitle: 'of [Test Project](http://project)',
+            text: 'Test Issue',
+            image: 'http://avatar'
+          }
+        )
+      end
+    end
+  end
+
+  context 'for merge request descriptions' do
+    let(:attachments) { [{ text: "MR\ndescription\n123", color: color }] }
+
+    before do
+      args[:object_attributes] = {
+        object_kind: 'merge_request',
+        iid: '42',
+        title: 'Test MR',
+        description: "MR\ndescription\n123",
+        url: 'http://merge_request'
+      }
+    end
+
+    it 'returns the appropriate message' do
+      expect(subject.pretext).to eq(
+        'Group <http://test/group|test/group> was mentioned ' \
+        'in <http://merge_request|merge request !42> ' \
+        'of <http://project|Test Project>: ' \
+        '*Test MR*'
+      )
+      expect(subject.attachments).to eq(attachments)
+    end
+  end
+
+  context 'for notes' do
+    let(:attachments) { [{ text: 'Test Comment', color: color }] }
+
+    before do
+      args[:object_attributes] = {
+        object_kind: 'note',
+        note: 'Test Comment',
+        url: 'http://note'
+      }
+    end
+
+    context 'on commits' do
+      before do
+        args[:commit] = {
+          id: '5f163b2b95e6f53cbd428f5f0b103702a52b9a23',
+          title: 'Test Commit',
+          message: "Commit\nmessage\n123\n"
+        }
+      end
+
+      it 'returns the appropriate message' do
+        expect(subject.pretext).to eq(
+          'Group <http://test/group|test/group> was mentioned ' \
+          'in <http://note|commit 5f163b2b> ' \
+          'of <http://project|Test Project>: ' \
+          '*Test Commit*'
+        )
+        expect(subject.attachments).to eq(attachments)
+      end
+    end
+
+    context 'on issues' do
+      before do
+        args[:issue] = {
+          iid: '42',
+          title: 'Test Issue'
+        }
+      end
+
+      it 'returns the appropriate message' do
+        expect(subject.pretext).to eq(
+          'Group <http://test/group|test/group> was mentioned ' \
+          'in <http://note|issue #42> ' \
+          'of <http://project|Test Project>: ' \
+          '*Test Issue*'
+        )
+        expect(subject.attachments).to eq(attachments)
+      end
+    end
+
+    context 'on merge requests' do
+      before do
+        args[:merge_request] = {
+          iid: '42',
+          title: 'Test MR'
+        }
+      end
+
+      it 'returns the appropriate message' do
+        expect(subject.pretext).to eq(
+          'Group <http://test/group|test/group> was mentioned ' \
+          'in <http://note|merge request !42> ' \
+          'of <http://project|Test Project>: ' \
+          '*Test MR*'
+        )
+        expect(subject.attachments).to eq(attachments)
+      end
+    end
+  end
+
+  context 'for unsupported object types' do
+    before do
+      args[:object_attributes] = { object_kind: 'unsupported' }
+    end
+
+    it 'raises an error' do
+      expect { described_class.new(args) }.to raise_error(NotImplementedError)
+    end
+  end
+
+  context 'for notes on unsupported object types' do
+    before do
+      args[:object_attributes] = {
+        object_kind: 'note',
+        note: 'Test Comment',
+        url: 'http://note'
+      }
+      # Not adding a supported object type's attributes
+    end
+
+    it 'raises an error' do
+      expect { described_class.new(args) }.to raise_error(NotImplementedError)
+    end
+  end
+end
diff --git a/spec/services/integrations/group_mention_service_spec.rb b/spec/services/integrations/group_mention_service_spec.rb
new file mode 100644
index 0000000000000..72d53ce6d0600
--- /dev/null
+++ b/spec/services/integrations/group_mention_service_spec.rb
@@ -0,0 +1,143 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Integrations::GroupMentionService, feature_category: :integrations do
+  subject(:execute) { described_class.new(mentionable, hook_data: hook_data, is_confidential: is_confidential).execute }
+
+  let_it_be(:user) { create(:user) }
+  let_it_be(:group) { create(:group) }
+
+  before do
+    allow(mentionable).to receive(:referenced_groups).with(user).and_return([group])
+  end
+
+  shared_examples 'group_mention_hooks' do
+    specify do
+      expect(group).to receive(:execute_integrations).with(anything, :group_mention_hooks)
+      expect(execute).to be_success
+    end
+  end
+
+  shared_examples 'group_confidential_mention_hooks' do
+    specify do
+      expect(group).to receive(:execute_integrations).with(anything, :group_confidential_mention_hooks)
+      expect(execute).to be_success
+    end
+  end
+
+  context 'for issue descriptions' do
+    let(:hook_data) { mentionable.to_hook_data(user) }
+    let(:is_confidential) { mentionable.confidential? }
+
+    context 'in public projects' do
+      let_it_be(:project) { create(:project, :public) }
+
+      context 'in public issues' do
+        let(:mentionable) do
+          create(:issue, confidential: false, project: project, author: user, description: "@#{group.full_path}")
+        end
+
+        it_behaves_like 'group_mention_hooks'
+      end
+
+      context 'in confidential issues' do
+        let(:mentionable) do
+          create(:issue, confidential: true, project: project, author: user, description: "@#{group.full_path}")
+        end
+
+        it_behaves_like 'group_confidential_mention_hooks'
+      end
+    end
+
+    context 'in private projects' do
+      let_it_be(:project) { create(:project, :private) }
+
+      context 'in public issues' do
+        let(:mentionable) do
+          create(:issue, confidential: false, project: project, author: user, description: "@#{group.full_path}")
+        end
+
+        it_behaves_like 'group_confidential_mention_hooks'
+      end
+
+      context 'in confidential issues' do
+        let(:mentionable) do
+          create(:issue, confidential: true, project: project, author: user, description: "@#{group.full_path}")
+        end
+
+        it_behaves_like 'group_confidential_mention_hooks'
+      end
+    end
+  end
+
+  context 'for merge request descriptions' do
+    let(:hook_data) { mentionable.to_hook_data(user) }
+    let(:is_confidential) { false }
+    let(:mentionable) do
+      create(:merge_request, source_project: project, target_project: project, author: user,
+        description: "@#{group.full_path}")
+    end
+
+    context 'in public projects' do
+      let_it_be(:project) { create(:project, :public) }
+
+      it_behaves_like 'group_mention_hooks'
+    end
+
+    context 'in private projects' do
+      let_it_be(:project) { create(:project, :private) }
+
+      it_behaves_like 'group_confidential_mention_hooks'
+    end
+  end
+
+  context 'for issue notes' do
+    let(:hook_data) { Gitlab::DataBuilder::Note.build(mentionable, mentionable.author) }
+    let(:is_confidential) { mentionable.confidential?(include_noteable: true) }
+
+    context 'in public projects' do
+      let_it_be(:project) { create(:project, :public) }
+
+      context 'in public issues' do
+        let(:issue) do
+          create(:issue, confidential: false, project: project, author: user, description: "@#{group.full_path}")
+        end
+
+        context 'for public notes' do
+          let(:mentionable) { create(:note_on_issue, noteable: issue, project: project, author: user) }
+
+          it_behaves_like 'group_mention_hooks'
+        end
+
+        context 'for internal notes' do
+          let(:mentionable) { create(:note_on_issue, :confidential, noteable: issue, project: project, author: user) }
+
+          it_behaves_like 'group_confidential_mention_hooks'
+        end
+      end
+    end
+
+    context 'in private projects' do
+      let_it_be(:project) { create(:project, :private) }
+
+      context 'in public issues' do
+        let(:issue) do
+          create(:issue, confidential: false, project: project, author: user, description: "@#{group.full_path}")
+        end
+
+        context 'for public notes' do
+          let(:mentionable) { create(:note_on_issue, noteable: issue, project: project, author: user) }
+
+          it_behaves_like 'group_confidential_mention_hooks'
+        end
+
+        context 'for internal notes' do
+          let(:mentionable) { create(:note_on_issue, :confidential, noteable: issue, project: project, author: user) }
+
+          it_behaves_like 'group_confidential_mention_hooks'
+        end
+      end
+    end
+  end
+end
diff --git a/spec/services/issues/create_service_spec.rb b/spec/services/issues/create_service_spec.rb
index 3dfc9571c9cd8..95f19bd6f0081 100644
--- a/spec/services/issues/create_service_spec.rb
+++ b/spec/services/issues/create_service_spec.rb
@@ -149,6 +149,12 @@
         issue
       end
 
+      it 'calls GroupMentionWorker' do
+        expect(Integrations::GroupMentionWorker).to receive(:perform_async)
+
+        issue
+      end
+
       context 'when a build_service is provided' do
         let(:result) { described_class.new(container: project, current_user: user, params: opts, build_service: build_service).execute }
 
diff --git a/spec/services/issues/reopen_service_spec.rb b/spec/services/issues/reopen_service_spec.rb
index bb1151dfac7db..377efdb3f9f5c 100644
--- a/spec/services/issues/reopen_service_spec.rb
+++ b/spec/services/issues/reopen_service_spec.rb
@@ -68,6 +68,12 @@
         expect { execute }.not_to change { issue.incident_management_timeline_events.count }
       end
 
+      it 'does not call GroupMentionWorker' do
+        expect(Integrations::GroupMentionWorker).not_to receive(:perform_async)
+
+        issue
+      end
+
       context 'issue is incident type' do
         let(:issue) { create(:incident, :closed, project: project) }
         let(:current_user) { user }
diff --git a/spec/services/merge_requests/after_create_service_spec.rb b/spec/services/merge_requests/after_create_service_spec.rb
index 7255d19ef8a8f..1126539b25a9b 100644
--- a/spec/services/merge_requests/after_create_service_spec.rb
+++ b/spec/services/merge_requests/after_create_service_spec.rb
@@ -75,6 +75,12 @@
       execute_service
     end
 
+    it 'calls GroupMentionWorker' do
+      expect(Integrations::GroupMentionWorker).to receive(:perform_async)
+
+      execute_service
+    end
+
     it_behaves_like 'records an onboarding progress action', :merge_request_created do
       let(:namespace) { merge_request.target_project.namespace }
     end
diff --git a/spec/services/merge_requests/reopen_service_spec.rb b/spec/services/merge_requests/reopen_service_spec.rb
index 7399b29d06e08..e173cd382f2e8 100644
--- a/spec/services/merge_requests/reopen_service_spec.rb
+++ b/spec/services/merge_requests/reopen_service_spec.rb
@@ -40,6 +40,10 @@
                                .with(merge_request, 'reopen')
       end
 
+      it 'does not call GroupMentionWorker' do
+        expect(Integrations::GroupMentionWorker).not_to receive(:perform_async)
+      end
+
       it 'sends email to user2 about reopen of merge_request', :sidekiq_might_not_need_inline do
         email = ActionMailer::Base.deliveries.last
         expect(email.to.first).to eq(user2.email)
diff --git a/spec/services/notes/post_process_service_spec.rb b/spec/services/notes/post_process_service_spec.rb
index 0bcfd6b63d2da..35d3620e42978 100644
--- a/spec/services/notes/post_process_service_spec.rb
+++ b/spec/services/notes/post_process_service_spec.rb
@@ -22,6 +22,9 @@
     it do
       expect(project).to receive(:execute_hooks)
       expect(project).to receive(:execute_integrations)
+      expect_next_instance_of(Integrations::GroupMentionService) do |group_mention_service|
+        expect(group_mention_service).to receive(:execute)
+      end
 
       described_class.new(@note).execute
     end
diff --git a/spec/workers/integrations/group_mention_worker_spec.rb b/spec/workers/integrations/group_mention_worker_spec.rb
new file mode 100644
index 0000000000000..111e3f5a107f8
--- /dev/null
+++ b/spec/workers/integrations/group_mention_worker_spec.rb
@@ -0,0 +1,65 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Integrations::GroupMentionWorker, :clean_gitlab_redis_shared_state, feature_category: :integrations do
+  describe '#perform' do
+    let(:worker) { described_class.new }
+    let(:service_class) { Integrations::GroupMentionService }
+
+    let_it_be(:project) { create(:project, :public) }
+    let_it_be(:user) { create(:user) }
+
+    let(:issue) { create(:issue, confidential: false, project: project, author: user) }
+    let(:hook_data) { issue.to_hook_data(user) }
+    let(:is_confidential) { issue.confidential? }
+
+    let(:args) do
+      {
+        mentionable_type: 'Issue',
+        mentionable_id: issue.id,
+        hook_data: hook_data,
+        is_confidential: is_confidential
+      }
+    end
+
+    it 'executes the service' do
+      expect_next_instance_of(service_class, issue, hook_data: hook_data, is_confidential: is_confidential) do |service|
+        expect(service).to receive(:execute)
+      end
+
+      worker.perform(args)
+    end
+
+    it_behaves_like 'an idempotent worker' do
+      let(:job_args) { [args] }
+    end
+
+    context 'when mentionable_type is not supported' do
+      let(:args) do
+        {
+          mentionable_type: 'Unsupported',
+          mentionable_id: 23,
+          hook_data: {},
+          is_confidential: false
+        }
+      end
+
+      it 'does not execute the service' do
+        expect(service_class).not_to receive(:new)
+
+        worker.perform(args)
+      end
+
+      it 'logs an error' do
+        expect(Sidekiq.logger).to receive(:error).with({
+          message: 'Integrations::GroupMentionWorker: mentionable not supported',
+          mentionable_type: 'Unsupported',
+          mentionable_id: 23
+        })
+
+        worker.perform(args)
+      end
+    end
+  end
+end
-- 
GitLab