scripts: add maintainer script
Adding the get_maintainer.py script Signed-off-by: Anas Nashif <anas.nashif@intel.com> Signed-off-by: Ioannis Glaropoulos <Ioannis.Glaropoulos@nordicsemi.no>
This commit is contained in:
parent
a2bd288ae8
commit
8d8875b752
1 changed files with 552 additions and 0 deletions
552
scripts/get_maintainer.py
Executable file
552
scripts/get_maintainer.py
Executable file
|
@ -0,0 +1,552 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
# Copyright (c) 2019 Nordic Semiconductor ASA
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
"""
|
||||
Lists maintainers for files or commits. Similar in function to
|
||||
scripts/get_maintainer.pl from Linux, but geared towards GitHub. The mapping is
|
||||
in MAINTAINERS.yml.
|
||||
|
||||
The comment at the top of MAINTAINERS.yml in Zephyr documents the file format.
|
||||
|
||||
See the help texts for the various subcommands for more information. They can
|
||||
be viewed with e.g.
|
||||
|
||||
./get_maintainer.py path --help
|
||||
|
||||
This executable doubles as a Python library. Identifiers not prefixed with '_'
|
||||
are part of the library API. The library documentation can be viewed with this
|
||||
command:
|
||||
|
||||
$ pydoc get_maintainer
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import operator
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from yaml import load, YAMLError
|
||||
try:
|
||||
# Use the speedier C LibYAML parser if available
|
||||
from yaml import CLoader as Loader
|
||||
except ImportError:
|
||||
from yaml import Loader
|
||||
|
||||
|
||||
def _main():
|
||||
# Entry point when run as an executable
|
||||
|
||||
args = _parse_args()
|
||||
try:
|
||||
args.cmd_fn(Maintainers(args.maintainers), args)
|
||||
except (MaintainersError, GitError) as e:
|
||||
_serr(e)
|
||||
|
||||
|
||||
def _parse_args():
|
||||
# Parses arguments when run as an executable
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
description=__doc__)
|
||||
|
||||
parser.add_argument(
|
||||
"-m", "--maintainers",
|
||||
metavar="MAINTAINERS_FILE",
|
||||
help="Maintainers file to load. If not specified, MAINTAINERS.yml in "
|
||||
"the top-level repository directory is used, and must exist. "
|
||||
"Paths in the maintainers file will always be taken as relative "
|
||||
"to the top-level directory.")
|
||||
|
||||
subparsers = parser.add_subparsers(
|
||||
help="Available commands (each has a separate --help text)")
|
||||
|
||||
id_parser = subparsers.add_parser(
|
||||
"path",
|
||||
help="List area(s) for paths")
|
||||
id_parser.add_argument(
|
||||
"paths",
|
||||
metavar="PATH",
|
||||
nargs="*",
|
||||
help="Path to list areas for")
|
||||
id_parser.set_defaults(cmd_fn=Maintainers._path_cmd)
|
||||
|
||||
commits_parser = subparsers.add_parser(
|
||||
"commits",
|
||||
help="List area(s) for commit range")
|
||||
commits_parser.add_argument(
|
||||
"commits",
|
||||
metavar="COMMIT_RANGE",
|
||||
nargs="*",
|
||||
help="Commit range to list areas for (default: HEAD~..)")
|
||||
commits_parser.set_defaults(cmd_fn=Maintainers._commits_cmd)
|
||||
|
||||
list_parser = subparsers.add_parser(
|
||||
"list",
|
||||
help="List files in areas")
|
||||
list_parser.add_argument(
|
||||
"area",
|
||||
metavar="AREA",
|
||||
nargs="?",
|
||||
help="Name of area to list files in. If not specified, all "
|
||||
"non-orphaned files are listed (all files that do not appear in "
|
||||
"any area).")
|
||||
list_parser.set_defaults(cmd_fn=Maintainers._list_cmd)
|
||||
|
||||
areas_parser = subparsers.add_parser(
|
||||
"areas",
|
||||
help="List areas and maintainers")
|
||||
areas_parser.add_argument(
|
||||
"maintainer",
|
||||
metavar="MAINTAINER",
|
||||
nargs="?",
|
||||
help="List all areas maintained by maintaier.")
|
||||
|
||||
areas_parser.set_defaults(cmd_fn=Maintainers._areas_cmd)
|
||||
|
||||
orphaned_parser = subparsers.add_parser(
|
||||
"orphaned",
|
||||
help="List orphaned files (files that do not appear in any area)")
|
||||
orphaned_parser.add_argument(
|
||||
"path",
|
||||
metavar="PATH",
|
||||
nargs="?",
|
||||
help="Limit to files under PATH")
|
||||
orphaned_parser.set_defaults(cmd_fn=Maintainers._orphaned_cmd)
|
||||
|
||||
args = parser.parse_args()
|
||||
if not hasattr(args, "cmd_fn"):
|
||||
# Called without a subcommand
|
||||
sys.exit(parser.format_usage().rstrip())
|
||||
|
||||
return args
|
||||
|
||||
|
||||
class Maintainers:
|
||||
"""
|
||||
Represents the contents of a maintainers YAML file.
|
||||
|
||||
These attributes are available:
|
||||
|
||||
areas:
|
||||
A dictionary that maps area names to Area instances, for all areas
|
||||
defined in the maintainers file
|
||||
|
||||
filename:
|
||||
The path to the maintainers file
|
||||
"""
|
||||
def __init__(self, filename=None):
|
||||
"""
|
||||
Creates a Maintainers instance.
|
||||
|
||||
filename (default: None):
|
||||
Path to the maintainers file to parse. If None, MAINTAINERS.yml in
|
||||
the top-level directory of the Git repository is used, and must
|
||||
exist.
|
||||
"""
|
||||
self._toplevel = pathlib.Path(_git("rev-parse", "--show-toplevel"))
|
||||
|
||||
if filename is None:
|
||||
self.filename = self._toplevel / "MAINTAINERS.yml"
|
||||
else:
|
||||
self.filename = pathlib.Path(filename)
|
||||
|
||||
self.areas = {}
|
||||
for area_name, area_dict in _load_maintainers(self.filename).items():
|
||||
area = Area()
|
||||
area.name = area_name
|
||||
area.status = area_dict.get("status")
|
||||
area.maintainers = area_dict.get("maintainers", [])
|
||||
area.collaborators = area_dict.get("collaborators", [])
|
||||
area.inform = area_dict.get("inform", [])
|
||||
area.labels = area_dict.get("labels", [])
|
||||
area.description = area_dict.get("description")
|
||||
|
||||
# area._match_fn(path) tests if the path matches files and/or
|
||||
# files-regex
|
||||
area._match_fn = \
|
||||
_get_match_fn(area_dict.get("files"),
|
||||
area_dict.get("files-regex"))
|
||||
|
||||
# Like area._match_fn(path), but for files-exclude and
|
||||
# files-regex-exclude
|
||||
area._exclude_match_fn = \
|
||||
_get_match_fn(area_dict.get("files-exclude"),
|
||||
area_dict.get("files-regex-exclude"))
|
||||
|
||||
self.areas[area_name] = area
|
||||
|
||||
def path2areas(self, path):
|
||||
"""
|
||||
Returns a list of Area instances for the areas that contain 'path',
|
||||
taken as relative to the current directory
|
||||
"""
|
||||
# Make directory paths end in '/' so that foo/bar matches foo/bar/.
|
||||
# Skip this check in _contains() itself, because the isdir() makes it
|
||||
# twice as slow in cases where it's not needed.
|
||||
is_dir = os.path.isdir(path)
|
||||
|
||||
# Make 'path' relative to the repository root and normalize it.
|
||||
# normpath() would remove a trailing '/', so we add it afterwards.
|
||||
path = os.path.normpath(os.path.join(
|
||||
os.path.relpath(os.getcwd(), self._toplevel),
|
||||
path))
|
||||
|
||||
if is_dir:
|
||||
path += "/"
|
||||
|
||||
return [area for area in self.areas.values()
|
||||
if area._contains(path)]
|
||||
|
||||
def commits2areas(self, commits):
|
||||
"""
|
||||
Returns a set() of Area instances for the areas that contain files that
|
||||
are modified by the commit range in 'commits'. 'commits' could be e.g.
|
||||
"HEAD~..", to inspect the tip commit
|
||||
"""
|
||||
res = set()
|
||||
# Final '--' is to make sure 'commits' is interpreted as a commit range
|
||||
# rather than a path. That might give better error messages.
|
||||
for path in _git("diff", "--name-only", commits, "--").splitlines():
|
||||
res.update(self.path2areas(path))
|
||||
return res
|
||||
|
||||
def __repr__(self):
|
||||
return "<Maintainers for '{}'>".format(self.filename)
|
||||
|
||||
#
|
||||
# Command-line subcommands
|
||||
#
|
||||
|
||||
def _path_cmd(self, args):
|
||||
# 'path' subcommand implementation
|
||||
|
||||
for path in args.paths:
|
||||
if not os.path.exists(path):
|
||||
_serr("'{}': no such file or directory".format(path))
|
||||
|
||||
res = set()
|
||||
orphaned = []
|
||||
for path in args.paths:
|
||||
areas = self.path2areas(path)
|
||||
res.update(areas)
|
||||
if not areas:
|
||||
orphaned.append(path)
|
||||
|
||||
_print_areas(res)
|
||||
if orphaned:
|
||||
if res:
|
||||
print()
|
||||
print("Orphaned paths (not in any area):\n" + "\n".join(orphaned))
|
||||
|
||||
def _commits_cmd(self, args):
|
||||
# 'commits' subcommand implementation
|
||||
|
||||
commits = args.commits or ("HEAD~..",)
|
||||
_print_areas({area for commit_range in commits
|
||||
for area in self.commits2areas(commit_range)})
|
||||
|
||||
def _areas_cmd(self, args):
|
||||
# 'areas' subcommand implementation
|
||||
for area in self.areas.values():
|
||||
if args.maintainer:
|
||||
if args.maintainer in area.maintainers:
|
||||
print("{:25}\t{}".format(area.name, ",".join(area.maintainers)))
|
||||
else:
|
||||
print("{:25}\t{}".format(area.name, ",".join(area.maintainers)))
|
||||
|
||||
def _list_cmd(self, args):
|
||||
# 'list' subcommand implementation
|
||||
|
||||
if args.area is None:
|
||||
# List all files that appear in some area
|
||||
for path in _ls_files():
|
||||
for area in self.areas.values():
|
||||
if area._contains(path):
|
||||
print(path)
|
||||
break
|
||||
else:
|
||||
# List all files that appear in the given area
|
||||
area = self.areas.get(args.area)
|
||||
if area is None:
|
||||
_serr("'{}': no such area defined in '{}'"
|
||||
.format(args.area, self.filename))
|
||||
|
||||
for path in _ls_files():
|
||||
if area._contains(path):
|
||||
print(path)
|
||||
|
||||
def _orphaned_cmd(self, args):
|
||||
# 'orphaned' subcommand implementation
|
||||
|
||||
if args.path is not None and not os.path.exists(args.path):
|
||||
_serr("'{}': no such file or directory".format(args.path))
|
||||
|
||||
for path in _ls_files(args.path):
|
||||
for area in self.areas.values():
|
||||
if area._contains(path):
|
||||
break
|
||||
else:
|
||||
print(path) # We get here if we never hit the 'break'
|
||||
|
||||
|
||||
class Area:
|
||||
"""
|
||||
Represents an entry for an area in MAINTAINERS.yml.
|
||||
|
||||
These attributes are available:
|
||||
|
||||
status:
|
||||
The status of the area, as a string. None if the area has no 'status'
|
||||
key. See MAINTAINERS.yml.
|
||||
|
||||
maintainers:
|
||||
List of maintainers. Empty if the area has no 'maintainers' key.
|
||||
|
||||
collaborators:
|
||||
List of collaborators. Empty if the area has no 'collaborators' key.
|
||||
|
||||
inform:
|
||||
List of people to inform on pull requests. Empty if the area has no
|
||||
'inform' key.
|
||||
|
||||
labels:
|
||||
List of GitHub labels for the area. Empty if the area has no 'labels'
|
||||
key.
|
||||
|
||||
description:
|
||||
Text from 'description' key, or None if the area has no 'description'
|
||||
key
|
||||
"""
|
||||
def _contains(self, path):
|
||||
# Returns True if the area contains 'path', and False otherwise
|
||||
|
||||
return self._match_fn and self._match_fn(path) and not \
|
||||
(self._exclude_match_fn and self._exclude_match_fn(path))
|
||||
|
||||
def __repr__(self):
|
||||
return "<Area {}>".format(self.name)
|
||||
|
||||
|
||||
def _print_areas(areas):
|
||||
first = True
|
||||
for area in sorted(areas, key=operator.attrgetter("name")):
|
||||
if not first:
|
||||
print()
|
||||
first = False
|
||||
|
||||
print("""\
|
||||
{}
|
||||
\tstatus: {}
|
||||
\tmaintainers: {}
|
||||
\tcollaborators: {}
|
||||
\tinform: {}
|
||||
\tlabels: {}
|
||||
\tdescription: {}""".format(area.name,
|
||||
area.status,
|
||||
", ".join(area.maintainers),
|
||||
", ".join(area.collaborators),
|
||||
", ".join(area.inform),
|
||||
", ".join(area.labels),
|
||||
area.description or ""))
|
||||
|
||||
|
||||
def _get_match_fn(globs, regexes):
|
||||
# Constructs a single regex that tests for matches against the globs in
|
||||
# 'globs' and the regexes in 'regexes'. Parts are joined with '|' (OR).
|
||||
# Returns the search() method of the compiled regex.
|
||||
#
|
||||
# Returns None if there are neither globs nor regexes, which should be
|
||||
# interpreted as no match.
|
||||
|
||||
if not (globs or regexes):
|
||||
return None
|
||||
|
||||
regex = ""
|
||||
|
||||
if globs:
|
||||
glob_regexes = []
|
||||
for glob in globs:
|
||||
# Construct a regex equivalent to the glob
|
||||
glob_regex = glob.replace(".", "\\.").replace("*", "[^/]*") \
|
||||
.replace("?", "[^/]")
|
||||
|
||||
if not glob.endswith("/"):
|
||||
# Require a full match for globs that don't end in /
|
||||
glob_regex += "$"
|
||||
|
||||
glob_regexes.append(glob_regex)
|
||||
|
||||
# The glob regexes must anchor to the beginning of the path, since we
|
||||
# return search(). (?:) is a non-capturing group.
|
||||
regex += "^(?:{})".format("|".join(glob_regexes))
|
||||
|
||||
if regexes:
|
||||
if regex:
|
||||
regex += "|"
|
||||
regex += "|".join(regexes)
|
||||
|
||||
return re.compile(regex).search
|
||||
|
||||
|
||||
def _load_maintainers(path):
|
||||
# Returns the parsed contents of the maintainers file 'filename', also
|
||||
# running checks on the contents. The returned format is plain Python
|
||||
# dicts/lists/etc., mirroring the structure of the file.
|
||||
|
||||
with open(path, encoding="utf-8") as f:
|
||||
try:
|
||||
yaml = load(f, Loader=Loader)
|
||||
except YAMLError as e:
|
||||
raise MaintainersError("{}: YAML error: {}".format(path, e))
|
||||
|
||||
_check_maintainers(path, yaml)
|
||||
return yaml
|
||||
|
||||
|
||||
def _check_maintainers(maints_path, yaml):
|
||||
# Checks the maintainers data in 'yaml', which comes from the maintainers
|
||||
# file at maints_path, which is a pathlib.Path instance
|
||||
|
||||
root = maints_path.parent
|
||||
|
||||
def ferr(msg):
|
||||
_err("{}: {}".format(maints_path, msg)) # Prepend the filename
|
||||
|
||||
if not isinstance(yaml, dict):
|
||||
ferr("empty or malformed YAML (not a dict)")
|
||||
|
||||
ok_keys = {"status", "maintainers", "collaborators", "inform", "files",
|
||||
"files-exclude", "files-regex", "files-regex-exclude",
|
||||
"labels", "description"}
|
||||
|
||||
ok_status = {"maintained", "odd fixes", "orphaned", "obsolete"}
|
||||
ok_status_s = ", ".join('"' + s + '"' for s in ok_status) # For messages
|
||||
|
||||
for area_name, area_dict in yaml.items():
|
||||
if not isinstance(area_dict, dict):
|
||||
ferr("malformed entry for area '{}' (not a dict)"
|
||||
.format(area_name))
|
||||
|
||||
for key in area_dict:
|
||||
if key not in ok_keys:
|
||||
ferr("unknown key '{}' in area '{}'"
|
||||
.format(key, area_name))
|
||||
|
||||
if "status" in area_dict and \
|
||||
area_dict["status"] not in ok_status:
|
||||
ferr("bad 'status' key on area '{}', should be one of {}"
|
||||
.format(area_name, ok_status_s))
|
||||
|
||||
if not area_dict.keys() & {"files", "files-regex"}:
|
||||
ferr("either 'files' or 'files-regex' (or both) must be specified "
|
||||
"for area '{}'".format(area_name))
|
||||
|
||||
for list_name in "maintainers", "collaborators", "inform", "files", \
|
||||
"files-regex", "labels":
|
||||
if list_name in area_dict:
|
||||
lst = area_dict[list_name]
|
||||
if not (isinstance(lst, list) and
|
||||
all(isinstance(elm, str) for elm in lst)):
|
||||
ferr("malformed '{}' value for area '{}' -- should "
|
||||
"be a list of strings".format(list_name, area_name))
|
||||
|
||||
for files_key in "files", "files-exclude":
|
||||
if files_key in area_dict:
|
||||
for glob_pattern in area_dict[files_key]:
|
||||
# This could be changed if it turns out to be too slow,
|
||||
# e.g. to only check non-globbing filenames. The tuple() is
|
||||
# needed due to pathlib's glob() returning a generator.
|
||||
paths = tuple(root.glob(glob_pattern))
|
||||
if not paths:
|
||||
ferr("glob pattern '{}' in '{}' in area '{}' does not "
|
||||
"match any files".format(glob_pattern, files_key,
|
||||
area_name))
|
||||
if not glob_pattern.endswith("/"):
|
||||
for path in paths:
|
||||
if path.is_dir():
|
||||
ferr("glob pattern '{}' in '{}' in area '{}' "
|
||||
"matches a directory, but has no "
|
||||
"trailing '/'"
|
||||
.format(glob_pattern, files_key,
|
||||
area_name))
|
||||
|
||||
for files_regex_key in "files-regex", "files-regex-exclude":
|
||||
if files_regex_key in area_dict:
|
||||
for regex in area_dict[files_regex_key]:
|
||||
try:
|
||||
re.compile(regex)
|
||||
except re.error as e:
|
||||
ferr("bad regular expression '{}' in '{}' in "
|
||||
"'{}': {}".format(regex, files_regex_key,
|
||||
area_name, e.msg))
|
||||
|
||||
if "description" in area_dict and \
|
||||
not isinstance(area_dict["description"], str):
|
||||
ferr("malformed 'description' value for area '{}' -- should be a "
|
||||
"string".format(area_name))
|
||||
|
||||
|
||||
def _git(*args):
|
||||
# Helper for running a Git command. Returns the rstrip()ed stdout output.
|
||||
# Called like git("diff"). Exits with SystemError (raised by sys.exit()) on
|
||||
# errors.
|
||||
|
||||
git_cmd = ("git",) + args
|
||||
git_cmd_s = " ".join(shlex.quote(word) for word in git_cmd) # For errors
|
||||
|
||||
try:
|
||||
git_process = subprocess.Popen(
|
||||
git_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
except FileNotFoundError:
|
||||
_giterr("git executable not found (when running '{}'). Check that "
|
||||
"it's in listed in the PATH environment variable"
|
||||
.format(git_cmd_s))
|
||||
except OSError as e:
|
||||
_giterr("error running '{}': {}".format(git_cmd_s, e))
|
||||
|
||||
stdout, stderr = git_process.communicate()
|
||||
if git_process.returncode:
|
||||
_giterr("error running '{}'\n\nstdout:\n{}\nstderr:\n{}".format(
|
||||
git_cmd_s, stdout.decode("utf-8"), stderr.decode("utf-8")))
|
||||
|
||||
return stdout.decode("utf-8").rstrip()
|
||||
|
||||
|
||||
def _ls_files(path=None):
|
||||
cmd = ["ls-files"]
|
||||
if path is not None:
|
||||
cmd.append(path)
|
||||
return _git(*cmd).splitlines()
|
||||
|
||||
|
||||
def _err(msg):
|
||||
raise MaintainersError(msg)
|
||||
|
||||
|
||||
def _giterr(msg):
|
||||
raise GitError(msg)
|
||||
|
||||
|
||||
def _serr(msg):
|
||||
# For reporting errors when get_maintainer.py is run as a script.
|
||||
# sys.exit() shouldn't be used otherwise.
|
||||
sys.exit("{}: error: {}".format(sys.argv[0], msg))
|
||||
|
||||
|
||||
class MaintainersError(Exception):
|
||||
"Exception raised for MAINTAINERS.yml-related errors"
|
||||
|
||||
|
||||
class GitError(Exception):
|
||||
"Exception raised for Git-related errors"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
_main()
|
Loading…
Add table
Add a link
Reference in a new issue