Merge pull request #116646 from Repiteo/ci/codeowners

CI: Create `CODEOWNERS` validator hook
This commit is contained in:
Thaddeus Crews 2026-03-06 12:39:53 -06:00
commit 992b3ff73a
No known key found for this signature in database
GPG key ID: 8C6E5FEB5FC03CCC
3 changed files with 173 additions and 9 deletions

28
.github/CODEOWNERS vendored
View file

@ -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

View file

@ -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

View 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)