blob: 752967b64887906d25b713c36cb43abe65783607 [file] [log] [blame]
#!/usr/bin/env python3
import argparse
import json
import os
import re
from typing import Dict, List, Optional, Set, TypedDict
import yaml
from sh import git # type: ignore
from yaml.loader import SafeLoader
# A list of Gerrit users (email addresses).
# Some OWNERS files have empty lists for 'owners' or 'reviewers', which
# results in a None type for the value.
UsersList = Optional[List[str]]
# A YAML node with an extra line number.
class NumberedNode(TypedDict):
line_number: int
class MatchEntry(TypedDict, total=False):
suffix: str
regex: str
partial_regex: str
exact: str
owners: UsersList
reviewers: UsersList
# The root YAML node of an OWNERS file
class OwnersData(NumberedNode, TypedDict, total=False):
owners: UsersList
reviewers: UsersList
matchers: List[MatchEntry]
# A YAML loader that adds the start line number onto each node (for
# later linting support)
class YamlLoader(SafeLoader):
def construct_mapping(
self, node: yaml.nodes.Node, deep: bool = False
) -> NumberedNode:
mapping: NumberedNode = super(YamlLoader, self).construct_mapping(
node, deep=deep
) # type: ignore
mapping["line_number"] = node.start_mark.line + 1
return mapping
# Load a file and return the OwnersData.
@staticmethod
def load(file: str) -> OwnersData:
data: OwnersData
with open(file, "r") as f:
data = yaml.load(f, Loader=YamlLoader)
return data
# Class to match commit information with OWNERS files.
class CommitMatch:
def __init__(
self, args: argparse.Namespace, owners: Dict[str, OwnersData]
):
files: Set[str] = set(
git.bake("-C", args.path)
.show(args.commit, pretty="", name_only=True, _tty_out=False)
.splitlines()
)
self.owners: Set[str] = set()
self.reviewers: Set[str] = set()
for f in files:
path = f
while True:
path = os.path.dirname(path)
if path not in owners:
if not path:
break
continue
local_owners = owners[path]
self.owners = self.owners.union(
local_owners.get("owners") or []
)
self.reviewers = self.reviewers.union(
local_owners.get("reviewers") or []
)
rel_file = os.path.relpath(f, path)
for e in local_owners.get("matchers", None) or []:
if "exact" in e:
self.__exact(rel_file, e)
elif "partial_regex" in e:
self.__partial_regex(rel_file, e)
elif "regex" in e:
self.__regex(rel_file, e)
elif "suffix" in e:
self.__suffix(rel_file, e)
if not path:
break
self.reviewers = self.reviewers.difference(self.owners)
def __add_entry(self, entry: MatchEntry) -> None:
self.owners = self.owners.union(entry.get("owners") or [])
self.reviewers = self.reviewers.union(entry.get("reviewers") or [])
def __exact(self, file: str, entry: MatchEntry) -> None:
if file == entry["exact"]:
self.__add_entry(entry)
def __partial_regex(self, file: str, entry: MatchEntry) -> None:
if re.search(entry["partial_regex"], file):
self.__add_entry(entry)
def __regex(self, file: str, entry: MatchEntry) -> None:
if re.fullmatch(entry["regex"], file):
self.__add_entry(entry)
def __suffix(self, file: str, entry: MatchEntry) -> None:
if os.path.splitext(file)[1] == entry["suffix"]:
self.__add_entry(entry)
# The subcommand to get the reviewers.
def subcmd_reviewers(
args: argparse.Namespace, data: Dict[str, OwnersData]
) -> None:
matcher = CommitMatch(args, data)
# Print in `git push refs/for/branch%<reviewers>` format.
if args.push_args:
result = []
for o in sorted(matcher.owners):
# Gerrit uses 'r' for the required reviewers (owners).
result.append(f"r={o}")
for r in sorted(matcher.reviewers):
# Gerrit uses 'cc' for the optional reviewers.
result.append(f"cc={r}")
print(",".join(result))
# Print as Gerrit Add Reviewers POST format.
# https://gerrit.openbmc.org/Documentation/rest-api-changes.html#add-reviewer
else:
def review_js(reviewer: str, state: str) -> str:
return json.dumps(
{
"reviewer": reviewer,
"state": state,
"notify": "NONE",
"notify_details": {"TO": {"accounts": [reviewer]}},
}
)
for o in sorted(matcher.owners):
print(review_js(o, "REVIEWER"))
for r in sorted(matcher.reviewers):
print(review_js(r, "CC"))
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument(
"-p", "--path", default=".", help="Root path to analyse"
)
subparsers = parser.add_subparsers()
parser_reviewers = subparsers.add_parser(
"reviewers", help="Generate List of Reviewers"
)
parser_reviewers.add_argument(
"--push-args",
default=False,
action="store_true",
help="Format as git push options",
)
parser_reviewers.add_argument(
"--commit",
default="HEAD",
help="Commit(s) to match against",
)
parser_reviewers.set_defaults(func=subcmd_reviewers)
args = parser.parse_args()
owners_files = git.bake("-C", args.path)(
"ls-files", "OWNERS", "**/OWNERS"
).splitlines()
files = {}
for f in owners_files:
file = YamlLoader.load(os.path.join(args.path, f))
dirpath = os.path.dirname(f)
files[dirpath] = file
args.func(args, files)
if __name__ == "__main__":
main()