Files
George Karrandtest 6d68bd6487 Adding milestone release calendar sync workflow (#48593)
This is a calendar sync workflow to trigger off of milestones being
created or due dates being updated to keep the release calendar
automatically in sync

example of me running it locally

```
❯ python sync.py --oauth --client-secret ./gcal-releases-oauth.json --apply
# Fleet release calendar sync — 2026-07-01 14:50

Calendar: <redacted>@group.calendar.google.com
Repo:     fleetdm/fleet

## Milestones
  4.88.0  due 2026-07-01
  4.89.0  due 2026-07-10
  4.90.0  due 2026-07-31
  4.91.0  due 2026-08-21
  4.92.0  due 2026-09-11
  4.93.0  due 2026-10-02
  4.94.0  due 2026-10-23
  4.95.0  due 2026-11-13
  4.96.0  due 2026-12-04
  4.97.0  due 2026-12-25
  4.98.0  due 2027-01-15
  4.99.0  due 2027-02-05
  4.100.0  due 2027-02-26
  4.101.0  due 2027-03-19
  4.102.0  due 2027-04-09

## Events scanned
  [develop    ] 'Develop (next release - 4.87.0)'  start=2026-05-18  end=2026-06-06
  [develop    ] 'Develop (next release - 4.89.0)'  start=2026-06-08  end=2026-06-27
  [rc         ] 'Release candidate (next release - 4.87.0)'  start=2026-06-08  end=2026-06-20
  [release_day] 'Release day: minor release - 4.87.0'  start=2026-06-19  end=2026-06-20
  [develop    ] 'Develop (next release - 4.90.0)'  start=2026-06-29  end=2026-07-18
  [rc         ] 'Release candidate (next release - 4.89.0)'  start=2026-06-29  end=2026-07-11
  [release_day] 'Release day: minor release - 4.89.0'  start=2026-07-10  end=2026-07-11
  [develop    ] 'Develop (next release - 4.91.0)'  start=2026-07-20  end=2026-08-08
  [rc         ] 'Release candidate (next release - 4.90.0)'  start=2026-07-20  end=2026-08-01
  [release_day] 'Release day: minor release - 4.90.0'  start=2026-07-31  end=2026-08-01
  [develop    ] 'Develop (next release - 4.92.0)'  start=2026-08-10  end=2026-08-29
  [rc         ] 'Release candidate (next release - 4.91.0)'  start=2026-08-10  end=2026-08-22
  [release_day] 'Release day: minor release - 4.91.0'  start=2026-08-21  end=2026-08-22
  [develop    ] 'Develop (next release - 4.93.0)'  start=2026-08-31  end=2026-09-19
  [rc         ] 'Release candidate (next release - 4.92.0)'  start=2026-08-31  end=2026-09-12
  [release_day] 'Release day: minor release - 4.92.0'  start=2026-09-11  end=2026-09-12
  [develop    ] 'Develop (next release - 4.94.0)'  start=2026-09-21  end=2026-10-10
  [rc         ] 'Release candidate (next release - 4.93.0)'  start=2026-09-21  end=2026-10-03
  [release_day] 'Release day: minor release - 4.93.0'  start=2026-10-02  end=2026-10-03
  [develop    ] 'Develop (next release - 4.95.0)'  start=2026-10-12  end=2026-10-31
  [rc         ] 'Release candidate (next release - 4.94.0)'  start=2026-10-12  end=2026-10-24
  [release_day] 'Release day: minor release - 4.94.0'  start=2026-10-23  end=2026-10-24
  [develop    ] 'Develop (next release - 4.96.0)'  start=2026-11-02  end=2026-11-21
  [rc         ] 'Release candidate (next release - 4.95.0)'  start=2026-11-02  end=2026-11-14
  [release_day] 'Release day: minor release - 4.95.0'  start=2026-11-13  end=2026-11-14
  [develop    ] 'Develop (next release - 4.97.0)'  start=2026-11-23  end=2026-12-12
  [rc         ] 'Release candidate (next release - 4.96.0)'  start=2026-11-23  end=2026-12-05
  [release_day] 'Release day: minor release - 4.96.0'  start=2026-12-04  end=2026-12-05
  [develop    ] 'Develop (next release - 4.98.0)'  start=2026-12-14  end=2027-01-02
  [rc         ] 'Release candidate (next release - 4.97.0)'  start=2026-12-14  end=2026-12-26
  [release_day] 'Release day: minor release - 4.97.0'  start=2026-12-25  end=2026-12-26
  [rc         ] 'Release candidate (next release - 4.90.0)'  start=2027-01-04  end=2027-01-16

## Proposed actions
  RC 4.98.0: title 'Release candidate (next release - 4.90.0)' -> 'Release candidate (next release - 4.98.0)'
  + CREATE Release day 4.88.0 on 2026-07-01
  + CREATE RC 4.88.0 2026-06-27..2026-07-02
  + CREATE Release day 4.98.0 on 2027-01-15
  + CREATE Release day 4.99.0 on 2027-02-05
  + CREATE RC 4.99.0 2027-02-01..2027-02-06
  + CREATE Develop 4.99.0 2027-01-04..2027-01-23
  + CREATE Release day 4.100.0 on 2027-02-26
  + CREATE RC 4.100.0 2027-02-22..2027-02-27
  + CREATE Develop 4.100.0 2027-01-25..2027-02-13
  + CREATE Release day 4.101.0 on 2027-03-19
  + CREATE RC 4.101.0 2027-03-15..2027-03-20
  + CREATE Develop 4.101.0 2027-02-15..2027-03-06
  + CREATE Release day 4.102.0 on 2027-04-09
  + CREATE RC 4.102.0 2027-04-05..2027-04-10
  + CREATE Develop 4.102.0 2027-03-08..2027-03-27

## Warnings / skipped
  ! Develop event 'Develop (next release - 4.87.0)' ending 2026-06-05 has no matching minor milestone — skipped
  ! RC event 'Release candidate (next release - 4.87.0)' ending 2026-06-19 has no matching milestone — skipped
  ! Release day event 'Release day: minor release - 4.87.0' on 2026-06-19 has no matching milestone (within 5d) — skipped
  ! Skipping Develop create for 4.88.0: sprint ended 2026-06-17 (past)

Total: 1 rename, 0 move, 15 create, 4 warning

Applying 16 change(s)...
  ok: RC 4.98.0: title 'Release candidate (next release - 4.90.0)' -> 'Release candidate (next release - 4.98.0)'
  ok: + CREATE Release day 4.88.0 on 2026-07-01
  ok: + CREATE RC 4.88.0 2026-06-27..2026-07-02
  ok: + CREATE Release day 4.98.0 on 2027-01-15
  ok: + CREATE Release day 4.99.0 on 2027-02-05
  ok: + CREATE RC 4.99.0 2027-02-01..2027-02-06
  ok: + CREATE Develop 4.99.0 2027-01-04..2027-01-23
  ok: + CREATE Release day 4.100.0 on 2027-02-26
  ok: + CREATE RC 4.100.0 2027-02-22..2027-02-27
  ok: + CREATE Develop 4.100.0 2027-01-25..2027-02-13
  ok: + CREATE Release day 4.101.0 on 2027-03-19
  ok: + CREATE RC 4.101.0 2027-03-15..2027-03-20
  ok: + CREATE Develop 4.101.0 2027-02-15..2027-03-06
  ok: + CREATE Release day 4.102.0 on 2027-04-09
  ok: + CREATE RC 4.102.0 2027-04-05..2027-04-10
  ok: + CREATE Develop 4.102.0 2027-03-08..2027-03-27
```

We can now get rid of the recurring unnamed events and just rely on this
once it's merged and added to milestone updates to trigger automatically

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added a release calendar synchronization tool that aligns Google
Calendar events with open release milestone dates.
* Supports dry-run previews and an optional apply mode to create,
update, and delete events.
* Categorizes and matches release-day, release-candidate, and
development entries, including duplicate and stale event cleanup.
* Added a manually triggered workflow to run the sync securely with
configurable apply behavior.
* Added support for service-account authentication and optional
interactive OAuth.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: test <test@test.com>
2026-07-22 14:57:50 -05:00

750 lines
28 KiB
Python
Executable File

#!/usr/bin/env python3
"""Sync the Fleet releases Google Calendar with GitHub milestone due dates.
Reads open milestones from fleetdm/fleet, finds the corresponding events on the
"Fleet releases" calendar ("Release day", "Release candidate", "Develop"), and
proposes title renames and date adjustments so they match the milestones.
Usage:
python sync.py # dry-run, prints proposed changes
python sync.py --apply # actually apply changes
Auth (first match wins):
--oauth # force interactive Google login (local testing)
GCAL_SERVICE_ACCOUNT_JSON # raw JSON contents of a Google service account key
GOOGLE_APPLICATION_CREDENTIALS # OR path to a service account JSON file
(none of the above) # falls back to interactive OAuth user login
GCAL_OAUTH_CLIENT_SECRET # path to OAuth client secret JSON (default: ./client_secret.json)
GITHUB_TOKEN / GH_TOKEN # optional; raises rate limit when calling GitHub
"""
from __future__ import annotations
import argparse
import datetime as dt
import json
import os
import re
import sys
from dataclasses import dataclass
from typing import Optional
import requests
from google.oauth2 import service_account
from googleapiclient.discovery import build
CALENDAR_ID = "c_v7943deqn1uns488a65v2d94bs@group.calendar.google.com"
GITHUB_REPO = "fleetdm/fleet"
VERSION_RE = re.compile(r"^\d+\.\d+\.\d+$")
# A milestone is "out-of-band" if its gap to the previous milestone is shorter
# than this. Normal cadence is 21 days; out-of-band patches land mid-sprint.
OUT_OF_BAND_GAP_DAYS = 14
# Default duration for the short RC event created for an out-of-band release.
PATCH_RC_DURATION_DAYS = 5
# Time-zone for date computations (the calendar's display zone).
TZ_NAME = "America/Chicago"
# Matching tolerances: a calendar event date must be within this many days of
# a milestone's due date to be considered a match.
RELEASE_DAY_MATCH_TOLERANCE_DAYS = 5
RC_END_MATCH_TOLERANCE_DAYS = 5
DEVELOP_END_TO_DUE_TARGET_DAYS = 14 # Develop_end + 14d ~= milestone due
DEVELOP_END_TO_DUE_TOLERANCE_DAYS = 5
DEVELOP_SPAN_DAYS = 18 # Monday start -> Friday display-end (3-week sprint)
RELEASE_DAY_RE = re.compile(r"^Release day: (?:minor|patch) release - (\d+\.\d+\.\d+)\s*$")
RC_RE = re.compile(r"^Release candidate \(next release - (\d+\.\d+\.\d+)\)\s*$")
DEVELOP_RE = re.compile(r"^Develop \(next release - (\d+\.\d+\.\d+)\)\s*$")
SCOPES = ["https://www.googleapis.com/auth/calendar"]
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
DEFAULT_CLIENT_SECRET = os.path.join(SCRIPT_DIR, "client_secret.json")
DEFAULT_TOKEN_PATH = os.path.join(SCRIPT_DIR, "token.json")
@dataclass
class Milestone:
number: int
title: str # "4.88.0"
due: dt.date
out_of_band: bool = False
@dataclass
class CalEvent:
id: str
summary: str
start: dt.date
end: Optional[dt.date] # exclusive for all-day events
raw: dict
@property
def is_all_day(self) -> bool:
return "date" in self.raw.get("start", {})
@dataclass
class Action:
kind: str # "rename", "move", "create", "delete", "noop"
description: str
event: Optional[CalEvent] = None
new_summary: Optional[str] = None
new_start: Optional[dt.date] = None
new_end: Optional[dt.date] = None
category: Optional[str] = None # "release_day" | "rc" | "develop" (for create)
def fetch_milestones() -> list[Milestone]:
token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
headers = {"Accept": "application/vnd.github+json"}
if token:
headers["Authorization"] = f"Bearer {token}"
url = f"https://api.github.com/repos/{GITHUB_REPO}/milestones"
out: list[Milestone] = []
page = 1
while True:
r = requests.get(
url,
headers=headers,
params={"state": "open", "per_page": 100, "page": page},
timeout=30,
)
r.raise_for_status()
data = r.json()
if not data:
break
for m in data:
if not m.get("due_on") or not VERSION_RE.match(m["title"]):
continue
due = dt.datetime.fromisoformat(m["due_on"].replace("Z", "+00:00")).date()
out.append(Milestone(number=m["number"], title=m["title"], due=due))
if len(data) < 100:
break
page += 1
out.sort(key=lambda x: x.due)
# A milestone is "out-of-band" only if it has SHORT gaps to BOTH neighbors.
# That avoids flagging the regular minor that immediately follows an OOB
# insertion (which has a short prev-gap but a normal next-gap).
for i, m in enumerate(out):
prev_gap = (m.due - out[i - 1].due).days if i > 0 else None
next_gap = (out[i + 1].due - m.due).days if i + 1 < len(out) else None
if (
prev_gap is not None
and prev_gap < OUT_OF_BAND_GAP_DAYS
and next_gap is not None
and next_gap < OUT_OF_BAND_GAP_DAYS
):
m.out_of_band = True
return out
def oauth_credentials(client_secret: str, token_path: str):
"""Interactive user login for local use. Caches a refreshable token so the
browser prompt only appears the first time (or after the token is revoked)."""
from google.auth.transport.requests import Request
from google.oauth2.credentials import Credentials
from google_auth_oauthlib.flow import InstalledAppFlow
creds = None
if os.path.exists(token_path):
creds = Credentials.from_authorized_user_file(token_path, SCOPES)
if not creds or not creds.valid:
if creds and creds.expired and creds.refresh_token:
creds.refresh(Request())
else:
if not os.path.exists(client_secret):
sys.exit(
f"ERROR: OAuth client secret not found at {client_secret}.\n"
"Create an OAuth client ID (type: Desktop app) in Google Cloud "
"Console → APIs & Services → Credentials, download the JSON, and "
"either save it there or set GCAL_OAUTH_CLIENT_SECRET to its path."
)
flow = InstalledAppFlow.from_client_secrets_file(client_secret, SCOPES)
creds = flow.run_local_server(port=0)
with open(token_path, "w", encoding="utf-8") as f:
f.write(creds.to_json())
print(f"Saved OAuth token to {token_path}")
return creds
def gcal_service(use_oauth: bool = False, client_secret: str = DEFAULT_CLIENT_SECRET,
token_path: str = DEFAULT_TOKEN_PATH):
creds_json = os.environ.get("GCAL_SERVICE_ACCOUNT_JSON")
creds_path = os.environ.get("GOOGLE_APPLICATION_CREDENTIALS")
if use_oauth or (not creds_json and not creds_path):
creds = oauth_credentials(client_secret, token_path)
elif creds_json:
info = json.loads(creds_json)
creds = service_account.Credentials.from_service_account_info(info, scopes=SCOPES)
else:
creds = service_account.Credentials.from_service_account_file(creds_path, scopes=SCOPES)
return build("calendar", "v3", credentials=creds, cache_discovery=False)
def fetch_events(service, start: dt.date, end: dt.date) -> list[CalEvent]:
out: list[CalEvent] = []
page_token = None
while True:
resp = (
service.events()
.list(
calendarId=CALENDAR_ID,
timeMin=dt.datetime.combine(start, dt.time.min).isoformat() + "Z",
timeMax=dt.datetime.combine(end, dt.time.min).isoformat() + "Z",
singleEvents=True,
orderBy="startTime",
maxResults=250,
pageToken=page_token,
)
.execute()
)
for e in resp.get("items", []):
s = e.get("start", {})
en = e.get("end", {})
if "date" in s:
start_date = dt.date.fromisoformat(s["date"])
end_date = dt.date.fromisoformat(en["date"]) if "date" in en else None
else:
start_date = dt.datetime.fromisoformat(
s["dateTime"].replace("Z", "+00:00")
).date()
end_date = (
dt.datetime.fromisoformat(en["dateTime"].replace("Z", "+00:00")).date()
if "dateTime" in en
else None
)
out.append(
CalEvent(
id=e["id"],
summary=e.get("summary", ""),
start=start_date,
end=end_date,
raw=e,
)
)
page_token = resp.get("nextPageToken")
if not page_token:
break
return out
def categorize(event: CalEvent) -> tuple[Optional[str], Optional[str]]:
s = event.summary or ""
for cat, regex in (
("release_day", RELEASE_DAY_RE),
("rc", RC_RE),
("develop", DEVELOP_RE),
):
m = regex.match(s)
if m:
return cat, m.group(1)
return None, None
def closest_milestone_by_due(
milestones: list[Milestone], target_date: dt.date, tolerance_days: int
) -> Optional[Milestone]:
best, best_gap = None, None
for m in milestones:
gap = abs((m.due - target_date).days)
if gap <= tolerance_days and (best_gap is None or gap < best_gap):
best, best_gap = m, gap
return best
def match_develop_to_milestone(
milestones: list[Milestone], develop_end: dt.date
) -> Optional[Milestone]:
"""Develop event ends ~2 weeks before its corresponding release.
Skip out-of-band milestones (patches don't have a Develop sprint)."""
best, best_gap = None, None
for m in milestones:
if m.out_of_band:
continue
target_due = develop_end + dt.timedelta(days=DEVELOP_END_TO_DUE_TARGET_DAYS)
gap = abs((m.due - target_due).days)
if gap <= DEVELOP_END_TO_DUE_TOLERANCE_DAYS and (best_gap is None or gap < best_gap):
best, best_gap = m, gap
return best
def release_kind(version: str) -> str:
"""A version ending in .0 is a minor release; anything else is a patch."""
return "minor" if version.endswith(".0") else "patch"
def release_day_summary(version: str) -> str:
return f"Release day: {release_kind(version)} release - {version}"
def desired_release_day(m: Milestone) -> tuple[dt.date, dt.date]:
"""All-day event: start=due, end=due+1 (exclusive)."""
return m.due, m.due + dt.timedelta(days=1)
def desired_rc(m: Milestone, current_start: Optional[dt.date]) -> tuple[dt.date, dt.date]:
"""RC end = due+1 (exclusive, so display ends on release day).
For a new event (no existing start), out-of-band milestones get a short
window of PATCH_RC_DURATION_DAYS; normal milestones get the standard window
that begins when the Develop sprint ends (~DEVELOP_END_TO_DUE_TARGET_DAYS
before the release day)."""
end_excl = m.due + dt.timedelta(days=1)
if current_start is not None:
return current_start, end_excl
duration = PATCH_RC_DURATION_DAYS if m.out_of_band else DEVELOP_END_TO_DUE_TARGET_DAYS
start = m.due - dt.timedelta(days=duration - 1)
return start, end_excl
def desired_develop(
current_start: Optional[dt.date], m: Milestone
) -> tuple[dt.date, dt.date]:
"""Develop display-end = milestone due - 14 days. For existing events the start is
preserved; for a new event (current_start is None) the start is the Monday three
weeks before the Friday display-end (a 3-week sprint)."""
end_excl = m.due - dt.timedelta(days=DEVELOP_END_TO_DUE_TARGET_DAYS - 1)
if current_start is None:
display_end = end_excl - dt.timedelta(days=1)
start = display_end - dt.timedelta(days=DEVELOP_SPAN_DAYS)
return start, end_excl
return current_start, end_excl
def fmt_date(d: Optional[dt.date]) -> str:
return d.isoformat() if d else "?"
def stale_event_action(ev: CalEvent, cat: str, reason: str, today: dt.date) -> Action:
"""A release/RC/Develop event that matches no milestone is stale — most often
because a milestone's due date moved further than the match tolerance (e.g.
4.89.1 slid from Jul 17 to Jul 24), leaving the old-date event behind while a
fresh one is created on the new date. Delete the leftover if it is still in
the future; leave past events alone so we don't rewrite release history."""
ref_end = ev.end or (ev.start + dt.timedelta(days=1)) # exclusive end
if ref_end > today:
return Action(
kind="delete",
description=(
f" - DELETE stale {cat} event '{ev.summary}' "
f"({fmt_date(ev.start)}..{fmt_date(ev.end)}) — {reason}"
),
event=ev,
)
return Action(
kind="noop",
description=(
f" ! {cat} event '{ev.summary}' "
f"({fmt_date(ev.start)}..{fmt_date(ev.end)}) — {reason}; already past, left as-is"
),
event=ev,
)
CATEGORY_LABEL = {
"release_day": "Release day",
"rc": "RC",
"develop": "Develop",
}
def _created_ts(ev: CalEvent) -> Optional[float]:
"""Event creation time (epoch seconds) from the Calendar API, if present."""
c = ev.raw.get("created")
if not c:
return None
try:
return dt.datetime.fromisoformat(c.replace("Z", "+00:00")).timestamp()
except ValueError:
return None
def pick_keeper(events: list[CalEvent]) -> CalEvent:
"""Among events that all match the same milestone/category, choose the one to
keep: the oldest-created event (the pre-existing / "previous" one), falling
back to the earliest start date when creation timestamps are unavailable."""
def sort_key(ev: CalEvent):
ts = _created_ts(ev)
# Events with a known creation time sort first, oldest to newest; the
# rest fall back to earliest start.
return (0, ts) if ts is not None else (1, ev.start.toordinal())
return sorted(events, key=sort_key)[0]
def build_sync_action(cat: str, m: Milestone, ev: CalEvent) -> Optional[Action]:
"""Build the rename/move action that brings a single kept event in line with
its milestone. Release day is anchored to the due date; RC and Develop keep
their existing start and only move their end date."""
if cat == "release_day":
new_summary = release_day_summary(m.title)
new_start, new_end = desired_release_day(m)
changes = []
if ev.summary != new_summary:
changes.append(f"title '{ev.summary}' -> '{new_summary}'")
if ev.start != new_start or ev.end != new_end:
changes.append(
f"dates {fmt_date(ev.start)}..{fmt_date(ev.end)} -> {fmt_date(new_start)}..{fmt_date(new_end)}"
)
elif cat == "rc":
new_summary = f"Release candidate (next release - {m.title})"
# Preserve the existing start; only the end tracks the milestone due date.
new_start, new_end = desired_rc(m, ev.start)
changes = []
if ev.summary != new_summary:
changes.append(f"title '{ev.summary}' -> '{new_summary}'")
if ev.end != new_end:
changes.append(f"end {fmt_date(ev.end)} -> {fmt_date(new_end)}")
else: # develop
new_summary = f"Develop (next release - {m.title})"
# Preserve the existing start; only the end tracks the milestone due date.
new_start, new_end = desired_develop(ev.start, m)
changes = []
if ev.summary != new_summary:
changes.append(f"title '{ev.summary}' -> '{new_summary}'")
if ev.end != new_end:
changes.append(f"end {fmt_date(ev.end)} -> {fmt_date(new_end)}")
if not changes:
return None
return Action(
kind="rename" if changes[0].startswith("title") else "move",
description=f" {CATEGORY_LABEL[cat]} {m.title}: {'; '.join(changes)}",
event=ev,
new_summary=new_summary,
new_start=new_start,
new_end=new_end,
)
def plan_actions(
milestones: list[Milestone], events: list[CalEvent], today: dt.date
) -> list[Action]:
actions: list[Action] = []
matched_milestone_ids: dict[str, set[int]] = {
"release_day": set(),
"rc": set(),
"develop": set(),
}
# 1) Match existing events to milestones, grouped by (category, milestone),
# so that duplicates for the same version can be collapsed to one.
matched_events: dict[str, dict[int, list[CalEvent]]] = {
"release_day": {},
"rc": {},
"develop": {},
}
for ev in events:
cat, ver = categorize(ev)
if cat is None:
continue
if cat == "release_day":
m = closest_milestone_by_due(milestones, ev.start, RELEASE_DAY_MATCH_TOLERANCE_DAYS)
reason = f"no milestone due within {RELEASE_DAY_MATCH_TOLERANCE_DAYS}d"
elif cat == "rc":
# Match by event END date (exclusive end - 1 = display end = release day = milestone due).
target = (ev.end - dt.timedelta(days=1)) if ev.end else ev.start
m = closest_milestone_by_due(milestones, target, RC_END_MATCH_TOLERANCE_DAYS)
reason = f"no milestone due within {RC_END_MATCH_TOLERANCE_DAYS}d of end {target}"
else: # develop
end_for_match = (ev.end - dt.timedelta(days=1)) if ev.end else ev.start
m = match_develop_to_milestone(milestones, end_for_match)
reason = f"no matching minor milestone (end {end_for_match})"
if not m:
actions.append(stale_event_action(ev, CATEGORY_LABEL[cat], reason, today))
continue
matched_events[cat].setdefault(m.number, []).append(ev)
# Resolve each (category, milestone) group: keep one event, delete the rest,
# and bring the kept event in line with its milestone.
milestone_by_number = {m.number: m for m in milestones}
for cat, per_ms in matched_events.items():
for number, evs in per_ms.items():
matched_milestone_ids[cat].add(number)
m = milestone_by_number[number]
keeper = pick_keeper(evs)
for dup in evs:
if dup is keeper:
continue
actions.append(
Action(
kind="delete",
description=(
f" - DELETE duplicate {CATEGORY_LABEL[cat]} {m.title} event "
f"'{dup.summary}' ({fmt_date(dup.start)}..{fmt_date(dup.end)}) — "
f"keeping {fmt_date(keeper.start)}..{fmt_date(keeper.end)}"
),
event=dup,
)
)
action = build_sync_action(cat, m, keeper)
if action is not None:
actions.append(action)
# 2) Find milestones with no matching events and propose creates.
for m in milestones:
if m.number not in matched_milestone_ids["release_day"]:
new_summary = release_day_summary(m.title)
new_start, new_end = desired_release_day(m)
actions.append(
Action(
kind="create",
description=f" + CREATE Release day {m.title} on {fmt_date(new_start)}",
new_summary=new_summary,
new_start=new_start,
new_end=new_end,
category="release_day",
)
)
if m.number not in matched_milestone_ids["rc"]:
new_summary = f"Release candidate (next release - {m.title})"
new_start, new_end = desired_rc(m, None)
actions.append(
Action(
kind="create",
description=f" + CREATE RC {m.title} {fmt_date(new_start)}..{fmt_date(new_end)}"
+ (" (out-of-band, short window)" if m.out_of_band else ""),
new_summary=new_summary,
new_start=new_start,
new_end=new_end,
category="rc",
)
)
if not m.out_of_band and m.number not in matched_milestone_ids["develop"]:
new_summary = f"Develop (next release - {m.title})"
new_start, new_end = desired_develop(None, m)
display_end = new_end - dt.timedelta(days=1)
if display_end < today:
# The sprint already ended; don't create a stale past event.
actions.append(
Action(
kind="noop",
description=(
f" ! Skipping Develop create for {m.title}: "
f"sprint ended {display_end} (past)"
),
)
)
else:
actions.append(
Action(
kind="create",
description=f" + CREATE Develop {m.title} {fmt_date(new_start)}..{fmt_date(new_end)}",
new_summary=new_summary,
new_start=new_start,
new_end=new_end,
category="develop",
)
)
return actions
def apply_action(service, action: Action) -> None:
if action.kind in ("rename", "move"):
body = {}
if action.new_summary is not None:
body["summary"] = action.new_summary
if action.new_start is not None and action.event and action.event.is_all_day:
body["start"] = {"date": action.new_start.isoformat()}
if action.new_end is not None and action.event and action.event.is_all_day:
body["end"] = {"date": action.new_end.isoformat()}
service.events().patch(
calendarId=CALENDAR_ID,
eventId=action.event.id,
body=body,
).execute()
elif action.kind == "create":
body = {
"summary": action.new_summary,
"start": {"date": action.new_start.isoformat()},
"end": {"date": action.new_end.isoformat()},
"transparency": "transparent",
}
created = service.events().insert(calendarId=CALENDAR_ID, body=body).execute()
if not created.get("id"):
raise RuntimeError(f"insert returned no event id: {created!r}")
elif action.kind == "delete":
service.events().delete(
calendarId=CALENDAR_ID,
eventId=action.event.id,
).execute()
def render_plan(
milestones: list[Milestone], events: list[CalEvent], actions: list[Action]
) -> str:
lines: list[str] = []
lines.append(f"# Fleet release calendar sync — {dt.datetime.now().strftime('%Y-%m-%d %H:%M')}")
lines.append("")
lines.append(f"Calendar: {CALENDAR_ID}")
lines.append(f"Repo: {GITHUB_REPO}")
lines.append("")
lines.append("## Milestones")
for m in milestones:
tag = " (OUT-OF-BAND)" if m.out_of_band else ""
lines.append(f" {m.title} due {m.due}{tag}")
lines.append("")
lines.append("## Events scanned")
for ev in events:
cat, ver = categorize(ev)
if not cat:
continue
lines.append(
f" [{cat:11s}] '{ev.summary}' start={ev.start} end={ev.end}"
)
lines.append("")
lines.append("## Proposed actions")
creates = [a for a in actions if a.kind == "create"]
renames = [a for a in actions if a.kind == "rename"]
moves = [a for a in actions if a.kind == "move"]
deletes = [a for a in actions if a.kind == "delete"]
noops = [a for a in actions if a.kind == "noop"]
if not (creates or renames or moves or deletes):
lines.append(" (no changes — calendar already matches milestones)")
for a in renames + moves + creates + deletes:
lines.append(a.description)
if noops:
lines.append("")
lines.append("## Warnings / skipped")
for a in noops:
lines.append(a.description)
lines.append("")
lines.append(
f"Total: {len(renames)} rename, {len(moves)} move, {len(creates)} create, "
f"{len(deletes)} delete, {len(noops)} warning"
)
return "\n".join(lines)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--apply", action="store_true", help="apply changes (default: dry-run)")
parser.add_argument(
"--window-days",
type=int,
default=200,
help=(
"minimum days into the future to scan calendar events (default: 200). "
"Always extended to cover the furthest open milestone due date."
),
)
parser.add_argument(
"--summary-file",
type=str,
default=None,
help="if set, also write the rendered plan to this path (e.g., $GITHUB_STEP_SUMMARY)",
)
parser.add_argument(
"--oauth",
action="store_true",
help="force interactive Google login instead of a service account (local testing)",
)
parser.add_argument(
"--client-secret",
type=str,
default=os.environ.get("GCAL_OAUTH_CLIENT_SECRET", DEFAULT_CLIENT_SECRET),
help="path to OAuth client secret JSON (default: ./client_secret.json)",
)
parser.add_argument(
"--token",
type=str,
default=DEFAULT_TOKEN_PATH,
help="path to cache the OAuth user token (default: ./token.json)",
)
args = parser.parse_args()
milestones = fetch_milestones()
if not milestones:
print("No open milestones with version-like titles and due dates found.")
return 1
today = dt.date.today()
service = gcal_service(
use_oauth=args.oauth,
client_secret=args.client_secret,
token_path=args.token,
)
# The scan window must span every open milestone we might create events for;
# otherwise those events fall outside the scan on the next run, look
# "missing", and get created again as duplicates. This applies on both ends:
# a milestone overdue by more than 30 days would otherwise start before the
# window, and a far-future one would end after it.
min_due = min(m.due for m in milestones)
max_due = max(m.due for m in milestones)
scan_start = min(
today - dt.timedelta(days=30),
min_due - dt.timedelta(days=30),
)
scan_end = max(
today + dt.timedelta(days=args.window_days),
max_due + dt.timedelta(days=30),
)
print(
f"Scanning calendar events {scan_start} .. {scan_end} "
f"(open milestones due {min_due} .. {max_due})"
)
events = fetch_events(
service,
start=scan_start,
end=scan_end,
)
actions = plan_actions(milestones, events, today)
plan_text = render_plan(milestones, events, actions)
print(plan_text)
if args.summary_file:
with open(args.summary_file, "a", encoding="utf-8") as f:
f.write(plan_text + "\n")
if not args.apply:
print()
print("Dry-run only. Re-run with --apply to make these changes.")
return 0
mutating = [a for a in actions if a.kind in ("rename", "move", "create", "delete")]
if not mutating:
print("Nothing to apply.")
return 0
print(f"\nApplying {len(mutating)} change(s)...")
failures = 0
for a in mutating:
try:
apply_action(service, a)
print(f" ok: {a.description.strip()}")
except Exception as e: # noqa: BLE001
failures += 1
# Surface the full error, including the Google API response body
# (HttpError.content), so silent-looking failures are diagnosable.
print(f" FAIL: {a.description.strip()}", file=sys.stderr)
print(f" {type(e).__name__}: {e}", file=sys.stderr)
detail = getattr(e, "content", None)
if isinstance(detail, (bytes, bytearray)):
detail = detail.decode("utf-8", "replace")
if detail:
print(f" response: {detail}", file=sys.stderr)
applied = len(mutating) - failures
if failures:
print(
f"\n{applied} of {len(mutating)} change(s) applied; {failures} failed "
f"(see FAIL lines above).",
file=sys.stderr,
)
return 2
print(f"\nAll {len(mutating)} change(s) applied.")
return 0
if __name__ == "__main__":
sys.exit(main())