Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 10 additions & 10 deletions .github/oncall_schedule.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,43 +8,43 @@
"date": "2026-06-24"
},
{
"user": "maanug-nv",
"user": "Connor-XY",
"date": "2026-07-01"
},
{
"user": "wujingyue",
"user": "dimapihtar",
"date": "2026-07-08"
},
{
"user": "Connor-XY",
"user": "guihong-nv",
"date": "2026-07-15"
},
{
"user": "Phlip79",
"user": "ilml",
"date": "2026-07-22"
},
{
"user": "YangFei1990",
"user": "janEbert",
"date": "2026-07-29"
},
{
"user": "asolergi-nv",
"user": "maanug-nv",
"date": "2026-08-05"
},
{
"user": "dimapihtar",
"user": "Phlip79",
"date": "2026-08-12"
},
{
"user": "guihong-nv",
"user": "wujingyue",
"date": "2026-08-19"
},
{
"user": "ilml",
"user": "YangFei1990",
"date": "2026-08-26"
},
{
"user": "janEbert",
"user": "asolergi-nv",
"date": "2026-09-02"
}
]
75 changes: 53 additions & 22 deletions .github/scripts/oncall_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
ACTIVE_ONCALL_TEAM_SLUG = "mcore-oncall"
SLACK_USERGROUP_HANDLE = "mcore-oncall"
COMMUNITY_REQUEST_LABEL = "community-request"
SERVICE_ACCOUNT_USERNAME = "svcnvidia-nemo-ci"
TARGET_WEEKS = 12

# Caches for email and Slack lookups
Expand All @@ -44,6 +45,11 @@ def get_headers():
if not token:
print("Error: GH_TOKEN or GITHUB_TOKEN not set")
sys.exit(1)

token = token.strip()
if not token or any(char.isspace() for char in token):
print("Error: GH_TOKEN or GITHUB_TOKEN is invalid")
sys.exit(1)

return {
"Authorization": f"token {token}",
Expand Down Expand Up @@ -258,6 +264,34 @@ def save_schedule(schedule):
json.dump(schedule, f, indent=4)
f.write('\n') # trailing newline

def get_rotation_order(repo_owner):
"""Returns rotation team members in alphabetical order."""
members = get_team_members(repo_owner, ROTATION_TEAM_SLUG)
members.discard(SERVICE_ACCOUNT_USERNAME)
return sorted(members, key=str.casefold)

def validate_schedule_users_in_rotation_team(schedule, rotation_order):
"""Validates scheduled users are members of the rotation team."""
schedule_users = {entry.get('user') for entry in schedule if entry.get('user')}
if not schedule_users:
print("Warning: No users found in schedule. Cannot validate rotation team membership.")
return

rotation_team_members = set(rotation_order)
if not rotation_team_members:
print(f"Error: No members found in {ROTATION_TEAM_SLUG}.")
sys.exit(1)

missing_users = sorted(schedule_users - rotation_team_members, key=str.casefold)
if missing_users:
print(
f"Error: Scheduled oncall user(s) are not members of "
f"{ROTATION_TEAM_SLUG}: {', '.join(missing_users)}"
)
sys.exit(1)

print(f"Validated {len(schedule_users)} scheduled user(s) in {ROTATION_TEAM_SLUG}.")

def update_active_oncall_team(org, new_oncall):
"""Updates the active oncall team to contain only the new oncall user."""
# 1. Get current members of the active team
Expand Down Expand Up @@ -289,6 +323,8 @@ def update_active_oncall_team(org, new_oncall):

def rotate_schedule(repo_owner, dry_run=False):
schedule = load_schedule()
rotation_order = get_rotation_order(repo_owner)
validate_schedule_users_in_rotation_team(schedule, rotation_order)
print(f"Current schedule length: {len(schedule)}")

# 1. Rotate (Remove past week)
Expand Down Expand Up @@ -319,7 +355,7 @@ def rotate_schedule(repo_owner, dry_run=False):
print("Schedule empty, nothing to rotate.")

# 2. Replenish
ensure_schedule_filled(schedule, repo_owner)
ensure_schedule_filled(schedule, rotation_order)

# 3. Update active oncall team
if schedule:
Expand All @@ -343,26 +379,20 @@ def get_last_wednesday():
offset = (today.weekday() - 2) % 7
return today - timedelta(days=offset)

def ensure_schedule_filled(schedule, repo_owner):
def ensure_schedule_filled(schedule, rotation_order=None):
"""Appends users to schedule until it reaches TARGET_WEEKS."""
members = get_team_members(repo_owner, ROTATION_TEAM_SLUG)
if not members:
print(f"Warning: No team members found in {ROTATION_TEAM_SLUG}.")
if not rotation_order:
print(f"Warning: No users found in {ROTATION_TEAM_SLUG}. Cannot fill schedule.")
return
if 'svcnvidia-nemo-ci' in members:
members.remove('svcnvidia-nemo-ci')
members = list(members)

members.sort() # Deterministic order

while len(schedule) < TARGET_WEEKS:
# Determine start date for the new entry
if not schedule:
# Start with the most recent Wednesday if list is empty
next_date = get_last_wednesday()

# Start with the first member alphabetically if list is empty
next_user = members[0]
# Start with the first user in the rotation team order if list is empty
next_user = rotation_order[0]
else:
last_entry = schedule[-1]
last_user = last_entry['user']
Expand All @@ -376,16 +406,16 @@ def ensure_schedule_filled(schedule, repo_owner):
next_date = get_last_wednesday() + timedelta(days=7 * len(schedule))

try:
# Find index of last scheduled user in the team list
if last_user in members:
last_idx = members.index(last_user)
next_idx = (last_idx + 1) % len(members)
next_user = members[next_idx]
# Find index of last scheduled user in the rotation team order
if last_user in rotation_order:
last_idx = rotation_order.index(last_user)
next_idx = (last_idx + 1) % len(rotation_order)
next_user = rotation_order[next_idx]
else:
# Last user not in team, just pick first member
next_user = members[0]
# Last user not in schedule order, just pick first user
next_user = rotation_order[0]
except ValueError:
next_user = members[0]
next_user = rotation_order[0]

new_entry = {"user": next_user, "date": next_date.strftime("%Y-%m-%d")}
schedule.append(new_entry)
Expand Down Expand Up @@ -459,12 +489,13 @@ def main():
rotate_schedule(owner, dry_run=args.dry_run)
elif args.command == "fill":
schedule = load_schedule()
ensure_schedule_filled(schedule, owner)
rotation_order = get_rotation_order(owner)
validate_schedule_users_in_rotation_team(schedule, rotation_order)
ensure_schedule_filled(schedule, rotation_order)
save_schedule(schedule)
print("Schedule filled and saved.")
elif args.command == "assign":
assign_reviewer(args.pr)

if __name__ == "__main__":
main()

107 changes: 107 additions & 0 deletions tests/test_utils/python_scripts/test_oncall_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,3 +123,110 @@ def test_assign_reviewer_requests_oncall_when_needed(oncall_manager, monkeypatch
"json": {"team_reviewers": ["mcore-oncall"]},
}
]


