Skip to content

Commit a054541

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

1 file changed

Lines changed: 352 additions & 0 deletions

File tree

tools/wip_wednesday.py

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

0 commit comments

Comments
 (0)