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>
750 lines
28 KiB
Python
Executable File
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())
|