blob: c4d6c8da073f6c67a2e77539941a6ee3b740c309 [file]
#!/usr/bin/env python3
# Utilities for manipulating/testing the MAINTAINERS.yml data.
# Copyright (C) 2026 Free Software Foundation, Inc.
#
# This file is part of GCC.
#
# GCC is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 3, or (at your option)
# any later version.
#
# GCC is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with GCC; see the file COPYING. If not, write to
# the Free Software Foundation, 51 Franklin Street, Fifth Floor,
# Boston, MA 02110-1301, USA.
import json
import jsonschema
import sys
import unidecode
import yaml
# Prefer the libYAML implementations if available. Fall back to the
# pure python versions as a backup.
try:
from yaml import CLoader as Loader, CDumper as Dumper
except:
from yaml import Loader, Dumper
label_pattern = '[a-zA-Z][-+a-zA-Z0-9]*(/[a-zA-Z][-+a-zA-Z0-9]*)+'
maintainer_schema = {
'$schema': 'https://json-schema.org/draft/2020-12/schema',
'type': 'object',
'properties': {
'users': {
'type': 'array',
'items': {
'type': 'object',
'properties': {
'sn': {
'type': 'string',
'minlength': 1,
},
'cn': {
'type': 'string',
'minlength': 1,
},
'email': {
'type': 'array',
'items': {
'type': 'string',
'format': 'email',
},
},
'roles': {
'type': 'array',
'items': {
'oneOf': [
{
'type': 'string',
'enum': ['Global', 'WriteAfter', 'BZ'],
},
{
'type': 'object',
'properties': {
'WriteAfter': {
'type': 'string',
'format': 'email',
},
'DCO': {
'type': 'string',
'format': 'email',
},
},
'additionalProperties': False,
},
{
'type': 'object',
'properties': {
'Maintainer': {
'type': 'string',
},
'Reviewer': {
'type': 'string',
},
'email': {
'type': 'string',
'format': 'email',
},
},
'additionalProperties': False,
'oneOf': [
{'required': ["Maintainer"]},
{'required': ["Reviewer"]},
],
},
],
},
'minlength': 1,
},
'account': {
'type': 'string',
'minlength': 1,
'pattern': '[a-zA-Z][a-zA-Z0-9]*',
},
'aliases': {
'type': 'array',
'minlength': 1,
'items': {
'type': 'string',
'minlength': 1,
},
},
'inactive': {
'type': 'boolean',
},
},
'additionalProperties': False,
'required': ['sn', 'cn', 'email', 'roles'],
},
},
'subsystems': {
'type': 'array',
'items': {
'type': 'object',
'properties': {
'name': {
'type': 'string',
'minlength': 1,
},
'class': {
'type': 'string',
'minlength': 1,
},
'labels': {
'type': 'array',
# Disabled until we populate the labels
# 'minlength': 1,
'items': {
'type': 'string',
'minlength': 3,
'pattern': label_pattern,
},
},
},
'additionalProperties': False,
'required': ['name', 'labels'],
},
},
},
}
error_count = 0
warning_count = 0
def _fatal(msg):
print(f"fatal: {msg}", file=sys.stderr)
sys.exit(1)
def _error(msg):
global error_count
print(f"error: {msg}", file=sys.stderr)
error_count += 1
def _warning(msg):
global warning_count
print(f"warning: {msg}", file=sys.stderr)
warning_count += 1
def _format_path(path, data):
"""Try to turn a validation error path into something more readable"""
elts = [""]
cur = data
try:
for elt in path:
if isinstance(cur, list) and isinstance(elt, int):
item = cur[elt]
if isinstance(item, dict) and 'cn' in item:
elts[-1] += f"[cn = {item['cn']}]"
elif isinstance(item, dict) and 'name' in item:
elts[-1] += f"[name = {item['name']}]"
else:
elts[-1] += f"[(anon index): {elt}]"
cur = item
else:
cur = cur[elt]
elts.append(str(elt))
except Exception as e:
print('/'.join(elts))
raise e
if elts[0] == "":
elts = elts[1:]
return '/'.join(elts)
def _check_schema(data):
try:
schema = jsonschema.validators.Draft202012Validator(maintainer_schema)
errors = sorted(schema.iter_errors(data), key=lambda e: e.path)
for err in errors:
_error(f"{_format_path(err.path, data)}: {err.message}")
except jsonschema.exceptions.SchemaError as e:
_fatal(str(e))
return
def validate(data):
"""Check the data against the schema and our own consistency checks"""
_check_schema(data)
# The data conforms to the expected schema, so now run some data
# validation checks.
# Maintainer and Reviewer roles need to match entries in the
# subsystems list; Maintainer entires must also have a class entry, though
# that is optional for Reviewers.
for u in data['users']:
# Users with the 'BZ' role should not have any other roles; we
# can quickly skip the additional checks if that is the case.
if len(u['roles']) == 1 and u['roles'][0] == 'BZ':
continue
seen_writeafter = False
only_dco = True
for r in u['roles']:
if isinstance(r, str):
if r == 'BZ':
_error(
f"User '{u['cn']}' has 'BZ' role as well as others."
)
if r == 'WriteAfter':
seen_writeafter = True
only_dco = False
continue
if 'WriteAfter' in r:
seen_writeafter = True
need_class = True
if 'DCO' in r:
continue
only_dco = False
n = r.get('Maintainer')
if not n:
need_class = False
n = r.get('Reviewer')
if n:
subsystems = list(
filter(lambda s: s['name'] == n, data['subsystems'])
)
if len(subsystems) == 1:
if need_class and not 'class' in subsystems[0]:
_error(f"subsystem '{n}' missing a class.")
elif len(subsystems) == 0:
_error(f"No subsystem entry for '{n}'.")
else:
_error(f"Multiple subsystem entries for '{n}'.")
if not seen_writeafter and not only_dco:
_error(f"User '{u['cn']}' lacks WriteAfter role.")
if error_count:
sys.exit(1)
return
def load(file):
with open(file, "r", encoding="utf-8") as f:
data = yaml.load(f, Loader=Loader)
return data
def main():
if len(sys.argv) != 2:
print(f"Usage: {sys.argv[0]} path-to-MAINTAINERS.yml")
return 1
validate(load(sys.argv[1]))
return 0
if __name__ == "__main__":
sys.exit(main())