Skip to content

Commit 886cb9a

Browse files
committed
Tools: Add WIP Wednesday generation script
- Add Python3 script to generate WIP Wednesday articles in Markdown
1 parent 77ee4cc commit 886cb9a

1 file changed

Lines changed: 390 additions & 0 deletions

File tree

tools/wip_wednesday.py

Lines changed: 390 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,390 @@
1+
import argparse
2+
import requests
3+
from collections import defaultdict
4+
from datetime import datetime, timedelta
5+
from email.utils import parsedate_to_datetime
6+
from pathlib import Path
7+
from typing import Any, DefaultDict
8+
9+
10+
GITHUB_REPO = "FreeCAD/FreeCAD"
11+
GITHUB_API = "https://api.github.com"
12+
13+
WORKBENCH_ORDER = [
14+
"Core", "Gui", "Part", "PartDesign", "Sketcher", "TechDraw", "Assembly",
15+
"CAM", "Draft", "BIM", "FEM", "Measure", "Mesh", "Material", "Spreadsheet", "Surface"
16+
]
17+
18+
19+
def warn(text: str) -> str:
20+
return f"\033[31m{text}\033[0m"
21+
22+
23+
def bold(text: str) -> str:
24+
return f"\033[1;34m{text}\033[0m"
25+
26+
27+
def parse_time(timestr: str) -> datetime:
28+
"""
29+
Parse time string in ISO 8601 (2026-01-01T01:23:45+00:00)
30+
or RFC 2822 (Thu, 01 Jan 2026 01:23:45 GMT) format.
31+
"""
32+
try:
33+
dt = datetime.fromisoformat(timestr)
34+
except ValueError:
35+
try:
36+
dt = parsedate_to_datetime(timestr)
37+
except Exception:
38+
raise SystemExit(f"{warn('⚠️ Error:')} Unsupported time format: {timestr}")
39+
40+
if dt.tzinfo is not None:
41+
dt = dt.astimezone().replace(tzinfo=None)
42+
43+
return dt
44+
45+
46+
def is_wednesday(dt: datetime) -> bool:
47+
return dt.weekday() == 2 # Monday=0, Wednesday=2
48+
49+
50+
def previous_wednesday(dt: datetime, set_time: tuple[int, int, int] | None = None) -> datetime:
51+
"""Return the most recent Wednesday with optional time (hour, minute, second)."""
52+
days_back = (dt.weekday() - 2) % 7
53+
new_dt = dt - timedelta(days=days_back)
54+
if set_time is not None:
55+
hour, minute, second = set_time
56+
new_dt = new_dt.replace(hour=hour, minute=minute, second=second, microsecond=0)
57+
return new_dt
58+
59+
60+
def format_dt(dt: datetime) -> str:
61+
return dt.strftime("%A, %d %B %Y %H:%M:%S")
62+
63+
64+
def info(message: str, dt: datetime) -> None:
65+
"""Print labeled datetime in bold."""
66+
print(f"→ {message}: {bold(format_dt(dt))}")
67+
68+
69+
def prompt_user_for_date(dt: datetime) -> datetime:
70+
new_dt = previous_wednesday(dt, set_time=(12, 0, 0)) # suggest Wednesday at 12:00 UTC
71+
72+
print(f"\n{warn('⚠️ Warning:')} The selected time is not a Wednesday.")
73+
info("Selected time", dt)
74+
print(f"How to proceed?")
75+
print(f" [Enter/Y] Continue with selected time")
76+
print(f" [W] Pick most recent Wednesday: {format_dt(new_dt)}")
77+
78+
try:
79+
choice = input("> ").strip().lower()
80+
except EOFError:
81+
print(f"\n{warn('⚠️ Warning:')} No input available.")
82+
info("Continuing with", dt)
83+
return dt
84+
85+
if choice in ("", "y", "yes"):
86+
info("Continuing with", dt)
87+
return dt
88+
elif choice == "w":
89+
info("Continuing with most recent Wednesday", new_dt)
90+
return new_dt
91+
else:
92+
print(f"\n{warn('⚠️ Warning:')} Invalid choice, defaulting to selected time.")
93+
info("Continuing with", dt)
94+
return dt
95+
96+
97+
def build_output_path(base_dir: Path, dt: datetime) -> Path:
98+
slug = dt.strftime("wip-wednesday-%d-%B-%Y").lower()
99+
100+
return base_dir / "content" / "en" / "news" / dt.strftime("%Y/%m") / slug / "index.md"
101+
102+
103+
def generate_front_matter(dt: datetime, author: str) -> str:
104+
return f"""---
105+
title: WIP Wednesday - {dt.strftime("%d %B %Y")}
106+
date: {dt.strftime("%Y-%m-%d")}
107+
authors: {author}
108+
draft: false
109+
categories: update
110+
tags:
111+
- WIP
112+
cover:
113+
image:
114+
caption:
115+
---
116+
"""
117+
118+
119+
def github_get(url: str, headers: dict[str, str], params: dict[str, Any]) -> requests.Response:
120+
r = requests.get(url, headers=headers, params=params)
121+
r.raise_for_status()
122+
return r
123+
124+
125+
def extract_page(link_header: str) -> int | None:
126+
if not link_header:
127+
return None
128+
try:
129+
part = link_header.split(",")[0]
130+
return int(part.split("page=")[-1].split(">")[0])
131+
except Exception:
132+
return None
133+
134+
135+
def fetch_github_data(dt: datetime, token: str | None = None) -> tuple[list[dict[str, Any]], int, int]:
136+
"""
137+
Fetch FreeCAD PR and issue data for the week ending at dt (12:00 UTC Wednesday).
138+
Returns:
139+
merged_prs: list of merged PRs in the week
140+
open_prs_count: number of currently open PRs
141+
open_issues_count: number of currently open issues
142+
"""
143+
until = dt
144+
since = dt - timedelta(days=7)
145+
146+
search_url = f"{GITHUB_API}/search/issues"
147+
headers = {"Accept": "application/vnd.github.v3+json"}
148+
if token:
149+
headers["Authorization"] = f"token {token}"
150+
151+
merged_prs = []
152+
153+
page = 1
154+
while True:
155+
params = {
156+
"q": f"repo:{GITHUB_REPO} is:pr is:merged merged:{since.isoformat()}..{until.isoformat()}",
157+
"sort": "updated",
158+
"order": "desc",
159+
"per_page": 100,
160+
"page": page,
161+
}
162+
r = github_get(search_url, headers, params)
163+
items = r.json().get("items", [])
164+
if not items:
165+
break
166+
merged_prs.extend(items)
167+
if "next" not in r.links:
168+
break
169+
page += 1
170+
171+
search_prs_url = f"{GITHUB_API}/repos/{GITHUB_REPO}/pulls"
172+
prs_params = {
173+
"state": "open",
174+
"per_page": 1
175+
}
176+
r_open_prs = github_get(search_prs_url, headers, prs_params)
177+
last_page = extract_page(r_open_prs.headers.get("Link", ""))
178+
open_prs_count = last_page if last_page is not None else len(r_open_prs.json())
179+
180+
issues_params = {
181+
"q": f"repo:{GITHUB_REPO} is:issue is:open",
182+
"per_page": 1,
183+
}
184+
r_open_issues = github_get(search_url, headers, issues_params)
185+
open_issues_count = r_open_issues.json().get("total_count", 0)
186+
187+
return merged_prs, open_prs_count, open_issues_count
188+
189+
190+
def get_pr_type(pr: dict) -> str:
191+
"""
192+
Classify PR into backport, feature, fix, other.
193+
Uses labels first then falls back to title heuristics.
194+
"""
195+
labels = [label["name"].lower() for label in pr.get("labels", [])]
196+
title = pr.get("title", "").lower()
197+
198+
if "backport" in title:
199+
return "backport"
200+
201+
if any(l in ("feature", "enhancement") for l in labels) \
202+
or any(k in title for k in ["add", "feature", "implement", "support", "enable", "improve", "introduce"]):
203+
return "feature"
204+
205+
if any(l in ("bug", "bugfix", "fix") for l in labels) \
206+
or any(k in title for k in ["fix", "bug", "crash", "error", "resolve", "regression", "correct"]):
207+
return "fix"
208+
209+
return "other"
210+
211+
212+
def get_pr_type_stats(prs: list[dict[str, Any]]) -> dict[str, int]:
213+
stats = {
214+
"total": len(prs),
215+
"backport": 0,
216+
"feature": 0,
217+
"fix": 0,
218+
"other": 0,
219+
}
220+
221+
for pr in prs:
222+
pr_type = get_pr_type(pr)
223+
stats[pr_type] += 1
224+
225+
return stats
226+
227+
228+
def get_pr_commit_authors(pr_number: int, headers: dict[str, str]) -> set[str]:
229+
url = f"{GITHUB_API}/repos/{GITHUB_REPO}/pulls/{pr_number}/commits"
230+
authors = set()
231+
232+
page = 1
233+
while True:
234+
params = {"per_page": 100, "page": page}
235+
r = github_get(url, headers, params)
236+
commits = r.json()
237+
if not commits:
238+
break
239+
240+
for c in commits:
241+
author = c.get("author")
242+
if author and author.get("login"):
243+
authors.add(author["login"])
244+
else:
245+
commit_author = c.get("commit", {}).get("author", {})
246+
name = commit_author.get("name")
247+
if name:
248+
authors.add(name)
249+
250+
if "next" not in r.links:
251+
break
252+
page += 1
253+
254+
return authors
255+
256+
257+
def class_prs(prs: list[dict[str, Any]], headers: dict[str, str]) -> tuple[
258+
DefaultDict[str, list[tuple[str, str, int, str]]],
259+
list[tuple[str, str, int, str]]
260+
]:
261+
"""Return workbench and other changes dictionaries."""
262+
workbench_changes = defaultdict(list)
263+
other_changes = []
264+
265+
for pr in prs:
266+
labels = [label["name"] for label in pr.get("labels", [])]
267+
title = pr.get("title", "")
268+
title_lower = title.lower()
269+
number = pr.get("number")
270+
url = pr.get("html_url")
271+
authors = get_pr_commit_authors(number, headers)
272+
authors.discard("pre-commit-ci[bot]")
273+
author_str = ", ".join(sorted(authors)) if authors else "unknown"
274+
275+
assigned = False
276+
for wb in WORKBENCH_ORDER:
277+
if wb.lower() in title_lower or any(wb.lower() in label.lower() for label in labels):
278+
workbench_changes[wb].append((author_str, title, number, url))
279+
assigned = True
280+
break
281+
if not assigned:
282+
other_changes.append((author_str, title, number, url))
283+
284+
return workbench_changes, other_changes
285+
286+
287+
def generate_body(
288+
dt: datetime,
289+
workbench_changes: dict[str, list[tuple[str, str]]],
290+
other_changes: list[tuple[str, str]],
291+
contributors: list[str],
292+
pr_stats: dict[str, int],
293+
issue_stats: dict[str, int],
294+
pr_type_stats: dict[str, int],
295+
) -> str:
296+
297+
lines = ["This week in FreeCAD development:\n"]
298+
lines.append(f"- Total PRs merged: {pr_type_stats['total']}")
299+
lines.append(f"- Backport PRs: {pr_type_stats['backport']}")
300+
lines.append(f"- Feature PRs: {pr_type_stats['feature']}")
301+
lines.append(f"- Fix PRs: {pr_type_stats['fix']}")
302+
lines.append(f"- Other PRs: {pr_type_stats['other']}\n")
303+
304+
for wb in WORKBENCH_ORDER:
305+
prs = workbench_changes.get(wb)
306+
if prs:
307+
lines.append(f"### {wb}:")
308+
for user, title, number, url in prs:
309+
lines.append(f" - {user} | {title} [#{number}]({url})")
310+
lines.append("")
311+
312+
if other_changes:
313+
lines.append("### Other changes:")
314+
for user, title, number, url in other_changes:
315+
lines.append(f" - {user} {title} [#{number}]({url})")
316+
lines.append("")
317+
318+
if contributors:
319+
lines.append(f"Additional improvements and fixes were contributed by {', '.join(contributors)}.\n")
320+
321+
lines.append(
322+
f"If you are interested in testing you can grab [the latest weekly build]"
323+
f"(https://github.com/{GITHUB_REPO}/releases/tag/weekly-{dt.strftime('%Y.%m.%d')}).\n")
324+
325+
lines.append(
326+
f"PR stats: since the previous report, {pr_stats['merged']} pull requests have been merged, "
327+
f"and {pr_stats['opened']} new pull requests have been opened.\n")
328+
lines.append(
329+
f"Issue stats: overall, there are {issue_stats['open']} open issues in the tracker, "
330+
f"up/down by {issue_stats['delta']} from last week.\n")
331+
332+
return "\n".join(lines)
333+
334+
335+
def main() -> None:
336+
parser = argparse.ArgumentParser(description="Generate Hugo WIP Wednesday markdown.")
337+
parser.add_argument("--time", help="Optional timestamp (ISO 8601 or RFC 2822)")
338+
parser.add_argument("--author", help="Optional Author name", default="")
339+
parser.add_argument("--root", type=Path, default=Path.cwd())
340+
parser.add_argument("--ci", action="store_true", help="Skip prompts")
341+
parser.add_argument("--token", help="GitHub token to increase API limits", default=None)
342+
args = parser.parse_args()
343+
344+
dt = parse_time(args.time) if args.time else datetime.now().astimezone().replace(tzinfo=None)
345+
346+
if is_wednesday(dt):
347+
dt = dt.replace(hour=12, minute=0, second=0, microsecond=0)
348+
info("Using Wednesday", dt)
349+
elif args.ci:
350+
dt = previous_wednesday(dt, set_time=(12, 0, 0))
351+
info("Using most recent Wednesday (CI)", dt)
352+
else:
353+
dt = prompt_user_for_date(dt)
354+
355+
out_path = build_output_path(args.root, dt)
356+
357+
if out_path.exists():
358+
raise SystemExit(f"{warn('⚠️ Error:')} File exists: {bold(out_path)}")
359+
360+
out_path.parent.mkdir(parents=True, exist_ok=True)
361+
362+
headers = {"Accept": "application/vnd.github.v3+json"}
363+
if args.token:
364+
headers["Authorization"] = f"token {args.token}"
365+
366+
merged_prs, open_prs_count, open_issues_count = fetch_github_data(dt, token=args.token)
367+
pr_type_stats = get_pr_type_stats(merged_prs)
368+
workbench_changes, other_changes = class_prs(merged_prs, headers)
369+
contributors = sorted({
370+
pr.get("user", {}).get("login", "unknown")
371+
for pr in merged_prs
372+
if pr.get("user")
373+
})
374+
375+
pr_stats = {
376+
"merged": len(merged_prs),
377+
"opened": open_prs_count,
378+
}
379+
380+
issue_stats = {"open": open_issues_count, "delta": 0}
381+
front_matter = generate_front_matter(dt, args.author)
382+
body = generate_body(dt, workbench_changes, other_changes, contributors, pr_stats, issue_stats, pr_type_stats)
383+
384+
out_path.write_text(front_matter + "\n" + body + "\n", encoding="utf-8")
385+
386+
print(f"Created: {bold(out_path)}")
387+
388+
389+
if __name__ == "__main__":
390+
main()

0 commit comments

Comments
 (0)