Skip to content

Commit eb701be

Browse files
feat: add average merge cycle time reports and enhance output organization
This commit introduces new reporting functionalities and improves output structure: - Added `report_avg_merge_cycle_time` and `report_avg_merge_cycle_time_by_team` to analyze average merge cycle times. - Updated `reports/team/__init__.py` and `reports/core/__init__.py` to include the new reports. - Enhanced the output organization by creating an `overview` directory for report images, ensuring better file management. - Updated `.gitignore` to track newly generated report images. These changes enhance the reporting capabilities, providing deeper insights into merge efficiency across teams.
1 parent 34873ef commit eb701be

6 files changed

Lines changed: 109 additions & 11 deletions

File tree

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ cache/
5959
# Reports output (generated charts, migration logs) - keep reports/*.py tracked
6060
reports/*.log
6161
reports/core/*.png
62-
reports/team/*.png
62+
reports/team/**/*.png
6363
reports/risk/*.png
6464
reports/fairness/*.png
6565
reports/advanced/*.png

reports/core/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from .reports import (
44
report_avg_complexity_rolling,
5+
report_avg_merge_cycle_time,
56
report_complexity_volume_by_month,
67
report_complexity_volume_over_time,
78
report_high_complexity_frequency,

reports/core/reports.py

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -135,21 +135,65 @@ def report_avg_complexity_rolling(df: pd.DataFrame, output_dir: Path) -> Optiona
135135
return str(out) if validate_png_has_content(out) else None
136136

137137

138+
def report_avg_merge_cycle_time(df: pd.DataFrame, output_dir: Path) -> Optional[str]:
139+
"""Report: Average Merge Cycle Time (created_at → merged_at) by week."""
140+
if "created_at" not in df.columns or "merged_at" not in df.columns:
141+
return None
142+
df = df.copy()
143+
df = df.dropna(subset=["created_at", "merged_at"])
144+
df["cycle_hours"] = (
145+
pd.to_datetime(df["merged_at"]) - pd.to_datetime(df["created_at"])
146+
).dt.total_seconds() / 3600
147+
df = df[df["cycle_hours"] >= 0]
148+
if df.empty:
149+
return None
150+
merged = pd.to_datetime(df["merged_at"])
151+
if merged.dt.tz is not None:
152+
merged = merged.dt.tz_localize(None, ambiguous="infer")
153+
df["week"] = merged.dt.to_period("W").dt.start_time
154+
weekly = df.groupby("week")["cycle_hours"].mean()
155+
if not has_plottable_series(weekly):
156+
return None
157+
fig, ax = plt.subplots(figsize=(12, 6))
158+
ax.plot(weekly.index, weekly.values, "b-o", markersize=4)
159+
ax.set_title(
160+
"Average Merge Cycle Time (by Week)\n"
161+
"What: Time from PR creation to merge. When: Process health. How: created_at → merged_at in hours."
162+
)
163+
ax.set_ylabel("Avg Cycle Time (hours)")
164+
ax.set_xlabel("Week (merge date)")
165+
ax.tick_params(axis="x", rotation=45)
166+
ax.xaxis.set_major_formatter(mdates.DateFormatter("%Y-%m-%d"))
167+
ax.set_ylim(bottom=0)
168+
fig.tight_layout()
169+
out = output_dir / "19-avg-merge-cycle-time.png"
170+
fig.savefig(out, dpi=150, bbox_inches="tight")
171+
plt.close(fig)
172+
return str(out) if validate_png_has_content(out) else None
173+
174+
138175
def report_high_complexity_frequency(df: pd.DataFrame, output_dir: Path) -> Optional[str]:
139-
"""Report 7: High Complexity PR Frequency (% PRs >= 8 per team)."""
176+
"""Report 7: High Complexity PR Frequency (% PRs >= 6 per team)."""
140177
df = df.copy()
141178
df["team"] = df.get("team", pd.Series([""] * len(df))).fillna("").replace("", "Unknown")
142-
high = df[df["complexity"] >= 8]
179+
df = df[df["team"] != "Unknown"]
180+
if df.empty:
181+
return None
182+
high = df[df["complexity"] >= 6]
143183
total = df.groupby("team").size()
144184
high_count = high.groupby("team").size()
145185
pct = (high_count.reindex(total.index, fill_value=0) / total * 100).fillna(0)
146186
if total.sum() == 0 or not has_plottable_series(total):
147187
return None
188+
if not has_plottable_series(pct):
189+
return None
148190
fig, ax = plt.subplots(figsize=(10, 6))
149191
pct.plot(kind="bar", ax=ax, color="coral", edgecolor="darkred")
192+
ax.set_ylim(bottom=0)
150193
ax.set_title(
151-
"% High-Risk PRs (complexity >= 8) per Team\n"
152-
"What: Share of risky PRs per team. When: Risk review. How: High % = more review focus needed."
194+
"% High-Risk PRs (complexity >= 6) per Team\n"
195+
"What: Share of risky PRs per team. When: Risk review. How: High % = more review focus needed. "
196+
"Note: Threshold 6 used for meaningful distribution (8+ often yields sparse data)."
153197
)
154198
ax.set_ylabel("% of PRs")
155199
ax.set_xlabel("Team")

reports/runner.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,10 +69,12 @@ def run_reports(
6969
from reports.core import report_complexity_volume_over_time
7070
from reports.core import report_pr_count_vs_complexity
7171
from reports.core import report_avg_complexity_rolling
72+
from reports.core import report_avg_merge_cycle_time
7273
from reports.core import report_high_complexity_frequency
7374
from reports.team import report_complexity_distribution_by_team
7475
from reports.team import report_developer_contribution
7576
from reports.team import report_complexity_per_dev_vs_pr_count
77+
from reports.team import report_avg_merge_cycle_time_by_team
7678
from reports.team import report_complexity_vs_cycle_time
7779
from reports.team import report_complexity_per_team_per_dev
7880
from reports.team import report_team_gini
@@ -89,10 +91,12 @@ def run_reports(
8991
(report_complexity_volume_by_month, "core"),
9092
(report_pr_count_vs_complexity, "core"),
9193
(report_avg_complexity_rolling, "core"),
94+
(report_avg_merge_cycle_time, "core"),
9295
(report_high_complexity_frequency, "core"),
9396
(report_complexity_distribution_by_team, "team"),
9497
(report_developer_contribution, "team"),
9598
(report_complexity_per_dev_vs_pr_count, "team"),
99+
(report_avg_merge_cycle_time_by_team, "team"),
96100
(report_complexity_vs_cycle_time, "team"),
97101
(report_complexity_per_team_per_dev, "team"),
98102
(report_team_gini, "team"),

reports/team/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Team-level leadership reports (4-6, 12, 14, 17)."""
22

33
from .reports import (
4+
report_avg_merge_cycle_time_by_team,
45
report_complexity_distribution_by_team,
56
report_complexity_per_dev_vs_pr_count,
67
report_complexity_per_team_per_dev,

reports/team/reports.py

Lines changed: 54 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
151193
def 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

Comments
 (0)