Skip to content

Commit 5f6b14e

Browse files
per capita
1 parent f174db0 commit 5f6b14e

7 files changed

Lines changed: 373 additions & 11 deletions

File tree

cli/team_config.py

Lines changed: 154 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
"""Team mapping configuration for developer-to-team assignment."""
22

3+
import logging
34
import re
5+
from datetime import date, timedelta
46
from pathlib import Path
5-
from typing import Dict, Optional
7+
from typing import Dict, List, Optional
68

79
import yaml
810

11+
logger = logging.getLogger(__name__)
12+
913

1014
def _parse_team_assignments_text(content: str) -> Dict[str, str]:
1115
"""
@@ -159,3 +163,152 @@ def get_team_for_repo(owner: str, repo: str, mapping: Optional[Dict[str, str]] =
159163
Kept for backward compatibility; returns empty string.
160164
"""
161165
return ""
166+
167+
168+
# ---------------------------------------------------------------------------
169+
# Developer tenure (start/end dates) for headcount calculations
170+
# ---------------------------------------------------------------------------
171+
172+
_TENURE_FILENAME = "developer-tenure.yaml"
173+
174+
175+
def _parse_date_field(val: object) -> Optional[date]:
176+
if not val:
177+
return None
178+
try:
179+
return date.fromisoformat(str(val))
180+
except (ValueError, TypeError):
181+
return None
182+
183+
184+
def _parse_leaves(raw_leaves: object) -> List[Dict[str, date]]:
185+
"""Parse a list of ``{from: ..., to: ...}`` leave ranges."""
186+
if not isinstance(raw_leaves, list):
187+
return []
188+
result: List[Dict[str, date]] = []
189+
for entry in raw_leaves:
190+
if not isinstance(entry, dict):
191+
continue
192+
leave_from = _parse_date_field(entry.get("from"))
193+
leave_to = _parse_date_field(entry.get("to"))
194+
if leave_from and leave_to:
195+
result.append({"from": leave_from, "to": leave_to})
196+
return result
197+
198+
199+
def load_developer_tenure(
200+
cwd: Optional[Path] = None,
201+
) -> Dict[str, dict]:
202+
"""Load developer start/end dates and leave ranges from developer-tenure.yaml.
203+
204+
Returns dict keyed by GitHub username::
205+
206+
{"alice": {"start": date(2024,1,1), "end": None, "leaves": [...]}, ...}
207+
208+
Each leave entry is ``{"from": date, "to": date}``.
209+
"""
210+
base = cwd or Path.cwd()
211+
path = base / _TENURE_FILENAME
212+
if not path.exists():
213+
logger.warning("%s not found – headcount will fall back to CSV-derived counts", _TENURE_FILENAME)
214+
return {}
215+
try:
216+
data = yaml.safe_load(path.read_text(encoding="utf-8"))
217+
except Exception as exc:
218+
logger.warning("Failed to parse %s: %s", _TENURE_FILENAME, exc)
219+
return {}
220+
if not isinstance(data, dict):
221+
return {}
222+
devs_raw = data.get("developers", data)
223+
if not isinstance(devs_raw, dict):
224+
return {}
225+
226+
result: Dict[str, dict] = {}
227+
for username, info in devs_raw.items():
228+
if not isinstance(info, dict):
229+
continue
230+
start_date = _parse_date_field(info.get("start"))
231+
if start_date is None:
232+
continue
233+
result[str(username).strip()] = {
234+
"start": start_date,
235+
"end": _parse_date_field(info.get("end")),
236+
"leaves": _parse_leaves(info.get("leaves")),
237+
}
238+
return result
239+
240+
241+
def _is_on_leave(week_start: date, week_end: date, leaves: List[Dict[str, date]]) -> bool:
242+
"""True if any leave range fully covers the week (from <= week_start and to >= week_end)."""
243+
for lv in leaves:
244+
if lv["from"] <= week_start and lv["to"] >= week_end:
245+
return True
246+
return False
247+
248+
249+
def get_active_headcount(
250+
week_start: date,
251+
team: Optional[str] = None,
252+
tenure: Optional[Dict[str, dict]] = None,
253+
team_mapping: Optional[Dict[str, str]] = None,
254+
) -> int:
255+
"""Return the number of developers active during the week starting at *week_start*.
256+
257+
A developer is active if ``start <= week_end`` and (``end`` is None or
258+
``end >= week_start``) and they are not on leave for the entire week.
259+
260+
Args:
261+
week_start: Monday of the ISO week.
262+
team: If given, only count developers belonging to this team.
263+
tenure: Pre-loaded tenure dict (from :func:`load_developer_tenure`).
264+
team_mapping: Pre-loaded team mapping (from :func:`load_team_mapping`).
265+
"""
266+
if tenure is None:
267+
tenure = load_developer_tenure()
268+
if team_mapping is None:
269+
team_mapping = load_team_mapping()
270+
271+
week_end = week_start + timedelta(days=6)
272+
count = 0
273+
for dev, info in tenure.items():
274+
start = info["start"]
275+
end = info.get("end")
276+
if start is None or start > week_end:
277+
continue
278+
if end is not None and end < week_start:
279+
continue
280+
if team is not None and team_mapping.get(dev, "") != team:
281+
continue
282+
if _is_on_leave(week_start, week_end, info.get("leaves", [])):
283+
continue
284+
count += 1
285+
return count
286+
287+
288+
def get_weekly_headcounts(
289+
weeks: List[date],
290+
teams: Optional[List[str]] = None,
291+
cwd: Optional[Path] = None,
292+
) -> Dict[str, List[int]]:
293+
"""Compute headcount for each week, for 'All Teams' and each team.
294+
295+
Returns::
296+
297+
{"All Teams": [12, 13, ...], "Core": [5, 5, ...], ...}
298+
"""
299+
tenure = load_developer_tenure(cwd)
300+
team_mapping = load_team_mapping(cwd)
301+
302+
if teams is None:
303+
teams = sorted({t for t in team_mapping.values() if t and t != "Bots"})
304+
305+
result: Dict[str, List[int]] = {"All Teams": []}
306+
for t in teams:
307+
result[t] = []
308+
309+
for w in weeks:
310+
result["All Teams"].append(get_active_headcount(w, team=None, tenure=tenure, team_mapping=team_mapping))
311+
for t in teams:
312+
result[t].append(get_active_headcount(w, team=t, tenure=tenure, team_mapping=team_mapping))
313+
314+
return result

