Skip to content

Commit d171850

Browse files
shijithkjayanclaude
andcommitted
Add weekly release tracker job and script
Runs every Saturday to count unique release days across all org repos for the week, writing results to a 'Releases' Google Sheet tab grouped by month. Supports a manual saturday_date input for backfilling weeks. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 3dd6a20 commit d171850

2 files changed

Lines changed: 321 additions & 0 deletions

File tree

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
name: Weekly Release Tracker
2+
3+
on:
4+
schedule:
5+
# Every Saturday at 11:00 PM IST (17:30 UTC)
6+
- cron: "30 17 * * 6"
7+
workflow_dispatch:
8+
inputs:
9+
saturday_date:
10+
description: "Saturday date to fetch releases for (YYYY-MM-DD). Defaults to today."
11+
required: false
12+
type: string
13+
14+
jobs:
15+
track-releases:
16+
runs-on: ubuntu-latest
17+
steps:
18+
- name: Checkout
19+
uses: actions/checkout@v4
20+
21+
- name: Set up Python
22+
uses: actions/setup-python@v5
23+
with:
24+
python-version: "3.11"
25+
26+
- name: Install dependencies
27+
run: pip install -r requirements.txt
28+
29+
- name: Run release tracker
30+
env:
31+
GITHUB_TOKEN: ${{ secrets.PROJECTS_READ_TOKEN }}
32+
ORG: glific
33+
GOOGLE_CREDENTIALS_JSON: ${{ secrets.GOOGLE_CREDENTIALS_JSON }}
34+
SPREADSHEET_ID: ${{ secrets.SPREADSHEET_ID }}
35+
SATURDAY_DATE: ${{ inputs.saturday_date }}
36+
run: python release_tracker.py

release_tracker.py

