22import hashlib
33import logging
44import os
5+ from pathlib import Path
56
67import matplotlib .pyplot as plt
78import numpy as np
@@ -249,6 +250,14 @@ def main():
249250 output_path = os .path .join (OUTPUT_DIR , f"{ prefix } .unique_contributors.png" )
250251 plot_unique_contributors (contributor_csv , table , output_path )
251252
253+ output_path = os .path .join (OUTPUT_DIR , f"{ prefix } .unique_contributors_yearly.png" )
254+ plot_yearly_contributors (contributor_csv , table , output_path )
255+
256+ # Same data as the yearly chart, also written as a markdown
257+ # table into trends/{repo}.md between AUTO markers.
258+ trends_md = Path (SCRIPT_DIR ).resolve ().parents [1 ] / "trends" / f"{ env ['REPO_NAME' ]} .md"
259+ update_yearly_contributors_table (contributor_csv , table , trends_md )
260+
252261 # Discussion trends
253262 disc_prefix = f"{ env ['REPO_OWNER' ]} _{ env ['REPO_NAME' ]} _discussions"
254263 disc_csv = os .path .join (args .input_dir , f"{ disc_prefix } .monthly_summary.csv" )
@@ -686,20 +695,7 @@ def plot_unique_contributors(path, table, output_path, window_months=12):
686695 new_counts = per_month .get (True , pd .Series (0 , index = window ))
687696 returning_counts = per_month .get (False , pd .Series (0 , index = window ))
688697
689- def window_stats (n_months ):
690- recent = window [- n_months :]
691- sub = df_window [df_window ["month" ].isin (recent )]
692- total = sub ["user_login" ].nunique ()
693- new_users = sub .loc [sub ["is_new" ], "user_login" ].nunique ()
694- return total , new_users , total - new_users
695-
696- s12 = window_stats (12 )
697- s6 = window_stats (6 )
698- s1 = window_stats (1 )
699-
700- fig , (ax , ax_stats ) = plt .subplots (
701- 1 , 2 , figsize = (14 , 6 ), gridspec_kw = {"width_ratios" : [3.2 , 1 ]}
702- )
698+ fig , ax = plt .subplots (figsize = (12 , 6 ))
703699
704700 x = np .arange (len (window ))
705701 ax .bar (x , returning_counts .values , color = "#8E5CE6" , label = "Returning" )
@@ -715,39 +711,178 @@ def window_stats(n_months):
715711 ax .set_xticklabels (window , rotation = 45 , ha = "right" )
716712 ax .yaxis .set_major_locator (MaxNLocator (integer = True ))
717713 set_axis_labels (ax , "Month" , "Unique contributors" )
718- ax .set_title (f"Unique { pretty_table (table )} contributors (last { len (window )} months)" , fontsize = 16 )
714+ ax .set_title (
715+ f"Unique { pretty_table (table )} contributors (last { len (window )} months)" ,
716+ fontsize = 16 ,
717+ )
719718 ax .legend (loc = "upper left" )
720719
721- ax_stats .axis ("off" )
722- ax_stats .set_title ("Window totals" , fontsize = 14 )
720+ plt .tight_layout ()
721+ plt .savefig (output_path )
722+ logging .info (f"Saved plot to { output_path } " )
723+ plt .close ()
724+ except Exception as e :
725+ logging .warning (f"[{ table } ] Could not generate unique-contributors plot: { e } " )
723726
724- def fmt (label , stats ):
725- total , new_users , returning_users = stats
726- return (
727- f"{ label } \n "
728- f" total: { total } \n "
729- f" new: { new_users } \n "
730- f" returning: { returning_users } \n "
731- )
732727
733- text = "\n " .join ([
734- fmt ("Last 12 months" , s12 ),
735- fmt ("Last 6 months" , s6 ),
736- fmt ("Last month" , s1 ),
737- ])
738- ax_stats .text (
739- 0.0 , 0.95 , text ,
740- ha = "left" , va = "top" ,
741- family = "monospace" , fontsize = 11 ,
742- transform = ax_stats .transAxes ,
728+ def _yearly_contributor_rows (path ):
729+ """Compute per-year (year, label, total, new, returning, partial) rows
730+ from contributor_monthly.csv. Excludes bots. `partial` is True when
731+ the year hasn't fully elapsed yet (typically just the current year)."""
732+ df = pd .read_csv (path )
733+ if df .empty :
734+ return []
735+ df = df [~ df ["user_login" ].map (is_bot_login )]
736+ df = df .dropna (subset = ["month" , "user_login" ])
737+ if df .empty :
738+ return []
739+
740+ df ["month" ] = df ["month" ].astype (str )
741+ df ["year" ] = df ["month" ].str [:4 ]
742+ first_year_by_user = df .groupby ("user_login" )["month" ].min ().str [:4 ]
743+
744+ today_year = pd .Timestamp .utcnow ().year
745+ rows = []
746+ for year , group in df .groupby ("year" ):
747+ users = group ["user_login" ].unique ()
748+ new_c = sum (first_year_by_user [u ] == year for u in users )
749+ total = len (users )
750+ months_seen = group ["month" ].nunique ()
751+ partial = int (year ) == today_year and months_seen < 12
752+ label = f"{ year } (YTD, { months_seen } mo)" if partial else f"{ year } "
753+ rows .append ((year , label , total , new_c , total - new_c , partial ))
754+ rows .sort (key = lambda r : r [0 ])
755+ return rows
756+
757+
758+ def plot_yearly_contributors (path , table , output_path , max_years = 6 ):
759+ """Horizontal stacked bars: one row per year (last `max_years`),
760+ split into new (green) vs returning (purple), with totals annotated."""
761+ try :
762+ rows = _yearly_contributor_rows (path )
763+ if not rows :
764+ return
765+ rows = rows [- max_years :]
766+
767+ labels = [r [1 ] for r in rows ]
768+ new_vals = np .array ([r [3 ] for r in rows ])
769+ ret_vals = np .array ([r [4 ] for r in rows ])
770+ partials = [r [5 ] for r in rows ]
771+ totals = new_vals + ret_vals
772+
773+ fig , ax = plt .subplots (figsize = (10 , max (3.5 , 0.7 * len (rows ) + 1.5 )))
774+ y = np .arange (len (labels ))
775+ # Per-row alpha: partial (YTD) years are muted to avoid suggesting
776+ # they're directly comparable to full years.
777+ alphas = [0.35 if p else 1.0 for p in partials ]
778+ # Squared edges — rounding stacked segments leaves gaps at the
779+ # junction.
780+ for i , a in enumerate (alphas ):
781+ ax .barh (y [i ], ret_vals [i ], color = "#8E5CE6" , height = 0.55 , alpha = a )
782+ ax .barh (y [i ], new_vals [i ], left = ret_vals [i ], color = "#36B37E" ,
783+ height = 0.55 , alpha = a )
784+
785+ x_pad = max (totals .max () * 0.015 , 0.5 )
786+ for i , (n , r , t , p ) in enumerate (zip (new_vals , ret_vals , totals , partials )):
787+ text_color_inner = "white"
788+ text_color_outer = DARK
789+ if p :
790+ # Inner labels become dark grey on muted bars so they stay
791+ # readable against the lighter fill.
792+ text_color_inner = "#555555"
793+ text_color_outer = "#555555"
794+ if r > 0 :
795+ ax .text (r / 2 , i , str (int (r )), ha = "center" , va = "center" ,
796+ color = text_color_inner , fontsize = 12 , fontweight = "bold" )
797+ if n > 0 :
798+ ax .text (r + n / 2 , i , str (int (n )), ha = "center" , va = "center" ,
799+ color = text_color_inner , fontsize = 12 , fontweight = "bold" )
800+ ax .text (t + x_pad , i , f"{ int (t )} total" ,
801+ va = "center" , fontsize = 12 , color = text_color_outer ,
802+ fontweight = "bold" ,
803+ fontstyle = "italic" if p else "normal" )
804+
805+ ax .set_yticks (y )
806+ ax .set_yticklabels (labels , fontsize = 12 )
807+ for tick , p in zip (ax .get_yticklabels (), partials ):
808+ if p :
809+ tick .set_fontstyle ("italic" )
810+ tick .set_color ("#555555" )
811+ ax .invert_yaxis ()
812+ ax .set_xlim (right = totals .max () * 1.28 )
813+
814+ ax .set_xticks ([])
815+ ax .xaxis .set_visible (False )
816+ for spine in ("top" , "right" , "bottom" ):
817+ ax .spines [spine ].set_visible (False )
818+ ax .grid (False )
819+
820+ adj = {"pull_requests" : "PR" , "issues" : "Issue" , "discussions" : "Discussion" }.get (table , table )
821+ ax .set_title (
822+ f"Unique { adj } contributors by year" ,
823+ fontsize = 18 , pad = 14 ,
824+ )
825+ from matplotlib .patches import Patch
826+ ax .legend (
827+ handles = [
828+ Patch (facecolor = "#8E5CE6" , label = "Returning" ),
829+ Patch (facecolor = "#36B37E" , label = "New" ),
830+ ],
831+ loc = "lower right" , frameon = False ,
743832 )
744833
745834 plt .tight_layout ()
746835 plt .savefig (output_path )
747836 logging .info (f"Saved plot to { output_path } " )
748837 plt .close ()
749838 except Exception as e :
750- logging .warning (f"[{ table } ] Could not generate unique-contributors plot: { e } " )
839+ logging .warning (f"[{ table } ] Could not generate yearly contributors chart: { e } " )
840+
841+
842+ def update_yearly_contributors_table (path , table , trends_md_path ):
843+ """Compute per-year unique-contributor stats (total / new / returning)
844+ and rewrite the section in trends/{repo}.md between the markers
845+ <!-- AUTO:yearly-contributors:start --> and ...:end -->."""
846+ try :
847+ rows = _yearly_contributor_rows (path )
848+ if not rows :
849+ return
850+
851+ lines = [
852+ "| Year | Unique | New | Returning |" ,
853+ "|------|--------|-----|-----------|" ,
854+ ]
855+ for _ , label , total , new_c , ret_c , _partial in rows :
856+ lines .append (f"| { label } | { total } | { new_c } | { ret_c } |" )
857+ table_md = "\n " .join (lines )
858+
859+ marker_start = "<!-- AUTO:yearly-contributors:start -->"
860+ marker_end = "<!-- AUTO:yearly-contributors:end -->"
861+
862+ if not trends_md_path .exists ():
863+ logging .warning (
864+ f"[{ table } ] Trends file not found, skipping yearly table: { trends_md_path } "
865+ )
866+ return
867+ content = trends_md_path .read_text ()
868+ if marker_start not in content or marker_end not in content :
869+ logging .warning (
870+ f"[{ table } ] Markers not found in { trends_md_path } ; "
871+ f"add a section bracketed by { marker_start } / { marker_end } "
872+ )
873+ return
874+
875+ import re
876+ pattern = re .compile (
877+ re .escape (marker_start ) + r".*?" + re .escape (marker_end ),
878+ re .DOTALL ,
879+ )
880+ replacement = f"{ marker_start } \n \n { table_md } \n \n { marker_end } "
881+ content = pattern .sub (replacement , content )
882+ trends_md_path .write_text (content )
883+ logging .info (f"Updated yearly-contributors table in { trends_md_path } " )
884+ except Exception as e :
885+ logging .warning (f"[{ table } ] Could not update yearly-contributors table: { e } " )
751886
752887
753888if __name__ == "__main__" :
0 commit comments