developer-tenure.yaml

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
# Developer employment/tenure dates.
2+
# start: first day the developer was active (required)
3+
# end: last day (omit or leave null if still active)
4+
# leaves: optional list of absence ranges (maternity, sabbatical, etc.)
5+
# each entry has "from" and "to" dates (inclusive)
6+
#
7+
# Example with leave:
8+
# alice:
9+
# start: "2023-01-15"
10+
# leaves:
11+
# - { from: "2025-06-01", to: "2025-09-30" } # maternity
12+
#
13+
# Used by the "Velocity Per Capita" chart to compute active headcount
14+
# per week. Team membership comes from github-teams.cfg.
15+
16+
developers:
17+
# ── Core ──
18+
OhadPerryBoomi:
19+
start: "2025-05-01"
20+
nvgoldin:
21+
start: "2024-01-01"
22+
orhss:
23+
start: "2024-01-01"
24+
OmerBor:
25+
start: "2024-01-01"
26+
hadasdd:
27+
start: "2024-01-01"
28+
noamtzu:
29+
start: "2024-01-01"
30+
end: "2025-12-01"
31+
OronW:
32+
start: "2024-01-01"
33+
end: "2026-01-01"
34+
35+
# ── FullStack ──
36+
shiran1989:
37+
start: "2024-01-01"
38+
Morzus90:
39+
start: "2024-01-01"
40+
Inara-Rivery:
41+
start: "2024-01-01"
42+
yairabramovitch:
43+
start: "2024-01-01"
44+
leaves:
45+
- { from: "2026-02-01", to: "2026-03-01" }
46+
# FeiginNastia:
47+
# start: "2024-01-01"
48+
noam-salomon:
49+
start: "2024-01-01"
50+
51+
# ── Integration ──
52+
RonKlar90:
53+
start: "2024-01-01"
54+
OmerMordechai1:
55+
start: "2024-01-01"
56+
Lizkhrapov:
57+
start: "2024-01-01"
58+
Amichai-B:
59+
start: "2024-01-01"
60+
61+
# ── CDC ──
62+
aaronabv:
63+
start: "2024-01-01"
64+
sigalikanevsky:
65+
start: "2024-01-01"
66+
eitamring:
67+
start: "2024-01-01"
68+
Omri-Groen:
69+
start: "2024-01-01"
70+
shristiguptaa:
71+
start: "2024-01-01"
72+
73+
# ── Ninja ──
74+
pocha-vijaymohanreddy:
75+
start: "2024-01-01"
76+
mayanks-Boomi:
77+
start: "2024-01-01"
78+
Srivasu-Boomi:
79+
start: "2024-01-01"
80+
vijay-prakash-singh-dev:
81+
start: "2024-01-01"
82+
bharat-boomi:
83+
start: "2024-01-01"
84+
vs1328:
85+
start: "2024-01-01"
86+
87+
# ── Devops ──
88+
alonalmog82:
89+
start: "2024-01-01"
90+
EdenReuveniRivery:
91+
start: "2024-01-01"
92+
leaves:
93+
- { from: "2025-12-01", to: "2026-05-01" }
94+
Alonreznik:
95+
start: "2024-01-01"
96+
devops-rivery:
97+
start: "2024-01-01"
98+
# Mikeygoldman1:
99+
# start: "2024-01-01"
100+
RavikiranDK:
101+
start: "2024-01-01"
102+
Chen-Poli:
103+
start: "2024-01-01"
104+
end: "2025-09-01"
105+
livninoam:
106+
start: "2024-01-01"
107+
end: "2025-07-01"
108+