Lines changed: 285 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,285 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Glific — Weekly Release Tracker → Google Sheets
4+
5+
Runs every Saturday. Fetches all GitHub releases published in the org during
6+
the current week (Monday–Saturday) and appends one row per week to a dedicated
7+
'Releases' tab in the Google Sheet. Weeks are grouped under their month so
8+
the sheet stays easy to read at a glance.
9+
10+
Required env vars:
11+
GITHUB_TOKEN - GitHub PAT with repo/read:org scope
12+
ORG - GitHub organisation name
13+
GOOGLE_CREDENTIALS_JSON - Service account JSON key (full JSON string)
14+
SPREADSHEET_ID - Target Google Sheet ID
15+
"""
16+
17+
import json
18+
import os
19+
import sys
20+
from datetime import date, datetime, timedelta, timezone
21+
22+
import requests
23+
from dotenv import load_dotenv
24+
from google.oauth2 import service_account
25+
from googleapiclient.discovery import build
26+
27+
load_dotenv()
28+
29+
SCOPES = ["https://www.googleapis.com/auth/spreadsheets"]
30+
SHEET_NAME = "Releases"
31+
HEADER = ["Month", "Week Start", "Week End", "Release Count", "Releases", "Last Updated"]
32+
EXCLUDED_REPOS = {"support-process"}
33+
34+
35+
# ── Date helpers ──────────────────────────────────────────────────────────────
36+
37+
38+
def _week_bounds(reference: date) -> tuple[date, date]:
39+
"""Return (monday, saturday) for the week containing *reference*."""
40+
monday = reference - timedelta(days=reference.weekday()) # weekday() 0=Mon
41+
saturday = monday + timedelta(days=5)
42+
return monday, saturday
43+
44+
45+
def _month_label(d: date) -> str:
46+
return d.strftime("%B %Y")
47+
48+
49+
# ── GitHub REST API ───────────────────────────────────────────────────────────
50+
51+
52+
def _gh_headers(token: str) -> dict:
53+
return {
54+
"Authorization": f"Bearer {token}",
55+
"Accept": "application/vnd.github+json",
56+
"X-GitHub-Api-Version": "2022-11-28",
57+
}
58+
59+
60+
def _list_repos(org: str, token: str) -> list[str]:
61+
"""Return all non-archived repo names in the org."""
62+
repos = []
63+
page = 1
64+
while True:
65+
resp = requests.get(
66+
f"https://api.github.com/orgs/{org}/repos",
67+
params={"per_page": 100, "page": page, "type": "all"},
68+
headers=_gh_headers(token),
69+
timeout=30,
70+
)
71+
resp.raise_for_status()
72+
data = resp.json()
73+
for r in data:
74+
if not r.get("archived") and r["name"] not in EXCLUDED_REPOS:
75+
repos.append(r["name"])
76+
if len(data) < 100:
77+
break
78+
page += 1
79+
return repos
80+
81+
82+
def _fetch_releases_in_range(
83+
org: str, repos: list[str], start: date, end: date, token: str
84+
) -> list[dict]:
85+
"""
86+
Return releases published between *start* (inclusive) and *end* (inclusive).
87+
Each entry: {"repo": str, "tag": str, "name": str, "published_at": str}
88+
"""
89+
start_dt = datetime(start.year, start.month, start.day, tzinfo=timezone.utc)
90+
end_dt = datetime(end.year, end.month, end.day, 23, 59, 59, tzinfo=timezone.utc)
91+
92+
releases = []
93+
for repo in repos:
94+
page = 1
95+
while True:
96+
resp = requests.get(
97+
f"https://api.github.com/repos/{org}/{repo}/releases",
98+
params={"per_page": 100, "page": page},
99+
headers=_gh_headers(token),
100+
timeout=30,
101+
)
102+
if resp.status_code == 404:
103+
break
104+
resp.raise_for_status()
105+
data = resp.json()
106+
found_any = False
107+
for r in data:
108+
pub = r.get("published_at")
109+
if not pub:
110+
continue
111+
pub_dt = datetime.fromisoformat(pub.rstrip("Z")).replace(tzinfo=timezone.utc)
112+
if pub_dt < start_dt:
113+
# Releases are newest-first; once we go past the window, stop.
114+
found_any = False
115+
break
116+
found_any = True
117+
if pub_dt <= end_dt:
118+
releases.append({
119+
"repo": repo,
120+
"tag": r.get("tag_name", ""),
121+
"name": r.get("name") or r.get("tag_name", ""),
122+
"published_at": pub,
123+
})
124+
if len(data) < 100 or not found_any:
125+
break
126+
page += 1
127+
return releases
128+
129+
130+
# ── Google Sheets ─────────────────────────────────────────────────────────────
131+
132+
133+
def _sheets_client():
134+
creds_json = os.environ.get("GOOGLE_CREDENTIALS_JSON")
135+
if not creds_json:
136+
sys.exit("GOOGLE_CREDENTIALS_JSON not set")
137+
info = json.loads(creds_json)
138+
creds = service_account.Credentials.from_service_account_info(info, scopes=SCOPES)
139+
return build("sheets", "v4", credentials=creds, cache_discovery=False).spreadsheets()
140+
141+
142+
def _ensure_sheet_tab(sheets, spreadsheet_id: str):
143+
meta = sheets.get(spreadsheetId=spreadsheet_id).execute()
144+
existing = [s["properties"]["title"] for s in meta.get("sheets", [])]
145+
if SHEET_NAME not in existing:
146+
sheets.batchUpdate(
147+
spreadsheetId=spreadsheet_id,
148+
body={"requests": [{"addSheet": {"properties": {"title": SHEET_NAME}}}]},
149+
).execute()
150+
print(f"Created sheet tab '{SHEET_NAME}'.")
151+
152+
153+
def _ensure_header(sheets, spreadsheet_id: str):
154+
result = sheets.values().get(
155+
spreadsheetId=spreadsheet_id,
156+
range=f"'{SHEET_NAME}'!A1:{chr(64 + len(HEADER))}1",
157+
).execute()
158+
if not result.get("values"):
159+
sheets.values().update(
160+
spreadsheetId=spreadsheet_id,
161+
range=f"'{SHEET_NAME}'!A1",
162+
valueInputOption="RAW",
163+
body={"values": [HEADER]},
164+
).execute()
165+
print("Header row written.")
166+
167+
168+
def _all_rows(sheets, spreadsheet_id: str) -> list[list[str]]:
169+
result = sheets.values().get(
170+
spreadsheetId=spreadsheet_id,
171+
range=f"'{SHEET_NAME}'!A:F",
172+
).execute()
173+
return result.get("values", [])
174+
175+
176+
def _find_week_row(rows: list[list[str]], week_start_iso: str) -> int | None:
177+
"""Return 1-based sheet row index where column B matches week_start_iso, or None."""
178+
for idx, row in enumerate(rows, start=1):
179+
if len(row) >= 2 and row[1] == week_start_iso:
180+
return idx
181+
return None
182+
183+
184+
def _write_row(sheets, spreadsheet_id: str, row_index: int, values: list):
185+
sheets.values().update(
186+
spreadsheetId=spreadsheet_id,
187+
range=f"'{SHEET_NAME}'!A{row_index}:{chr(64 + len(HEADER))}{row_index}",
188+
valueInputOption="RAW",
189+
body={"values": [values]},
190+
).execute()
191+
192+
193+
def _month_already_in_sheet(rows: list[list[str]], month_label: str) -> bool:
194+
"""True if any existing row already carries this month label in column A."""
195+
return any(row and row[0] == month_label for row in rows)
196+
197+
198+
# ── Main ──────────────────────────────────────────────────────────────────────
199+
200+
201+
def main():
202+
token = os.environ.get("GITHUB_TOKEN")
203+
org = os.environ.get("ORG")
204+
spreadsheet_id = os.environ.get("SPREADSHEET_ID")
205+
206+
if not all([token, org, spreadsheet_id]):
207+
sys.exit("Missing required env vars: GITHUB_TOKEN, ORG, SPREADSHEET_ID")
208+
209+
saturday_input = os.environ.get("SATURDAY_DATE", "").strip()
210+
if saturday_input:
211+
try:
212+
reference = date.fromisoformat(saturday_input)
213+
except ValueError:
214+
sys.exit(f"Invalid SATURDAY_DATE '{saturday_input}' — expected YYYY-MM-DD.")
215+
if reference.weekday() != 5: # 5 = Saturday
216+
sys.exit(
217+
f"SATURDAY_DATE '{saturday_input}' is not a Saturday "
218+
f"(it's a {reference.strftime('%A')})."
219+
)
220+
today = reference
221+
else:
222+
today = date.today()
223+
224+
week_start, week_end = _week_bounds(today)
225+
month_label = _month_label(week_end) # week belongs to the month it ends in
226+
227+
print(f"Week : {week_start}{week_end}")
228+
print(f"Month : {month_label}")
229+
print(f"Fetching repos for org '{org}'…")
230+
231+
repos = _list_repos(org, token)
232+
print(f"Repos found: {len(repos)}")
233+
234+
print(f"Fetching releases published {week_start}{week_end}…")
235+
releases = _fetch_releases_in_range(org, repos, week_start, week_end, token)
236+
print(f"Releases found: {len(releases)}")
237+
for r in releases:
238+
print(f" {r['repo']} {r['tag']} ({r['published_at']})")
239+
240+
# Deduplicate by date: multiple repos releasing on the same day count as 1.
241+
release_dates = {
242+
datetime.fromisoformat(r["published_at"].rstrip("Z")).date()
243+
for r in releases
244+
}
245+
release_count = len(release_dates)
246+
print(f"Unique release days: {release_count}")
247+
248+
# Build a compact summary string: "repo@tag, repo@tag, …"
249+
release_summary = ", ".join(f"{r['repo']}@{r['tag']}" for r in releases) or "—"
250+
251+
sheets = _sheets_client()
252+
_ensure_sheet_tab(sheets, spreadsheet_id)
253+
_ensure_header(sheets, spreadsheet_id)
254+
255+
rows = _all_rows(sheets, spreadsheet_id)
256+
257+
# Decide whether to show the month label (only on the first week of that month)
258+
month_cell = month_label if not _month_already_in_sheet(rows[1:], month_label) else ""
259+
260+
row_values = [
261+
month_cell,
262+
week_start.isoformat(),
263+
week_end.isoformat(),
264+
release_count,
265+
release_summary,
266+
today.isoformat(),
267+
]
268+
269+
existing_row = _find_week_row(rows, week_start.isoformat())
270+
if existing_row:
271+
print(f"Updating existing row {existing_row}…")
272+
# Preserve the month cell if it was already set (don't blank it on re-run)
273+
if rows[existing_row - 1] and rows[existing_row - 1][0]:
274+
row_values[0] = rows[existing_row - 1][0]
275+
_write_row(sheets, spreadsheet_id, existing_row, row_values)
276+
else:
277+
next_row = len(rows) + 1
278+
print(f"Appending new row {next_row}…")
279+
_write_row(sheets, spreadsheet_id, next_row, row_values)
280+
281+
print("Done.")
282+
283+
284+
if __name__ == "__main__":
285+
main()

0 commit comments

Comments
 (0)