143 lines
4.9 KiB
Python
143 lines
4.9 KiB
Python
|
|
#!/usr/bin/env python3
|
||
|
|
|
||
|
|
import argparse
|
||
|
|
import json
|
||
|
|
import re
|
||
|
|
import sys
|
||
|
|
import posixpath
|
||
|
|
import os
|
||
|
|
|
||
|
|
# Exit codes
|
||
|
|
SUCCESS = 0
|
||
|
|
NOT_APPROVED = 1
|
||
|
|
TECHNICAL_ERROR = 255
|
||
|
|
|
||
|
|
|
||
|
|
def main():
|
||
|
|
parser = argparse.ArgumentParser(
|
||
|
|
description="Validate PR changes against auto-approver rules."
|
||
|
|
)
|
||
|
|
parser.add_argument(
|
||
|
|
"--config",
|
||
|
|
default=".github/auto-approvers.json",
|
||
|
|
help="Path to the rules JSON.",
|
||
|
|
)
|
||
|
|
parser.add_argument(
|
||
|
|
"--changed-files", help="Path to the fetched changed files JSON."
|
||
|
|
)
|
||
|
|
parser.add_argument(
|
||
|
|
"--expected-count", type=int, help="Total number of files expected in the PR."
|
||
|
|
)
|
||
|
|
parser.add_argument(
|
||
|
|
"--contributors", nargs="+", help="List of GitHub usernames to validate."
|
||
|
|
)
|
||
|
|
parser.add_argument(
|
||
|
|
"--check-config",
|
||
|
|
action="store_true",
|
||
|
|
help="Only validate the configuration file and exit.",
|
||
|
|
)
|
||
|
|
args = parser.parse_args()
|
||
|
|
|
||
|
|
# REGEX: Strict path structure, prevents absolute paths and weird characters
|
||
|
|
VALID_PATH = re.compile(r"^([a-zA-Z0-9_.-]+/)+$")
|
||
|
|
|
||
|
|
# Load and validate config
|
||
|
|
try:
|
||
|
|
with open(args.config) as f:
|
||
|
|
rules = json.load(f)
|
||
|
|
except FileNotFoundError:
|
||
|
|
print(f"::error::❌ Config file not found at {args.config}")
|
||
|
|
sys.exit(TECHNICAL_ERROR)
|
||
|
|
except json.JSONDecodeError as e:
|
||
|
|
print(f"::error::❌ Failed to parse config JSON: {e}")
|
||
|
|
sys.exit(TECHNICAL_ERROR)
|
||
|
|
|
||
|
|
safe_rules = {}
|
||
|
|
for directory, users in rules.items():
|
||
|
|
if not isinstance(users, list):
|
||
|
|
print(
|
||
|
|
f"::error::❌ Users for '{directory}' must be a JSON array (list), not a string."
|
||
|
|
)
|
||
|
|
sys.exit(TECHNICAL_ERROR)
|
||
|
|
|
||
|
|
if not VALID_PATH.match(directory) or ".." in directory.split("/"):
|
||
|
|
print(f"::error::❌ Invalid config path: {directory}")
|
||
|
|
sys.exit(TECHNICAL_ERROR)
|
||
|
|
|
||
|
|
safe_rules[directory] = [str(u).lower() for u in users]
|
||
|
|
|
||
|
|
if not args.check_config:
|
||
|
|
# Validate that required arguments are present if not in --check-config mode
|
||
|
|
if not (
|
||
|
|
args.changed_files and args.expected_count is not None and args.contributors
|
||
|
|
):
|
||
|
|
print(
|
||
|
|
"::error::❌ Missing required arguments: --changed-files, --expected-count, and --contributors are required unless --check-config is used."
|
||
|
|
)
|
||
|
|
sys.exit(TECHNICAL_ERROR)
|
||
|
|
|
||
|
|
# Load and flatten changed files
|
||
|
|
try:
|
||
|
|
with open(args.changed_files) as f:
|
||
|
|
file_objects = json.load(f)
|
||
|
|
except FileNotFoundError:
|
||
|
|
print(f"::error::❌ Changed files JSON not found at {args.changed_files}")
|
||
|
|
sys.exit(TECHNICAL_ERROR)
|
||
|
|
except json.JSONDecodeError as e:
|
||
|
|
print(f"::error::❌ Failed to parse changed files JSON: {e}")
|
||
|
|
sys.exit(TECHNICAL_ERROR)
|
||
|
|
|
||
|
|
if not file_objects or len(file_objects) != args.expected_count:
|
||
|
|
print(
|
||
|
|
f"::error::❌ File truncation mismatch or empty PR. Expected {args.expected_count}, got {len(file_objects) if file_objects else 0}."
|
||
|
|
)
|
||
|
|
sys.exit(TECHNICAL_ERROR)
|
||
|
|
|
||
|
|
if not all(isinstance(obj, list) for obj in file_objects):
|
||
|
|
print("::error::❌ Invalid payload format. Expected a list of lists.")
|
||
|
|
sys.exit(TECHNICAL_ERROR)
|
||
|
|
|
||
|
|
changed_files = [path for obj in file_objects for path in obj]
|
||
|
|
|
||
|
|
# Validate every file against every contributor
|
||
|
|
contributors = set(str(c).lower() for c in args.contributors)
|
||
|
|
print(f"👥 Validating contributors: {', '.join(contributors)}")
|
||
|
|
|
||
|
|
for raw_file_path in changed_files:
|
||
|
|
file_path = posixpath.normpath(raw_file_path)
|
||
|
|
|
||
|
|
# Find the most specific (longest) matching directory rule.
|
||
|
|
longest_match_dir = None
|
||
|
|
for directory in safe_rules.keys():
|
||
|
|
if file_path.startswith(directory):
|
||
|
|
if longest_match_dir is None or len(directory) > len(
|
||
|
|
longest_match_dir
|
||
|
|
):
|
||
|
|
longest_match_dir = directory
|
||
|
|
|
||
|
|
# First, explicitly fail if the file isn't covered by ANY rule.
|
||
|
|
if not longest_match_dir:
|
||
|
|
print(
|
||
|
|
f"::error::❌ File '{file_path}' does not fall under any configured auto-approve directory."
|
||
|
|
)
|
||
|
|
sys.exit(NOT_APPROVED)
|
||
|
|
|
||
|
|
# Then, verify every contributor has access to that specific rule.
|
||
|
|
for user in contributors:
|
||
|
|
if user not in safe_rules[longest_match_dir]:
|
||
|
|
print(
|
||
|
|
f"::error::❌ Contributor @{user} not authorized for '{file_path}'."
|
||
|
|
)
|
||
|
|
sys.exit(NOT_APPROVED)
|
||
|
|
|
||
|
|
if args.check_config:
|
||
|
|
print("✅ Configuration is structurally valid")
|
||
|
|
else:
|
||
|
|
print("✅ Validation passed")
|
||
|
|
|
||
|
|
sys.exit(SUCCESS)
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
main()
|