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