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