| |
| """ |
| Unified Quality Gate Enforcement Script for Atom CI/CD |
| |
| This script enforces all quality gates in the CI pipeline: |
| - Coverage gate: 80% line coverage, 70% branch coverage minimum |
| - Pass rate gate: 98% minimum pass rate |
| - Regression gate: No more than 5% coverage drop from baseline |
| - Flaky test gate: Warn if >5% flaky, fail if >10% flaky |
| - Main branch gate: 80% overall coverage enforced on main branch merges |
| |
| Usage: |
| python ci_quality_gate.py |
| python ci_quality_gate.py --coverage-min 85 --pass-rate-min 99 |
| python ci_quality_gate.py --strict |
| python ci_quality_gate.py --main-branch-min 80 --aggregated |
| |
| Exit Codes: |
| 0: All gates passed |
| 1: One or more gates failed |
| 2: Error in execution |
| """ |
|
|
| import argparse |
| import json |
| import os |
| import sys |
| from pathlib import Path |
| from typing import Dict, List, Tuple, Optional |
|
|
|
|
| |
| DEFAULT_COVERAGE_LINE_MIN = 75.0 |
| DEFAULT_COVERAGE_BRANCH_MIN = 60.0 |
| DEFAULT_PASS_RATE_MIN = 90.0 |
| DEFAULT_REGRESSION_THRESHOLD = 10.0 |
| DEFAULT_FLAKY_WARN = 10.0 |
| DEFAULT_FLAKY_FAIL = 20.0 |
| DEFAULT_MAIN_BRANCH_MIN = 75.0 |
| DEFAULT_BACKEND_WEIGHT = 0.7 |
| DEFAULT_FRONTEND_WEIGHT = 0.3 |
|
|
|
|
| def is_main_branch_merge() -> bool: |
| """Check if current CI run is for main branch push (not PR).""" |
| ref = os.getenv("GITHUB_REF", "") |
| return ref == "refs/heads/main" or ref.startswith("refs/tags/") |
|
|
|
|
| def has_coverage_exception_label() -> bool: |
| """Check for !coverage-exception PR label via GitHub API.""" |
| |
| event_name = os.getenv("GITHUB_EVENT_NAME", "") |
| if event_name != "pull_request": |
| return False |
|
|
| |
| |
| |
| return os.getenv("COVERAGE_EXCEPTION", "false").lower() == "true" |
|
|
|
|
| def load_backend_coverage(coverage_file) -> float: |
| """Load backend coverage from pytest coverage.json format.""" |
| coverage_path = Path(coverage_file) if not isinstance(coverage_file, Path) else coverage_file |
|
|
| if not coverage_path.exists(): |
| return 0.0 |
|
|
| try: |
| with open(coverage_path) as f: |
| data = json.load(f) |
| totals = data.get("totals", {}) |
| return totals.get("percent_covered", 0.0) |
| except (json.JSONDecodeError, IOError): |
| return 0.0 |
|
|
|
|
| def load_frontend_coverage(coverage_file) -> float: |
| """Load frontend coverage from Jest coverage-final.json format.""" |
| coverage_path = Path(coverage_file) if not isinstance(coverage_file, Path) else coverage_file |
|
|
| if not coverage_path.exists(): |
| return 0.0 |
|
|
| try: |
| with open(coverage_path) as f: |
| data = json.load(f) |
|
|
| total_statements = 0 |
| covered_statements = 0 |
|
|
| for file_path, file_data in data.items(): |
| if "node_modules" in file_path or "__tests__" in file_path: |
| continue |
| statements = file_data.get("s", {}) |
| for stmt_id, count in statements.items(): |
| total_statements += 1 |
| if count > 0: |
| covered_statements += 1 |
|
|
| return (covered_statements / total_statements * 100) if total_statements > 0 else 0.0 |
| except (json.JSONDecodeError, IOError): |
| return 0.0 |
|
|
|
|
| def check_aggregated_coverage( |
| backend_cov_file, |
| frontend_cov_file, |
| weights: Tuple[float, float] = (DEFAULT_BACKEND_WEIGHT, DEFAULT_FRONTEND_WEIGHT) |
| ) -> Tuple[float, float, float, bool, str]: |
| """ |
| Check aggregated coverage (backend + frontend) with weighted average. |
| |
| Args: |
| backend_cov_file: Path to backend coverage.json |
| frontend_cov_file: Path to frontend coverage-final.json |
| weights: Tuple of (backend_weight, frontend_weight) |
| |
| Returns: |
| (overall_pct, backend_pct, frontend_pct, passed, message) |
| """ |
| backend_cov = load_backend_coverage(backend_cov_file) |
| frontend_cov = load_frontend_coverage(frontend_cov_file) |
|
|
| overall = (backend_cov * weights[0]) + (frontend_cov * weights[1]) |
|
|
| passed = overall >= 80.0 |
| message = ( |
| f"Aggregated: {overall:.2f}% " |
| f"(Backend: {backend_cov:.2f}%, Frontend: {frontend_cov:.2f}%, " |
| f"Weights: {weights[0]*100:.0f}%/{weights[1]*100:.0f}%)" |
| ) |
|
|
| return overall, backend_cov, frontend_cov, passed, message |
|
|
|
|
| def check_main_branch_coverage_gate( |
| backend_cov_file: Path, |
| frontend_cov_file: Path, |
| min_coverage: float = DEFAULT_MAIN_BRANCH_MIN |
| ) -> Tuple[bool, str]: |
| """ |
| Check main branch coverage gate (enforced only on main branch merges). |
| |
| Args: |
| backend_cov_file: Path to backend coverage.json |
| frontend_cov_file: Path to frontend coverage-final.json |
| min_coverage: Minimum overall coverage percentage |
| |
| Returns: |
| (passed, message) tuple |
| """ |
| |
| if not is_main_branch_merge(): |
| return True, "SKIP - Not on main branch (PR or other branch)" |
|
|
| |
| if has_coverage_exception_label(): |
| return True, "PASS - Coverage exception label present (!coverage-exception)" |
|
|
| |
| overall, backend_cov, frontend_cov, passed, message = check_aggregated_coverage( |
| backend_cov_file, |
| frontend_cov_file |
| ) |
|
|
| if not passed: |
| message = ( |
| f"FAIL - {message}, below {min_coverage:.0f}% threshold. " |
| f"Add !coverage-exception label to bypass." |
| ) |
| return False, message |
|
|
| return True, message |
|
|
|
|
| def check_coverage_gate( |
| coverage_file, |
| line_min, |
| branch_min |
| ): |
| """ |
| Check coverage gate against minimum thresholds. |
| |
| Args: |
| coverage_file: Path to coverage.json |
| line_min: Minimum line coverage percentage |
| branch_min: Minimum branch coverage percentage |
| |
| Returns: |
| (passed, message) tuple |
| """ |
| if not coverage_file.exists(): |
| return False, f"FAIL - Coverage file not found: {coverage_file}" |
|
|
| try: |
| with open(coverage_file, 'r') as f: |
| data = json.load(f) |
| except (json.JSONDecodeError, IOError) as e: |
| return False, f"FAIL - Error reading coverage file: {e}" |
|
|
| |
| totals = data.get("totals", {}) |
| line_coverage = totals.get("percent_covered", 0.0) |
| branch_coverage = totals.get("percent_branches_covered", 0.0) |
|
|
| |
| line_passed = line_coverage >= line_min |
| branch_passed = branch_coverage >= branch_min |
|
|
| if line_passed and branch_passed: |
| message = ( |
| f"PASS - Line: {line_coverage:.2f}% (>= {line_min:.0f}%), " |
| f"Branch: {branch_coverage:.2f}% (>= {branch_min:.0f}%)" |
| ) |
| return True, message |
| else: |
| failures = [] |
| if not line_passed: |
| failures.append(f"Line coverage {line_coverage:.2f}% < {line_min:.0f}%") |
| if not branch_passed: |
| failures.append(f"Branch coverage {branch_coverage:.2f}% < {branch_min:.0f}%") |
|
|
| message = f"FAIL - " + ", ".join(failures) |
| return False, message |
|
|
|
|
| def check_pass_rate_gate( |
| health_file, |
| pass_rate_min |
| ): |
| """ |
| Check pass rate gate against minimum threshold. |
| |
| Args: |
| health_file: Path to test_health.json |
| pass_rate_min: Minimum pass rate percentage |
| |
| Returns: |
| (passed, message) tuple |
| """ |
| if not health_file.exists(): |
| return False, f"WARN - Test health file not found: {health_file} (skipping pass rate check)" |
|
|
| try: |
| with open(health_file, 'r') as f: |
| data = json.load(f) |
| except (json.JSONDecodeError, IOError) as e: |
| return False, f"WARN - Error reading health file: {e} (skipping pass rate check)" |
|
|
| |
| history = data.get("pass_rate_history", []) |
| if not history: |
| return False, "WARN - No pass rate history found (skipping pass rate check)" |
|
|
| latest = history[-1] |
| pass_rate = latest.get("pass_rate", 0.0) |
| total_tests = latest.get("total_tests", 0) |
| passed = latest.get("passed", 0) |
| failed = latest.get("failed", 0) |
|
|
| if pass_rate >= pass_rate_min: |
| message = ( |
| f"PASS - {pass_rate:.2f}% (>= {pass_rate_min:.0f}%), " |
| f"{passed}/{total_tests} tests passed" |
| ) |
| return True, message |
| else: |
| message = ( |
| f"FAIL - {pass_rate:.2f}% < {pass_rate_min:.0f}%, " |
| f"{failed}/{total_tests} tests failed" |
| ) |
| return False, message |
|
|
|
|
| def check_regression_gate( |
| trending_file, |
| coverage_file, |
| regression_threshold |
| ): |
| """ |
| Check coverage regression gate against baseline. |
| |
| Args: |
| trending_file: Path to trending.json |
| coverage_file: Path to coverage.json (for current coverage) |
| regression_threshold: Maximum allowed coverage drop percentage |
| |
| Returns: |
| (passed, message) tuple |
| """ |
| if not trending_file.exists(): |
| return False, "WARN - Trending file not found (skipping regression check)" |
|
|
| try: |
| with open(trending_file, 'r') as f: |
| trending_data = json.load(f) |
| except (json.JSONDecodeError, IOError): |
| return False, "WARN - Error reading trending file (skipping regression check)" |
|
|
| |
| baselines = trending_data.get("baselines", {}) |
| baseline = baselines.get("090-baseline") or baselines.get("v3.2") |
|
|
| if not baseline: |
| return False, "WARN - No baseline coverage found (skipping regression check)" |
|
|
| baseline_coverage = baseline.get("coverage_pct", 0.0) |
|
|
| |
| if not coverage_file.exists(): |
| return False, "WARN - Coverage file not found (skipping regression check)" |
|
|
| try: |
| with open(coverage_file, 'r') as f: |
| coverage_data = json.load(f) |
| except (json.JSONDecodeError, IOError): |
| return False, "WARN - Error reading coverage file (skipping regression check)" |
|
|
| current_coverage = coverage_data.get("totals", {}).get("percent_covered", 0.0) |
|
|
| |
| coverage_change = current_coverage - baseline_coverage |
|
|
| if coverage_change >= -regression_threshold: |
| if coverage_change >= 0: |
| message = ( |
| f"PASS - {current_coverage:.2f}% (↑{coverage_change:+.2f}% from baseline {baseline_coverage:.2f}%)" |
| ) |
| else: |
| message = ( |
| f"PASS - {current_coverage:.2f}% (↓{coverage_change:+.2f}% from baseline {baseline_coverage:.2f}%, " |
| f"within {regression_threshold:.0f}% threshold)" |
| ) |
| return True, message |
| else: |
| message = ( |
| f"FAIL - {current_coverage:.2f}% (↓{coverage_change:+.2f}% from baseline {baseline_coverage:.2f}%, " |
| f"exceeds {regression_threshold:.0f}% threshold)" |
| ) |
| return False, message |
|
|
|
|
| def check_flaky_test_gate( |
| health_file, |
| warn_threshold, |
| fail_threshold |
| ): |
| """ |
| Check flaky test gate against thresholds. |
| |
| Args: |
| health_file: Path to test_health.json |
| warn_threshold: Warning threshold for flaky test percentage |
| fail_threshold: Failure threshold for flaky test percentage |
| |
| Returns: |
| (passed, message) tuple |
| """ |
| if not health_file.exists(): |
| return True, "PASS - Test health file not found (skipping flaky test check)" |
|
|
| try: |
| with open(health_file, 'r') as f: |
| data = json.load(f) |
| except (json.JSONDecodeError, IOError): |
| return True, "PASS - Error reading health file (skipping flaky test check)" |
|
|
| |
| flaky_tests = data.get("flaky_tests", []) |
| history = data.get("pass_rate_history", []) |
|
|
| if not history: |
| return True, "PASS - No test history found (skipping flaky test check)" |
|
|
| latest = history[-1] |
| total_tests = latest.get("total_tests", 0) |
|
|
| if total_tests == 0: |
| return True, "PASS - No tests found (skipping flaky test check)" |
|
|
| flaky_count = len(flaky_tests) |
| flaky_percentage = (flaky_count / total_tests) * 100 |
|
|
| if flaky_percentage >= fail_threshold: |
| message = ( |
| f"FAIL - {flaky_count}/{total_tests} tests flaky ({flaky_percentage:.1f}% >= {fail_threshold:.0f}%)" |
| ) |
| return False, message |
| elif flaky_percentage >= warn_threshold: |
| message = ( |
| f"WARN - {flaky_count}/{total_tests} tests flaky ({flaky_percentage:.1f}% >= {warn_threshold:.0f}%)" |
| ) |
| return True, message |
| else: |
| message = ( |
| f"PASS - {flaky_count}/{total_tests} tests flaky ({flaky_percentage:.1f}% < {warn_threshold:.0f}%)" |
| ) |
| return True, message |
|
|
|
|
| def print_summary(results): |
| """ |
| Print formatted summary of all gate results. |
| |
| Args: |
| results: Dictionary mapping gate names to (passed, message) tuples |
| """ |
| print("\n" + "=" * 80) |
| print("QUALITY GATES ENFORCEMENT") |
| print("=" * 80) |
|
|
| |
| for gate_name, (passed, message) in results.items(): |
| status = "✓" if passed else "✗" |
| print(f"\n{gate_name}: {status}") |
| print(f" {message}") |
|
|
| |
| all_passed = all(passed for passed, _ in results.values()) |
|
|
| print("\n" + "=" * 80) |
| if all_passed: |
| print("OVERALL: PASS ✓") |
| print("=" * 80 + "\n") |
| else: |
| failed_gates = [ |
| name for name, (passed, _) in results.items() |
| if not passed |
| ] |
| print(f"OVERALL: FAIL ✗") |
| print(f"Failed gates: {', '.join(failed_gates)}") |
| print("=" * 80 + "\n") |
|
|
|
|
| def print_remediation(failed_gates): |
| """ |
| Print remediation steps for failed gates. |
| |
| Args: |
| failed_gates: List of failed gate names |
| """ |
| if not failed_gates: |
| return |
|
|
| print("=" * 80) |
| print("REMEDIATION STEPS") |
| print("=" * 80 + "\n") |
|
|
| remediation = { |
| "COVERAGE": """Coverage below 80%: |
| 1. Run tests locally: pytest --cov=core --cov=api --cov=tools --cov-report=term-missing |
| 2. Identify uncovered lines in the report above |
| 3. Add tests for uncovered lines (focus on high-value modules first) |
| 4. Re-run tests to verify improvement""", |
| "PASS_RATE": """Pass rate below 98%: |
| 1. Fix failing tests: pytest tests/ -v --tb=short |
| 2. Remove broken tests that cannot be fixed |
| 3. Investigate flaky tests (check timing issues, external dependencies) |
| 4. Run tests multiple times to verify stability: pytest --reruns 2""", |
| "REGRESSION": """Coverage regression detected: |
| 1. Review new code for test gaps |
| 2. Add coverage for changed functionality |
| 3. Ensure no test files were accidentally removed |
| 4. Check if coverage exclusion patterns changed""", |
| "FLAKY": """Too many flaky tests: |
| 1. Identify flaky tests in test_health.json |
| 2. Fix timing issues (add proper async coordination) |
| 3. Add mocks for external dependencies (network, databases) |
| 4. Use unique_resource_name fixture for parallel test isolation |
| 5. Remove test non-determinism (random data, timestamps)""" |
| } |
|
|
| for gate in failed_gates: |
| gate_key = gate.split()[0] |
| if gate_key in remediation: |
| print(f"\n{remediation[gate_key]}\n") |
|
|
|
|
| def main(): |
| """Main entry point for quality gate enforcement.""" |
| parser = argparse.ArgumentParser( |
| description="Enforce quality gates in CI/CD pipeline", |
| formatter_class=argparse.RawDescriptionHelpFormatter, |
| epilog=""" |
| Examples: |
| python ci_quality_gate.py |
| python ci_quality_gate.py --coverage-min 85 --pass-rate-min 99 |
| python ci_quality_gate.py --strict |
| |
| Exit Codes: |
| 0: All gates passed |
| 1: One or more gates failed |
| 2: Error in execution |
| """ |
| ) |
|
|
| parser.add_argument( |
| "--coverage-file", |
| type=str, |
| default="tests/coverage_reports/metrics/coverage.json", |
| help="Path to coverage.json (default: tests/coverage_reports/metrics/coverage.json)" |
| ) |
|
|
| parser.add_argument( |
| "--health-file", |
| type=str, |
| default="tests/coverage_reports/metrics/test_health.json", |
| help="Path to test_health.json (default: tests/coverage_reports/metrics/test_health.json)" |
| ) |
|
|
| parser.add_argument( |
| "--trending-file", |
| type=str, |
| default="tests/coverage_reports/metrics/trending.json", |
| help="Path to trending.json (default: tests/coverage_reports/metrics/trending.json)" |
| ) |
|
|
| parser.add_argument( |
| "--coverage-min", |
| type=float, |
| default=DEFAULT_COVERAGE_LINE_MIN, |
| help="Minimum line coverage percentage (default: {:.0f}%%)".format(DEFAULT_COVERAGE_LINE_MIN) |
| ) |
|
|
| parser.add_argument( |
| "--branch-min", |
| type=float, |
| default=DEFAULT_COVERAGE_BRANCH_MIN, |
| help="Minimum branch coverage percentage (default: {:.0f}%%)".format(DEFAULT_COVERAGE_BRANCH_MIN) |
| ) |
|
|
| parser.add_argument( |
| "--pass-rate-min", |
| type=float, |
| default=DEFAULT_PASS_RATE_MIN, |
| help="Minimum pass rate percentage (default: {:.0f}%%)".format(DEFAULT_PASS_RATE_MIN) |
| ) |
|
|
| parser.add_argument( |
| "--regression-threshold", |
| type=float, |
| default=DEFAULT_REGRESSION_THRESHOLD, |
| help="Maximum coverage regression percentage (default: {:.0f}%%)".format(DEFAULT_REGRESSION_THRESHOLD) |
| ) |
|
|
| parser.add_argument( |
| "--flaky-warn", |
| type=float, |
| default=DEFAULT_FLAKY_WARN, |
| help="Flaky test warning threshold (default: {:.0f}%%)".format(DEFAULT_FLAKY_WARN) |
| ) |
|
|
| parser.add_argument( |
| "--flaky-fail", |
| type=float, |
| default=DEFAULT_FLAKY_FAIL, |
| help="Flaky test failure threshold (default: {:.0f}%%)".format(DEFAULT_FLAKY_FAIL) |
| ) |
|
|
| parser.add_argument( |
| "--strict", |
| action="store_true", |
| help="Enable strict mode (fail on warnings, higher thresholds)" |
| ) |
|
|
| parser.add_argument( |
| "--main-branch-min", |
| type=float, |
| default=DEFAULT_MAIN_BRANCH_MIN, |
| help="Minimum overall coverage for main branch (default: {:.0f}%%)".format(DEFAULT_MAIN_BRANCH_MIN) |
| ) |
|
|
| parser.add_argument( |
| "--aggregated", |
| action="store_true", |
| help="Use aggregated coverage (backend + frontend) instead of backend only" |
| ) |
|
|
| parser.add_argument( |
| "--frontend-coverage", |
| type=str, |
| default="../frontend-nextjs/coverage/coverage-final.json", |
| help="Path to frontend coverage-final.json (default: ../frontend-nextjs/coverage/coverage-final.json)" |
| ) |
|
|
| parser.add_argument( |
| "--weights", |
| type=str, |
| default=f"{DEFAULT_BACKEND_WEIGHT},{DEFAULT_FRONTEND_WEIGHT}", |
| help="Backend/frontend weights for aggregated coverage (default: 0.7,0.3)" |
| ) |
|
|
| parser.add_argument( |
| "--allow-exception-label", |
| action="store_true", |
| help="Allow !coverage-exception PR label to bypass gate" |
| ) |
|
|
| args = parser.parse_args() |
|
|
| |
| if args.strict: |
| args.coverage_min = max(args.coverage_min, 85.0) |
| args.branch_min = max(args.branch_min, 75.0) |
| args.pass_rate_min = max(args.pass_rate_min, 99.0) |
| args.regression_threshold = min(args.regression_threshold, 3.0) |
| args.flaky_warn = min(args.flaky_warn, 3.0) |
| args.flaky_fail = min(args.flaky_fail, 5.0) |
|
|
| |
| backend_dir = Path(__file__).parent.parent.parent |
| coverage_file = backend_dir / args.coverage_file |
| health_file = backend_dir / args.health_file |
| trending_file = backend_dir / args.trending_file |
|
|
| |
| try: |
| weights_str = args.weights.split(",") |
| backend_weight = float(weights_str[0]) |
| frontend_weight = float(weights_str[1]) if len(weights_str) > 1 else 1.0 - backend_weight |
| except (ValueError, IndexError): |
| print(f"ERROR: Invalid weights format: {args.weights}. Use comma-separated values (e.g., 0.7,0.3)") |
| sys.exit(2) |
|
|
| weights = (backend_weight, frontend_weight) |
|
|
| |
| frontend_coverage = backend_dir / args.frontend_coverage |
|
|
| |
| results = {} |
|
|
| |
| if is_main_branch_merge(): |
| results["MAIN BRANCH GATE"] = check_main_branch_coverage_gate( |
| coverage_file, |
| frontend_coverage, |
| args.main_branch_min |
| ) |
|
|
| |
| if args.aggregated: |
| overall, backend_cov, frontend_cov, passed, message = check_aggregated_coverage( |
| coverage_file, |
| frontend_coverage, |
| weights |
| ) |
| results["COVERAGE GATE"] = (passed, message) |
| else: |
| results["COVERAGE GATE"] = check_coverage_gate( |
| coverage_file, |
| args.coverage_min, |
| args.branch_min |
| ) |
|
|
| results["PASS RATE GATE"] = check_pass_rate_gate( |
| health_file, |
| args.pass_rate_min |
| ) |
|
|
| results["REGRESSION GATE"] = check_regression_gate( |
| trending_file, |
| coverage_file, |
| args.regression_threshold |
| ) |
|
|
| results["FLAKY TEST GATE"] = check_flaky_test_gate( |
| health_file, |
| args.flaky_warn, |
| args.flaky_fail |
| ) |
|
|
| |
| print_summary(results) |
|
|
| |
| failed_gates = [ |
| name for name, (passed, _) in results.items() |
| if not passed |
| ] |
| print_remediation(failed_gates) |
|
|
| |
| if all(passed for passed, _ in results.values()): |
| sys.exit(0) |
| else: |
| sys.exit(1) |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|