diff --git a/Jenkinsfile b/Jenkinsfile index e45e128..129b15a 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -82,13 +82,68 @@ spec: failed_runs="$(awk -F= '$1=="failed"{print $2}' build/quality-gate.state 2>/dev/null | tail -n1)" [ -n "${ok_runs}" ] || ok_runs=0 [ -n "${failed_runs}" ] || failed_runs=0 + gate_rc="$(cat build/quality-gate.rc 2>/dev/null || echo 1)" + tests_passed=0 + tests_failed=1 + if [ "${gate_rc}" -eq 0 ]; then + tests_passed=1 + tests_failed=0 + fi + coverage_percent="$(python3 - <<'PY' +import re +from pathlib import Path + +log_path = Path("build/quality-gate.out") +text = log_path.read_text(encoding="utf-8", errors="ignore") if log_path.exists() else "" +values = [float(match.group(1)) for match in re.finditer(r"([0-9]+(?:\.[0-9]+)?)%", text)] +print(values[-1] if values else 0.0) +PY +)" + source_lines_over_500="$(python3 - <<'PY' +from pathlib import Path + +root = Path(".") +skip = {".git", ".venv", "venv", "build", "dist", "node_modules", "__pycache__", ".pytest_cache"} +suffixes = {".go", ".py", ".sh", ".json", ".yaml", ".yml"} +count = 0 +for path in root.rglob("*"): + if not path.is_file(): + continue + if any(part in skip for part in path.parts): + continue + if path.name != "Jenkinsfile" and path.suffix.lower() not in suffixes: + continue + try: + lines = sum(1 for _ in path.open("r", encoding="utf-8", errors="ignore")) + except OSError: + continue + if lines > 500: + count += 1 +print(count) +PY +)" + check_status="failed" + if [ "${gate_rc}" -eq 0 ]; then + check_status="ok" + fi python3 scripts/publish_quality_metrics.py \ --pushgateway-url "${PUSHGATEWAY_URL}" \ --job-name platform-quality-ci \ --suite "${SUITE_NAME}" \ --trigger jenkins \ --local-ok "${ok_runs}" \ - --local-failed "${failed_runs}" + --local-failed "${failed_runs}" \ + --tests-passed "${tests_passed}" \ + --tests-failed "${tests_failed}" \ + --tests-error 0 \ + --tests-skipped 0 \ + --coverage-percent "${coverage_percent}" \ + --source-lines-over-500 "${source_lines_over_500}" \ + --check "tests:${check_status}" \ + --check "hygiene:${check_status}" \ + --check "lint:${check_status}" \ + --check "coverage:${check_status}" \ + --check "gate:${check_status}" ''' } } diff --git a/scripts/publish_quality_metrics.py b/scripts/publish_quality_metrics.py index 357247b..f15e86a 100755 --- a/scripts/publish_quality_metrics.py +++ b/scripts/publish_quality_metrics.py @@ -71,14 +71,54 @@ def _fetch_existing_counter(pushgateway_url: str, metric: str, labels: dict[str, return 0.0 -def _build_payload(suite: str, trigger: str, ok_count: int, failed_count: int) -> str: +def _parse_checks(raw_checks: list[str]) -> list[tuple[str, str]]: + parsed: list[tuple[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 + parsed.append((normalized_name, normalized_status)) + return parsed + + +def _build_payload( + suite: str, + trigger: str, + ok_count: int, + failed_count: int, + tests_passed: int, + tests_failed: int, + tests_error: int, + tests_skipped: int, + coverage_percent: float, + source_lines_over_500: int, + checks: list[tuple[str, str]], +) -> str: lines = [ "# TYPE platform_quality_gate_runs_total counter", f'platform_quality_gate_runs_total{{suite="{suite}",status="ok"}} {ok_count}', f'platform_quality_gate_runs_total{{suite="{suite}",status="failed"}} {failed_count}', + "# TYPE ananke_quality_gate_tests_total gauge", + f'ananke_quality_gate_tests_total{{suite="{suite}",result="passed"}} {max(tests_passed, 0)}', + f'ananke_quality_gate_tests_total{{suite="{suite}",result="failed"}} {max(tests_failed, 0)}', + f'ananke_quality_gate_tests_total{{suite="{suite}",result="error"}} {max(tests_error, 0)}', + f'ananke_quality_gate_tests_total{{suite="{suite}",result="skipped"}} {max(tests_skipped, 0)}', + "# TYPE platform_quality_gate_workspace_line_coverage_percent gauge", + f'platform_quality_gate_workspace_line_coverage_percent{{suite="{suite}"}} {coverage_percent:.3f}', + "# TYPE platform_quality_gate_source_lines_over_500_total gauge", + f'platform_quality_gate_source_lines_over_500_total{{suite="{suite}"}} {max(source_lines_over_500, 0)}', + "# TYPE ananke_quality_gate_checks_total gauge", "# TYPE ananke_quality_gate_publish_info gauge", f'ananke_quality_gate_publish_info{_label_str({"suite": suite, "trigger": trigger})} 1', ] + for check_name, check_status in checks: + lines.append( + f'ananke_quality_gate_checks_total{{suite="{suite}",check="{_escape_label(check_name)}",result="{check_status}"}} 1' + ) return "\n".join(lines) + "\n" @@ -96,6 +136,13 @@ def parse_args(argv: list[str]) -> argparse.Namespace: parser.add_argument("--trigger", default=os.getenv("ANANKE_QUALITY_PUSHGATEWAY_TRIGGER", "host")) parser.add_argument("--local-ok", type=int, required=True) parser.add_argument("--local-failed", type=int, required=True) + parser.add_argument("--tests-passed", type=int, default=0) + parser.add_argument("--tests-failed", type=int, default=0) + parser.add_argument("--tests-error", type=int, default=0) + parser.add_argument("--tests-skipped", type=int, default=0) + parser.add_argument("--coverage-percent", type=float, default=0.0) + parser.add_argument("--source-lines-over-500", type=int, default=0) + parser.add_argument("--check", action="append", default=[], help="check_name:ok|failed") parser.add_argument( "--timeout-seconds", type=float, @@ -143,7 +190,22 @@ def main(argv: list[str] | None = None) -> int: resolved_ok = max(args.local_ok, remote_ok) resolved_failed = max(args.local_failed, remote_failed) - payload = _build_payload(args.suite, args.trigger, resolved_ok, resolved_failed) + checks = _parse_checks(args.check) + if not checks: + checks = [("gate", "ok" if args.local_failed <= 0 else "failed")] + payload = _build_payload( + args.suite, + args.trigger, + resolved_ok, + resolved_failed, + args.tests_passed, + args.tests_failed, + args.tests_error, + args.tests_skipped, + args.coverage_percent, + args.source_lines_over_500, + checks, + ) if args.dry_run: sys.stdout.write(payload) @@ -152,7 +214,11 @@ def main(argv: list[str] | None = None) -> int: push_url = f"{args.pushgateway_url.rstrip('/')}/metrics/job/{args.job_name}/suite/{args.suite}" _post_text(push_url, payload, args.timeout_seconds, max(args.attempts, 1), max(args.retry_delay_seconds, 0.0)) - summary = f"[quality] published Pushgateway metrics suite={args.suite} job={args.job_name} ok={resolved_ok} failed={resolved_failed}" + summary = ( + f"[quality] published Pushgateway metrics suite={args.suite} job={args.job_name} ok={resolved_ok} " + f"failed={resolved_failed} checks={len(checks)} coverage={args.coverage_percent:.2f} " + f"over_500={max(args.source_lines_over_500, 0)}" + ) if remote_error: summary += f" remote_read_error={remote_error}" print(summary)