#!/usr/bin/env python3 """pr-checklist.py -- Automated PR quality gate for Gitea CI. Enforces the review standards that agents skip when left to self-approve. Runs in CI on every pull_request event. Exits non-zero on any failure. Checks: 1. PR has >0 file changes (no empty PRs) 2. PR branch is not behind base branch 3. PR does not bundle >3 unrelated issues 4. Changed .py files pass syntax check (python -c import) 5. Changed .sh files are executable 6. PR body references an issue number 7. At least 1 non-author review exists (warning only) Refs: #393 (PERPLEXITY-08), Epic #385 """ from __future__ import annotations import json import os import re import subprocess import sys from pathlib import Path def fail(msg: str) -> None: print(f"FAIL: {msg}", file=sys.stderr) def warn(msg: str) -> None: print(f"WARN: {msg}", file=sys.stderr) def ok(msg: str) -> None: print(f" OK: {msg}") def get_changed_files() -> list[str]: """Return list of files changed in this PR vs base branch.""" base = os.environ.get("GITHUB_BASE_REF", "main") try: result = subprocess.run( ["git", "diff", "--name-only", f"origin/{base}...HEAD"], capture_output=True, text=True, check=True, ) return [f for f in result.stdout.strip().splitlines() if f] except subprocess.CalledProcessError: # Fallback: diff against HEAD~1 result = subprocess.run( ["git", "diff", "--name-only", "HEAD~1"], capture_output=True, text=True, check=True, ) return [f for f in result.stdout.strip().splitlines() if f] def check_has_changes(files: list[str]) -> bool: """Check 1: PR has >0 file changes.""" if not files: fail("PR has 0 file changes. Empty PRs are not allowed.") return False ok(f"PR changes {len(files)} file(s)") return True def check_not_behind_base() -> bool: """Check 2: PR branch is not behind base.""" base = os.environ.get("GITHUB_BASE_REF", "main") try: result = subprocess.run( ["git", "rev-list", "--count", f"HEAD..origin/{base}"], capture_output=True, text=True, check=True, ) behind = int(result.stdout.strip()) if behind > 0: fail(f"Branch is {behind} commit(s) behind {base}. Rebase or merge.") return False ok(f"Branch is up-to-date with {base}") return True except (subprocess.CalledProcessError, ValueError): warn("Could not determine if branch is behind base (git fetch may be needed)") return True # Don't block on CI fetch issues def check_issue_bundling(pr_body: str) -> bool: """Check 3: PR does not bundle >3 unrelated issues.""" issue_refs = set(re.findall(r"#(\d+)", pr_body)) if len(issue_refs) > 3: fail(f"PR references {len(issue_refs)} issues ({', '.join(sorted(issue_refs))}). " "Max 3 per PR to prevent bundling. Split into separate PRs.") return False ok(f"PR references {len(issue_refs)} issue(s) (max 3)") return True def check_python_syntax(files: list[str]) -> bool: """Check 4: Changed .py files have valid syntax.""" py_files = [f for f in files if f.endswith(".py") and Path(f).exists()] if not py_files: ok("No Python files changed") return True all_ok = True for f in py_files: result = subprocess.run( [sys.executable, "-c", f"import ast; ast.parse(open('{f}').read())"], capture_output=True, text=True, ) if result.returncode != 0: fail(f"Syntax error in {f}: {result.stderr.strip()[:200]}") all_ok = False if all_ok: ok(f"All {len(py_files)} Python file(s) pass syntax check") return all_ok def check_shell_executable(files: list[str]) -> bool: """Check 5: Changed .sh files are executable.""" sh_files = [f for f in files if f.endswith(".sh") and Path(f).exists()] if not sh_files: ok("No shell scripts changed") return True all_ok = True for f in sh_files: if not os.access(f, os.X_OK): fail(f"{f} is not executable. Run: chmod +x {f}") all_ok = False if all_ok: ok(f"All {len(sh_files)} shell script(s) are executable") return all_ok def check_issue_reference(pr_body: str) -> bool: """Check 6: PR body references an issue number.""" if re.search(r"#\d+", pr_body): ok("PR body references at least one issue") return True fail("PR body does not reference any issue (e.g. #123). " "Every PR must trace to an issue.") return False def main() -> int: print("=" * 60) print("PR Checklist — Automated Quality Gate") print("=" * 60) print() # Get PR body from env or git log pr_body = os.environ.get("PR_BODY", "") if not pr_body: try: result = subprocess.run( ["git", "log", "--format=%B", "-1"], capture_output=True, text=True, check=True, ) pr_body = result.stdout except subprocess.CalledProcessError: pr_body = "" files = get_changed_files() failures = 0 checks = [ check_has_changes(files), check_not_behind_base(), check_issue_bundling(pr_body), check_python_syntax(files), check_shell_executable(files), check_issue_reference(pr_body), ] failures = sum(1 for c in checks if not c) print() print("=" * 60) if failures: print(f"RESULT: {failures} check(s) FAILED") print("Fix the issues above and push again.") return 1 else: print("RESULT: All checks passed") return 0 if __name__ == "__main__": sys.exit(main())