chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,203 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Daily Discord-watch rotation reminder.
|
||||
|
||||
Picks the person on watch for the current day and pings them in Slack on the
|
||||
morning of *their* local timezone. The rotation is deterministic — the
|
||||
assignee is a function of the date and the person's position in the list — so
|
||||
there is no state to store anywhere.
|
||||
|
||||
The GitHub Actions workflow wakes at a couple of fixed UTC times (one per
|
||||
timezone's morning). On each run the day's assignee is pinged only if it's
|
||||
currently morning where they live; if not, the run for their timezone's
|
||||
morning handles them. Our timezones are far enough apart that only one is ever
|
||||
in its morning at a time, so at most one person is pinged per run.
|
||||
|
||||
Set SLACK_WEBHOOK_URL to post for real. Leave it unset for a dry run that just
|
||||
prints what it would do — handy for testing the rotation order without Slack.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
# Each cron run is one timezone's morning scan: we ping today's assignee only
|
||||
# if it's currently morning where they are. A run that's morning in SF is night
|
||||
# in Singapore and vice versa, so at most one timezone matches per run. Morning
|
||||
# is a band rather than an exact hour, which absorbs both daylight saving and
|
||||
# GitHub's frequently-delayed cron schedule — a run that fires a few hours late
|
||||
# still counts as that person's morning. The band starts at 05:00 (not
|
||||
# midnight) so a delayed *other* timezone's cron spilling past local midnight
|
||||
# isn't mistaken for this timezone's morning, which would double-ping.
|
||||
MORNING_START_HOUR = 5
|
||||
MORNING_END_HOUR = 12
|
||||
|
||||
# Skip Saturdays and Sundays (in each person's local time). The rotation also
|
||||
# advances by workdays only, so Friday hands off straight to Monday.
|
||||
WEEKDAYS_ONLY = True
|
||||
|
||||
# Rotation anchor: workday 0 is this date. Any Monday works; it only sets the
|
||||
# phase of the cycle, not who is in it.
|
||||
EPOCH = datetime.date(2026, 1, 5) # a Monday
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Person:
|
||||
name: str # for logs / dry-run output only
|
||||
slack_id: str # Slack member ID, e.g. "U01ABC2DEF" (NOT the display name)
|
||||
tz: str # IANA timezone name, e.g. "America/Los_Angeles"
|
||||
# Out-of-office spans as inclusive (start, end) ISO date pairs, e.g.
|
||||
# (("2026-07-13", "2026-07-17"),). On any OOO day the person is skipped and
|
||||
# the next available person covers; the OOO person keeps their later slots.
|
||||
ooo: tuple[tuple[str, str], ...] = ()
|
||||
|
||||
|
||||
# Rotation order. Slack member IDs (profile -> ⋮ More -> Copy member ID) and
|
||||
# each person's IANA timezone.
|
||||
PEOPLE: list[Person] = [
|
||||
Person("Aravind Segu", "U01A12R8NUR", "America/Los_Angeles"),
|
||||
Person("Bryan Qiu", "U05KA5T983Y", "America/Los_Angeles"),
|
||||
Person("Daniel Lok", "U060CNWNHSQ", "Asia/Singapore"),
|
||||
Person("Dhruv Gupta", "U0A76097E1F", "America/Los_Angeles"),
|
||||
Person("Edwin He", "U077B1V6WQJ", "America/Los_Angeles"),
|
||||
Person("Pat Sukprasert", "U05HRKWFY81", "Asia/Singapore"),
|
||||
Person("Sabhya Chhabria", "U07A1KQDXAB", "America/Los_Angeles"),
|
||||
Person("Serena Ruan", "U0571L5KNLR", "Asia/Singapore"),
|
||||
Person("Shivam Mittal", "U09FZKX9S6B", "America/Los_Angeles"),
|
||||
Person("Tomu Hirata", "U07TX4PR5MZ", "Asia/Singapore"),
|
||||
Person("Zeyi (Rice) Fan", "U09L5HT4CH0", "America/Los_Angeles"),
|
||||
]
|
||||
|
||||
|
||||
def _workdays_between(start: datetime.date, end: datetime.date) -> int:
|
||||
"""Number of Mon–Fri days in [start, end). Negative if end precedes start."""
|
||||
if end < start:
|
||||
return -_workdays_between(end, start)
|
||||
full_weeks, extra = divmod((end - start).days, 7)
|
||||
count = full_weeks * 5
|
||||
for i in range(extra):
|
||||
if (start + datetime.timedelta(days=full_weeks * 7 + i)).weekday() < 5:
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def is_ooo(person: Person, local_date: datetime.date) -> bool:
|
||||
"""Whether person is out of office on local_date (inclusive spans)."""
|
||||
for start, end in person.ooo:
|
||||
if datetime.date.fromisoformat(start) <= local_date <= datetime.date.fromisoformat(end):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def assignee_for(local_date: datetime.date) -> Person | None:
|
||||
"""The person on watch for a given local workday, or None if all are OOO.
|
||||
|
||||
Indexed by the number of workdays since EPOCH (which is itself a Monday),
|
||||
so weekends advance nobody and Friday hands off directly to Monday. If the
|
||||
slot's person is OOO, the next available person covers — probing forward so
|
||||
coverage stays a pure function of the date (no stored state). Only
|
||||
meaningful for weekdays; weekends are filtered out before this is called.
|
||||
"""
|
||||
workday_number = _workdays_between(EPOCH, local_date)
|
||||
for offset in range(len(PEOPLE)):
|
||||
person = PEOPLE[(workday_number + offset) % len(PEOPLE)]
|
||||
if not is_ooo(person, local_date):
|
||||
return person
|
||||
return None # everyone is OOO that day
|
||||
|
||||
|
||||
def whose_turn_now(now_utc: datetime.datetime) -> Person | None:
|
||||
"""Return the person to ping right now, or None if it isn't anyone's morning.
|
||||
|
||||
Each person is evaluated in their own timezone: it must be a weekday morning
|
||||
(before noon) there, and today's rotation slot must land on them. Since our
|
||||
timezones are far enough apart that only one is ever in its morning at a
|
||||
time, at most one person matches. A person missed by a late/early run is
|
||||
picked up by the next run that lands in their morning.
|
||||
"""
|
||||
for person in PEOPLE:
|
||||
local = now_utc.astimezone(ZoneInfo(person.tz))
|
||||
if not (MORNING_START_HOUR <= local.hour < MORNING_END_HOUR):
|
||||
continue
|
||||
if WEEKDAYS_ONLY and local.weekday() >= 5: # 5=Sat, 6=Sun
|
||||
continue
|
||||
if assignee_for(local.date()) == person:
|
||||
return person
|
||||
return None
|
||||
|
||||
|
||||
class SlackPostError(RuntimeError):
|
||||
"""Raised when the Slack POST fails, without exposing the webhook URL."""
|
||||
|
||||
|
||||
def post_to_slack(webhook_url: str, person: Person) -> None:
|
||||
text = (
|
||||
f"<@{person.slack_id}> you're on *Discord watch* today \U0001f440 "
|
||||
f"— please keep an eye on the channel."
|
||||
)
|
||||
payload = json.dumps({"text": text}).encode()
|
||||
req = urllib.request.Request(
|
||||
webhook_url,
|
||||
data=payload,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
# Catch and re-raise without the URL: urllib errors stringify the full
|
||||
# webhook URL, which must never reach the Actions log or error output.
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
resp.read()
|
||||
except urllib.error.HTTPError as exc:
|
||||
raise SlackPostError(f"Slack returned HTTP {exc.code} {exc.reason}") from None
|
||||
except urllib.error.URLError as exc:
|
||||
raise SlackPostError(f"could not reach Slack: {exc.reason}") from None
|
||||
|
||||
|
||||
def _report_todays_assignees(now_utc: datetime.datetime) -> None:
|
||||
"""Log who's on watch for each timezone's current local date.
|
||||
|
||||
Runs regardless of the morning window so a manual run is always
|
||||
informative, even outside anyone's ping window.
|
||||
"""
|
||||
for tz in sorted({p.tz for p in PEOPLE}):
|
||||
local = now_utc.astimezone(ZoneInfo(tz))
|
||||
if local.weekday() >= 5: # 5=Sat, 6=Sun
|
||||
who = "nobody (weekend)"
|
||||
else:
|
||||
person = assignee_for(local.date())
|
||||
who = person.name if person else "nobody (all OOO)"
|
||||
print(f" {tz}: {local:%Y-%m-%d %a} -> {who}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
now_utc = datetime.datetime.now(datetime.timezone.utc)
|
||||
|
||||
print(f"Today's watch by timezone (as of {now_utc:%Y-%m-%d %H:%M UTC}):")
|
||||
_report_todays_assignees(now_utc)
|
||||
|
||||
person = whose_turn_now(now_utc)
|
||||
|
||||
if person is None:
|
||||
print(f"{now_utc:%Y-%m-%d %H:%M UTC}: nobody's on watch right now, nothing to do.")
|
||||
return
|
||||
|
||||
local = now_utc.astimezone(ZoneInfo(person.tz))
|
||||
webhook_url = os.environ.get("SLACK_WEBHOOK_URL")
|
||||
if not webhook_url:
|
||||
print(
|
||||
f"[dry run] Would ping {person.name} ({person.slack_id}) "
|
||||
f"— it's {local:%Y-%m-%d %H:%M} in {person.tz}. "
|
||||
f"Set SLACK_WEBHOOK_URL to post for real."
|
||||
)
|
||||
return
|
||||
|
||||
post_to_slack(webhook_url, person)
|
||||
print(f"Pinged {person.name} ({person.slack_id}) at {local:%Y-%m-%d %H:%M %Z}.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user