All checks were successful
PR Checklist / pr-checklist (pull_request) Successful in 2m21s
Python script that enforces PR quality standards: - Checks for actual code changes - Validates branch is not behind base - Detects issue bundling in PR body - Runs Python syntax validation - Verifies shell script executability - Ensures issue references exist Closes #393
191 lines
5.7 KiB
Python
191 lines
5.7 KiB
Python
#!/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()) |