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