Skip to content

Commit f2e73f7

Browse files
ctruedenclaude
andcommitted
Improve team command behavior
* lead role behavior — is_lead logic removed from _workload.py. The lead role now only causes a developer to appear in the table (they're counted in components), but doesn't inherit PRs/issues/bugs/features/releases from other roles. The note "Lead developers are counted in all role columns" is gone from the HTML. * Configurable role names — New TeamConfig dataclass with six role keys (lead, developer, debugger, reviewer, support, maintainer), each defaulting to its own name as a single-element list. Set globally in [team] or per-component in [components."g:a"], accepting either a string or list of strings. * [team] includes/excludes — TeamConfig has includes and excludes list fields, parsed from [team] in pombast.toml, using the same ComponentFilter glob logic as [smelt]. * Components popup — The Components count cell is now clickable (using the same modal as the other cells). Each component links to its project URL if available, or shows the G:A as plain text. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent c7c2749 commit f2e73f7

5 files changed

Lines changed: 115 additions & 32 deletions

File tree

src/pombast/cli/_team.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -247,7 +247,7 @@ def _gh_progress(fetched: int, total: int) -> None:
247247
console.print(f" Got stats for {len(repo_stats)} repos.")
248248

249249
# Phase 4: aggregate and display
250-
workload_rows = build_workloads(entries, dev_roles, repo_stats)
250+
workload_rows = build_workloads(entries, dev_roles, repo_stats, pombast_config)
251251

252252
table = Table(title="Team Workload", show_lines=False)
253253
table.add_column("Developer", style="cyan", no_wrap=True)

src/pombast/config/_settings.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,15 @@
1212
import tomli as tomllib
1313

1414

15+
def _parse_role_value(value: object, default: str) -> list[str]:
16+
"""Coerce a TOML role value (str or list[str]) to list[str]."""
17+
if isinstance(value, str):
18+
return [value]
19+
if isinstance(value, list):
20+
return [str(v) for v in value]
21+
return [default]
22+
23+
1524
def parse_repo_spec(spec: str, fallback_id: str) -> tuple[str, str]:
1625
"""Parse 'id=url' or bare 'url'; return (repo_id, url)."""
1726
name, sep, url = spec.partition("=")
@@ -35,6 +44,27 @@ class FilterConfig:
3544
excludes: list[str] = field(default_factory=list)
3645

3746

47+
_TEAM_ROLE_KEYS = ("lead", "developer", "debugger", "reviewer", "support", "maintainer")
48+
49+
50+
@dataclass
51+
class TeamConfig:
52+
"""Configuration for the team command, including role mappings."""
53+
54+
includes: list[str] = field(default_factory=list)
55+
excludes: list[str] = field(default_factory=list)
56+
lead: list[str] = field(default_factory=lambda: ["lead"])
57+
developer: list[str] = field(default_factory=lambda: ["developer"])
58+
debugger: list[str] = field(default_factory=lambda: ["debugger"])
59+
reviewer: list[str] = field(default_factory=lambda: ["reviewer"])
60+
support: list[str] = field(default_factory=lambda: ["support"])
61+
maintainer: list[str] = field(default_factory=lambda: ["maintainer"])
62+
63+
def role_mapping(self) -> dict[str, list[str]]:
64+
"""Return {semantic_key: [pom_role_strings]} for all role keys."""
65+
return {key: getattr(self, key) for key in _TEAM_ROLE_KEYS}
66+
67+
3868
@dataclass
3969
class StatusConfig:
4070
"""Configuration for the status command."""
@@ -71,6 +101,7 @@ class PombastConfig:
71101
component_overrides: dict[str, dict[str, object]] = field(default_factory=dict)
72102
mega_melt: MegaMeltConfig = field(default_factory=MegaMeltConfig)
73103
status: StatusConfig = field(default_factory=StatusConfig)
104+
team: TeamConfig = field(default_factory=TeamConfig)
74105

75106
@classmethod
76107
def load(cls, path: Path) -> PombastConfig:
@@ -116,6 +147,17 @@ def resolve(section: dict, key: str) -> Path | None:
116147
nexus_base=status_data.get("nexus-base", ""),
117148
)
118149

150+
team_data = data.get("team", {})
151+
team_config = TeamConfig(
152+
includes=team_data.get("includes", []),
153+
excludes=team_data.get("excludes", []),
154+
**{
155+
key: _parse_role_value(team_data[key], key)
156+
for key in _TEAM_ROLE_KEYS
157+
if key in team_data
158+
},
159+
)
160+
119161
return cls(
120162
filter=filter_config,
121163
default_java=int(default_java) if default_java is not None else None,
@@ -126,6 +168,7 @@ def resolve(section: dict, key: str) -> Path | None:
126168
component_overrides={k: v for k, v in data.get("components", {}).items()},
127169
mega_melt=mega_melt_config,
128170
status=status_config,
171+
team=team_config,
129172
)
130173

131174
@classmethod

