Commit 42cff1c2 authored by Mario de la Ossa's avatar Mario de la Ossa

Send email notifications on Issue Iteration change

When an Iteration in an Issue changes or gets removed, we need to send
an email notification to the right users
parent 12eb6a8f
# frozen_string_literal: true
module MilestonesHelper
module TimeboxesHelper
include EntityDateHelper
include Gitlab::Utils::StrongMemoize
......@@ -209,23 +209,24 @@ module MilestonesHelper
end
end
def milestone_date_range(milestone)
if milestone.start_date && milestone.due_date
"#{milestone.start_date.to_s(:medium)}#{milestone.due_date.to_s(:medium)}"
elsif milestone.due_date
if milestone.due_date.past?
_("expired on %{milestone_due_date}") % { milestone_due_date: milestone.due_date.strftime('%b %-d, %Y') }
def timebox_date_range(timebox)
if timebox.start_date && timebox.due_date
"#{timebox.start_date.to_s(:medium)}#{timebox.due_date.to_s(:medium)}"
elsif timebox.due_date
if timebox.due_date.past?
_("expired on %{timebox_due_date}") % { timebox_due_date: timebox.due_date.to_s(:medium) }
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
elsif milestone.start_date
if milestone.start_date.past?
_("started on %{milestone_start_date}") % { milestone_start_date: milestone.start_date.strftime('%b %-d, %Y') }
elsif timebox.start_date
if timebox.start_date.past?
_("started on %{timebox_start_date}") % { timebox_start_date: timebox.start_date.to_s(:medium) }
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
alias_method :milestone_date_range, :timebox_date_range
def milestone_tab_path(milestone, tab)
if milestone.global_milestone?
......@@ -306,4 +307,4 @@ module MilestonesHelper
end
end
MilestonesHelper.prepend_if_ee('EE::MilestonesHelper')
TimeboxesHelper.prepend_if_ee('EE::TimeboxesHelper')
......@@ -126,3 +126,5 @@ module Emails
end
end
end
Emails::Issues.prepend_if_ee('EE::Emails::Issues')
......@@ -19,7 +19,7 @@ class Notify < ApplicationMailer
include Emails::Releases
include Emails::Groups
helper MilestonesHelper
helper TimeboxesHelper
helper MergeRequestsHelper
helper DiffHelper
helper BlobHelper
......
# frozen_string_literal: true
module EE
module MilestonesHelper
module TimeboxesHelper
def burndown_chart(milestone)
if milestone.supports_burndown_charts?
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
result
end
override :handle_changes
def handle_changes(issue, _options)
super
handle_iteration_change(issue)
end
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)
return unless params.delete(:promote_to_epic)
......
......@@ -61,6 +61,14 @@ module EE
mailer.project_mirror_user_changed_email(new_mirror_user.id, deleted_user_name, project.id).deliver_later
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
def send_new_review_notification(review)
......@@ -108,6 +116,30 @@ module EE
mailer.service_desk_new_note_email(issue.id, note.id).deliver_later
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)
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
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
before do
project.add_maintainer(user)
......
......@@ -328,6 +328,159 @@ describe EE::NotificationService, :mailer do
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
let_it_be(:group) { create(:group, :private) }
let_it_be(:epic) { create(:epic, group: group) }
......
......@@ -12188,6 +12188,12 @@ msgstr ""
msgid "It's you"
msgstr ""
msgid "Iteration changed to"
msgstr ""
msgid "Iteration removed"
msgstr ""
msgid "Iterations"
msgstr ""
......@@ -25965,10 +25971,10 @@ msgstr ""
msgid "exceeds the limit of %{bytes} bytes for directory name \"%{dirname}\""
msgstr ""
msgid "expired on %{milestone_due_date}"
msgid "expired on %{timebox_due_date}"
msgstr ""
msgid "expires on %{milestone_due_date}"
msgid "expires on %{timebox_due_date}"
msgstr ""
msgid "external_url"
......@@ -26743,10 +26749,10 @@ msgstr ""
msgid "started a discussion on %{design_link}"
msgstr ""
msgid "started on %{milestone_start_date}"
msgid "started on %{timebox_start_date}"
msgstr ""
msgid "starts on %{milestone_start_date}"
msgid "starts on %{timebox_start_date}"
msgstr ""
msgid "stuck"
......
......@@ -2,7 +2,7 @@
require 'spec_helper'
describe MilestonesHelper do
describe TimeboxesHelper do
describe '#milestones_filter_dropdown_path' do
let(:project) { create(:project) }
let(:project2) { create(:project) }
......@@ -39,23 +39,34 @@ describe MilestonesHelper do
end
end
describe "#milestone_date_range" do
def result_for(*args)
milestone_date_range(build(:milestone, *args))
end
describe "#timebox_date_range" do
let(:yesterday) { Date.yesterday }
let(:tomorrow) { yesterday + 2 }
let(:format) { '%b %-d, %Y' }
let(:yesterday_formatted) { yesterday.strftime(format) }
let(:tomorrow_formatted) { tomorrow.strftime(format) }
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}") }
context 'milestone' do
def result_for(*args)
timebox_date_range(build(:milestone, *args))
end
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
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