|
| 1 | +#!/usr/bin/env python3 |
| 2 | +import os |
| 3 | +import re |
| 4 | +from datetime import datetime, timedelta, timezone |
| 5 | +from zoneinfo import ZoneInfo |
| 6 | + |
| 7 | +SCHEDULE_TIMEZONE = "America/Chicago" |
| 8 | +MAX_SCHEDULE_DAYS = 30 |
| 9 | + |
| 10 | + |
| 11 | +def write_output(key: str, value: str) -> None: |
| 12 | + path = os.environ.get("GITHUB_OUTPUT") |
| 13 | + if not path: |
| 14 | + return |
| 15 | + with open(path, "a", encoding="utf-8") as handle: |
| 16 | + handle.write(f"{key}={value}\n") |
| 17 | + |
| 18 | + |
| 19 | +def fail(message: str) -> None: |
| 20 | + write_output("valid", "false") |
| 21 | + write_output("error", message) |
| 22 | + |
| 23 | + |
| 24 | +raw = os.environ.get("RAW", "").strip() |
| 25 | +if not raw: |
| 26 | + fail("Missing schedule time.") |
| 27 | + raise SystemExit(0) |
| 28 | + |
| 29 | +normalized = re.sub(r"\s+", " ", raw).strip() |
| 30 | +match = re.match( |
| 31 | + r"^(?P<date>\d{4}-\d{2}-\d{2})\s+(?P<time>\d{1,2}(:\d{2})?\s*(am|pm)?)\s*(?P<tz>ct|cst|cdt)?$", |
| 32 | + normalized, |
| 33 | + re.IGNORECASE, |
| 34 | +) |
| 35 | + |
| 36 | +if not match: |
| 37 | + fail( |
| 38 | + "Invalid time format. Use `/schedule-merge YYYY-MM-DD HH:MM CT` or `/schedule-merge YYYY-MM-DD 9am CT`." |
| 39 | + ) |
| 40 | + raise SystemExit(0) |
| 41 | + |
| 42 | +date_part = match.group("date") |
| 43 | +time_part = match.group("time").strip().lower() |
| 44 | + |
| 45 | +hour = None |
| 46 | +minute = 0 |
| 47 | + |
| 48 | +ampm_match = re.match(r"^(?P<h>\d{1,2})(:(?P<m>\d{2}))?(?P<ampm>am|pm)$", time_part.replace(" ", "")) |
| 49 | +if ampm_match: |
| 50 | + hour = int(ampm_match.group("h")) |
| 51 | + minute = int(ampm_match.group("m") or 0) |
| 52 | + ampm = ampm_match.group("ampm") |
| 53 | + if hour < 1 or hour > 12 or minute > 59: |
| 54 | + fail("Invalid time value.") |
| 55 | + raise SystemExit(0) |
| 56 | + if ampm == "pm" and hour != 12: |
| 57 | + hour += 12 |
| 58 | + if ampm == "am" and hour == 12: |
| 59 | + hour = 0 |
| 60 | +else: |
| 61 | + hm_match = re.match(r"^(?P<h>\d{1,2})(:(?P<m>\d{2}))?$", time_part) |
| 62 | + if not hm_match: |
| 63 | + fail("Invalid time value.") |
| 64 | + raise SystemExit(0) |
| 65 | + hour = int(hm_match.group("h")) |
| 66 | + minute = int(hm_match.group("m") or 0) |
| 67 | + if hour > 23 or minute > 59: |
| 68 | + fail("Invalid time value.") |
| 69 | + raise SystemExit(0) |
| 70 | + |
| 71 | +year, month, day = [int(part) for part in date_part.split("-")] |
| 72 | +local_tz = ZoneInfo(SCHEDULE_TIMEZONE) |
| 73 | +try: |
| 74 | + local_dt = datetime(year, month, day, hour, minute, tzinfo=local_tz) |
| 75 | +except ValueError: |
| 76 | + fail("Invalid calendar date.") |
| 77 | + raise SystemExit(0) |
| 78 | +utc_dt = local_dt.astimezone(timezone.utc) |
| 79 | + |
| 80 | +now_utc = datetime.now(timezone.utc) |
| 81 | +if utc_dt <= now_utc: |
| 82 | + fail("Scheduled time must be in the future.") |
| 83 | + raise SystemExit(0) |
| 84 | + |
| 85 | +max_future = now_utc + timedelta(days=MAX_SCHEDULE_DAYS) |
| 86 | +if utc_dt > max_future: |
| 87 | + fail(f"Scheduled time must be within {MAX_SCHEDULE_DAYS} days from now.") |
| 88 | + raise SystemExit(0) |
| 89 | + |
| 90 | +utc_iso = utc_dt.replace(microsecond=0).isoformat().replace("+00:00", "Z") |
| 91 | +local_iso = local_dt.replace(microsecond=0).isoformat() |
| 92 | + |
| 93 | +write_output("valid", "true") |
| 94 | +write_output("utc", utc_iso) |
| 95 | +write_output("local", local_iso) |
| 96 | +write_output("display", local_dt.strftime("%Y-%m-%d %I:%M %p CT")) |
0 commit comments