Skip to content

Commit 08e0dc6

Browse files
authored
feat: add repository-level CODEOWNERS coverage chart (hiero-hackers#265)
* feat: add repository-level CODEOWNERS coverage chart Signed-off-by: aashu2006 <akshatp439@gmail.com> * fix lint by ruff check Signed-off-by: aashu2006 <akshatp439@gmail.com> * fix: aggregate duplicate repository records in CODEOWNERS summary Signed-off-by: aashu2006 <akshatp439@gmail.com> --------- Signed-off-by: aashu2006 <akshatp439@gmail.com>
1 parent 5e6df6b commit 08e0dc6

9 files changed

Lines changed: 246 additions & 1 deletion

File tree

426 KB
Loading
-2.44 KB
Loading
-28 KB
Loading

src/hiero_analytics/analysis/codeowner_workflow_analysis.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,32 @@ def prepare_repo_level_codeowner_summary(codeowners: list[CodeOwnersRecord]) ->
3030
)
3131

3232

33+
def prepare_stacked_codeowner_summary(codeowners: list[CodeOwnersRecord]) -> pd.DataFrame:
34+
"""Aggregates CODEOWNERS presence per repository for stacked bar chart visualization.
35+
36+
If duplicate repository entries exist, a presence-wins policy is applied (i.e.
37+
if any entry for a repository is marked as Present, the repository is resolved
38+
as Present).
39+
"""
40+
if not codeowners:
41+
return pd.DataFrame(columns=["repo", "Present", "Missing"])
42+
43+
repo_status: dict[str, bool] = {}
44+
for r in codeowners:
45+
repo_status[r.repo] = repo_status.get(r.repo, False) or r.status
46+
47+
rows = [
48+
{
49+
"repo": repo,
50+
"Present": 1 if has_owners else 0,
51+
"Missing": 0 if has_owners else 1,
52+
}
53+
for repo, has_owners in repo_status.items()
54+
]
55+
56+
return pd.DataFrame(rows)
57+
58+
3359
def runner_records_to_dataframe(runners: list[RunnerRecord]) -> pd.DataFrame:
3460
"""Converts a list of RunnerRecords into DataFrame."""
3561
return records_to_dataframe(

src/hiero_analytics/config/charts.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,3 +158,9 @@
158158
"text_light": "#FFFFFF",
159159
"tick": "#64748B",
160160
}
161+
162+
# Compliance / Codeowners status colors.
163+
CODEOWNER_STATUS_COLORS = {
164+
"Present": "#2A9D8F", # teal
165+
"Missing": "#E76F51", # coral
166+
}

src/hiero_analytics/dashboard_spec.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,7 @@
211211
"description": "CODEOWNERS coverage and the CI runners configured across repos.",
212212
"files": [
213213
("Code-owner coverage", "org_codeowner_summary.png"),
214+
("Code-owner coverage by repo", "org_codeowner_by_repo.png"),
214215
("Runners", "org_runner_chart.png"),
215216
],
216217
},
@@ -671,6 +672,9 @@
671672
"Code-Review, Branch-Protection), so you can see which practices contribute."
672673
),
673674
"org_codeowner_summary.png": ("How many repositories have a CODEOWNERS file (Present) versus none (Missing)."),
675+
"org_codeowner_by_repo.png": (
676+
"CODEOWNERS file presence per repository. Teal indicates Present, while coral indicates Missing."
677+
),
674678
"org_runner_chart.png": (
675679
"GitHub Actions runner usage per repository, stacked by type: self-hosted, standard "
676680
"(GitHub-hosted), or indeterminate (could not be classified)."
@@ -793,4 +797,8 @@
793797
"Sum the weighted score per repository per month — each event counts once.",
794798
"Rank repositories by total, show the busiest 25, and colour each cell by its monthly score.",
795799
],
800+
"org_codeowner_by_repo.png": [
801+
"Check each repository for the presence of a CODEOWNERS file in standard locations (root, .github/, or docs/).",
802+
"Represent presence/absence per repository as a stacked bar chart showing 100% compliance status (Present vs. Missing).",
803+
],
796804
}

