Merge pull request #116646 from Repiteo/ci/codeowners
CI: Create `CODEOWNERS` validator hook
This commit is contained in:
commit
992b3ff73a
3 changed files with 173 additions and 9 deletions
28
.github/CODEOWNERS
vendored
28
.github/CODEOWNERS
vendored
|
|
@ -40,10 +40,13 @@
|
|||
/drivers/vulkan/ @godotengine/rendering
|
||||
|
||||
## OS
|
||||
/drivers/apple*/ @godotengine/macos
|
||||
/drivers/unix/ @godotengine/linux-bsd
|
||||
/drivers/windows/ @godotengine/windows
|
||||
|
||||
## Misc
|
||||
/drivers/register_driver_types.* @godotengine/buildsystem
|
||||
/drivers/accesskit/ @godotengine/buildsystem
|
||||
/drivers/png/ @godotengine/asset-pipeline
|
||||
|
||||
# Editor
|
||||
|
|
@ -54,7 +57,6 @@
|
|||
/editor/debugger/ @godotengine/editor @godotengine/debugger
|
||||
/editor/doc/ @godotengine/editor @godotengine/documentation
|
||||
/editor/gui/ @godotengine/editor @godotengine/gui-nodes
|
||||
/editor/icons/ @godotengine/editor
|
||||
/editor/import/ @godotengine/editor @godotengine/asset-pipeline
|
||||
/editor/scene/2d/ @godotengine/editor @godotengine/2d-nodes
|
||||
/editor/scene/2d/physics/ @godotengine/editor @godotengine/physics
|
||||
|
|
@ -125,7 +127,6 @@
|
|||
/modules/multiplayer/ @godotengine/network
|
||||
/modules/multiplayer/doc_classes/ @godotengine/network @godotengine/documentation
|
||||
/modules/multiplayer/editor/ @godotengine/network @godotengine/editor
|
||||
/modules/multiplayer/icons/ @godotengine/network
|
||||
/modules/multiplayer/tests/ @godotengine/network @godotengine/tests
|
||||
/modules/upnp/ @godotengine/network
|
||||
/modules/upnp/doc_classes/ @godotengine/network @godotengine/documentation
|
||||
|
|
@ -152,14 +153,12 @@
|
|||
/modules/gdscript/ @godotengine/gdscript
|
||||
/modules/gdscript/doc_classes/ @godotengine/gdscript @godotengine/documentation
|
||||
/modules/gdscript/editor/ @godotengine/gdscript @godotengine/script-editor
|
||||
/modules/gdscript/icons/ @godotengine/gdscript
|
||||
/modules/gdscript/tests/ @godotengine/gdscript @godotengine/tests
|
||||
/modules/jsonrpc/ @godotengine/gdscript @godotengine/network
|
||||
/modules/jsonrpc/tests/ @godotengine/gdscript @godotengine/network @godotengine/tests
|
||||
/modules/mono/ @godotengine/dotnet
|
||||
/modules/mono/doc_classes/ @godotengine/dotnet @godotengine/documentation
|
||||
/modules/mono/editor/ @godotengine/dotnet @godotengine/script-editor
|
||||
/modules/mono/icons/ @godotengine/dotnet
|
||||
|
||||
## Text
|
||||
/modules/freetype/ @godotengine/buildsystem
|
||||
|
|
@ -180,15 +179,14 @@
|
|||
/modules/webxr/doc_classes/ @godotengine/xr @godotengine/documentation
|
||||
|
||||
## Misc
|
||||
/modules/register_module_types.* @godotengine/buildsystem
|
||||
/modules/csg/ @godotengine/3d-nodes
|
||||
/modules/csg/doc_classes/ @godotengine/3d-nodes @godotengine/documentation
|
||||
/modules/csg/editor/ @godotengine/3d-nodes @godotengine/editor
|
||||
/modules/csg/icons/ @godotengine/3d-nodes
|
||||
/modules/csg/tests/ @godotengine/3d-nodes @godotengine/tests
|
||||
/modules/gridmap/ @godotengine/3d-nodes
|
||||
/modules/gridmap/doc_classes/ @godotengine/3d-nodes @godotengine/documentation
|
||||
/modules/gridmap/editor/ @godotengine/3d-nodes @godotengine/editor
|
||||
/modules/gridmap/icons/ @godotengine/3d-nodes
|
||||
/modules/navigation_2d/ @godotengine/navigation
|
||||
/modules/navigation_2d/editor/ @godotengine/navigation @godotengine/editor
|
||||
/modules/navigation_3d/ @godotengine/navigation
|
||||
|
|
@ -196,13 +194,11 @@
|
|||
/modules/noise/ @godotengine/core
|
||||
/modules/noise/doc_classes/ @godotengine/core @godotengine/documentation
|
||||
/modules/noise/editor/ @godotengine/core @godotengine/editor
|
||||
/modules/noise/icons/ @godotengine/core
|
||||
/modules/noise/tests/ @godotengine/core @godotengine/tests
|
||||
/modules/objectdb_profiler/ @godotengine/debugger
|
||||
/modules/objectdb_profiler/editor/ @godotengine/debugger @godotengine/editor
|
||||
/modules/regex/ @godotengine/core
|
||||
/modules/regex/doc_classes/ @godotengine/core @godotengine/documentation
|
||||
/modules/regex/icons/ @godotengine/core
|
||||
/modules/regex/tests/ @godotengine/core @godotengine/tests
|
||||
/modules/zip/ @godotengine/core
|
||||
/modules/zip/doc_classes/ @godotengine/core @godotengine/documentation
|
||||
|
|
@ -210,6 +206,7 @@
|
|||
|
||||
# Platform
|
||||
|
||||
/platform/register_platform_apis.* @godotengine/buildsystem
|
||||
/platform/android/ @godotengine/android
|
||||
/platform/android/doc_classes/ @godotengine/android @godotengine/documentation
|
||||
/platform/ios/ @godotengine/ios
|
||||
|
|
@ -218,6 +215,8 @@
|
|||
/platform/linuxbsd/doc_classes/ @godotengine/linux-bsd @godotengine/documentation
|
||||
/platform/macos/ @godotengine/macos
|
||||
/platform/macos/doc_classes/ @godotengine/macos @godotengine/documentation
|
||||
/platform/visionos/ @godotengine/xr
|
||||
/platform/visionos/doc_classes/ @godotengine/xr @godotengine/documentation
|
||||
/platform/web/ @godotengine/web
|
||||
/platform/web/doc_classes/ @godotengine/web @godotengine/documentation
|
||||
/platform/windows/ @godotengine/windows
|
||||
|
|
@ -225,6 +224,9 @@
|
|||
|
||||
# Scene
|
||||
|
||||
/scene/property_* @godotengine/buildsystem
|
||||
/scene/register_scene_types.* @godotengine/buildsystem
|
||||
/scene/scene_string_names.* @godotengine/buildsystem
|
||||
/scene/2d/ @godotengine/2d-nodes
|
||||
/scene/2d/navigation/ @godotengine/2d-nodes @godotengine/navigation
|
||||
/scene/2d/physics/ @godotengine/2d-nodes @godotengine/physics
|
||||
|
|
@ -237,24 +239,32 @@
|
|||
/scene/debugger/ @godotengine/debugger
|
||||
/scene/gui/ @godotengine/gui-nodes
|
||||
/scene/main/ @godotengine/core
|
||||
/scene/resources/* @godotengine/gui-nodes
|
||||
/scene/resources/2d/ @godotengine/2d-nodes
|
||||
/scene/resources/2d/skeleton/ @godotengine/2d-nodes @godotengine/animation
|
||||
/scene/resources/3d/ @godotengine/3d-nodes
|
||||
/scene/resources/animated* @godotengine/animation
|
||||
/scene/resources/animation* @godotengine/animation
|
||||
/scene/resources/audio* @godotengine/audio
|
||||
/scene/resources/bone* @godotengine/animation
|
||||
/scene/resources/font* @godotengine/gui-nodes
|
||||
/scene/resources/physics* @godotengine/physics
|
||||
/scene/resources/shader* @godotengine/shaders
|
||||
/scene/resources/skeleton* @godotengine/animation
|
||||
/scene/resources/text_* @godotengine/gui-nodes
|
||||
/scene/resources/visual_shader* @godotengine/shaders
|
||||
/scene/theme/ @godotengine/gui-nodes
|
||||
/scene/theme/icons/ @godotengine/gui-nodes
|
||||
|
||||
# Servers
|
||||
|
||||
/servers/nav_heap.* @godotengine/navigation
|
||||
/servers/register_server_types.* @godotengine/buildsystem
|
||||
/servers/server_wrap_* @godotengine/buildsystem
|
||||
/servers/audio/ @godotengine/audio
|
||||
/servers/camera/ @godotengine/xr
|
||||
/servers/debugger/ @godotengine/debugger
|
||||
/servers/display/ @godotengine/rendering
|
||||
/servers/movie_writer/ @godotengine/rendering
|
||||
/servers/navigation_2d/ @godotengine/navigation
|
||||
/servers/navigation_3d/ @godotengine/navigation
|
||||
/servers/physics_2d/ @godotengine/physics
|
||||
|
|
|
|||
|
|
@ -109,6 +109,12 @@ repos:
|
|||
files: ^(doc/classes|.*/doc_classes)/.*\.xml$
|
||||
additional_dependencies: [xmlschema]
|
||||
|
||||
- id: validate-codeowners
|
||||
name: validate codeowners
|
||||
language: python
|
||||
entry: python misc/scripts/validate_codeowners.py
|
||||
args: [--unowned]
|
||||
|
||||
- id: eslint
|
||||
name: eslint
|
||||
language: node
|
||||
|
|
|
|||
148
misc/scripts/validate_codeowners.py
Executable file
148
misc/scripts/validate_codeowners.py
Executable file
|
|
@ -0,0 +1,148 @@
|
|||
#!/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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue