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())