@@ -54,7 +54,9 @@ def report_complexity_distribution_by_team(df: pd.DataFrame, output_dir: Path) -
5454 plt .suptitle ("" )
5555 ax .tick_params (axis = "x" , rotation = 45 )
5656 fig .tight_layout ()
57- out = output_dir / "04-complexity-distribution-by-team.png"
57+ overview_dir = output_dir / "overview"
58+ overview_dir .mkdir (parents = True , exist_ok = True )
59+ out = overview_dir / "04-complexity-distribution-by-team.png"
5860 fig .savefig (out , dpi = 150 , bbox_inches = "tight" )
5961 plt .close (fig )
6062 return str (out ) if validate_png_has_content (out ) else None
@@ -99,7 +101,9 @@ def report_developer_contribution(df: pd.DataFrame, output_dir: Path) -> Optiona
99101 ax .legend (bbox_to_anchor = (1.02 , 1 ), ncol = 2 )
100102 fig .tight_layout ()
101103 safe_team = "" .join (c if c .isalnum () or c in "-_" else "_" for c in team )
102- out = output_dir / f"05-developer-contribution-{ safe_team } .png"
104+ team_dir = output_dir / safe_team
105+ team_dir .mkdir (parents = True , exist_ok = True )
106+ out = team_dir / "05-developer-contribution.png"
103107 fig .savefig (out , dpi = 150 , bbox_inches = "tight" )
104108 plt .close (fig )
105109 if validate_png_has_content (out ):
@@ -140,14 +144,52 @@ def report_complexity_per_dev_vs_pr_count(df: pd.DataFrame, output_dir: Path) ->
140144 ax .set_ylabel ("Total Complexity" )
141145 fig .tight_layout ()
142146 safe_team = "" .join (c if c .isalnum () or c in "-_" else "_" for c in team )
143- out = output_dir / f"06-complexity-per-dev-vs-pr-count-{ safe_team } .png"
147+ team_dir = output_dir / safe_team
148+ team_dir .mkdir (parents = True , exist_ok = True )
149+ out = team_dir / "06-complexity-per-dev-vs-pr-count.png"
144150 fig .savefig (out , dpi = 150 , bbox_inches = "tight" )
145151 plt .close (fig )
146152 if validate_png_has_content (out ):
147153 generated .append (str (out ))
148154 return generated if generated else None
149155
150156
157+ def report_avg_merge_cycle_time_by_team (df : pd .DataFrame , output_dir : Path ) -> Optional [str ]:
158+ """Report: Average Merge Cycle Time per Team (created_at → merged_at)."""
159+ if "created_at" not in df .columns or "merged_at" not in df .columns :
160+ return None
161+ df = df .copy ()
162+ df ["team" ] = df .get ("team" , pd .Series (["" ] * len (df ))).fillna ("" ).replace ("" , "Unknown" )
163+ df = df [df ["team" ] != "Unknown" ]
164+ df = df .dropna (subset = ["created_at" , "merged_at" ])
165+ df ["cycle_hours" ] = (
166+ pd .to_datetime (df ["merged_at" ]) - pd .to_datetime (df ["created_at" ])
167+ ).dt .total_seconds () / 3600
168+ df = df [df ["cycle_hours" ] >= 0 ]
169+ if df .empty :
170+ return None
171+ team_avg = df .groupby ("team" )["cycle_hours" ].mean ().sort_values (ascending = False )
172+ if not has_plottable_series (team_avg ):
173+ return None
174+ fig , ax = plt .subplots (figsize = (10 , 6 ))
175+ team_avg .plot (kind = "bar" , ax = ax , color = "teal" , edgecolor = "darkgreen" )
176+ ax .set_title (
177+ "Average Merge Cycle Time per Team\n "
178+ "What: Time from PR creation to merge by team. When: Process comparison. How: created_at → merged_at in hours."
179+ )
180+ ax .set_ylabel ("Avg Cycle Time (hours)" )
181+ ax .set_xlabel ("Team" )
182+ ax .tick_params (axis = "x" , rotation = 45 )
183+ ax .set_ylim (bottom = 0 )
184+ fig .tight_layout ()
185+ overview_dir = output_dir / "overview"
186+ overview_dir .mkdir (parents = True , exist_ok = True )
187+ out = overview_dir / "20-avg-merge-cycle-time-by-team.png"
188+ fig .savefig (out , dpi = 150 , bbox_inches = "tight" )
189+ plt .close (fig )
190+ return str (out ) if validate_png_has_content (out ) else None
191+
192+
151193def report_complexity_vs_cycle_time (df : pd .DataFrame , output_dir : Path ) -> Optional [str ]:
152194 """Report 14: Complexity vs Cycle Time - scatter (known teams only)."""
153195 if "created_at" not in df .columns or "merged_at" not in df .columns :
@@ -180,7 +222,9 @@ def report_complexity_vs_cycle_time(df: pd.DataFrame, output_dir: Path) -> Optio
180222 ax .set_xlabel ("Complexity" )
181223 ax .set_ylabel ("Cycle Time (hours)" )
182224 fig .tight_layout ()
183- out = output_dir / "14-complexity-vs-cycle-time.png"
225+ overview_dir = output_dir / "overview"
226+ overview_dir .mkdir (parents = True , exist_ok = True )
227+ out = overview_dir / "14-complexity-vs-cycle-time.png"
184228 fig .savefig (out , dpi = 150 , bbox_inches = "tight" )
185229 plt .close (fig )
186230 return str (out ) if validate_png_has_content (out ) else None
@@ -211,7 +255,9 @@ def report_complexity_per_team_per_dev(df: pd.DataFrame, output_dir: Path) -> Op
211255 ax .set_xlabel ("Team" )
212256 ax .tick_params (axis = "x" , rotation = 45 )
213257 fig .tight_layout ()
214- out = output_dir / "17-complexity-per-team-per-dev.png"
258+ overview_dir = output_dir / "overview"
259+ overview_dir .mkdir (parents = True , exist_ok = True )
260+ out = overview_dir / "17-complexity-per-team-per-dev.png"
215261 fig .savefig (out , dpi = 150 , bbox_inches = "tight" )
216262 plt .close (fig )
217263 return str (out ) if validate_png_has_content (out ) else None
@@ -237,7 +283,9 @@ def report_team_gini(df: pd.DataFrame, output_dir: Path) -> Optional[str]:
237283 ax .set_xlabel ("Team" )
238284 ax .tick_params (axis = "x" , rotation = 45 )
239285 fig .tight_layout ()
240- out = output_dir / "12-team-gini.png"
286+ overview_dir = output_dir / "overview"
287+ overview_dir .mkdir (parents = True , exist_ok = True )
288+ out = overview_dir / "12-team-gini.png"
241289 fig .savefig (out , dpi = 150 , bbox_inches = "tight" )
242290 plt .close (fig )
243291 return str (out ) if validate_png_has_content (out ) else None
0 commit comments