src/hiero_analytics/run_codeowner_and_runner.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@
55
from hiero_analytics.analysis.codeowner_workflow_analysis import (
66
prepare_org_codeowners_summary,
77
prepare_repo_level_codeowner_summary,
8+
prepare_stacked_codeowner_summary,
89
prepare_stacked_runner_summary,
910
runner_records_to_dataframe,
1011
)
12+
from hiero_analytics.config.charts import CODEOWNER_STATUS_COLORS
1113
from hiero_analytics.config.logging_config import setup_logging
1214
from hiero_analytics.config.paths import ORG, ensure_org_dirs
1315
from hiero_analytics.data_sources.cache import load_records_cache, save_records_cache
@@ -145,7 +147,7 @@ def main() -> None:
145147
y_col="count",
146148
title="Organization Wide Codeowners File Summary",
147149
output_path=org_charts_dir / "org_codeowner_summary.png",
148-
colors={"Present": "#2A9D8F", "Missing": "#E76F51"},
150+
colors=CODEOWNER_STATUS_COLORS,
149151
)
150152

151153
codeowners_repo_df = prepare_repo_level_codeowner_summary(codeowners)
@@ -154,6 +156,19 @@ def main() -> None:
154156

155157
generate_codeowners_markdown_report(records=codeowners, output_path=org_data_dir / "codeowners_report.md")
156158

159+
codeowners_stacked_df = prepare_stacked_codeowner_summary(codeowners)
160+
if not codeowners_stacked_df.empty:
161+
plot_stacked_bar(
162+
df=codeowners_stacked_df,
163+
x_col="repo",
164+
stack_cols=["Present", "Missing"],
165+
labels=["Present", "Missing"],
166+
title="Repository Wide Codeowners Status Breakdown",
167+
output_path=org_charts_dir / "org_codeowner_by_repo.png",
168+
colors=CODEOWNER_STATUS_COLORS,
169+
annotate_totals=False,
170+
)
171+
157172
runners = get_workflow_for_repos(client, ORG, repos)
158173

159174
runners_df = runner_records_to_dataframe(runners)