def test_get_headers_rejects_invalid_token(oncall_manager, monkeypatch, capsys):
monkeypatch.setenv("GH_TOKEN", "not a token\nwith newline")

with pytest.raises(SystemExit) as error:
oncall_manager.get_headers()

assert error.value.code == 1
assert "GH_TOKEN or GITHUB_TOKEN is invalid" in capsys.readouterr().out


def test_get_rotation_order_uses_alphabetical_rotation_team(oncall_manager, monkeypatch):
monkeypatch.setattr(
oncall_manager,
"get_team_members",
lambda org, team_slug: {"charlie", "Alice", "bob", "svcnvidia-nemo-ci"},
)

assert oncall_manager.get_rotation_order("NVIDIA") == ["Alice", "bob", "charlie"]


def test_ensure_schedule_filled_uses_rotation_team_order(oncall_manager, monkeypatch):
schedule = [{"user": "bob", "date": "2026-01-07"}]
rotation_order = ["Alice", "bob", "charlie"]
monkeypatch.setattr(oncall_manager, "TARGET_WEEKS", 5)
monkeypatch.setattr(
oncall_manager,
"get_team_members",
lambda *_args, **_kwargs: pytest.fail("team members should not determine oncall order"),
)

oncall_manager.ensure_schedule_filled(schedule, rotation_order)

assert [entry["user"] for entry in schedule] == ["bob", "charlie", "Alice", "bob", "charlie"]
assert [entry["date"] for entry in schedule[-4:]] == [
"2026-01-14",
"2026-01-21",
"2026-01-28",
"2026-02-04",
]


def test_validate_schedule_users_in_rotation_team_accepts_all_users(
oncall_manager, monkeypatch, capsys
):
schedule = [
{"user": "charlie", "date": "2026-01-07"},
{"user": "alice", "date": "2026-01-14"},
{"user": "bob", "date": "2026-01-21"},
{"user": "alice", "date": "2026-01-28"},
]
monkeypatch.setattr(
oncall_manager,
"get_team_members",
lambda org, team_slug: {"alice", "bob", "charlie", "dana"},
)

rotation_order = ["alice", "bob", "charlie", "dana"]

oncall_manager.validate_schedule_users_in_rotation_team(schedule, rotation_order)

assert "Validated 3 scheduled user(s) in mcore-oncall-rotation" in capsys.readouterr().out


def test_validate_schedule_users_in_rotation_team_rejects_missing_user(
oncall_manager, monkeypatch, capsys
):
schedule = [{"user": "charlie", "date": "2026-01-07"}, {"user": "alice", "date": "2026-01-14"}]
with pytest.raises(SystemExit) as error:
oncall_manager.validate_schedule_users_in_rotation_team(schedule, ["alice"])

assert error.value.code == 1
assert "charlie" in capsys.readouterr().out


def test_rotate_schedule_keeps_popped_user_in_rotation_order(oncall_manager, monkeypatch):
schedule = [
{"user": "charlie", "date": "2026-01-07"},
{"user": "alice", "date": "2026-01-14"},
{"user": "bob", "date": "2026-01-21"},
]
saved_schedule = []
real_datetime = oncall_manager.datetime

class FakeDateTime(real_datetime):
@classmethod
def now(cls, tz=None):
return real_datetime(2026, 1, 14, tzinfo=tz)

monkeypatch.setattr(oncall_manager, "TARGET_WEEKS", 3)
monkeypatch.setattr(oncall_manager, "datetime", FakeDateTime)
monkeypatch.setattr(
oncall_manager, "load_schedule", lambda: [entry.copy() for entry in schedule]
)
monkeypatch.setattr(
oncall_manager, "save_schedule", lambda new_schedule: saved_schedule.extend(new_schedule)
)
monkeypatch.setattr(
oncall_manager, "get_team_members", lambda org, team_slug: {"alice", "bob", "charlie"}
)
monkeypatch.setattr(oncall_manager, "update_active_oncall_team", lambda *_args, **_kwargs: None)

oncall_manager.rotate_schedule("NVIDIA")

assert [entry["user"] for entry in saved_schedule] == ["alice", "bob", "charlie"]
assert saved_schedule[-1]["date"] == "2026-01-28"
Loading