From 1a023818f4526c3b985dca07831cf97b672f37d8 Mon Sep 17 00:00:00 2001 From: Brad Stein Date: Fri, 17 Apr 2026 04:53:52 -0300 Subject: [PATCH] quality: publish bstein-home platform hygiene metrics --- Jenkinsfile | 2 + testing/ci/publish_metrics.py | 20 ++++++++++ testing/ci/quality_gate.py | 56 +++++++++++++++++++++++++-- testing/ci/summary.py | 34 ++++++++++++++-- testing/tests/test_publish_metrics.py | 2 + 5 files changed, 108 insertions(+), 6 deletions(-) diff --git a/Jenkinsfile b/Jenkinsfile index 9b9958d..1d1e932 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -269,6 +269,7 @@ spec: --suite "${SUITE_NAME}" \ --job platform-quality-ci \ --status ok \ + --quality-report build/quality-gate.json \ --junit build/junit-backend.xml build/junit-frontend-unit.xml build/junit-frontend-component.xml build/junit-frontend-e2e.xml ''' } @@ -282,6 +283,7 @@ spec: --suite "${SUITE_NAME}" \ --job platform-quality-ci \ --status failed \ + --quality-report build/quality-gate.json \ --junit build/junit-backend.xml build/junit-frontend-unit.xml build/junit-frontend-component.xml build/junit-frontend-e2e.xml ''' } diff --git a/testing/ci/publish_metrics.py b/testing/ci/publish_metrics.py index 8b3959b..f6fd30f 100644 --- a/testing/ci/publish_metrics.py +++ b/testing/ci/publish_metrics.py @@ -3,6 +3,7 @@ from __future__ import annotations """Command-line entry point for publishing CI test metrics.""" import argparse +import json from pathlib import Path from .summary import load_junit_summary, publish_quality_metrics @@ -17,21 +18,40 @@ def _build_parser() -> argparse.ArgumentParser: parser.add_argument("--job", default="platform-quality-ci", help="Pushgateway job label") parser.add_argument("--status", choices=("ok", "failed"), required=True, help="Gate outcome") parser.add_argument("--junit", nargs="*", default=(), help="JUnit XML files to aggregate") + parser.add_argument("--quality-report", default="build/quality-gate.json", help="Quality gate JSON report") return parser +def _load_quality_report(path: Path) -> tuple[float, int]: + """Read workspace coverage/LOC summary from the quality gate JSON output.""" + + if not path.exists(): + return 0.0, 0 + payload = json.loads(path.read_text(encoding="utf-8")) + coverage = payload.get("workspace_line_coverage_percent") + if not isinstance(coverage, (int, float)): + coverage = 0.0 + source_lines = payload.get("source_lines_over_500") + if not isinstance(source_lines, int): + source_lines = 0 + return float(coverage), int(source_lines) + + def main(argv: list[str] | None = None) -> int: """Parse arguments, aggregate JUnit files, and publish metrics.""" parser = _build_parser() args = parser.parse_args(argv) summary = load_junit_summary(Path(path) for path in args.junit) + coverage_percent, source_lines_over_500 = _load_quality_report(Path(args.quality_report)) publish_quality_metrics( gateway=args.gateway, suite=args.suite, job=args.job, status=args.status, summary=summary, + workspace_line_coverage_percent=coverage_percent, + source_lines_over_500=source_lines_over_500, ) return 0 diff --git a/testing/ci/quality_gate.py b/testing/ci/quality_gate.py index 3640186..868f3ea 100644 --- a/testing/ci/quality_gate.py +++ b/testing/ci/quality_gate.py @@ -221,6 +221,41 @@ def check_coverage( return issues +def compute_workspace_line_coverage( + paths: Iterable[Path], + *, + backend_report: Path, + frontend_report: Path, +) -> float: + """Compute the mean line coverage percentage across managed coverage files.""" + + backend_cov = _load_backend_coverage(backend_report) if backend_report.exists() else {} + frontend_cov = _load_frontend_coverage(frontend_report) if frontend_report.exists() else {} + samples: list[float] = [] + + for path in paths: + if not path.exists(): + continue + rel = path.relative_to(ROOT).as_posix() if path.is_absolute() else _normalize_key(str(path)) + if rel.startswith("backend/"): + metrics = _coverage_lookup(backend_cov, rel) + if not metrics: + continue + samples.append(float(metrics.get("lines", 0.0))) + elif rel.startswith("frontend/"): + lookup = rel.split("frontend/", 1)[1] + metrics = _coverage_lookup(frontend_cov, lookup) + if not metrics: + continue + lines = metrics.get("lines") + if isinstance(lines, dict): + samples.append(float(lines.get("pct", 0.0))) + + if not samples: + return 0.0 + return round(sum(samples) / len(samples), 3) + + def _build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description="Run the repo's unified quality gate") parser.add_argument("--contract", default=str(DEFAULT_CONTRACT), help="Path to the JSON gate contract") @@ -239,15 +274,30 @@ def run_gate(contract_path: Path, *, backend_coverage: Path, frontend_coverage: threshold = float(contract.get("coverage_threshold_pct", 95)) issues: list[GateIssue] = [] - issues.extend(check_file_sizes(managed_files, max_lines=max_lines)) - issues.extend(check_docstrings(docstring_files)) - issues.extend(check_coverage(coverage_files, backend_report=backend_coverage, frontend_report=frontend_coverage, threshold=threshold)) + loc_issues = check_file_sizes(managed_files, max_lines=max_lines) + doc_issues = check_docstrings(docstring_files) + coverage_issues = check_coverage( + coverage_files, + backend_report=backend_coverage, + frontend_report=frontend_coverage, + threshold=threshold, + ) + issues.extend(loc_issues) + issues.extend(doc_issues) + issues.extend(coverage_issues) + workspace_line_coverage = compute_workspace_line_coverage( + coverage_files, + backend_report=backend_coverage, + frontend_report=frontend_coverage, + ) report = { "managed_files": [str(path.relative_to(ROOT)) for path in managed_files], "docstring_files": [str(path.relative_to(ROOT)) for path in docstring_files], "coverage_files": [str(path.relative_to(ROOT)) for path in coverage_files], "max_lines": max_lines, "coverage_threshold_pct": threshold, + "workspace_line_coverage_percent": workspace_line_coverage, + "source_lines_over_500": len(loc_issues), "issue_count": len(issues), "issues": [issue.__dict__ for issue in issues], } diff --git a/testing/ci/summary.py b/testing/ci/summary.py index 335be54..e89404d 100644 --- a/testing/ci/summary.py +++ b/testing/ci/summary.py @@ -63,7 +63,15 @@ def read_pushgateway_counters(text: str, *, suite: str, job: str) -> dict[str, f return counters -def render_payload(*, suite: str, ok: int, failed: int, summary: RunSummary) -> str: +def render_payload( + *, + suite: str, + ok: int, + failed: int, + summary: RunSummary, + workspace_line_coverage_percent: float = 0.0, + source_lines_over_500: int = 0, +) -> str: """Render the Pushgateway payload for the quality-gate counters.""" return ( @@ -75,10 +83,23 @@ def render_payload(*, suite: str, ok: int, failed: int, summary: RunSummary) -> f'bstein_home_quality_gate_tests_total{{suite="{suite}",result="failed"}} {summary.failures}\n' f'bstein_home_quality_gate_tests_total{{suite="{suite}",result="error"}} {summary.errors}\n' f'bstein_home_quality_gate_tests_total{{suite="{suite}",result="skipped"}} {summary.skipped}\n' + "# TYPE platform_quality_gate_workspace_line_coverage_percent gauge\n" + f'platform_quality_gate_workspace_line_coverage_percent{{suite="{suite}"}} {workspace_line_coverage_percent:.3f}\n' + "# TYPE platform_quality_gate_source_lines_over_500_total gauge\n" + f'platform_quality_gate_source_lines_over_500_total{{suite="{suite}"}} {int(source_lines_over_500)}\n' ) -def publish_quality_metrics(*, gateway: str, suite: str, job: str, status: str, summary: RunSummary) -> None: +def publish_quality_metrics( + *, + gateway: str, + suite: str, + job: str, + status: str, + summary: RunSummary, + workspace_line_coverage_percent: float = 0.0, + source_lines_over_500: int = 0, +) -> None: """Publish run and test totals to Pushgateway.""" gateway = gateway.rstrip("/") @@ -89,7 +110,14 @@ def publish_quality_metrics(*, gateway: str, suite: str, job: str, status: str, else: counters["failed"] += 1 - payload = render_payload(suite=suite, ok=int(counters["ok"]), failed=int(counters["failed"]), summary=summary) + payload = render_payload( + suite=suite, + ok=int(counters["ok"]), + failed=int(counters["failed"]), + summary=summary, + workspace_line_coverage_percent=workspace_line_coverage_percent, + source_lines_over_500=source_lines_over_500, + ) req = urllib.request.Request( f"{gateway}/metrics/job/{job}/suite/{suite}", data=payload.encode("utf-8"), diff --git a/testing/tests/test_publish_metrics.py b/testing/tests/test_publish_metrics.py index 1958a89..b2ae1b2 100644 --- a/testing/tests/test_publish_metrics.py +++ b/testing/tests/test_publish_metrics.py @@ -17,3 +17,5 @@ def test_load_junit_summary_combines_suites(tmp_path: Path) -> None: payload = render_payload(suite="bstein-home", ok=2, failed=0, summary=summary) assert 'platform_quality_gate_runs_total{suite="bstein-home",status="ok"} 2' in payload assert 'bstein_home_quality_gate_tests_total{suite="bstein-home",result="skipped"} 1' in payload + assert 'platform_quality_gate_workspace_line_coverage_percent{suite="bstein-home"} 0.000' in payload + assert 'platform_quality_gate_source_lines_over_500_total{suite="bstein-home"} 0' in payload