Last active
April 28, 2023 18:57
-
-
Save sambostock/750fe2a03af9a17ae3326b2101e6b58e to your computer and use it in GitHub Desktop.
Oncall scheduler script for PagerDuty - Given a list of junior and senior developers to be added to primary and secondary schedules, finds a suitable set of pairings.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# frozen_string_literal: true | |
Dev = Struct.new(:name, :senior, :prefers_consecutive_shifts, keyword_init: true) do | |
def junior? | |
!senior | |
end | |
def short_name | |
first, last = name.split(' ', 2) | |
return first if last.nil? | |
initial = last[0] | |
"#{first} #{initial}." | |
end | |
def prefers_consecutive_shifts? | |
prefers_consecutive_shifts | |
end | |
end | |
DEVS = [ | |
Dev.new(name: 'Adam Adamson', senior: true , prefers_consecutive_shifts: false).freeze, | |
Dev.new(name: 'Betty Bettydotir', senior: false, prefers_consecutive_shifts: false).freeze, | |
Dev.new(name: 'Charles Charleson', senior: false, prefers_consecutive_shifts: false).freeze, | |
Dev.new(name: 'Debra Debradotir', senior: true , prefers_consecutive_shifts: false).freeze, | |
Dev.new(name: 'Edward Edwardson', senior: true , prefers_consecutive_shifts: true ).freeze, | |
Dev.new(name: 'Fiona Fionadotir', senior: true , prefers_consecutive_shifts: false).freeze, | |
Dev.new(name: 'Greg Gregson', senior: false, prefers_consecutive_shifts: false).freeze, | |
].freeze | |
# Depending on the size and composition of your rotation, it may not be possible to meet all requirements. | |
# If the script seems to run forever, try tweaking these values to disable what you care least about. | |
CONFIG = { | |
allow_inverse_pairing: false, # Allow the same pair to be repeated in reverse | |
# (e.g. Alice & Bob one shift, Bob & Alice the next shift) | |
allow_pairing_juniors: false, # Allow two junior devs to be paired together | |
consecutive_shifts_window: 3, # Prevent multiple shifts over this many consecutive periods | |
# Disabled if < 2 | |
ignore_consecutive_shifts_preference: true, # Ignore the "prefers_consecutive_shifts" flag for back to back shifts | |
}.freeze | |
# For the purposes of this script, "junior" and "senior" don't reflect job titles; | |
# they reflect seniority within the rotation. You wouldn't pair two "juniors" together. | |
def juniors_paired?(pairs) | |
pairs.any? do |primary, secondary| | |
primary.junior? && secondary.junior? | |
end | |
end | |
# Obviously, you don't want someone paired with themselves, for redundancy purposes. | |
def same_primary_as_secondary?(pairs) | |
pairs.any? do |primary, secondary| | |
primary == secondary | |
end | |
end | |
# Avoid giving people more than one shift in window_size consecutive periods. | |
def consecutive_shifts?(pairs, window_size: 2) | |
return false if window_size < 2 | |
extended_pairs = pairs + [pairs.take(window_size - 1)] | |
extended_pairs.each_cons(window_size).any? do |consecutive_pairs| | |
first_pair, *remaining_pairs = consecutive_pairs | |
first_pair.any? do |person| | |
remaining_pairs.any? { |pair| pair.include?(person) } unless person.prefers_consecutive_shifts? | |
end | |
end | |
end | |
# Avoid repeating pairings of two people. | |
def same_pairing?(pairs) | |
pairs.any? do |pair| | |
pairs.include?(pair.reverse) | |
end | |
end | |
# Ensure people who do prefer consecutive shifts receive them. | |
def prefered_consecutive_shifts?(pairs) | |
extended_pairs = pairs + [pairs.take(1)] | |
extended_pairs.each_cons(2).all? do |(first_primary, _), (_, second_secondary)| | |
!first_primary.prefers_consecutive_shifts? || first_primary == second_secondary | |
end | |
end | |
# Check if a group of pairings meets all configured criteria. | |
def meets_criteria?(pairs) | |
# Least expensive checks first. | |
!same_primary_as_secondary?(pairs) && # non-configurable | |
(CONFIG.fetch(:allow_pairing_juniors) || !juniors_paired?(pairs)) && | |
(CONFIG.fetch(:allow_inverse_pairing) || !same_pairing?(pairs)) && | |
!consecutive_shifts?(pairs, window_size: CONFIG.fetch(:consecutive_shifts_window)) && | |
(CONFIG.fetch(:ignore_consecutive_shifts_preference) || prefered_consecutive_shifts?(pairs)) | |
end | |
# Generate random pairings until one is found that satisfies the conditions. | |
def find_arrangement | |
loop.with_index(1) do |_, tries| | |
pairs = DEVS.shuffle.zip(DEVS.shuffle) | |
puts "##{tries} - Checking #{pairs.map { |p, s| "#{p.short_name} & #{s.short_name}"}.join(', ')}" | |
break pairs if meets_criteria?(pairs) | |
end | |
end | |
def print_arrangement(pairs) | |
list = [["Primary", "Secondary"]] + pairs.map { |pair| pair.map(&:name) } | |
primary_width = list.map { |pair| pair[0].length }.max | |
secondary_width = list.map { |pair| pair[1].length }.max | |
puts | |
puts 'Found:' | |
list.each do |(primary, secondary)| | |
puts "#{primary.ljust(primary_width)} #{secondary.rjust(secondary_width)}" | |
end | |
end | |
print_arrangement(find_arrangement) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment