Commit b896e48f authored by Sarah Yasonik's avatar Sarah Yasonik Committed by Peter Leitzen

Allow issue updates to update incident escalation status

parent c0b28915
......@@ -85,8 +85,11 @@ class Issue < ApplicationRecord
has_many :issue_customer_relations_contacts, class_name: 'CustomerRelations::IssueContact', inverse_of: :issue
has_many :customer_relations_contacts, through: :issue_customer_relations_contacts, source: :contact, class_name: 'CustomerRelations::Contact', inverse_of: :issues
alias_attribute :escalation_status, :incident_management_issuable_escalation_status
accepts_nested_attributes_for :issuable_severity, update_only: true
accepts_nested_attributes_for :sentry_issue
accepts_nested_attributes_for :incident_management_issuable_escalation_status, update_only: true
validates :project, presence: true
validates :issue_type, presence: true
......
......@@ -399,6 +399,7 @@ class ProjectPolicy < BasePolicy
enable :destroy_feature_flag
enable :admin_feature_flag
enable :admin_feature_flags_user_lists
enable :update_escalation_status
end
rule { can?(:developer_access) & user_confirmed? }.policy do
......
# frozen_string_literal: true
module IncidentManagement
module IssuableEscalationStatuses
class PrepareUpdateService
include Gitlab::Utils::StrongMemoize
SUPPORTED_PARAMS = %i[status].freeze
InvalidParamError = Class.new(StandardError)
def initialize(issuable, current_user, params)
@issuable = issuable
@current_user = current_user
@params = params.dup || {}
@project = issuable.project
end
def execute
return availability_error unless available?
filter_unsupported_params
filter_attributes
filter_redundant_params
ServiceResponse.success(payload: { escalation_status: params })
rescue InvalidParamError
invalid_param_error
end
private
attr_reader :issuable, :current_user, :params, :project
def available?
Feature.enabled?(:incident_escalations, project) &&
user_has_permissions? &&
issuable.supports_escalation? &&
escalation_status.present?
end
def user_has_permissions?
current_user&.can?(:update_escalation_status, issuable)
end
def escalation_status
strong_memoize(:escalation_status) do
issuable.escalation_status
end
end
def filter_unsupported_params
params.slice!(*supported_params)
end
def supported_params
SUPPORTED_PARAMS
end
def filter_attributes
filter_status
end
def filter_status
status = params.delete(:status)
return unless status
status_event = escalation_status.status_event_for(status)
raise InvalidParamError unless status_event
params[:status_event] = status_event
end
def filter_redundant_params
params.delete_if do |key, value|
current_params.key?(key) && current_params[key] == value
end
end
def current_params
strong_memoize(:current_params) do
{
status_event: escalation_status.status_event_for(escalation_status.status_name)
}
end
end
def availability_error
ServiceResponse.error(message: 'Escalation status updates are not available for this issue, user, or project.')
end
def invalid_param_error
ServiceResponse.error(message: 'Invalid value was provided for a parameter.')
end
end
end
end
::IncidentManagement::IssuableEscalationStatuses::PrepareUpdateService.prepend_mod
......@@ -63,6 +63,7 @@ class IssuableBaseService < ::BaseProjectService
filter_milestone
filter_labels
filter_severity(issuable)
filter_escalation_status(issuable)
end
def filter_assignees(issuable)
......@@ -152,6 +153,18 @@ class IssuableBaseService < ::BaseProjectService
params[:issuable_severity_attributes] = { severity: severity }
end
def filter_escalation_status(issuable)
result = ::IncidentManagement::IssuableEscalationStatuses::PrepareUpdateService.new(
issuable,
current_user,
params.delete(:escalation_status)
).execute
return unless result.success? && result.payload.present?
params[:incident_management_issuable_escalation_status_attributes] = result[:escalation_status]
end
def process_label_ids(attributes, existing_label_ids: nil, extra_label_ids: [])
label_ids = attributes.delete(:label_ids)
add_label_ids = attributes.delete(:add_label_ids)
......@@ -471,6 +484,7 @@ class IssuableBaseService < ::BaseProjectService
associations[:description] = issuable.description
associations[:reviewers] = issuable.reviewers.to_a if issuable.allows_reviewers?
associations[:severity] = issuable.severity if issuable.supports_severity?
associations[:escalation_status] = issuable.escalation_status&.slice(:status, :policy_id) if issuable.supports_escalation?
associations
end
......
......@@ -53,6 +53,7 @@ module Issues
old_mentioned_users = old_associations.fetch(:mentioned_users, [])
old_assignees = old_associations.fetch(:assignees, [])
old_severity = old_associations[:severity]
old_escalation_status = old_associations[:escalation_status]
if has_changes?(issue, old_labels: old_labels, old_assignees: old_assignees)
todo_service.resolve_todos_for_target(issue, current_user)
......@@ -69,6 +70,7 @@ module Issues
handle_milestone_change(issue)
handle_added_mentions(issue, old_mentioned_users)
handle_severity_change(issue, old_severity)
handle_escalation_status_change(issue, old_escalation_status)
handle_issue_type_change(issue)
end
......@@ -208,6 +210,18 @@ module Issues
::IncidentManagement::AddSeveritySystemNoteWorker.perform_async(issue.id, current_user.id)
end
def handle_escalation_status_change(issue, old_escalation_status)
return unless old_escalation_status.present?
return if issue.escalation_status&.slice(:status, :policy_id) == old_escalation_status
return unless issue.alert_management_alert
::AlertManagement::Alerts::UpdateService.new(
issue.alert_management_alert,
current_user,
status: issue.escalation_status.status_name
).execute
end
# rubocop: disable CodeReuse/ActiveRecord
def issuable_for_positioning(id, board_group_id = nil)
return unless id
......
# frozen_string_literal: true
module EE
module IncidentManagement
module IssuableEscalationStatuses
module PrepareUpdateService
extend ::Gitlab::Utils::Override
EE_SUPPORTED_PARAMS = %i[policy].freeze
override :supported_params
def supported_params
super + EE_SUPPORTED_PARAMS
end
override :filter_attributes
def filter_attributes
super
filter_policy
end
def filter_policy
policy = params.delete(:policy)
return unless ::Gitlab::IncidentManagement.escalation_policies_available?(project)
return if issuable.alert_management_alert # Cannot change the policy for an alert
if policy
return if policy.id == escalation_status.policy_id
if policy.project_id != issuable.project_id
raise ::IncidentManagement::IssuableEscalationStatuses::PrepareUpdateService::InvalidParamError
end
# Override any provided status if setting new policy
params[:status_event] = :trigger
end
params[:policy] = policy
params[:escalations_started_at] = policy ? Time.current : nil
end
override :current_params
def current_params
strong_memoize(:current_params) do
super.merge(
policy: escalation_status.policy
)
end
end
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe IncidentManagement::IssuableEscalationStatuses::PrepareUpdateService do
let_it_be(:escalation_status, reload: true) { create(:incident_management_issuable_escalation_status, :triggered) }
let_it_be(:policy) { create(:incident_management_escalation_policy, project: escalation_status.issue.project) }
let_it_be(:user_with_permissions) { create(:user) }
let(:current_user) { user_with_permissions }
let(:issue) { escalation_status.issue }
let(:status) { :acknowledged }
let(:params) { { status: status, policy: policy } }
let(:service) { IncidentManagement::IssuableEscalationStatuses::PrepareUpdateService.new(issue, current_user, params) }
subject(:result) { service.execute }
before do
issue.project.add_developer(user_with_permissions)
stub_licensed_features(oncall_schedules: true, escalation_policies: true)
end
shared_examples 'successful response' do
it 'returns valid parameters which can be used to update the issue', :freeze_time do
expect(result).to be_success
expect(result.payload).to eq(payload)
end
end
shared_examples 'successful response without policy params' do
include_examples 'successful response' do
let(:payload) { { escalation_status: { status_event: :acknowledge } } }
end
end
it_behaves_like 'successful response' do
let(:payload) do
{
escalation_status: {
policy: policy,
escalations_started_at: Time.current
}
}
end
end
context 'when escalation policies feature is unavailable' do
before do
stub_licensed_features(oncall_schedules: false, escalation_policies: false)
end
it_behaves_like 'successful response without policy params'
end
context 'when issue is associated with an alert' do
let!(:alert) { create(:alert_management_alert, issue: issue, project: issue.project) }
it_behaves_like 'successful response without policy params'
end
context 'when provided policy is in a different project' do
let(:issue) { create(:incident) }
before do
create(:incident_management_issuable_escalation_status, issue: issue)
end
it 'returns an error response' do
expect(result).to be_error
expect(result.message).to eq('Invalid value was provided for a parameter.')
end
end
context 'when the escalation status is already associated with a policy' do
before do
escalation_status.update!(policy_id: policy.id, escalations_started_at: Time.current)
end
context 'when policy is unchanged' do
it_behaves_like 'successful response without policy params'
end
context 'when policy is nil' do
let(:params) { { status: status, policy: nil } }
it_behaves_like 'successful response' do
let(:payload) do
{
escalation_status: {
status_event: :acknowledge,
policy: nil,
escalations_started_at: nil
}
}
end
end
end
end
end
......@@ -439,6 +439,87 @@ RSpec.describe Issues::UpdateService do
end
end
context 'updating escalation status' do
let(:opts) { { escalation_status: { policy: policy } } }
let!(:escalation_status) { create(:incident_management_issuable_escalation_status, issue: issue) }
let!(:policy) { create(:incident_management_escalation_policy, project: project) }
before do
stub_licensed_features(oncall_schedules: true, escalation_policies: true)
group.add_developer(user)
end
# Requires `expoected_policy` and `expected_status` to be defined
shared_examples 'escalation status record has correct values' do
specify do
update_issue(opts)
expect(issue.escalation_status.policy).to eq(expected_policy)
expect(issue.escalation_status.status_name).to eq(expected_status)
end
end
shared_examples 'does not change the status record' do
specify do
expect { update_issue(opts) }.not_to change { issue.escalation_status.reload }
end
it 'does not trigger side-effects' do
expect(::AlertManagement::Alerts::UpdateService).not_to receive(:new)
update_issue(opts)
end
end
context 'when issue is an incident' do
let(:issue) { create(:incident, project: project) }
context 'setting the escalation policy' do
include_examples 'escalation status record has correct values' do
let(:expected_policy) { policy }
let(:expected_status) { :triggered }
end
context 'with the policy value defined but unchanged' do
let!(:escalation_status) { create(:incident_management_issuable_escalation_status, :paging, issue: issue, policy: policy) }
it_behaves_like 'does not change the status record'
end
end
context 'unsetting the escalation policy' do
let(:policy) { nil }
context 'when the policy is already set' do
let!(:escalation_status) { create(:incident_management_issuable_escalation_status, :paging, issue: issue) }
include_examples 'escalation status record has correct values' do
let(:expected_policy) { nil }
let(:expected_status) { :triggered }
end
context 'in addition to other attributes' do
let(:opts) { { escalation_status: { policy: policy, status: 'acknowledged' } } }
include_examples 'escalation status record has correct values' do
let(:expected_policy) { nil }
let(:expected_status) { :acknowledged }
end
end
end
context 'with the policy value defined but unchanged' do
it_behaves_like 'does not change the status record'
end
end
end
context 'when issue is not an incident' do
it_behaves_like 'does not change the status record'
end
end
it_behaves_like 'existing issuable with scoped labels' do
let(:issuable) { issue }
let(:parent) { project }
......
......@@ -2,7 +2,7 @@
FactoryBot.define do
factory :incident_management_issuable_escalation_status, class: 'IncidentManagement::IssuableEscalationStatus' do
issue
association :issue, factory: :incident
triggered
trait :triggered do
......
......@@ -922,6 +922,22 @@ RSpec.describe Issuable do
end
end
describe '#supports_escalation?' do
where(:issuable_type, :supports_escalation) do
:issue | false
:incident | true
:merge_request | false
end
with_them do
let(:issuable) { build_stubbed(issuable_type) }
subject { issuable.supports_escalation? }
it { is_expected.to eq(supports_escalation) }
end
end
describe '#incident?' do
where(:issuable_type, :incident) do
:issue | false
......
......@@ -1580,4 +1580,13 @@ RSpec.describe Issue do
expect(participant.issue.email_participants_emails_downcase).to match([participant.email.downcase])
end
end
describe '#escalation_status' do
it 'returns the incident_management_issuable_escalation_status association' do
escalation_status = create(:incident_management_issuable_escalation_status)
issue = escalation_status.issue
expect(issue.escalation_status).to eq(escalation_status)
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe IncidentManagement::IssuableEscalationStatuses::PrepareUpdateService do
let_it_be(:escalation_status) { create(:incident_management_issuable_escalation_status, :triggered) }
let_it_be(:user_with_permissions) { create(:user) }
let(:current_user) { user_with_permissions }
let(:issue) { escalation_status.issue }
let(:status) { :acknowledged }
let(:params) { { status: status } }
let(:service) { IncidentManagement::IssuableEscalationStatuses::PrepareUpdateService.new(issue, current_user, params) }
subject(:result) { service.execute }
before do
issue.project.add_developer(user_with_permissions)
end
shared_examples 'successful response' do |payload|
it 'returns valid parameters which can be used to update the issue' do
expect(result).to be_success
expect(result.payload).to eq(escalation_status: payload)
end
end
shared_examples 'error response' do |message|
specify do
expect(result).to be_error
expect(result.message).to eq(message)
end
end
shared_examples 'availability error response' do
include_examples 'error response', 'Escalation status updates are not available for this issue, user, or project.'
end
shared_examples 'invalid params error response' do
include_examples 'error response', 'Invalid value was provided for a parameter.'
end
it_behaves_like 'successful response', { status_event: :acknowledge }
context 'when feature flag is disabled' do
before do
stub_feature_flags(incident_escalations: false)
end
it_behaves_like 'availability error response'
end
context 'when user is anonymous' do
let(:current_user) { nil }
it_behaves_like 'availability error response'
end
context 'when user does not have permissions' do
let(:current_user) { create(:user) }
it_behaves_like 'availability error response'
end
context 'when called with an unsupported issue type' do
let(:issue) { create(:issue) }
it_behaves_like 'availability error response'
end
context 'when an IssuableEscalationStatus record for the issue does not exist' do
let(:issue) { create(:incident) }
it_behaves_like 'availability error response'
end
context 'when called without params' do
let(:params) { nil }
it_behaves_like 'successful response', {}
end
context 'when called with unsupported params' do
let(:params) { { escalations_started_at: Time.current } }
it_behaves_like 'successful response', {}
end
context 'with status param' do
context 'when status matches the current status' do
let(:params) { { status: :triggered } }
it_behaves_like 'successful response', {}
end
context 'when status is unsupported' do
let(:params) { { status: :mitigated } }
it_behaves_like 'invalid params error response'
end
context 'when status is a String' do
let(:params) { { status: 'acknowledged' } }
it_behaves_like 'successful response', { status_event: :acknowledge }
end
end
end
......@@ -1157,6 +1157,76 @@ RSpec.describe Issues::UpdateService, :mailer do
end
end
context 'updating escalation status' do
let(:opts) { { escalation_status: { status: 'acknowledged' } } }
shared_examples 'updates the escalation status record' do |expected_status|
it 'has correct value' do
update_issue(opts)
expect(issue.escalation_status.status_name).to eq(expected_status)
end
end
shared_examples 'does not change the status record' do
it 'retains the original value' do
expected_status = issue.escalation_status&.status_name
update_issue(opts)
expect(issue.escalation_status&.status_name).to eq(expected_status)
end
it 'does not trigger side-effects' do
expect(::AlertManagement::Alerts::UpdateService).not_to receive(:new)
update_issue(opts)
end
end
context 'when issue is an incident' do
let(:issue) { create(:incident, project: project) }
context 'with an escalation status record' do
before do
create(:incident_management_issuable_escalation_status, issue: issue)
end
it_behaves_like 'updates the escalation status record', :acknowledged
context 'with associated alert' do
let!(:alert) { create(:alert_management_alert, issue: issue, project: project) }
it 'syncs the update back to the alert' do
update_issue(opts)
expect(alert.reload.status_name).to eq(:acknowledged)
end
end
context 'with unsupported status value' do
let(:opts) { { escalation_status: { status: 'unsupported-status' } } }
it_behaves_like 'does not change the status record'
end
context 'with status value defined but unchanged' do
let(:opts) { { escalation_status: { status: issue.escalation_status.status_name } } }
it_behaves_like 'does not change the status record'
end
end
context 'without an escalation status record' do
it_behaves_like 'does not change the status record'
end
end
context 'when issue type is not incident' do
it_behaves_like 'does not change the status record'
end
end
context 'duplicate issue' do
let(:canonical_issue) { create(:issue, project: project) }
......
......@@ -50,7 +50,7 @@ RSpec.shared_context 'ProjectPolicy context' do
resolve_note update_build update_commit_status update_container_image
update_deployment update_environment update_merge_request
update_metrics_dashboard_annotation update_pipeline update_release destroy_release
read_resource_group update_resource_group
read_resource_group update_resource_group update_escalation_status
]
end
......
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