src/pombast/team/_html.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,9 @@ def _row_data(r: DeveloperRow) -> dict:
5252
"maintainer_releases": [
5353
{"ga": i.ga, "url": i.url} for i in r.maintainer_release_items
5454
],
55+
"components": [
56+
{"ga": ga, "url": url} for ga, url in r.component_url_items
57+
],
5558
}
5659
return {
5760
"dev_link": _dev_link(r),
@@ -63,7 +66,6 @@ def _row_data(r: DeveloperRow) -> dict:
6366
"maintainer_releases": r.maintainer_releases,
6467
"total": r.total,
6568
"component_count": len(r.components),
66-
"components": sorted(r.components),
6769
}
6870

6971
row_list = [_row_data(r) for r in rows]

src/pombast/team/_workload.py

Lines changed: 59 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -6,21 +6,11 @@
66
from typing import TYPE_CHECKING
77

88
if TYPE_CHECKING:
9+
from pombast.config._settings import PombastConfig
910
from pombast.status._entry import StatusEntry
1011
from pombast.team._github import RepoItem, RepoStats
1112
from pombast.team._pom_devs import Developer
1213

13-
# Roles that represent ongoing maintenance responsibility.
14-
# "founder" is excluded — it's historical credit, not active duty.
15-
MAINTENANCE_ROLES = {
16-
"lead",
17-
"developer",
18-
"debugger",
19-
"reviewer",
20-
"support",
21-
"maintainer",
22-
}
23-
2414

2515
@dataclass
2616
class ReleaseItem:
@@ -38,6 +28,7 @@ class DeveloperRow:
3828
_features: dict[str, RepoItem] = field(default_factory=dict)
3929
_releases: dict[str, ReleaseItem] = field(default_factory=dict) # ga → item
4030
components: list[str] = field(default_factory=list)
31+
_component_urls: dict[str, str] = field(default_factory=dict) # ga → project URL
4132

4233
@property
4334
def reviewer_prs(self) -> int:
@@ -79,6 +70,10 @@ def maintainer_releases(self) -> int:
7970
def maintainer_release_items(self) -> list[ReleaseItem]:
8071
return list(self._releases.values())
8172

73+
@property
74+
def component_url_items(self) -> list[tuple[str, str]]:
75+
return [(ga, self._component_urls.get(ga, "")) for ga in sorted(self.components)]
76+
8277
@property
8378
def total(self) -> int:
8479
return (
@@ -90,21 +85,63 @@ def total(self) -> int:
9085
)
9186

9287

88+
def _effective_role_mapping(
89+
ga: str,
90+
pombast_config: PombastConfig,
91+
) -> dict[str, set[str]]:
92+
"""Return {semantic_key: {pom_role_strings}} for this component.
93+
94+
Starts from global [team] role mappings and applies per-component overrides
95+
from [components."g:a"] sections.
96+
"""
97+
base: dict[str, set[str]] = {
98+
key: set(vals) for key, vals in pombast_config.team.role_mapping().items()
99+
}
100+
ov = pombast_config.component_overrides.get(ga, {})
101+
for key in base:
102+
if key in ov:
103+
val = ov[key]
104+
base[key] = {val} if isinstance(val, str) else set(str(v) for v in val)
105+
return base
106+
107+
108+
def _semantic_roles(
109+
pom_roles: set[str],
110+
mapping: dict[str, set[str]],
111+
) -> set[str]:
112+
"""Map POM role strings to semantic role keys using the given mapping."""
113+
return {key for key, pom_names in mapping.items() if pom_roles & pom_names}
114+
115+
93116
def build_workloads(
94117
entries: list[StatusEntry],
95118
dev_roles: dict[str, list[tuple[Developer, set[str]]]],
96119
repo_stats: dict[str, RepoStats],
120+
pombast_config: PombastConfig | None = None,
97121
) -> list[DeveloperRow]:
98122
"""Build per-developer workload rows sorted by total workload descending.
99123
100124
Args:
101125
entries: StatusEntry list from query_status (provides release status per component).
102126
dev_roles: G:A → [(Developer, roles)] from component POM <developers> sections.
103127
repo_stats: GitHub repo slug → RepoStats from the GitHub search API.
128+
pombast_config: Full pombast config (for role mappings, team includes/excludes).
104129
"""
130+
from pombast.config._settings import PombastConfig
131+
from pombast.core._filter import ComponentFilter
132+
133+
if pombast_config is None:
134+
pombast_config = PombastConfig.empty()
135+
136+
team_cfg = pombast_config.team
137+
team_filter = ComponentFilter(includes=team_cfg.includes, excludes=team_cfg.excludes)
138+
105139
rows: dict[str, DeveloperRow] = {}
106140

