#!/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%` 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()