Skip to content

Commit bd2206d

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

1 file changed

Lines changed: 350 additions & 0 deletions

File tree

tools/wip_wednesday.py

Lines changed: 350 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,350 @@
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 class_prs(prs: list[dict[str, Any]]) -> tuple[DefaultDict[str, list[tuple[str, str]]], list[tuple[str, str]]]:
229+
"""Return workbench and other changes dictionaries."""
230+
workbench_changes = defaultdict(list)
231+
other_changes = []
232+
233+
for pr in prs:
234+
labels = [label["name"] for label in pr.get("labels", [])]
235+
title = pr.get("title", "")
236+
title_lower = title.lower()
237+
user = pr.get("user", {}).get("login", "unknown")
238+
239+
assigned = False
240+
for wb in WORKBENCH_ORDER:
241+
if wb.lower() in title_lower or any(wb.lower() in label.lower() for label in labels):
242+
workbench_changes[wb].append((user, title))
243+
assigned = True
244+
break
245+
if not assigned:
246+
other_changes.append((user, title))
247+
248+
return workbench_changes, other_changes
249+
250+
251+
def generate_body(
252+
dt: datetime,
253+
workbench_changes: dict[str, list[tuple[str, str]]],
254+
other_changes: list[tuple[str, str]],
255+
contributors: list[str],
256+
pr_stats: dict[str, int],
257+
issue_stats: dict[str, int],
258+
pr_type_stats: dict[str, int],
259+
) -> str:
260+
261+
lines = ["This week in FreeCAD development:\n"]
262+
lines.append(f"- Total PRs merged: {pr_type_stats['total']}")
263+
lines.append(f"- Backport PRs: {pr_type_stats['backport']}")
264+
lines.append(f"- Feature PRs: {pr_type_stats['feature']}")
265+
lines.append(f"- Fix PRs: {pr_type_stats['fix']}")
266+
lines.append(f"- Other PRs: {pr_type_stats['other']}\n")
267+
268+
for wb in WORKBENCH_ORDER:
269+
prs = workbench_changes.get(wb)
270+
if prs:
271+
lines.append(f"### {wb}:")
272+
for user, title in prs:
273+
lines.append(f" - {user} {title}")
274+
lines.append("")
275+
276+
if other_changes:
277+
lines.append("### Other changes:")
278+
for user, title in other_changes:
279+
lines.append(f" - {user} {title}")
280+
lines.append("")
281+
282+
if contributors:
283+
lines.append(f"Additional improvements and fixes were contributed by {', '.join(contributors)}.\n")
284+
285+
lines.append(
286+
f"If you are interested in testing you can grab [the latest weekly build]"
287+
f"(https://github.com/{GITHUB_REPO}/releases/tag/weekly-{dt.strftime('%Y.%m.%d')}).\n")
288+
289+
lines.append(
290+
f"PR stats: since the previous report, {pr_stats['merged']} pull requests have been merged, "
291+
f"and {pr_stats['opened']} new pull requests have been opened.\n")
292+
lines.append(
293+
f"Issue stats: overall, there are {issue_stats['open']} open issues in the tracker, "
294+
f"up/down by {issue_stats['delta']} from last week.\n")
295+
296+
return "\n".join(lines)
297+
298+
299+
def main() -> None:
300+
parser = argparse.ArgumentParser(description="Generate Hugo WIP Wednesday markdown.")
301+
parser.add_argument("--time", help="Optional timestamp (ISO 8601 or RFC 2822)")
302+
parser.add_argument("--author", help="Optional Author name", default="")
303+
parser.add_argument("--root", type=Path, default=Path.cwd())
304+
parser.add_argument("--ci", action="store_true", help="Skip prompts")
305+
parser.add_argument("--token", help="GitHub token to increase API limits", default=None)
306+
args = parser.parse_args()
307+
308+
dt = parse_time(args.time) if args.time else datetime.now().astimezone().replace(tzinfo=None)
309+
310+
if is_wednesday(dt):
311+
dt = dt.replace(hour=12, minute=0, second=0, microsecond=0)
312+
info("Using Wednesday", dt)
313+
elif args.ci:
314+
dt = previous_wednesday(dt, set_time=(12, 0, 0))
315+
info("Using most recent Wednesday (CI)", dt)
316+
else:
317+
dt = prompt_user_for_date(dt)
318+
319+
out_path = build_output_path(args.root, dt)
320+
321+
if out_path.exists():
322+
raise SystemExit(f"{warn('⚠️ Error:')} File exists: {bold(out_path)}")
323+
324+
out_path.parent.mkdir(parents=True, exist_ok=True)
325+
326+
merged_prs, open_prs_count, open_issues_count = fetch_github_data(dt, token=args.token)
327+
pr_type_stats = get_pr_type_stats(merged_prs)
328+
workbench_changes, other_changes = class_prs(merged_prs)
329+
contributors = sorted({
330+
pr.get("user", {}).get("login", "unknown")
331+
for pr in merged_prs
332+
if pr.get("user")
333+
})
334+
335+
pr_stats = {
336+
"merged": len(merged_prs),
337+
"opened": open_prs_count,
338+
}
339+
340+
issue_stats = {"open": open_issues_count, "delta": 0}
341+
front_matter = generate_front_matter(dt, args.author)
342+
body = generate_body(dt, workbench_changes, other_changes, contributors, pr_stats, issue_stats, pr_type_stats)
343+
344+
out_path.write_text(front_matter + "\n" + body + "\n", encoding="utf-8")
345+
346+
print(f"Created: {bold(out_path)}")
347+
348+
349+
if __name__ == "__main__":
350+
main()

0 commit comments

Comments
 (0)