Commit ca421dc9 authored by Patrick Bajao's avatar Patrick Bajao

Merge branch 'create-email-notifications-for-merge-request-reviewers' into 'master'

Create email notifications for merge request reviewers

See merge request gitlab-org/gitlab!41851
parents c801180b aca3e8b8
...@@ -218,8 +218,28 @@ module EmailsHelper ...@@ -218,8 +218,28 @@ module EmailsHelper
_('Please contact your administrator with any questions.') _('Please contact your administrator with any questions.')
end end
def change_reviewer_notification_text(new_reviewers, previous_reviewers, html_tag = nil)
new = new_reviewers.any? ? users_to_sentence(new_reviewers) : s_('ChangeReviewer|Unassigned')
old = previous_reviewers.any? ? users_to_sentence(previous_reviewers) : nil
if html_tag.present?
new = content_tag(html_tag, new)
old = content_tag(html_tag, old) if old.present?
end
if old.present?
s_('ChangeReviewer|Reviewer changed from %{old} to %{new}').html_safe % { old: old, new: new }
else
s_('ChangeReviewer|Reviewer changed to %{new}').html_safe % { new: new }
end
end
private private
def users_to_sentence(users)
sanitize_name(users.map(&:name).to_sentence)
end
def generate_link(text, url) def generate_link(text, url)
link_to(text, url, target: :_blank, rel: 'noopener noreferrer') link_to(text, url, target: :_blank, rel: 'noopener noreferrer')
end end
......
...@@ -34,6 +34,17 @@ module Emails ...@@ -34,6 +34,17 @@ module Emails
end end
# rubocop: enable CodeReuse/ActiveRecord # rubocop: enable CodeReuse/ActiveRecord
# rubocop: disable CodeReuse/ActiveRecord
def changed_reviewer_of_merge_request_email(recipient_id, merge_request_id, previous_reviewer_ids, updated_by_user_id, reason = nil)
setup_merge_request_mail(merge_request_id, recipient_id)
@previous_reviewers = []
@previous_reviewers = User.where(id: previous_reviewer_ids) if previous_reviewer_ids.any?
mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id, reason))
end
# rubocop: enable CodeReuse/ActiveRecord
def relabeled_merge_request_email(recipient_id, merge_request_id, label_names, updated_by_user_id, reason = nil) def relabeled_merge_request_email(recipient_id, merge_request_id, label_names, updated_by_user_id, reason = nil)
setup_merge_request_mail(merge_request_id, recipient_id) setup_merge_request_mail(merge_request_id, recipient_id)
......
...@@ -121,6 +121,8 @@ class MergeRequest < ApplicationRecord ...@@ -121,6 +121,8 @@ class MergeRequest < ApplicationRecord
# when creating new merge request # when creating new merge request
attr_accessor :can_be_created, :compare_commits, :diff_options, :compare attr_accessor :can_be_created, :compare_commits, :diff_options, :compare
participant :reviewers
# Keep states definition to be evaluated before the state_machine block to avoid spec failures. # Keep states definition to be evaluated before the state_machine block to avoid spec failures.
# If this gets evaluated after, the `merged` and `locked` states which are overrided can be nil. # If this gets evaluated after, the `merged` and `locked` states which are overrided can be nil.
def self.available_state_names def self.available_state_names
......
...@@ -5,6 +5,7 @@ ...@@ -5,6 +5,7 @@
class NotificationReason class NotificationReason
OWN_ACTIVITY = 'own_activity' OWN_ACTIVITY = 'own_activity'
ASSIGNED = 'assigned' ASSIGNED = 'assigned'
REVIEW_REQUESTED = 'review_requested'
MENTIONED = 'mentioned' MENTIONED = 'mentioned'
SUBSCRIBED = 'subscribed' SUBSCRIBED = 'subscribed'
...@@ -12,6 +13,7 @@ class NotificationReason ...@@ -12,6 +13,7 @@ class NotificationReason
REASON_PRIORITY = [ REASON_PRIORITY = [
OWN_ACTIVITY, OWN_ACTIVITY,
ASSIGNED, ASSIGNED,
REVIEW_REQUESTED,
MENTIONED, MENTIONED,
SUBSCRIBED SUBSCRIBED
].freeze ].freeze
......
...@@ -43,6 +43,7 @@ class NotificationSetting < ApplicationRecord ...@@ -43,6 +43,7 @@ class NotificationSetting < ApplicationRecord
:reopen_merge_request, :reopen_merge_request,
:close_merge_request, :close_merge_request,
:reassign_merge_request, :reassign_merge_request,
:change_reviewer_merge_request,
:merge_merge_request, :merge_merge_request,
:failed_pipeline, :failed_pipeline,
:fixed_pipeline, :fixed_pipeline,
......
...@@ -365,6 +365,7 @@ class IssuableBaseService < BaseService ...@@ -365,6 +365,7 @@ class IssuableBaseService < BaseService
} }
associations[:total_time_spent] = issuable.total_time_spent if issuable.respond_to?(:total_time_spent) associations[:total_time_spent] = issuable.total_time_spent if issuable.respond_to?(:total_time_spent)
associations[:description] = issuable.description associations[:description] = issuable.description
associations[:reviewers] = issuable.reviewers.to_a if issuable.allows_reviewers?
associations associations
end end
......
...@@ -112,6 +112,7 @@ module MergeRequests ...@@ -112,6 +112,7 @@ module MergeRequests
end end
def handle_reviewers_change(merge_request, old_reviewers) def handle_reviewers_change(merge_request, old_reviewers)
notification_service.async.changed_reviewer_of_merge_request(merge_request, current_user, old_reviewers)
todo_service.reassigned_reviewable(merge_request, current_user, old_reviewers) todo_service.reassigned_reviewable(merge_request, current_user, old_reviewers)
end end
......
...@@ -34,6 +34,9 @@ module NotificationRecipients ...@@ -34,6 +34,9 @@ module NotificationRecipients
when :reassign_merge_request, :reassign_issue when :reassign_merge_request, :reassign_issue
add_recipients(previous_assignees, :mention, nil) add_recipients(previous_assignees, :mention, nil)
add_recipients(target.assignees, :mention, NotificationReason::ASSIGNED) add_recipients(target.assignees, :mention, NotificationReason::ASSIGNED)
when :change_reviewer_merge_request
add_recipients(previous_assignees, :mention, nil)
add_recipients(target.reviewers, :mention, NotificationReason::REVIEW_REQUESTED)
end end
add_subscribed_users add_subscribed_users
......
...@@ -238,6 +238,33 @@ class NotificationService ...@@ -238,6 +238,33 @@ class NotificationService
end end
end end
# When we change reviewer in a merge_request we should send an email to:
#
# * merge_request old reviewers if their notification level is not Disabled
# * merge_request new reviewers if their notification level is not Disabled
# * users with custom level checked with "change reviewer merge request"
#
def changed_reviewer_of_merge_request(merge_request, current_user, previous_reviewers = [])
recipients = NotificationRecipients::BuildService.build_recipients(
merge_request,
current_user,
action: "change_reviewer",
previous_assignees: previous_reviewers
)
previous_reviewer_ids = previous_reviewers.map(&:id)
recipients.each do |recipient|
mailer.changed_reviewer_of_merge_request_email(
recipient.user.id,
merge_request.id,
previous_reviewer_ids,
current_user.id,
recipient.reason
).deliver_later
end
end
# When we add labels to a merge request we should send an email to: # When we add labels to a merge request we should send an email to:
# #
# * watchers of the mr's labels # * watchers of the mr's labels
......
%p
= change_reviewer_notification_text(@merge_request.reviewers, @previous_reviewers, :strong)
<%= change_reviewer_notification_text(@merge_request.reviewers, @previous_reviewers) %>
---
title: Add notification setting for merge request reviewers
merge_request: 41851
author:
type: added
# frozen_string_literal: true
class AddChangeReviewerMergeRequestToNotificationSettings < ActiveRecord::Migration[6.0]
DOWNTIME = false
def change
add_column :notification_settings, :change_reviewer_merge_request, :boolean
end
end
8b2090e953e6205b65555408a88d3da7f6bce28b0baa52d1a43a3a3e8001b7e1
\ No newline at end of file
...@@ -13727,7 +13727,8 @@ CREATE TABLE notification_settings ( ...@@ -13727,7 +13727,8 @@ CREATE TABLE notification_settings (
notification_email character varying, notification_email character varying,
fixed_pipeline boolean, fixed_pipeline boolean,
new_release boolean, new_release boolean,
moved_project boolean DEFAULT true NOT NULL moved_project boolean DEFAULT true NOT NULL,
change_reviewer_merge_request boolean
); );
CREATE SEQUENCE notification_settings_id_seq CREATE SEQUENCE notification_settings_id_seq
......
...@@ -24,6 +24,7 @@ RSpec.describe NotificationSetting do ...@@ -24,6 +24,7 @@ RSpec.describe NotificationSetting do
:reopen_merge_request, :reopen_merge_request,
:close_merge_request, :close_merge_request,
:reassign_merge_request, :reassign_merge_request,
:change_reviewer_merge_request,
:merge_merge_request, :merge_merge_request,
:failed_pipeline, :failed_pipeline,
:fixed_pipeline, :fixed_pipeline,
...@@ -53,6 +54,7 @@ RSpec.describe NotificationSetting do ...@@ -53,6 +54,7 @@ RSpec.describe NotificationSetting do
:reopen_merge_request, :reopen_merge_request,
:close_merge_request, :close_merge_request,
:reassign_merge_request, :reassign_merge_request,
:change_reviewer_merge_request,
:merge_merge_request, :merge_merge_request,
:failed_pipeline, :failed_pipeline,
:fixed_pipeline, :fixed_pipeline,
...@@ -81,6 +83,7 @@ RSpec.describe NotificationSetting do ...@@ -81,6 +83,7 @@ RSpec.describe NotificationSetting do
:reopen_merge_request, :reopen_merge_request,
:close_merge_request, :close_merge_request,
:reassign_merge_request, :reassign_merge_request,
:change_reviewer_merge_request,
:merge_merge_request, :merge_merge_request,
:failed_pipeline, :failed_pipeline,
:fixed_pipeline, :fixed_pipeline,
......
...@@ -4621,6 +4621,15 @@ msgstr "" ...@@ -4621,6 +4621,15 @@ msgstr ""
msgid "Change your password or recover your current one" msgid "Change your password or recover your current one"
msgstr "" msgstr ""
msgid "ChangeReviewer|Reviewer changed from %{old} to %{new}"
msgstr ""
msgid "ChangeReviewer|Reviewer changed to %{new}"
msgstr ""
msgid "ChangeReviewer|Unassigned"
msgstr ""
msgid "ChangeTypeActionLabel|Pick into branch" msgid "ChangeTypeActionLabel|Pick into branch"
msgstr "" msgstr ""
...@@ -17416,6 +17425,9 @@ msgstr "" ...@@ -17416,6 +17425,9 @@ msgstr ""
msgid "Notification settings saved" msgid "Notification settings saved"
msgstr "" msgstr ""
msgid "NotificationEvent|Change reviewer merge request"
msgstr ""
msgid "NotificationEvent|Close issue" msgid "NotificationEvent|Close issue"
msgstr "" msgstr ""
......
...@@ -16,3 +16,4 @@ N_('NotificationEvent|Merge merge request') ...@@ -16,3 +16,4 @@ N_('NotificationEvent|Merge merge request')
N_('NotificationEvent|Failed pipeline') N_('NotificationEvent|Failed pipeline')
N_('NotificationEvent|Fixed pipeline') N_('NotificationEvent|Fixed pipeline')
N_('NotificationEvent|New release') N_('NotificationEvent|New release')
N_('NotificationEvent|Change reviewer merge request')
...@@ -361,4 +361,116 @@ RSpec.describe EmailsHelper do ...@@ -361,4 +361,116 @@ RSpec.describe EmailsHelper do
end end
end end
end end
describe '#change_reviewer_notification_text' do
let(:mary) { build(:user, name: 'Mary') }
let(:john) { build(:user, name: 'John') }
let(:ted) { build(:user, name: 'Ted') }
context 'to new reviewers only' do
let(:previous_reviewers) { [] }
let(:new_reviewers) { [john] }
context 'with no html tag' do
let(:expected_output) do
'Reviewer changed to John'
end
it 'returns the expected output' do
expect(change_reviewer_notification_text(new_reviewers, previous_reviewers)).to eq(expected_output)
end
end
context 'with <strong> tag' do
let(:expected_output) do
'Reviewer changed to <strong>John</strong>'
end
it 'returns the expected output' do
expect(change_reviewer_notification_text(new_reviewers, previous_reviewers, :strong)).to eq(expected_output)
end
end
end
context 'from previous reviewers to new reviewers' do
let(:previous_reviewers) { [john, mary] }
let(:new_reviewers) { [ted] }
context 'with no html tag' do
let(:expected_output) do
'Reviewer changed from John and Mary to Ted'
end
it 'returns the expected output' do
expect(change_reviewer_notification_text(new_reviewers, previous_reviewers)).to eq(expected_output)
end
end
context 'with <strong> tag' do
let(:expected_output) do
'Reviewer changed from <strong>John and Mary</strong> to <strong>Ted</strong>'
end
it 'returns the expected output' do
expect(change_reviewer_notification_text(new_reviewers, previous_reviewers, :strong)).to eq(expected_output)
end
end
end
context 'from previous reviewers to no reviewers' do
let(:previous_reviewers) { [john, mary] }
let(:new_reviewers) { [] }
context 'with no html tag' do
let(:expected_output) do
'Reviewer changed from John and Mary to Unassigned'
end
it 'returns the expected output' do
expect(change_reviewer_notification_text(new_reviewers, previous_reviewers)).to eq(expected_output)
end
end
context 'with <strong> tag' do
let(:expected_output) do
'Reviewer changed from <strong>John and Mary</strong> to <strong>Unassigned</strong>'
end
it 'returns the expected output' do
expect(change_reviewer_notification_text(new_reviewers, previous_reviewers, :strong)).to eq(expected_output)
end
end
end
context "with a <script> tag in user's name" do
let(:previous_reviewers) { [] }
let(:new_reviewers) { [fishy_user] }
let(:fishy_user) { build(:user, name: "<script>alert('hi')</script>") }
let(:expected_output) do
'Reviewer changed to <strong>&lt;script&gt;alert(&#39;hi&#39;)&lt;/script&gt;</strong>'
end
it 'escapes the html tag' do
expect(change_reviewer_notification_text(new_reviewers, previous_reviewers, :strong)).to eq(expected_output)
end
end
context "with url in user's name" do
subject(:email_helper) { Object.new.extend(described_class) }
let(:previous_reviewers) { [] }
let(:new_reviewers) { [fishy_user] }
let(:fishy_user) { build(:user, name: "example.com") }
let(:expected_output) do
'Reviewer changed to example_com'
end
it "sanitizes user's name" do
expect(email_helper).to receive(:sanitize_name).and_call_original
expect(email_helper.change_reviewer_notification_text(new_reviewers, previous_reviewers)).to eq(expected_output)
end
end
end
end end
...@@ -4256,24 +4256,6 @@ RSpec.describe MergeRequest, factory_default: :keep do ...@@ -4256,24 +4256,6 @@ RSpec.describe MergeRequest, factory_default: :keep do
end end
end end
describe '#allows_reviewers?' do
it 'returns false without merge_request_reviewers feature' do
stub_feature_flags(merge_request_reviewers: false)
merge_request = build_stubbed(:merge_request)
expect(merge_request.allows_reviewers?).to be(false)
end
it 'returns true with merge_request_reviewers feature' do
stub_feature_flags(merge_request_reviewers: true)
merge_request = build_stubbed(:merge_request)
expect(merge_request.allows_reviewers?).to be(true)
end
end
describe '#merge_ref_head' do describe '#merge_ref_head' do
let(:merge_request) { create(:merge_request) } let(:merge_request) { create(:merge_request) }
...@@ -4299,4 +4281,22 @@ RSpec.describe MergeRequest, factory_default: :keep do ...@@ -4299,4 +4281,22 @@ RSpec.describe MergeRequest, factory_default: :keep do
end end
end end
end end
describe '#allows_reviewers?' do
it 'returns false without merge_request_reviewers feature' do
stub_feature_flags(merge_request_reviewers: false)
merge_request = build_stubbed(:merge_request)
expect(merge_request.allows_reviewers?).to be(false)
end
it 'returns true with merge_request_reviewers feature' do
stub_feature_flags(merge_request_reviewers: true)
merge_request = build_stubbed(:merge_request)
expect(merge_request.allows_reviewers?).to be(true)
end
end
end end
...@@ -175,6 +175,7 @@ RSpec.describe NotificationSetting do ...@@ -175,6 +175,7 @@ RSpec.describe NotificationSetting do
:reopen_merge_request, :reopen_merge_request,
:close_merge_request, :close_merge_request,
:reassign_merge_request, :reassign_merge_request,
:change_reviewer_merge_request,
:merge_merge_request, :merge_merge_request,
:failed_pipeline, :failed_pipeline,
:success_pipeline, :success_pipeline,
......
...@@ -52,7 +52,6 @@ RSpec.describe MergeRequests::UpdateService, :mailer do ...@@ -52,7 +52,6 @@ RSpec.describe MergeRequests::UpdateService, :mailer do
title: 'New title', title: 'New title',
description: 'Also please fix', description: 'Also please fix',
assignee_ids: [user.id], assignee_ids: [user.id],
reviewer_ids: [user.id],
state_event: 'close', state_event: 'close',
label_ids: [label.id], label_ids: [label.id],
target_branch: 'target', target_branch: 'target',
...@@ -76,7 +75,7 @@ RSpec.describe MergeRequests::UpdateService, :mailer do ...@@ -76,7 +75,7 @@ RSpec.describe MergeRequests::UpdateService, :mailer do
expect(@merge_request).to be_valid expect(@merge_request).to be_valid
expect(@merge_request.title).to eq('New title') expect(@merge_request.title).to eq('New title')
expect(@merge_request.assignees).to match_array([user]) expect(@merge_request.assignees).to match_array([user])
expect(@merge_request.reviewers).to match_array([user]) expect(@merge_request.reviewers).to match_array([])
expect(@merge_request).to be_closed expect(@merge_request).to be_closed
expect(@merge_request.labels.count).to eq(1) expect(@merge_request.labels.count).to eq(1)
expect(@merge_request.labels.first.title).to eq(label.name) expect(@merge_request.labels.first.title).to eq(label.name)
...@@ -94,6 +93,7 @@ RSpec.describe MergeRequests::UpdateService, :mailer do ...@@ -94,6 +93,7 @@ RSpec.describe MergeRequests::UpdateService, :mailer do
labels: [], labels: [],
mentioned_users: [user2], mentioned_users: [user2],
assignees: [user3], assignees: [user3],
reviewers: [],
milestone: nil, milestone: nil,
total_time_spent: 0, total_time_spent: 0,
description: "FYI #{user2.to_reference}" description: "FYI #{user2.to_reference}"
...@@ -405,15 +405,15 @@ RSpec.describe MergeRequests::UpdateService, :mailer do ...@@ -405,15 +405,15 @@ RSpec.describe MergeRequests::UpdateService, :mailer do
end end
context 'when reviewers gets changed' do context 'when reviewers gets changed' do
before do it 'marks pending todo as done' do
update_merge_request({ reviewer_ids: [user2.id] }) update_merge_request({ reviewer_ids: [user2.id] })
end
it 'marks pending todo as done' do
expect(pending_todo.reload).to be_done expect(pending_todo.reload).to be_done
end end
it 'creates a pending todo for new review request' do it 'creates a pending todo for new review request' do
update_merge_request({ reviewer_ids: [user2.id] })
attributes = { attributes = {
project: project, project: project,
author: user, author: user,
...@@ -426,6 +426,17 @@ RSpec.describe MergeRequests::UpdateService, :mailer do ...@@ -426,6 +426,17 @@ RSpec.describe MergeRequests::UpdateService, :mailer do
expect(Todo.where(attributes).count).to eq 1 expect(Todo.where(attributes).count).to eq 1
end end
it 'sends email reviewer change notifications to old and new reviewers', :sidekiq_might_not_need_inline do
merge_request.reviewers = [user2]
perform_enqueued_jobs do
update_merge_request({ reviewer_ids: [user3.id] })
end
should_email(user2)
should_email(user3)
end
end end
context 'when the milestone is removed' do context 'when the milestone is removed' do
......
...@@ -150,6 +150,16 @@ RSpec.describe NotificationService, :mailer do ...@@ -150,6 +150,16 @@ RSpec.describe NotificationService, :mailer do
end end
end end
shared_examples 'participating by reviewer notification' do
it 'emails the participant' do
issuable.reviewers << participant
notification_trigger
should_email(participant)
end
end
shared_examples_for 'participating notifications' do shared_examples_for 'participating notifications' do
it_behaves_like 'participating by note notification' it_behaves_like 'participating by note notification'
it_behaves_like 'participating by author notification' it_behaves_like 'participating by author notification'
...@@ -1778,6 +1788,60 @@ RSpec.describe NotificationService, :mailer do ...@@ -1778,6 +1788,60 @@ RSpec.describe NotificationService, :mailer do
end end
end end
describe '#changed_reviewer_of_merge_request' do
let(:merge_request) { create(:merge_request, author: author, source_project: project, reviewers: [reviewer], description: 'cc @participant') }
let_it_be(:current_user) { create(:user) }
let_it_be(:reviewer) { create(:user) }
before do
update_custom_notification(:change_reviewer_merge_request, @u_guest_custom, resource: project)
update_custom_notification(:change_reviewer_merge_request, @u_custom_global)
end
it 'sends emails to relevant users only', :aggregate_failures do
notification.changed_reviewer_of_merge_request(merge_request, current_user, [reviewer])
merge_request.reviewers.each { |reviewer| should_email(reviewer) }
should_email(merge_request.author)
should_email(@u_watcher)
should_email(@u_participant_mentioned)
should_email(@subscriber)
should_email(@watcher_and_subscriber)
should_email(@u_guest_watcher)
should_email(@u_guest_custom)
should_email(@u_custom_global)
should_not_email(@unsubscriber)
should_not_email(@u_participating)
should_not_email(@u_disabled)
should_not_email(@u_lazy_participant)
end
it 'adds "review requested" reason for new reviewer' do
notification.changed_reviewer_of_merge_request(merge_request, current_user, [reviewer])
merge_request.reviewers.each do |assignee|
email = find_email_for(assignee)
expect(email).to have_header('X-GitLab-NotificationReason', NotificationReason::REVIEW_REQUESTED)
end
end
context 'participating notifications with reviewers' do
let(:participant) { create(:user, username: 'user-participant') }
let(:issuable) { merge_request }
let(:notification_trigger) { notification.changed_reviewer_of_merge_request(merge_request, current_user, [reviewer]) }
it_behaves_like 'participating notifications'
it_behaves_like 'participating by reviewer notification'
end
it_behaves_like 'project emails are disabled' do
let(:notification_target) { merge_request }
let(:notification_trigger) { notification.changed_reviewer_of_merge_request(merge_request, current_user, [reviewer]) }
end
end
describe '#push_to_merge_request' do describe '#push_to_merge_request' do
before do before do
update_custom_notification(:push_to_merge_request, @u_guest_custom, resource: project) update_custom_notification(:push_to_merge_request, @u_guest_custom, resource: project)
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment