Skip to content

Instantly share code, notes, and snippets.

@sambostock
Last active April 28, 2023 18:57
Show Gist options
  • Save sambostock/750fe2a03af9a17ae3326b2101e6b58e to your computer and use it in GitHub Desktop.
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.
# 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