107141
for entry in entries:
142+
if not team_filter.is_included(entry.component):
143+
continue
144+
108145
ga = entry.component.ga
109146
url = entry.project_url or ""
110147
slug = (
@@ -115,8 +152,11 @@ def build_workloads(
115152
stats = repo_stats.get(slug) if slug else None
116153
needs_release = entry.action == "Cut"
117154

118-
for dev, roles in dev_roles.get(ga, []):
119-
if not (roles & MAINTENANCE_ROLES):
155+
role_mapping = _effective_role_mapping(ga, pombast_config)
156+
157+
for dev, pom_roles in dev_roles.get(ga, []):
158+
semantic = _semantic_roles(pom_roles, role_mapping)
159+
if not semantic:
120160
continue
121161

122162
if dev.id not in rows:
@@ -125,24 +165,23 @@ def build_workloads(
125165

126166
if ga not in row.components:
127167
row.components.append(ga)
128-
129-
is_lead = "lead" in roles
168+
row._component_urls.setdefault(ga, url)
130169

131170
if stats:
132-
if is_lead or "reviewer" in roles:
171+
if "reviewer" in semantic:
133172
for item in stats.prs:
134173
row._prs.setdefault(item.url, item)
135-
if is_lead or "support" in roles:
174+
if "support" in semantic:
136175
for item in stats.issues:
137176
row._issues.setdefault(item.url, item)
138-
if is_lead or "debugger" in roles:
177+
if "debugger" in semantic:
139178
for item in stats.bugs:
140179
row._bugs.setdefault(item.url, item)
141-
if is_lead or "developer" in roles:
180+
if "developer" in semantic:
142181
for item in stats.enhancements:
143182
row._features.setdefault(item.url, item)
144183

145-
if needs_release and (is_lead or "maintainer" in roles):
184+
if needs_release and "maintainer" in semantic:
146185
row._releases.setdefault(ga, ReleaseItem(ga=ga, url=url))
147186

148187
return sorted(rows.values(), key=lambda r: r.total, reverse=True)

src/pombast/team/templates/team.html.j2

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ td.has-popup:hover { filter: brightness(1.3); }
4747
<th class="role-header" title="Open issues labeled &quot;enhancement&quot;, excluding unscheduled milestone (developer role)">Features&nbsp;requested</th>
4848
<th class="role-header" title="Components with unreleased commits (maintainer role)">Releases&nbsp;needed</th>
4949
<th class="role-header" title="Sum of all role columns">Total</th>
50-
<th class="role-header" title="Number of BOM components this developer is involved with (hover for list)">Components</th>
50+
<th class="role-header" title="Number of BOM components this developer is involved with (click for list)">Components</th>
5151
</tr>
5252
{%- for row in rows %}
5353
<tr data-dev="{{ row.dev_id }}">
@@ -58,15 +58,14 @@ td.has-popup:hover { filter: brightness(1.3); }
5858
<td class="{{ 'nonzero has-popup' if row.developer_features > 0 else 'zero' }}" data-col="developer_features">{{ row.developer_features }}</td>
5959
<td class="{{ 'nonzero has-popup' if row.maintainer_releases > 0 else 'zero' }}" data-col="maintainer_releases">{{ row.maintainer_releases }}</td>
6060
<td class="total-cell">{{ row.total }}</td>
61-
<td title="{{ row.components | join(', ') }}">{{ row.component_count }}</td>
61+
<td class="{{ 'has-popup' if row.component_count > 0 else '' }}" data-col="components">{{ row.component_count }}</td>
6262
</tr>
6363
{%- endfor %}
6464
</table>
6565
<p style="margin: 1em; font-size: 0.8em; color: #777;">
66-
<b>Note:</b> Lead developers are counted in all role columns for each of their components.
67-
GitHub data excludes draft PRs and issues labeled &ldquo;question&rdquo;.
66+
<b>Note:</b> GitHub data excludes draft PRs and issues labeled &ldquo;question&rdquo;.
6867
Enhancement counts exclude issues with milestone &ldquo;unscheduled&rdquo;.
69-
Click any nonzero cell to see the contributing items.
68+
Click any nonzero cell to see the contributing items; click the Components count to see all associated components.
7069
</p>
7170
{%- if popup_data_json %}
7271
<div id="team-modal">
@@ -91,7 +90,8 @@ td.has-popup:hover { filter: brightness(1.3); }
9190
support_issues: 'Issues to answer',
9291
debugger_bugs: 'Bugs to fix',
9392
developer_features: 'Features requested',
94-
maintainer_releases: 'Releases needed'
93+
maintainer_releases: 'Releases needed',
94+
components: 'Components'
9595
};
9696
9797
function show(devId, col, devName) {
@@ -101,11 +101,10 @@ td.has-popup:hover { filter: brightness(1.3); }
101101
list.innerHTML = '';
102102
items.forEach(function(item) {
103103
var li = document.createElement('li');
104-
if (col === 'maintainer_releases') {
105-
var repoLabel = item.url ? item.url.replace('https://github.com/', '') : item.ga;
104+
if (col === 'maintainer_releases' || col === 'components') {
106105
li.innerHTML = item.url
107-
? '<a href="' + item.url + '/commits" target="_blank">' + item.ga + '</a>'
108-
: item.ga;
106+
? '<a href="' + (col === 'maintainer_releases' ? item.url + '/commits' : item.url) + '" target="_blank">' + escHtml(item.ga) + '</a>'
107+
: escHtml(item.ga);
109108
} else {
110109
li.innerHTML = '<a href="' + item.url + '" target="_blank">#' + item.number + '' + escHtml(item.title) + '</a>'
111110
+ '<span class="popup-repo">' + escHtml(item.repo) + '</span>';

0 commit comments

Comments
 (0)