tests/analysis/test_codeowners_worlflow_analysis.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from hiero_analytics.analysis.codeowner_workflow_analysis import (
66
prepare_org_codeowners_summary,
77
prepare_repo_level_codeowner_summary,
8+
prepare_stacked_codeowner_summary,
89
prepare_stacked_runner_summary,
910
runner_records_to_dataframe,
1011
)
@@ -115,3 +116,55 @@ def test_prepare_stacked_runner_summary_columns_exist():
115116
assert df.iloc[0]["Standard"] == 1
116117
assert df.iloc[0]["Self-Hosted"] == 0
117118
assert df.iloc[0]["Indeterminate"] == 0
119+
120+
121+
def test_prepare_stacked_codeowner_summary_empty():
122+
"""Test empty list returns empty DataFrame with correct columns."""
123+
df = prepare_stacked_codeowner_summary([])
124+
assert isinstance(df, pd.DataFrame)
125+
assert list(df.columns) == ["repo", "Present", "Missing"]
126+
assert df.empty
127+
128+
129+
def test_prepare_stacked_codeowner_summary_aggregation():
130+
"""Verify correct mapping of boolean status to Present/Missing columns."""
131+
records = [
132+
CodeOwnersRecord(repo="repo-a", status=True),
133+
CodeOwnersRecord(repo="repo-b", status=False),
134+
]
135+
df = prepare_stacked_codeowner_summary(records)
136+
137+
assert len(df) == 2
138+
139+
# repo-a check (status=True -> Present=1, Missing=0)
140+
row_a = df[df["repo"] == "repo-a"].iloc[0]
141+
assert row_a["Present"] == 1
142+
assert row_a["Missing"] == 0
143+
144+
# repo-b check (status=False -> Present=0, Missing=1)
145+
row_b = df[df["repo"] == "repo-b"].iloc[0]
146+
assert row_b["Present"] == 0
147+
assert row_b["Missing"] == 1
148+
149+
150+
def test_prepare_stacked_codeowner_summary_duplicates():
151+
"""Verify that duplicate repo entries are aggregated with a presence-wins policy."""
152+
records = [
153+
CodeOwnersRecord(repo="repo-a", status=False),
154+
CodeOwnersRecord(repo="repo-a", status=True),
155+
CodeOwnersRecord(repo="repo-b", status=False),
156+
CodeOwnersRecord(repo="repo-b", status=False),
157+
]
158+
df = prepare_stacked_codeowner_summary(records)
159+
160+
assert len(df) == 2
161+
162+
# repo-a check (status False and True -> resolved as Present=1, Missing=0)
163+
row_a = df[df["repo"] == "repo-a"].iloc[0]
164+
assert row_a["Present"] == 1
165+
assert row_a["Missing"] == 0
166+
167+
# repo-b check (status False and False -> resolved as Present=0, Missing=1)
168+
row_b = df[df["repo"] == "repo-b"].iloc[0]
169+
assert row_b["Present"] == 0
170+
assert row_b["Missing"] == 1
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
"""Integration tests for the codeowner and runner analytics pipeline."""
2+
3+
from __future__ import annotations
4+
5+
import os
6+
from pathlib import Path
7+
from unittest.mock import MagicMock
8+
9+
import matplotlib
10+
import pytest
11+
12+
matplotlib.use("Agg")
13+
14+
import hiero_analytics.run_codeowner_and_runner as runner
15+
from hiero_analytics.data_sources.models import (
16+
CodeOwnersRecord,
17+
RepositoryRecord,
18+
RunnerRecord,
19+
)
20+
21+
22+
@pytest.fixture
23+
def mock_github_client():
24+
"""Mock GitHubClient."""
25+
return MagicMock()
26+
27+
28+
@pytest.fixture
29+
def synthetic_repos():
30+
"""Synthetic repository data."""
31+
return [
32+
RepositoryRecord(
33+
full_name="hiero-ledger/repo1",
34+
name="repo1",
35+
owner="hiero-ledger",
36+
),
37+
RepositoryRecord(
38+
full_name="hiero-ledger/repo2",
39+
name="repo2",
40+
owner="hiero-ledger",
41+
),
42+
]
43+
44+
45+
@pytest.fixture
46+
def synthetic_codeowners():
47+
"""Synthetic CODEOWNERS data."""
48+
return [
49+
CodeOwnersRecord(repo="repo1", status=True),
50+
CodeOwnersRecord(repo="repo2", status=False),
51+
]
52+
53+
54+
@pytest.fixture
55+
def synthetic_runners():
56+
"""Synthetic runner data."""
57+
return [
58+
RunnerRecord(
59+
repo="repo1",
60+
workflow_file="ci.yml",
61+
job_name="test-job-1",
62+
runner="self-hosted",
63+
is_self_hosted=True,
64+
),
65+
RunnerRecord(
66+
repo="repo2",
67+
workflow_file="ci.yml",
68+
job_name="test-job-2",
69+
runner="ubuntu-latest",
70+
is_self_hosted=False,
71+
),
72+
]
73+
74+
75+
def test_main_creates_output_files(
76+
tmp_path: Path,
77+
monkeypatch: pytest.MonkeyPatch,
78+
mock_github_client,
79+
synthetic_repos,
80+
synthetic_codeowners,
81+
synthetic_runners,
82+
):
83+
"""Running main() should create expected chart and data files."""
84+
# Redirect paths to tmp_path
85+
monkeypatch.setattr(
86+
"hiero_analytics.run_codeowner_and_runner.ensure_org_dirs",
87+
lambda _org: (tmp_path / "data", tmp_path / "charts"),
88+
)
89+
90+
# Mock fetch functions
91+
monkeypatch.setattr(
92+
"hiero_analytics.run_codeowner_and_runner.fetch_org_repos",
93+
lambda _client, _org: synthetic_repos,
94+
)
95+
monkeypatch.setattr(
96+
"hiero_analytics.run_codeowner_and_runner.get_codeowners_for_repos",
97+
lambda _client, _org, _repos: synthetic_codeowners,
98+
)
99+
monkeypatch.setattr(
100+
"hiero_analytics.run_codeowner_and_runner.get_workflow_for_repos",
101+
lambda _client, _org, _repos: synthetic_runners,
102+
)
103+
104+
# Mock GitHubClient initialization
105+
monkeypatch.setattr(
106+
"hiero_analytics.run_codeowner_and_runner.GitHubClient",
107+
lambda: mock_github_client,
108+
)
109+
110+
# Run the main function
111+
runner.main()
112+
113+
# Assert expected output files exist
114+
charts_dir = tmp_path / "charts"
115+
data_dir = tmp_path / "data"
116+
117+
expected_charts = [
118+
"org_codeowner_summary.png",
119+
"org_codeowner_by_repo.png",
120+
"org_runner_chart.png",
121+
]
122+
expected_data = [
123+
"repo_wise_codeowner_status.csv",
124+
"org_runner_status.csv",
125+
"codeowners_report.md",
126+
"runner_report.md",
127+
]
128+
129+
for chart_file in expected_charts:
130+
chart_path = charts_dir / chart_file
131+
assert chart_path.exists(), f"Chart {chart_file} not created"
132+
assert os.path.getsize(chart_path) > 0, f"Chart {chart_file} is empty"
133+
134+
for data_file in expected_data:
135+
data_path = data_dir / data_file
136+
assert data_path.exists(), f"Data file {data_file} not created"
137+
assert os.path.getsize(data_path) > 0, f"Data file {data_file} is empty"

0 commit comments

Comments
 (0)