Commit 9dba0a79 authored by Sean McGivern's avatar Sean McGivern

Merge branch '262860-generate-shifts-dynamically' into 'master'

Generate shifts for a timeframe based on on-call rotation params

See merge request gitlab-org/gitlab!49665
parents 846e04c8 b13eb238
......@@ -11,6 +11,8 @@ module IncidentManagement
belongs_to :user, class_name: 'User', foreign_key: :user_id
has_many :shifts, class_name: 'OncallShift', inverse_of: :participant, foreign_key: :participant_id
scope :ordered_asc, -> { order(id: :asc) }
# Uniqueness validations added here should be duplicated
# in IncidentManagement::OncallRotation::CreateService
# as bulk insertion skips validations
......
......@@ -19,9 +19,14 @@ module IncidentManagement
validates :name, presence: true, uniqueness: { scope: :oncall_schedule_id }, length: { maximum: NAME_LENGTH }
validates :starts_at, presence: true
validates :length, presence: true
validates :length, presence: true, numericality: true
validates :length_unit, presence: true
delegate :project, to: :schedule
def shift_duration
# As length_unit is an enum, input is guaranteed to be appropriate
length.public_send(length_unit) # rubocop:disable GitlabSecurity/PublicSend
end
end
end
# frozen_string_literal: true
module IncidentManagement
class OncallShiftGenerator
# @param rotation [IncidentManagement::OncallRotation]
def initialize(rotation)
@rotation = rotation
end
# Generates an array of shifts which cover the provided time range.
#
# @param starts_at [ActiveSupport::TimeWithZone]
# @param ends_at [ActiveSupport::TimeWithZone]
# @return [IncidentManagement::OncallShift]
def for_timeframe(starts_at:, ends_at:)
starts_at = [apply_timezone(starts_at), rotation_starts_at].max
ends_at = apply_timezone(ends_at)
return [] unless starts_at < ends_at
return [] unless rotation.participants.any?
# The first shift within the timeframe may begin before
# the timeframe. We want to begin generating shifts
# based on the actual start time of the shift.
elapsed_shift_count = elapsed_whole_shifts(starts_at)
shift_starts_at = shift_start_time(elapsed_shift_count)
shifts = []
while shift_starts_at < ends_at
shifts << shift_for(elapsed_shift_count, shift_starts_at)
shift_starts_at += shift_duration
elapsed_shift_count += 1
end
shifts
end
# Generates a single shift during which the timestamp occurs.
#
# @param timestamp [ActiveSupport::TimeWithZone]
# @return IncidentManagement::OncallShift
def for_timestamp(timestamp)
timestamp = apply_timezone(timestamp)
return if timestamp < rotation_starts_at
return unless rotation.participants.any?
elapsed_shift_count = elapsed_whole_shifts(timestamp)
shift_starts_at = shift_start_time(elapsed_shift_count)
shift_for(elapsed_shift_count, shift_starts_at)
end
private
attr_reader :rotation
delegate :shift_duration, to: :rotation
# Starting time of a shift which covers the timestamp.
# @return [ActiveSupport::TimeWithZone]
def shift_start_time(elapsed_shift_count)
rotation_starts_at + (elapsed_shift_count * shift_duration)
end
# Total completed shifts passed between rotation start
# time and the provided timestamp.
# @return [Integer]
def elapsed_whole_shifts(timestamp)
elapsed_duration = timestamp - rotation_starts_at
unless rotation.hours?
# Changing timezones (like during daylight savings) can
# cause a "day" to have a duration other than 24 hours ("weeks" too).
# Since elapsed_duration is in seconds, we need
# account for this variable day/week length to
# determine how many actual shifts have elapsed.
#
# Ex) If a location with daylight savings sets their
# clocks forward an hour, a 1-day shift will last for
# 23 hours if it occurs over that transition.
#
# If we want to generate a shift which occurs 1 week
# after the timezone change, the real elapsed seconds
# will equal 1 week minus an hour.
#
# Seconds per average week: 2 * 7 * 24 * 60 * 60 = 1209600
# Seconds in zone-shifted week: 1209600 - (60 * 60) = 1206000
#
# If we count in seconds, minutes, or hours, these are different durations.
# If we count in "days" or "weeks", these durations are equivalent.
#
# To determine how many effective days or weeks
# a duration (in seconds) was, we need to normalize
# the duration to fit the definition of a 24-hour day.
# We can do this by diffing the UTC-offsets between the
# start time of the rotation and the relevant timestamp.
# This should account for different hemispheres,
# offsets changes other an 1 hour, and one-off timezone changes.
elapsed_duration += timestamp.utc_offset - rotation_starts_at.utc_offset
end
# Uses #round to account for floating point inconsistencies.
(elapsed_duration / shift_duration).round(5).floor
end
# Returns an UNSAVED shift, as this shift won't necessarily
# be persisted.
# @return [IncidentManagement::OncallShift]
def shift_for(elapsed_shift_count, shift_starts_at)
IncidentManagement::OncallShift.new(
rotation: rotation,
participant: participants[participant_rank(elapsed_shift_count)],
starts_at: shift_starts_at,
ends_at: shift_starts_at + shift_duration
)
end
# Position in an array of participants based on the
# number of shifts which have elasped for the rotation.
# @return [Integer]
def participant_rank(elapsed_shifts_count)
elapsed_shifts_count % participants.length
end
def participants
@participants ||= rotation.participants.ordered_asc
end
def rotation_starts_at
@rotation_starts_at ||= apply_timezone(rotation.starts_at)
end
def apply_timezone(timestamp)
timestamp.in_time_zone(rotation.schedule.timezone)
end
end
end
This diff is collapsed.
......@@ -14,13 +14,13 @@ RSpec.describe IncidentManagement::OncallParticipant do
rotation.project.add_developer(user)
end
describe '.associations' do
describe 'associations' do
it { is_expected.to belong_to(:rotation) }
it { is_expected.to belong_to(:user) }
it { is_expected.to have_many(:shifts) }
end
describe '.validations' do
describe 'validations' do
it { is_expected.to validate_presence_of(:rotation) }
it { is_expected.to validate_presence_of(:user) }
it { is_expected.to validate_presence_of(:color_weight) }
......@@ -38,6 +38,18 @@ RSpec.describe IncidentManagement::OncallParticipant do
end
end
describe 'scopes' do
describe '.ordered_asc' do
let_it_be(:participant1) { create(:incident_management_oncall_participant, :with_developer_access, rotation: rotation) }
let_it_be(:participant2) { create(:incident_management_oncall_participant, :with_developer_access, rotation: rotation) }
let_it_be(:participant3) { create(:incident_management_oncall_participant, :with_developer_access, rotation: rotation) }
subject { described_class.ordered_asc }
it { is_expected.to eq([participant1, participant2, participant3]) }
end
end
private
def remove_user_from_project(user, project)
......
......@@ -20,6 +20,7 @@ RSpec.describe IncidentManagement::OncallRotation do
it { is_expected.to validate_uniqueness_of(:name).scoped_to(:oncall_schedule_id) }
it { is_expected.to validate_presence_of(:starts_at) }
it { is_expected.to validate_presence_of(:length) }
it { is_expected.to validate_numericality_of(:length) }
it { is_expected.to validate_presence_of(:length_unit) }
context 'when the oncall rotation with the same name exists' do
......@@ -33,4 +34,20 @@ RSpec.describe IncidentManagement::OncallRotation do
end
end
end
describe '#shift_duration' do
let_it_be(:rotation) { create(:incident_management_oncall_rotation, schedule: schedule, length: 5, length_unit: :days) }
subject { rotation.shift_duration }
it { is_expected.to eq(5.days) }
described_class.length_units.each_key do |unit|
context "with a length unit of #{unit}" do
let(:rotation) { build(:incident_management_oncall_rotation, schedule: schedule, length_unit: unit) }
it { is_expected.to be_a(ActiveSupport::Duration) }
end
end
end
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