godot-module-template/misc/scripts/validate_codeowners.py
2026-03-06 08:35:57 -06:00

148 lines
4.8 KiB
Python
Executable file

#!/usr/bin/env python3
if __name__ != "__main__":
raise SystemExit(f'Utility script "{__file__}" should not be used as a module!')
import argparse
import re
import subprocess
import sys
sys.path.insert(0, "./")
try:
from methods import print_error, print_info
except ImportError:
raise SystemExit(f"Utility script {__file__} must be run from repository root!")
def glob_to_regex(glob: str) -> re.Pattern[str]:
"""Convert a CODEOWNERS glob to a RegEx pattern."""
# Heavily inspired by: https://github.com/hmarr/codeowners/blob/main/match.go
# Handle specific edgecases first.
if "***" in glob:
raise SyntaxError("Pattern cannot contain three consecutive asterisks")
if glob == "/":
raise SyntaxError('Standalone "/" will not match anything')
if not glob:
raise ValueError("Empty pattern")
segments = glob.split("/")
if not segments[0]:
# Leading slash; relative to root.
segments = segments[1:]
else:
# Check for single-segment pattern, which matches relative to any descendent path.
# This is equivalent to a leading `**/`.
if len(segments) == 1 or (len(segments) == 2 and not segments[1]):
if segments[0] != "**":
segments.insert(0, "**")
if len(segments) > 1 and not segments[-1]:
# A trailing slash is equivalent to `/**`.
segments[-1] = "**"
last_index = len(segments) - 1
need_slash = False
pattern = r"\A"
for index, segment in enumerate(segments):
if segment == "**":
if index == 0 and index == last_index:
pattern += r".+" # Pattern is just `**`; match everything.
elif index == 0:
pattern += r"(?:.+/)?" # Pattern starts with `**`; match any leading path segment.
need_slash = False
elif index == last_index:
pattern += r"/.*" # Pattern ends with `**`; match any trailing path segment.
else:
pattern += r"(?:/.+)?" # Pattern contains `**`; match zero or more path segments.
need_slash = True
elif segment == "*":
if need_slash:
pattern += "/"
# Regular wildcard; match any non-separator characters.
pattern += r"[^/]+"
need_slash = True
else:
if need_slash:
pattern += "/"
escape = False
for char in segment:
if escape:
escape = False
pattern += re.escape(char)
continue
elif char == "\\":
escape = True
elif char == "*":
# Multi-character wildcard.
pattern += r"[^/]*"
elif char == "?":
# Single-character wildcard.
pattern += r"[^/]"
else:
# Regular character
pattern += re.escape(char)
if index == last_index:
pattern += r"(?:/.*)?" # No trailing slash; match descendent paths.
need_slash = True
pattern += r"\Z"
return re.compile(pattern)
RE_CODEOWNERS = re.compile(r"^(?P<code>[^#](?:\\ |[^\s])+) +(?P<owners>(?:[^#][^\s]+ ?)+)")
def parse_codeowners() -> list[tuple[re.Pattern[str], list[str]]]:
codeowners = []
with open(".github/CODEOWNERS", encoding="utf-8", newline="\n") as file:
for line in reversed(file.readlines()): # Lower items have higher precedence.
if match := RE_CODEOWNERS.match(line):
codeowners.append((glob_to_regex(match["code"]), match["owners"].split()))
return codeowners
def main() -> int:
parser = argparse.ArgumentParser(description="Utility script for validating CODEOWNERS assignment.")
parser.add_argument("files", nargs="*", help="A list of files to validate. If excluded, checks all owned files.")
parser.add_argument("-u", "--unowned", action="store_true", help="Only output files without an owner.")
args = parser.parse_args()
files: list[str] = args.files
if not files:
files = subprocess.run(["git", "ls-files"], text=True, capture_output=True).stdout.splitlines()
ret = 0
codeowners = parse_codeowners()
for file in files:
matched = False
for code, owners in codeowners:
if code.match(file):
matched = True
if not args.unowned:
print_info(f"{file}: {owners}")
break
if not matched:
print_error(f"{file}: <UNOWNED>")
ret += 1
return ret
try:
raise SystemExit(main())
except KeyboardInterrupt:
import os
import signal
signal.signal(signal.SIGINT, signal.SIG_DFL)
os.kill(os.getpid(), signal.SIGINT)