Skip to content

Commit 6d89e87

Browse files
committed
[email-rotation] give extend_rotation an --ensure-weeks flag
This allows for easy cronjob writing. You can e.g., automatically `--ensure-weeks=16` every month, and `extend_rotation.py` will handle making sure that you always have rotations covering up to a quarter out. Tested by running locally on a few different values of `--ensure-weeks`.
1 parent 5bebc1a commit 6d89e87

File tree

2 files changed

+132
-9
lines changed

2 files changed

+132
-9
lines changed

email-rotation/extend_rotation.py

Lines changed: 70 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44
import collections
55
import dataclasses
66
import datetime
7+
import itertools
78
import logging
9+
import math
810
from pathlib import Path
911
from typing import Iterator
1012

@@ -77,6 +79,30 @@ def generate_additional_rotations(
7779
least_recent_assignees.extend(people_on_this_rotation)
7880

7981

82+
def calculate_rotations_to_cover(
83+
when: datetime.datetime,
84+
rotation_length_weeks: int,
85+
current_rotations: list[rotations.Rotation],
86+
) -> int:
87+
"""Calculates how many additional rotations are needed to cover up to `when`."""
88+
if not current_rotations:
89+
raise ValueError("No existing rotations to extend from.")
90+
91+
last_rotation = current_rotations[-1]
92+
rotation_length = datetime.timedelta(weeks=rotation_length_weeks)
93+
end_of_last_rotation = last_rotation.start_time + rotation_length
94+
95+
if end_of_last_rotation >= when:
96+
return 0
97+
98+
delta = when - end_of_last_rotation
99+
# Add one because there's almost certainly a non-day component to `delta`.
100+
days_needed = delta.days + 1
101+
weeks_needed = days_needed / 7
102+
rotations_needed = int(math.ceil(weeks_needed / rotation_length_weeks))
103+
return rotations_needed
104+
105+
80106
def parse_args() -> argparse.Namespace:
81107
parser = argparse.ArgumentParser(
82108
description="Extend a rotation with additional members."
@@ -107,12 +133,6 @@ def parse_args() -> argparse.Namespace:
107133
than overwriting the existing file.
108134
""",
109135
)
110-
parser.add_argument(
111-
"--num-rotations",
112-
type=int,
113-
default=5,
114-
help="Number of rotations to add. Default is %(default)d.",
115-
)
116136
parser.add_argument(
117137
"--people-per-rotation",
118138
type=int,
@@ -124,6 +144,21 @@ def parse_args() -> argparse.Namespace:
124144
action="store_true",
125145
help="Enable debug logging.",
126146
)
147+
148+
rotation_group = parser.add_mutually_exclusive_group()
149+
rotation_group.add_argument(
150+
"--num-rotations",
151+
type=int,
152+
help="Number of rotations to add, defaults to 5.",
153+
)
154+
rotation_group.add_argument(
155+
"--ensure-weeks",
156+
type=int,
157+
help="""
158+
Ensure the rotation schedule covers at least this many weeks into
159+
the future.
160+
""",
161+
)
127162
return parser.parse_args()
128163

129164

@@ -136,24 +171,50 @@ def main() -> None:
136171
)
137172

138173
dry_run: bool = opts.dry_run
139-
num_rotations: int = opts.num_rotations
140174
people_per_rotation: int = opts.people_per_rotation
141175
rotation_file_path: Path = opts.rotation_file
142176
rotation_length_weeks: int = opts.rotation_length_weeks
143177
rotation_members_file_path: Path = opts.rotation_members_file
144178

179+
now = datetime.datetime.now(tz=datetime.timezone.utc)
145180
members_file = rotations.RotationMembersFile.parse_file(rotation_members_file_path)
146181
current_rotation = rotations.RotationFile.parse_file(rotation_file_path)
147182

183+
# Determine number of rotations based on flags
184+
if opts.num_rotations is not None:
185+
num_rotations_to_add: int = opts.num_rotations
186+
elif opts.ensure_weeks is not None:
187+
ensure_weeks: int = opts.ensure_weeks
188+
num_rotations_to_add = calculate_rotations_to_cover(
189+
now + datetime.timedelta(weeks=ensure_weeks),
190+
rotation_length_weeks,
191+
current_rotation.rotations,
192+
)
193+
if num_rotations_to_add == 0:
194+
logging.info(
195+
"Current rotations already cover the next %d weeks; no new rotations needed.",
196+
ensure_weeks,
197+
)
198+
return
199+
logging.info(
200+
"Ensuring %d weeks of coverage with %d rotations (%d weeks per rotation)",
201+
ensure_weeks,
202+
num_rotations_to_add,
203+
rotation_length_weeks,
204+
)
205+
else:
206+
# Default to 5 rotations if neither flag is specified
207+
num_rotations_to_add = 5
208+
148209
rotation_generator = generate_additional_rotations(
149210
current_rotation.rotations,
150211
members_file.members,
151212
people_per_rotation,
152213
rotation_length_weeks,
153-
now=datetime.datetime.now(tz=datetime.timezone.utc),
214+
now=now,
154215
)
155216

156-
extra_rotations = [x for x, _ in zip(rotation_generator, range(num_rotations))]
217+
extra_rotations = list(itertools.islice(rotation_generator, num_rotations_to_add))
157218
new_rotations = current_rotation.rotations + extra_rotations
158219
new_rotations_file = dataclasses.replace(current_rotation, rotations=new_rotations)
159220

email-rotation/extend_rotation_test.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from extend_rotation import (
88
MIN_TIME_UTC,
9+
calculate_rotations_to_cover,
910
find_most_recent_service_times,
1011
generate_additional_rotations,
1112
)
@@ -63,6 +64,67 @@ def test_member_in_multiple_rotations_gets_latest(self) -> None:
6364
self.assertEqual(result, expected)
6465

6566

67+
class TestCalculateRotationsToCover(unittest.TestCase):
68+
def test_no_existing_rotations_raises_error(self) -> None:
69+
when = datetime.datetime(2025, 6, 1, tzinfo=datetime.timezone.utc)
70+
with self.assertRaises(ValueError):
71+
calculate_rotations_to_cover(
72+
when, rotation_length_weeks=2, current_rotations=[]
73+
)
74+
75+
def test_target_time_before_last_rotation_end_returns_zero(self) -> None:
76+
last_rotation_start = datetime.datetime(
77+
2025, 5, 1, tzinfo=datetime.timezone.utc
78+
)
79+
first_rotation_start = last_rotation_start - datetime.timedelta(weeks=2)
80+
current_rotations = [
81+
Rotation(start_time=first_rotation_start, members=["bob", "charlie"]),
82+
Rotation(start_time=last_rotation_start, members=["alice", "bob"]),
83+
]
84+
85+
# Target time is May 10th, which is before the rotation ends on the
86+
# 15th.
87+
when = datetime.datetime(2025, 5, 10, tzinfo=datetime.timezone.utc)
88+
result = calculate_rotations_to_cover(
89+
when, rotation_length_weeks=2, current_rotations=current_rotations
90+
)
91+
self.assertEqual(result, 0)
92+
93+
def test_one_rotation_needed(self) -> None:
94+
# Last rotation starts May 1st, with 2-week length it ends May 15th
95+
last_rotation_start = datetime.datetime(
96+
2025, 5, 1, tzinfo=datetime.timezone.utc
97+
)
98+
current_rotations = [
99+
Rotation(start_time=last_rotation_start, members=["alice", "bob"])
100+
]
101+
102+
# Target time is May 20th, needs one more 2-week rotation, since the
103+
# previous one ends on the 15th.
104+
when = datetime.datetime(2025, 5, 20, tzinfo=datetime.timezone.utc)
105+
result = calculate_rotations_to_cover(
106+
when, rotation_length_weeks=2, current_rotations=current_rotations
107+
)
108+
self.assertEqual(result, 1)
109+
110+
def test_multiple_rotations_needed(self) -> None:
111+
last_rotation_start = datetime.datetime(
112+
2025, 5, 1, tzinfo=datetime.timezone.utc
113+
)
114+
current_rotations = [
115+
Rotation(start_time=last_rotation_start, members=["alice", "bob"])
116+
]
117+
118+
# Target time is June 10th, needs multiple rotations. May 15 to June 10
119+
# is about 26 days, which is about 3.7 weeks. With 2-week rotations, we
120+
# need 2 rotations (4 weeks total).
121+
when = datetime.datetime(2025, 6, 10, tzinfo=datetime.timezone.utc)
122+
result = calculate_rotations_to_cover(
123+
when, rotation_length_weeks=2, current_rotations=current_rotations
124+
)
125+
self.assertEqual(result, 2)
126+
127+
66128
class TestGenerateAdditionalRotations(unittest.TestCase):
67129
def test_generate_no_prior_rotations(self) -> None:
68130
members = ["alice", "bob", "charlie"]

0 commit comments

Comments
 (0)