Commit aca3e8b8 authored by David Kim's avatar David Kim Committed by Patrick Bajao

Add email notifications for merge request reviewers

Notifications for reviewers should be pretty much the same as Assignees.
parent 4c001105
...@@ -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,
......
...@@ -4618,6 +4618,15 @@ msgstr "" ...@@ -4618,6 +4618,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 ""
...@@ -17407,6 +17416,9 @@ msgstr "" ...@@ -17407,6 +17416,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