77import json
88import os
99import re
10+ import tempfile
1011from typing import Any , Dict , List , Optional , Set , Tuple
1112
1213import aiohttp
@@ -183,6 +184,14 @@ def render_action_summary(report: Dict[str, Any]) -> str:
183184 monthly = report .get ("monthly_commits" , {})
184185 monthly_total = monthly .get ("total" , 0 )
185186 monthly_current = monthly .get ("current_month" , {})
187+ monthly_source = monthly .get ("source" , {})
188+ monthly_discovery = monthly_source .get ("discovery" , {})
189+ monthly_discovery_warnings = monthly_discovery .get ("warnings" , [])
190+ monthly_discovery_warning_text = (
191+ "\n " .join (f"- { warning } " for warning in monthly_discovery_warnings )
192+ if monthly_discovery_warnings
193+ else "- None"
194+ )
186195 experimental = report .get ("experimental" , {})
187196 experimental_limited = experimental .get ("limited_data" , [])
188197 star_history = report .get ("star_history" , {})
@@ -199,6 +208,10 @@ def render_action_summary(report: Dict[str, Any]) -> str:
199208- Merged pull requests: { stats ["merged_pull_requests" ]:,}
200209- Monthly commits shown: { monthly_total :,}
201210- Current month commits: { monthly_current .get ("count" , 0 ):,}
211+ - Monthly scan repositories: { monthly_source .get ("repo_count" , 0 ):,}
212+ - Monthly contribution repositories discovered: { monthly_discovery .get ("contribution_repo_count" , 0 ):,}
213+ - Monthly discovery incomplete: { len (monthly_discovery .get ("incomplete_months" , []))}
214+ - Monthly discovery warnings: { len (monthly_discovery .get ("warnings" , []))}
202215- Repositories: { stats ["repos" ]:,}
203216- Repository views: { stats ["views" ]:,}
204217- Star history samples: { star_history .get ("samples" , 0 ):,}
@@ -235,6 +248,9 @@ def render_action_summary(report: Dict[str, Any]) -> str:
235248### Monthly Commit Scan Warnings
236249
237250{ _format_degradation_items (monthly_commit_items )}
251+ ### Monthly Discovery Warnings
252+
253+ { monthly_discovery_warning_text }
238254"""
239255
240256
@@ -261,6 +277,20 @@ def validate_run_report(report: Dict[str, Any]) -> None:
261277 f"{ traffic_degraded_count } traffic endpoints degraded."
262278 )
263279
280+ strict_monthly_commits = env_truthy ("STRICT_MONTHLY_COMMIT_VALIDATION" )
281+ monthly = report .get ("monthly_commits" , {})
282+ monthly_source = monthly .get ("source" , {})
283+ current_month = monthly .get ("current_month" , {})
284+ if strict_monthly_commits and (
285+ current_month .get ("scan_degraded" )
286+ or int (monthly_source .get ("degraded_months" , 0 )) > 0
287+ or len (api .get ("monthly_commits_degraded" , [])) > 0
288+ ):
289+ failures .append (
290+ "Monthly commit discovery or scanning is incomplete; "
291+ "generated monthly artifacts were not accepted."
292+ )
293+
264294 if failures :
265295 raise RuntimeError (
266296 "Generated metrics failed validation:\n - " + "\n - " .join (failures )
@@ -295,6 +325,7 @@ async def build_run_report(s: Stats) -> Dict[str, Any]:
295325 "total" : sum (int (item .get ("count" , 0 )) for item in months ),
296326 "current_month" : current_month ,
297327 "month_count" : len (months ),
328+ "source" : monthly_cache .get ("source" , {}),
298329 },
299330 "star_history" : {
300331 "samples" : len (star_history_records ),
@@ -544,9 +575,10 @@ async def build_monthly_commit_cache(
544575 force_backfill : bool = False ,
545576 now : Optional [dt .datetime ] = None ,
546577 cache_path : str = MONTHLY_COMMITS_CACHE ,
578+ write_cache : bool = True ,
547579) -> Dict [str , Any ]:
548580 windows = rolling_month_windows (now )
549- existing = {} if force_backfill else load_monthly_commits_cache (cache_path )
581+ existing = load_monthly_commits_cache (cache_path )
550582 existing_by_key = _monthly_cache_records (existing )
551583 windows_to_scan = [
552584 window
@@ -558,30 +590,61 @@ async def build_monthly_commit_cache(
558590 scanned_counts : Dict [str , int ] = {}
559591 audit_counts : Dict [str , int ] = {}
560592 degraded_scan_keys : Set [str ] = set ()
561- repo_count = len (await s .repos )
593+ discovery_repo_count = 0
594+ discovery_incomplete_keys : Set [str ] = set ()
595+ warnings : List [str ] = []
596+ base_repos = set (await s .repos )
597+ configured_extra_repos = parse_repo_list (
598+ os .getenv ("MONTHLY_COMMITS_EXTRA_REPOS" )
599+ )
600+ repo_count = len (base_repos | configured_extra_repos )
562601 if windows_to_scan :
563- audit_counts = await s .audit_monthly_commit_counts (windows_to_scan )
602+ discovery = await s .discover_monthly_commit_repositories (windows_to_scan )
603+ audit_counts = discovery .attributed_counts
604+ discovery_repo_count = len (discovery .repositories )
605+ discovery_incomplete_keys = discovery .incomplete_months
606+ degraded_scan_keys .update (discovery_incomplete_keys )
564607 scan_result = await s .scan_monthly_commits (
565608 windows_to_scan ,
566609 identity_patterns = patterns ,
567- extra_repos = parse_repo_list ( os . getenv ( "MONTHLY_COMMITS_EXTRA_REPOS" )) ,
610+ extra_repos = discovery . repositories | configured_extra_repos ,
568611 )
569612 scanned_counts = scan_result .counts
570- degraded_scan_keys = scan_result .degraded_months
613+ degraded_scan_keys . update ( scan_result .degraded_months )
571614 repo_count = scan_result .repo_count
572615
573616 records = []
574617 for window in windows :
618+ cached_count = int (existing_by_key .get (window .key , {}).get ("count" , 0 ))
619+ maybe_scanned_count = scanned_counts .get (window .key )
620+ if (
621+ not force_backfill
622+ and window .is_current
623+ and window .key in existing_by_key
624+ and maybe_scanned_count is not None
625+ and maybe_scanned_count < cached_count
626+ ):
627+ degraded_scan_keys .add (window .key )
628+ warnings .append (
629+ f"{ window .key } : candidate count { maybe_scanned_count } "
630+ f"regressed below cached count { cached_count } "
631+ )
632+
575633 should_preserve_cached_count = (
576634 window .key in degraded_scan_keys and window .key in existing_by_key
577635 )
578636 if window .key in scanned_counts and not should_preserve_cached_count :
579637 record = _record_from_window (window , scanned_counts [window .key ])
580638 else :
581- cached_count = int (existing_by_key .get (window .key , {}).get ("count" , 0 ))
582639 record = _record_from_window (window , cached_count )
583640 record_data = record .__dict__
584641 record_data ["scan_degraded" ] = window .key in degraded_scan_keys
642+ if maybe_scanned_count is not None :
643+ record_data ["scan_candidate_count" ] = int (maybe_scanned_count )
644+ elif "scan_candidate_count" in existing_by_key .get (window .key , {}):
645+ record_data ["scan_candidate_count" ] = int (
646+ existing_by_key [window .key ]["scan_candidate_count" ]
647+ )
585648 if window .key in audit_counts :
586649 record_data ["github_attributed_count" ] = audit_counts [window .key ]
587650 else :
@@ -611,10 +674,24 @@ async def build_monthly_commit_cache(
611674 "scanned_months" : len (windows_to_scan ),
612675 "degraded_months" : len (degraded_scan_keys ),
613676 "degraded_repo_months" : len (s .report .monthly_commits_degraded ),
677+ "discovery" : {
678+ "base_repo_count" : len (base_repos ),
679+ "contribution_repo_count" : discovery_repo_count ,
680+ "configured_extra_repo_count" : len (configured_extra_repos ),
681+ "scan_repo_count" : repo_count ,
682+ "incomplete_months" : sorted (discovery_incomplete_keys ),
683+ "warnings" : warnings ,
684+ },
614685 },
615686 "months" : records ,
616687 }
617- write_monthly_commits_cache (cache , cache_path )
688+ if force_backfill and degraded_scan_keys and write_cache :
689+ raise RuntimeError (
690+ "Monthly commit backfill is incomplete for: "
691+ + ", " .join (sorted (degraded_scan_keys ))
692+ )
693+ if write_cache :
694+ write_monthly_commits_cache (cache , cache_path )
618695 return cache
619696
620697
@@ -1191,19 +1268,8 @@ def _monthly_commit_bars(months: List[Dict[str, Any]]) -> str:
11911268 return "\n " .join (bars )
11921269
11931270
1194- async def generate_monthly_commits (
1195- s : Stats ,
1196- force_backfill : bool = False ,
1197- now : Optional [dt .datetime ] = None ,
1198- cache_path : str = MONTHLY_COMMITS_CACHE ,
1199- ) -> None :
1200- cache = await build_monthly_commit_cache (
1201- s ,
1202- force_backfill = force_backfill ,
1203- now = now ,
1204- cache_path = cache_path ,
1205- )
1206-
1271+ def render_monthly_commits_svg (cache : Dict [str , Any ]) -> str :
1272+ """Render the monthly commit SVG from a validated cache candidate."""
12071273 with open ("templates/monthly-commits.svg" , "r" ) as f :
12081274 output = f .read ()
12091275
@@ -1220,15 +1286,68 @@ async def generate_monthly_commits(
12201286 html .escape (str (current_month .get ("label" , "" ))),
12211287 output ,
12221288 )
1223- output = re .sub (
1289+ return re .sub (
12241290 r"{{ current_count }}" ,
12251291 f"{ int (current_month .get ('count' , 0 )):,} " ,
12261292 output ,
12271293 )
12281294
1229- generate_output_folder ()
1230- with open ("generated/monthly-commits.svg" , "w" ) as f :
1231- f .write (output )
1295+
1296+ def _atomic_write_text (path : str , content : str ) -> None :
1297+ parent = os .path .dirname (path )
1298+ if parent and not os .path .isdir (parent ):
1299+ os .makedirs (parent )
1300+ temp_parent = parent or "."
1301+ target_mode = os .stat (path ).st_mode & 0o777 if os .path .exists (path ) else 0o644
1302+ temporary_path = ""
1303+ try :
1304+ with tempfile .NamedTemporaryFile (
1305+ "w" ,
1306+ encoding = "utf-8" ,
1307+ dir = temp_parent ,
1308+ delete = False ,
1309+ ) as temporary_file :
1310+ temporary_file .write (content )
1311+ temporary_path = temporary_file .name
1312+ os .chmod (temporary_path , target_mode )
1313+ os .replace (temporary_path , path )
1314+ finally :
1315+ if temporary_path and os .path .exists (temporary_path ):
1316+ os .unlink (temporary_path )
1317+
1318+
1319+ def write_monthly_commit_artifacts (
1320+ cache : Dict [str , Any ],
1321+ cache_path : str = MONTHLY_COMMITS_CACHE ,
1322+ svg_path : str = os .path .join ("generated" , "monthly-commits.svg" ),
1323+ ) -> None :
1324+ """Replace monthly JSON and SVG via same-directory atomic writes."""
1325+ cache_output = json .dumps (cache , indent = 2 ) + "\n "
1326+ svg_output = render_monthly_commits_svg (cache )
1327+ _atomic_write_text (cache_path , cache_output )
1328+ _atomic_write_text (svg_path , svg_output )
1329+
1330+
1331+ async def generate_monthly_commits (
1332+ s : Stats ,
1333+ force_backfill : bool = False ,
1334+ now : Optional [dt .datetime ] = None ,
1335+ cache_path : str = MONTHLY_COMMITS_CACHE ,
1336+ ) -> None :
1337+ cache = await build_monthly_commit_cache (
1338+ s ,
1339+ force_backfill = force_backfill ,
1340+ now = now ,
1341+ cache_path = cache_path ,
1342+ write_cache = False ,
1343+ )
1344+ degraded_months = int (cache .get ("source" , {}).get ("degraded_months" , 0 ))
1345+ if force_backfill and degraded_months > 0 :
1346+ raise RuntimeError (
1347+ "Refusing to write incomplete monthly commit backfill "
1348+ f"({ degraded_months } degraded months)."
1349+ )
1350+ write_monthly_commit_artifacts (cache , cache_path = cache_path )
12321351
12331352
12341353def _svg_text (value : Any ) -> str :
0 commit comments