Commit 9707d257 authored by Andrew Fontaine's avatar Andrew Fontaine

Merge branch '218003-iteration_changes_emails' into 'master'

Send email notifications on Issue Iteration change

See merge request gitlab-org/gitlab!32817
parents 28948ee6 42cff1c2
# frozen_string_literal: true # frozen_string_literal: true
module MilestonesHelper module TimeboxesHelper
include EntityDateHelper include EntityDateHelper
include Gitlab::Utils::StrongMemoize include Gitlab::Utils::StrongMemoize
...@@ -209,23 +209,24 @@ module MilestonesHelper ...@@ -209,23 +209,24 @@ module MilestonesHelper
end end
end end
def milestone_date_range(milestone) def timebox_date_range(timebox)
if milestone.start_date && milestone.due_date if timebox.start_date && timebox.due_date
"#{milestone.start_date.to_s(:medium)}#{milestone.due_date.to_s(:medium)}" "#{timebox.start_date.to_s(:medium)}#{timebox.due_date.to_s(:medium)}"
elsif milestone.due_date elsif timebox.due_date
if milestone.due_date.past? if timebox.due_date.past?
_("expired on %{milestone_due_date}") % { milestone_due_date: milestone.due_date.strftime('%b %-d, %Y') } _("expired on %{timebox_due_date}") % { timebox_due_date: timebox.due_date.to_s(:medium) }
else else
_("expires on %{milestone_due_date}") % { milestone_due_date: milestone.due_date.strftime('%b %-d, %Y') } _("expires on %{timebox_due_date}") % { timebox_due_date: timebox.due_date.to_s(:medium) }
end end
elsif milestone.start_date elsif timebox.start_date
if milestone.start_date.past? if timebox.start_date.past?
_("started on %{milestone_start_date}") % { milestone_start_date: milestone.start_date.strftime('%b %-d, %Y') } _("started on %{timebox_start_date}") % { timebox_start_date: timebox.start_date.to_s(:medium) }
else else
_("starts on %{milestone_start_date}") % { milestone_start_date: milestone.start_date.strftime('%b %-d, %Y') } _("starts on %{timebox_start_date}") % { timebox_start_date: timebox.start_date.to_s(:medium) }
end end
end end
end end
alias_method :milestone_date_range, :timebox_date_range
def milestone_tab_path(milestone, tab) def milestone_tab_path(milestone, tab)
if milestone.global_milestone? if milestone.global_milestone?
...@@ -306,4 +307,4 @@ module MilestonesHelper ...@@ -306,4 +307,4 @@ module MilestonesHelper
end end
end end
MilestonesHelper.prepend_if_ee('EE::MilestonesHelper') TimeboxesHelper.prepend_if_ee('EE::TimeboxesHelper')
...@@ -126,3 +126,5 @@ module Emails ...@@ -126,3 +126,5 @@ module Emails
end end
end end
end end
Emails::Issues.prepend_if_ee('EE::Emails::Issues')
...@@ -19,7 +19,7 @@ class Notify < ApplicationMailer ...@@ -19,7 +19,7 @@ class Notify < ApplicationMailer
include Emails::Releases include Emails::Releases
include Emails::Groups include Emails::Groups
helper MilestonesHelper helper TimeboxesHelper
helper MergeRequestsHelper helper MergeRequestsHelper
helper DiffHelper helper DiffHelper
helper BlobHelper helper BlobHelper
......
# frozen_string_literal: true # frozen_string_literal: true
module EE module EE
module MilestonesHelper module TimeboxesHelper
def burndown_chart(milestone) def burndown_chart(milestone)
if milestone.supports_burndown_charts? if milestone.supports_burndown_charts?
issues = milestone.issues_visible_to_user(current_user) issues = milestone.issues_visible_to_user(current_user)
......
# frozen_string_literal: true
module EE
module Emails
module Issues
def removed_iteration_issue_email(recipient_id, issue_id, updated_by_user_id, reason = nil)
setup_issue_mail(issue_id, recipient_id)
mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, recipient_id, reason))
end
def changed_iteration_issue_email(recipient_id, issue_id, iteration, updated_by_user_id, reason = nil)
setup_issue_mail(issue_id, recipient_id)
@iteration = iteration
@iteration_url = iteration_url(@iteration)
mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, recipient_id, reason).merge({
template_name: 'changed_iteration_email'
}))
end
end
end
end
...@@ -21,8 +21,29 @@ module EE ...@@ -21,8 +21,29 @@ module EE
result result
end end
override :handle_changes
def handle_changes(issue, _options)
super
handle_iteration_change(issue)
end
private private
def handle_iteration_change(issue)
return unless issue.previous_changes.include?('sprint_id')
send_iteration_change_notification(issue)
end
def send_iteration_change_notification(issue)
if issue.iteration.nil?
notification_service.async.removed_iteration_issue(issue, current_user)
else
notification_service.async.changed_iteration_issue(issue, issue.iteration, current_user)
end
end
def handle_promotion(issue) def handle_promotion(issue)
return unless params.delete(:promote_to_epic) return unless params.delete(:promote_to_epic)
......
...@@ -61,6 +61,14 @@ module EE ...@@ -61,6 +61,14 @@ module EE
mailer.project_mirror_user_changed_email(new_mirror_user.id, deleted_user_name, project.id).deliver_later mailer.project_mirror_user_changed_email(new_mirror_user.id, deleted_user_name, project.id).deliver_later
end end
def removed_iteration_issue(issue, current_user)
removed_iteration_resource_email(issue, current_user)
end
def changed_iteration_issue(issue, new_iteration, current_user)
changed_iteration_resource_email(issue, new_iteration, current_user)
end
private private
def send_new_review_notification(review) def send_new_review_notification(review)
...@@ -108,6 +116,30 @@ module EE ...@@ -108,6 +116,30 @@ module EE
mailer.service_desk_new_note_email(issue.id, note.id).deliver_later mailer.service_desk_new_note_email(issue.id, note.id).deliver_later
end end
def removed_iteration_resource_email(target, current_user)
recipients = ::NotificationRecipients::BuildService.build_recipients(
target,
current_user,
action: 'removed_iteration'
)
recipients.each do |recipient|
mailer.removed_iteration_issue_email(recipient.user.id, target.id, current_user.id).deliver_later
end
end
def changed_iteration_resource_email(target, iteration, current_user)
recipients = ::NotificationRecipients::BuildService.build_recipients(
target,
current_user,
action: 'changed_iteration'
)
recipients.each do |recipient|
mailer.changed_iteration_issue_email(recipient.user.id, target.id, iteration, current_user.id).deliver_later
end
end
def epic_status_change_email(target, current_user, status) def epic_status_change_email(target, current_user, status)
action = status == 'reopened' ? 'reopen' : 'close' action = status == 'reopened' ? 'reopen' : 'close'
......
%p
= _('Iteration changed to')
%strong= link_to(@iteration.name, @iteration_url)
- if date_range = timebox_date_range(@iteration)
= "(#{date_range})"
<%= _('Iteration changed to') %> <%= @iteration.name %><% if date_range = timebox_date_range(@iteration) %> (<%= date_range %>)<% end %> ( <%= @iteration_url %> )
---
title: Send email notifications on Issue Iteration change
merge_request: 32817
author:
type: added
# frozen_string_literal: true
require 'spec_helper'
require 'email_spec'
describe Emails::Issues do
include EmailSpec::Matchers
let_it_be(:user) { create(:user) }
let_it_be(:group) { create(:group) }
let_it_be(:project) { create(:project, namespace: group) }
let_it_be(:issue) { create(:issue, project: project) }
context 'iterations' do
let_it_be(:iteration) { create(:iteration, group: group) }
describe '#changed_iteration_issue_email' do
subject { Notify.changed_iteration_issue_email(user.id, issue.id, iteration, user.id) }
it 'shows the iteration it was changed to' do
expect(subject).to have_body_text 'Iteration changed to'
expect(subject).to have_body_text iteration.name
end
end
describe '#removed_iteration_issue_email' do
subject { Notify.removed_iteration_issue_email(user.id, issue.id, user.id) }
it 'says iteration was removed' do
expect(subject).to have_body_text 'Iteration removed'
end
end
end
end
...@@ -27,6 +27,48 @@ describe Issues::UpdateService do ...@@ -27,6 +27,48 @@ describe Issues::UpdateService do
end end
end end
context 'updating iteration' do
let(:iteration) { create(:iteration, group: group) }
context 'when issue does not already have an iteration' do
it 'calls NotificationService#changed_iteration_issue' do
expect_next_instance_of(NotificationService::Async) do |ns|
expect(ns).to receive(:changed_iteration_issue)
end
update_issue(iteration: iteration)
end
end
context 'when issue already has an iteration' do
let(:old_iteration) { create(:iteration, group: group) }
before do
update_issue(iteration: old_iteration)
end
context 'setting to nil' do
it 'calls NotificationService#removed_iteration_issue' do
expect_next_instance_of(NotificationService::Async) do |ns|
expect(ns).to receive(:removed_iteration_issue)
end
update_issue(iteration: nil)
end
end
context 'setting to another iteration' do
it 'calls NotificationService#changed_iteration_issue' do
expect_next_instance_of(NotificationService::Async) do |ns|
expect(ns).to receive(:changed_iteration_issue)
end
update_issue(iteration: iteration)
end
end
end
end
context 'updating weight' do context 'updating weight' do
before do before do
project.add_maintainer(user) project.add_maintainer(user)
......
...@@ -328,6 +328,159 @@ describe EE::NotificationService, :mailer do ...@@ -328,6 +328,159 @@ describe EE::NotificationService, :mailer do
end end
end end
describe 'issues' do
let(:group) { create(:group) }
let(:project) { create(:project, :public, namespace: group) }
let(:assignee) { create(:user) }
let(:issue) { create :issue, project: project, assignees: [assignee], description: 'cc @participant @unsubscribed_mentioned' }
let(:notification) { NotificationService.new }
before do
build_group_members(group)
add_users_with_subscription(group, issue)
reset_delivered_emails!
end
around do |example|
perform_enqueued_jobs { example.run }
end
shared_examples 'altered iteration notification on issue' do
it 'sends the email to the correct people' do
should_email(subscriber_to_new_iteration)
issue.assignees.each do |a|
should_email(a)
end
should_email(@u_watcher)
should_email(@u_guest_watcher)
should_email(@u_participant_mentioned)
should_email(@subscriber)
should_email(@subscribed_participant)
should_email(@watcher_and_subscriber)
should_not_email(@u_guest_custom)
should_not_email(@u_committer)
should_not_email(@unsubscriber)
should_not_email(@u_participating)
should_not_email(@u_lazy_participant)
should_not_email(issue.author)
should_not_email(@u_disabled)
should_not_email(@u_custom_global)
should_not_email(@u_mentioned)
end
end
describe '#removed_iteration_issue' do
let(:mailer_method) { :removed_iteration_issue_email }
context do
let(:iteration) { create(:iteration, group: group, issues: [issue]) }
let!(:subscriber_to_new_iteration) { create(:user) { |u| issue.toggle_subscription(u, project) } }
it_behaves_like 'altered iteration notification on issue' do
before do
notification.removed_iteration_issue(issue, issue.author)
end
end
it_behaves_like 'project emails are disabled' do
let(:notification_target) { issue }
let(:notification_trigger) { notification.removed_iteration_issue(issue, issue.author) }
end
end
context 'confidential issues' do
let(:author) { create(:user) }
let(:assignee) { create(:user) }
let(:non_member) { create(:user) }
let(:member) { create(:user) }
let(:guest) { create(:user) }
let(:admin) { create(:admin) }
let(:confidential_issue) { create(:issue, :confidential, project: project, title: 'Confidential issue', author: author, assignees: [assignee]) }
let(:iteration) { create(:iteration, project: project, issues: [confidential_issue]) }
it "emails subscribers of the issue's iteration that can read the issue" do
project.add_developer(member)
project.add_guest(guest)
confidential_issue.subscribe(non_member, project)
confidential_issue.subscribe(author, project)
confidential_issue.subscribe(assignee, project)
confidential_issue.subscribe(member, project)
confidential_issue.subscribe(guest, project)
confidential_issue.subscribe(admin, project)
reset_delivered_emails!
notification.removed_iteration_issue(confidential_issue, @u_disabled)
should_not_email(non_member)
should_not_email(guest)
should_email(author)
should_email(assignee)
should_email(member)
should_email(admin)
end
end
end
describe '#changed_iteration_issue' do
let(:mailer_method) { :changed_iteration_issue_email }
context do
let(:new_iteration) { create(:iteration, project: project, issues: [issue]) }
let!(:subscriber_to_new_iteration) { create(:user) { |u| issue.toggle_subscription(u, project) } }
it_behaves_like 'altered iteration notification on issue' do
before do
notification.changed_iteration_issue(issue, new_iteration, issue.author)
end
end
it_behaves_like 'project emails are disabled' do
let(:notification_target) { issue }
let(:notification_trigger) { notification.changed_iteration_issue(issue, new_iteration, issue.author) }
end
end
context 'confidential issues' do
let(:author) { create(:user) }
let(:assignee) { create(:user) }
let(:non_member) { create(:user) }
let(:member) { create(:user) }
let(:guest) { create(:user) }
let(:admin) { create(:admin) }
let(:confidential_issue) { create(:issue, :confidential, project: project, title: 'Confidential issue', author: author, assignees: [assignee]) }
let(:new_iteration) { create(:iteration, project: project, issues: [confidential_issue]) }
it "emails subscribers of the issue's iteration that can read the issue" do
project.add_developer(member)
project.add_guest(guest)
confidential_issue.subscribe(non_member, project)
confidential_issue.subscribe(author, project)
confidential_issue.subscribe(assignee, project)
confidential_issue.subscribe(member, project)
confidential_issue.subscribe(guest, project)
confidential_issue.subscribe(admin, project)
reset_delivered_emails!
notification.changed_iteration_issue(confidential_issue, new_iteration, @u_disabled)
should_not_email(non_member)
should_not_email(guest)
should_email(author)
should_email(assignee)
should_email(member)
should_email(admin)
end
end
end
end
describe 'epics' do describe 'epics' do
let_it_be(:group) { create(:group, :private) } let_it_be(:group) { create(:group, :private) }
let_it_be(:epic) { create(:epic, group: group) } let_it_be(:epic) { create(:epic, group: group) }
......
...@@ -12202,6 +12202,12 @@ msgstr "" ...@@ -12202,6 +12202,12 @@ msgstr ""
msgid "It's you" msgid "It's you"
msgstr "" msgstr ""
msgid "Iteration changed to"
msgstr ""
msgid "Iteration removed"
msgstr ""
msgid "Iterations" msgid "Iterations"
msgstr "" msgstr ""
...@@ -26006,10 +26012,10 @@ msgstr "" ...@@ -26006,10 +26012,10 @@ msgstr ""
msgid "exceeds the limit of %{bytes} bytes for directory name \"%{dirname}\"" msgid "exceeds the limit of %{bytes} bytes for directory name \"%{dirname}\""
msgstr "" msgstr ""
msgid "expired on %{milestone_due_date}" msgid "expired on %{timebox_due_date}"
msgstr "" msgstr ""
msgid "expires on %{milestone_due_date}" msgid "expires on %{timebox_due_date}"
msgstr "" msgstr ""
msgid "external_url" msgid "external_url"
...@@ -26784,10 +26790,10 @@ msgstr "" ...@@ -26784,10 +26790,10 @@ msgstr ""
msgid "started a discussion on %{design_link}" msgid "started a discussion on %{design_link}"
msgstr "" msgstr ""
msgid "started on %{milestone_start_date}" msgid "started on %{timebox_start_date}"
msgstr "" msgstr ""
msgid "starts on %{milestone_start_date}" msgid "starts on %{timebox_start_date}"
msgstr "" msgstr ""
msgid "stuck" msgid "stuck"
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
require 'spec_helper' require 'spec_helper'
describe MilestonesHelper do describe TimeboxesHelper do
describe '#milestones_filter_dropdown_path' do describe '#milestones_filter_dropdown_path' do
let(:project) { create(:project) } let(:project) { create(:project) }
let(:project2) { create(:project) } let(:project2) { create(:project) }
...@@ -39,23 +39,34 @@ describe MilestonesHelper do ...@@ -39,23 +39,34 @@ describe MilestonesHelper do
end end
end end
describe "#milestone_date_range" do describe "#timebox_date_range" do
def result_for(*args)
milestone_date_range(build(:milestone, *args))
end
let(:yesterday) { Date.yesterday } let(:yesterday) { Date.yesterday }
let(:tomorrow) { yesterday + 2 } let(:tomorrow) { yesterday + 2 }
let(:format) { '%b %-d, %Y' } let(:format) { '%b %-d, %Y' }
let(:yesterday_formatted) { yesterday.strftime(format) } let(:yesterday_formatted) { yesterday.strftime(format) }
let(:tomorrow_formatted) { tomorrow.strftime(format) } let(:tomorrow_formatted) { tomorrow.strftime(format) }
it { expect(result_for(due_date: nil, start_date: nil)).to be_nil } context 'milestone' do
it { expect(result_for(due_date: tomorrow)).to eq("expires on #{tomorrow_formatted}") } def result_for(*args)
it { expect(result_for(due_date: yesterday)).to eq("expired on #{yesterday_formatted}") } timebox_date_range(build(:milestone, *args))
it { expect(result_for(start_date: tomorrow)).to eq("starts on #{tomorrow_formatted}") } end
it { expect(result_for(start_date: yesterday)).to eq("started on #{yesterday_formatted}") }
it { expect(result_for(start_date: yesterday, due_date: tomorrow)).to eq("#{yesterday_formatted}#{tomorrow_formatted}") } it { expect(result_for(due_date: nil, start_date: nil)).to be_nil }
it { expect(result_for(due_date: tomorrow)).to eq("expires on #{tomorrow_formatted}") }
it { expect(result_for(due_date: yesterday)).to eq("expired on #{yesterday_formatted}") }
it { expect(result_for(start_date: tomorrow)).to eq("starts on #{tomorrow_formatted}") }
it { expect(result_for(start_date: yesterday)).to eq("started on #{yesterday_formatted}") }
it { expect(result_for(start_date: yesterday, due_date: tomorrow)).to eq("#{yesterday_formatted}#{tomorrow_formatted}") }
end
context 'iteration' do
# Iterations always have start and due dates, so only A-B format is expected
it 'formats properly' do
iteration = build(:iteration, start_date: yesterday, due_date: tomorrow)
expect(timebox_date_range(iteration)).to eq("#{yesterday_formatted}#{tomorrow_formatted}")
end
end
end end
describe '#milestone_counts' do describe '#milestone_counts' do
......
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