reports/basic/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""Basic operational reports (1-3, 7, 18, 19)."""
1+
"""Basic operational reports (1-3, 7, 18, 19, 22)."""
22

33
from .reports import (
44
report_avg_complexity_rolling as report_avg_complexity_rolling,
@@ -7,4 +7,5 @@
77
report_complexity_volume_over_time as report_complexity_volume_over_time,
88
report_high_complexity_frequency as report_high_complexity_frequency,
99
report_pr_count_vs_complexity as report_pr_count_vs_complexity,
10+
report_velocity_per_capita as report_velocity_per_capita,
1011
)

reports/basic/reports.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import matplotlib.pyplot as plt
88
import pandas as pd
99

10+
from cli.team_config import get_weekly_headcounts
1011
from reports.validation import has_plottable_agg, has_plottable_series, validate_png_has_content
1112

1213

@@ -203,3 +204,61 @@ def report_high_complexity_frequency(df: pd.DataFrame, output_dir: Path) -> Opti
203204
fig.savefig(out, dpi=150, bbox_inches="tight")
204205
plt.close(fig)
205206
return str(out) if validate_png_has_content(out) else None
207+
208+
209+
def report_velocity_per_capita(df: pd.DataFrame, output_dir: Path) -> Optional[str]:
210+
"""Report 22: Velocity Per Capita – weekly complexity / active headcount."""
211+
df = _ensure_date(df)
212+
if df.empty:
213+
return None
214+
df = df.copy()
215+
df["week"] = pd.to_datetime(df["date"]).dt.to_period("W").dt.start_time
216+
weekly_vel = df.groupby("week")["complexity"].sum().sort_index()
217+
if not has_plottable_series(weekly_vel, min_points=2):
218+
return None
219+
220+
week_dates = [w.date() for w in weekly_vel.index]
221+
df["team"] = df.get("team", pd.Series([""] * len(df))).fillna("").replace("", "Unknown")
222+
teams = sorted(t for t in df["team"].unique() if t and t != "Unknown")
223+
headcounts = get_weekly_headcounts(week_dates, teams=teams or None)
224+
225+
fig, ax = plt.subplots(figsize=(14, 6))
226+
227+
hc_all = headcounts.get("All Teams", [])
228+
if hc_all:
229+
per_capita = [
230+
float(weekly_vel.iloc[i]) / max(h, 1) for i, h in enumerate(hc_all)
231+
]
232+
ax.plot(weekly_vel.index, per_capita, "k-o", markersize=4, linewidth=2, label="All Teams")
233+
234+
for team_name in teams:
235+
team_weekly = (
236+
df[df["team"] == team_name]
237+
.groupby("week")["complexity"]
238+
.sum()
239+
.reindex(weekly_vel.index, fill_value=0)
240+
)
241+
hc_team = headcounts.get(team_name, [])
242+
if not hc_team:
243+
continue
244+
per_capita_team = [
245+
float(team_weekly.iloc[i]) / max(h, 1) for i, h in enumerate(hc_team)
246+
]
247+
ax.plot(weekly_vel.index, per_capita_team, "-o", markersize=3, label=team_name)
248+
249+
ax.set_title(
250+
"Velocity Per Capita (by Week)\n"
251+
"What: Complexity output per active developer. When: Normalize for headcount changes. "
252+
"How: Total complexity / active developers each week."
253+
)
254+
ax.set_ylabel("Complexity / Developer")
255+
ax.set_xlabel("Week")
256+
ax.legend(loc="upper left", fontsize=8, ncol=2)
257+
ax.tick_params(axis="x", rotation=45)
258+
ax.xaxis.set_major_formatter(mdates.DateFormatter("%Y-%m-%d"))
259+
ax.set_ylim(bottom=0)
260+
fig.tight_layout()
261+
out = output_dir / "22-velocity-per-capita.png"
262+
fig.savefig(out, dpi=150, bbox_inches="tight")
263+
plt.close(fig)
264+
return str(out) if validate_png_has_content(out) else None

0 commit comments

Comments
 (0)