quality: publish bstein-home platform hygiene metrics

This commit is contained in:
Brad Stein 2026-04-17 04:53:52 -03:00
parent 8245e1aaa7
commit 1a023818f4
5 changed files with 108 additions and 6 deletions

2
Jenkinsfile vendored
View File

@ -269,6 +269,7 @@ spec:
--suite "${SUITE_NAME}" \ --suite "${SUITE_NAME}" \
--job platform-quality-ci \ --job platform-quality-ci \
--status ok \ --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 --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}" \ --suite "${SUITE_NAME}" \
--job platform-quality-ci \ --job platform-quality-ci \
--status failed \ --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 --junit build/junit-backend.xml build/junit-frontend-unit.xml build/junit-frontend-component.xml build/junit-frontend-e2e.xml
''' '''
} }

View File

@ -3,6 +3,7 @@ from __future__ import annotations
"""Command-line entry point for publishing CI test metrics.""" """Command-line entry point for publishing CI test metrics."""
import argparse import argparse
import json
from pathlib import Path from pathlib import Path
from .summary import load_junit_summary, publish_quality_metrics 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("--job", default="platform-quality-ci", help="Pushgateway job label")
parser.add_argument("--status", choices=("ok", "failed"), required=True, help="Gate outcome") 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("--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 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: def main(argv: list[str] | None = None) -> int:
"""Parse arguments, aggregate JUnit files, and publish metrics.""" """Parse arguments, aggregate JUnit files, and publish metrics."""
parser = _build_parser() parser = _build_parser()
args = parser.parse_args(argv) args = parser.parse_args(argv)
summary = load_junit_summary(Path(path) for path in args.junit) 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( publish_quality_metrics(
gateway=args.gateway, gateway=args.gateway,
suite=args.suite, suite=args.suite,
job=args.job, job=args.job,
status=args.status, status=args.status,
summary=summary, summary=summary,
workspace_line_coverage_percent=coverage_percent,
source_lines_over_500=source_lines_over_500,
) )
return 0 return 0

View File

@ -221,6 +221,41 @@ def check_coverage(
return issues 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: def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Run the repo's unified quality gate") 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") 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)) threshold = float(contract.get("coverage_threshold_pct", 95))
issues: list[GateIssue] = [] issues: list[GateIssue] = []
issues.extend(check_file_sizes(managed_files, max_lines=max_lines)) loc_issues = check_file_sizes(managed_files, max_lines=max_lines)
issues.extend(check_docstrings(docstring_files)) doc_issues = check_docstrings(docstring_files)
issues.extend(check_coverage(coverage_files, backend_report=backend_coverage, frontend_report=frontend_coverage, threshold=threshold)) 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 = { report = {
"managed_files": [str(path.relative_to(ROOT)) for path in managed_files], "managed_files": [str(path.relative_to(ROOT)) for path in managed_files],
"docstring_files": [str(path.relative_to(ROOT)) for path in docstring_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], "coverage_files": [str(path.relative_to(ROOT)) for path in coverage_files],
"max_lines": max_lines, "max_lines": max_lines,
"coverage_threshold_pct": threshold, "coverage_threshold_pct": threshold,
"workspace_line_coverage_percent": workspace_line_coverage,
"source_lines_over_500": len(loc_issues),
"issue_count": len(issues), "issue_count": len(issues),
"issues": [issue.__dict__ for issue in issues], "issues": [issue.__dict__ for issue in issues],
} }

View File

@ -63,7 +63,15 @@ def read_pushgateway_counters(text: str, *, suite: str, job: str) -> dict[str, f
return counters 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.""" """Render the Pushgateway payload for the quality-gate counters."""
return ( 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="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="error"}} {summary.errors}\n'
f'bstein_home_quality_gate_tests_total{{suite="{suite}",result="skipped"}} {summary.skipped}\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.""" """Publish run and test totals to Pushgateway."""
gateway = gateway.rstrip("/") gateway = gateway.rstrip("/")
@ -89,7 +110,14 @@ def publish_quality_metrics(*, gateway: str, suite: str, job: str, status: str,
else: else:
counters["failed"] += 1 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( req = urllib.request.Request(
f"{gateway}/metrics/job/{job}/suite/{suite}", f"{gateway}/metrics/job/{job}/suite/{suite}",
data=payload.encode("utf-8"), data=payload.encode("utf-8"),

View File

@ -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) 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 '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 '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