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