bstein-dev-home/testing/ci/publish_metrics.py

155 lines
5.6 KiB
Python

from __future__ import annotations
"""Command-line entry point for publishing CI test metrics."""
import argparse
import json
from pathlib import Path
import re
import xml.etree.ElementTree as ET
from .summary import load_junit_summary, publish_quality_metrics
def _parse_check_args(raw_checks: list[str]) -> dict[str, str]:
checks: dict[str, str] = {}
for item in raw_checks:
if ":" not in item:
continue
name, status = item.split(":", 1)
normalized_name = name.strip()
normalized_status = status.strip().lower()
if not normalized_name or normalized_status not in {"ok", "failed"}:
continue
checks[normalized_name] = normalized_status
return checks
def _derive_quality_report(path: Path) -> tuple[int, dict[str, str]]:
if not path.exists():
return 0, {}
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError:
return 0, {}
issues = payload.get("issues") if isinstance(payload, dict) else []
if not isinstance(issues, list):
return 0, {}
over_500 = 0
checks = {"docs": "ok", "loc": "ok", "coverage": "ok"}
for issue in issues:
if not isinstance(issue, dict):
continue
check_name = str(issue.get("check", ""))
if check_name == "docstring":
checks["docs"] = "failed"
elif check_name == "loc":
checks["loc"] = "failed"
over_500 += 1
elif check_name == "coverage":
checks["coverage"] = "failed"
return over_500, checks
def _load_backend_coverage_percent(path: Path) -> float:
if not path.exists():
return 0.0
try:
root = ET.parse(path).getroot()
except ET.ParseError:
return 0.0
raw = root.attrib.get("line-rate")
if raw is None:
return 0.0
try:
return float(raw) * 100.0
except ValueError:
return 0.0
def _load_frontend_coverage_percent(path: Path) -> float:
if not path.exists():
return 0.0
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError:
return 0.0
total = payload.get("total") if isinstance(payload, dict) else {}
lines = total.get("lines") if isinstance(total, dict) else {}
pct = lines.get("pct") if isinstance(lines, dict) else None
if isinstance(pct, (int, float)):
return float(pct)
if isinstance(pct, str):
match = re.search(r"[0-9]+(?:\.[0-9]+)?", pct)
if match:
return float(match.group(0))
return 0.0
def _workspace_coverage_percent(backend_path: Path, frontend_path: Path) -> float:
backend = _load_backend_coverage_percent(backend_path)
frontend = _load_frontend_coverage_percent(frontend_path)
values = [value for value in (backend, frontend) if value > 0]
if not values:
return 0.0
return sum(values) / len(values)
def _build_parser() -> argparse.ArgumentParser:
"""Build the CLI parser for the metrics publisher."""
parser = argparse.ArgumentParser(description="Publish test-suite metrics to Pushgateway")
parser.add_argument("--gateway", required=True, help="Pushgateway base URL")
parser.add_argument("--suite", required=True, help="Logical suite name")
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")
parser.add_argument("--backend-coverage", default="build/backend-coverage.xml", help="Backend coverage XML")
parser.add_argument(
"--frontend-coverage",
default="frontend/coverage/coverage-summary.json",
help="Frontend coverage summary JSON",
)
parser.add_argument("--coverage-percent", type=float, help="Override workspace coverage percent")
parser.add_argument("--source-lines-over-500", type=int, help="Override count of source files over 500 LOC")
parser.add_argument("--check", action="append", default=[], help="check_name:ok|failed")
return parser
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)
report_over_500, report_checks = _derive_quality_report(Path(args.quality_report))
source_lines_over_500 = args.source_lines_over_500 if args.source_lines_over_500 is not None else report_over_500
coverage_percent = (
args.coverage_percent
if args.coverage_percent is not None
else _workspace_coverage_percent(Path(args.backend_coverage), Path(args.frontend_coverage))
)
checks = {
"tests": "ok" if summary.tests > 0 and summary.failures == 0 and summary.errors == 0 else "failed",
"coverage": "ok" if coverage_percent >= 95.0 else "failed",
"loc": "ok" if source_lines_over_500 == 0 else "failed",
}
checks.update(report_checks)
checks.update(_parse_check_args(args.check))
publish_quality_metrics(
gateway=args.gateway,
suite=args.suite,
job=args.job,
status=args.status,
summary=summary,
coverage_percent=coverage_percent,
source_lines_over_500=source_lines_over_500,
checks=checks,
)
return 0
if __name__ == "__main__":
raise SystemExit(main())