1*d5d63950SPatrick Williams#!/usr/bin/env python3 26cef255aSPatrick Williamsimport argparse 36cef255aSPatrick Williamsimport json 4ca1b89ebSPatrick Williamsimport os 5ca1b89ebSPatrick Williamsimport re 6*d5d63950SPatrick Williamsfrom typing import Dict, List, Optional, Set, TypedDict 76cef255aSPatrick Williams 8*d5d63950SPatrick Williamsimport yaml 9ca1b89ebSPatrick Williamsfrom sh import git # type: ignore 106cef255aSPatrick Williamsfrom yaml.loader import SafeLoader 116cef255aSPatrick Williams 126cef255aSPatrick Williams# A list of Gerrit users (email addresses). 13ca1b89ebSPatrick Williams# Some OWNERS files have empty lists for 'owners' or 'reviewers', which 14ca1b89ebSPatrick Williams# results in a None type for the value. 15ca1b89ebSPatrick WilliamsUsersList = Optional[List[str]] 166cef255aSPatrick Williams 17*d5d63950SPatrick Williams 186cef255aSPatrick Williams# A YAML node with an extra line number. 196cef255aSPatrick Williamsclass NumberedNode(TypedDict): 206cef255aSPatrick Williams line_number: int 216cef255aSPatrick Williams 226cef255aSPatrick Williams 23ca1b89ebSPatrick Williamsclass MatchEntry(TypedDict, total=False): 24ca1b89ebSPatrick Williams suffix: str 25ca1b89ebSPatrick Williams regex: str 26ca1b89ebSPatrick Williams partial_regex: str 27ca1b89ebSPatrick Williams exact: str 28ca1b89ebSPatrick Williams owners: UsersList 29ca1b89ebSPatrick Williams reviewers: UsersList 30ca1b89ebSPatrick Williams 31ca1b89ebSPatrick Williams 326cef255aSPatrick Williams# The root YAML node of an OWNERS file 336cef255aSPatrick Williamsclass OwnersData(NumberedNode, TypedDict, total=False): 346cef255aSPatrick Williams owners: UsersList 356cef255aSPatrick Williams reviewers: UsersList 36ca1b89ebSPatrick Williams matchers: List[MatchEntry] 376cef255aSPatrick Williams 386cef255aSPatrick Williams 396cef255aSPatrick Williams# A YAML loader that adds the start line number onto each node (for 406cef255aSPatrick Williams# later linting support) 416cef255aSPatrick Williamsclass YamlLoader(SafeLoader): 426cef255aSPatrick Williams def construct_mapping( 436cef255aSPatrick Williams self, node: yaml.nodes.Node, deep: bool = False 446cef255aSPatrick Williams ) -> NumberedNode: 456cef255aSPatrick Williams mapping: NumberedNode = super(YamlLoader, self).construct_mapping( 466cef255aSPatrick Williams node, deep=deep 476cef255aSPatrick Williams ) # type: ignore 486cef255aSPatrick Williams mapping["line_number"] = node.start_mark.line + 1 496cef255aSPatrick Williams return mapping 506cef255aSPatrick Williams 516cef255aSPatrick Williams # Load a file and return the OwnersData. 526cef255aSPatrick Williams @staticmethod 536cef255aSPatrick Williams def load(file: str) -> OwnersData: 546cef255aSPatrick Williams data: OwnersData 556cef255aSPatrick Williams with open(file, "r") as f: 566cef255aSPatrick Williams data = yaml.load(f, Loader=YamlLoader) 576cef255aSPatrick Williams return data 586cef255aSPatrick Williams 596cef255aSPatrick Williams 606cef255aSPatrick Williams# Class to match commit information with OWNERS files. 616cef255aSPatrick Williamsclass CommitMatch: 628cfff0d5SPatrick Williams def __init__( 638cfff0d5SPatrick Williams self, args: argparse.Namespace, owners: Dict[str, OwnersData] 648cfff0d5SPatrick Williams ): 65ca1b89ebSPatrick Williams files: Set[str] = set( 66ca1b89ebSPatrick Williams git.bake("-C", args.path) 67ca1b89ebSPatrick Williams .show(args.commit, pretty="", name_only=True, _tty_out=False) 68ca1b89ebSPatrick Williams .splitlines() 69ca1b89ebSPatrick Williams ) 706cef255aSPatrick Williams 718cfff0d5SPatrick Williams self.owners: Set[str] = set() 728cfff0d5SPatrick Williams self.reviewers: Set[str] = set() 738cfff0d5SPatrick Williams 748cfff0d5SPatrick Williams for f in files: 758cfff0d5SPatrick Williams path = f 768cfff0d5SPatrick Williams 778cfff0d5SPatrick Williams while True: 788cfff0d5SPatrick Williams path = os.path.dirname(path) 798cfff0d5SPatrick Williams 808cfff0d5SPatrick Williams if path not in owners: 818cfff0d5SPatrick Williams if not path: 828cfff0d5SPatrick Williams break 838cfff0d5SPatrick Williams continue 848cfff0d5SPatrick Williams 858cfff0d5SPatrick Williams local_owners = owners[path] 868cfff0d5SPatrick Williams 878cfff0d5SPatrick Williams self.owners = self.owners.union( 888cfff0d5SPatrick Williams local_owners.get("owners") or [] 898cfff0d5SPatrick Williams ) 908cfff0d5SPatrick Williams self.reviewers = self.reviewers.union( 918cfff0d5SPatrick Williams local_owners.get("reviewers") or [] 928cfff0d5SPatrick Williams ) 938cfff0d5SPatrick Williams 948cfff0d5SPatrick Williams rel_file = os.path.relpath(f, path) 958cfff0d5SPatrick Williams 968cfff0d5SPatrick Williams for e in local_owners.get("matchers", None) or []: 97ca1b89ebSPatrick Williams if "exact" in e: 988cfff0d5SPatrick Williams self.__exact(rel_file, e) 99ca1b89ebSPatrick Williams elif "partial_regex" in e: 1008cfff0d5SPatrick Williams self.__partial_regex(rel_file, e) 101ca1b89ebSPatrick Williams elif "regex" in e: 1028cfff0d5SPatrick Williams self.__regex(rel_file, e) 103ca1b89ebSPatrick Williams elif "suffix" in e: 1048cfff0d5SPatrick Williams self.__suffix(rel_file, e) 1058cfff0d5SPatrick Williams 1068cfff0d5SPatrick Williams if not path: 1078cfff0d5SPatrick Williams break 108ca1b89ebSPatrick Williams 109ca1b89ebSPatrick Williams self.reviewers = self.reviewers.difference(self.owners) 110ca1b89ebSPatrick Williams 111ca1b89ebSPatrick Williams def __add_entry(self, entry: MatchEntry) -> None: 112ca1b89ebSPatrick Williams self.owners = self.owners.union(entry.get("owners") or []) 113ca1b89ebSPatrick Williams self.reviewers = self.reviewers.union(entry.get("reviewers") or []) 114ca1b89ebSPatrick Williams 1158cfff0d5SPatrick Williams def __exact(self, file: str, entry: MatchEntry) -> None: 1168cfff0d5SPatrick Williams if file == entry["exact"]: 117ca1b89ebSPatrick Williams self.__add_entry(entry) 118ca1b89ebSPatrick Williams 1198cfff0d5SPatrick Williams def __partial_regex(self, file: str, entry: MatchEntry) -> None: 1208cfff0d5SPatrick Williams if re.search(entry["partial_regex"], file): 121ca1b89ebSPatrick Williams self.__add_entry(entry) 122ca1b89ebSPatrick Williams 1238cfff0d5SPatrick Williams def __regex(self, file: str, entry: MatchEntry) -> None: 1248cfff0d5SPatrick Williams if re.fullmatch(entry["regex"], file): 125ca1b89ebSPatrick Williams self.__add_entry(entry) 126ca1b89ebSPatrick Williams 1278cfff0d5SPatrick Williams def __suffix(self, file: str, entry: MatchEntry) -> None: 1288cfff0d5SPatrick Williams if os.path.splitext(file)[1] == entry["suffix"]: 129ca1b89ebSPatrick Williams self.__add_entry(entry) 1306cef255aSPatrick Williams 1316cef255aSPatrick Williams 1326cef255aSPatrick Williams# The subcommand to get the reviewers. 1338cfff0d5SPatrick Williamsdef subcmd_reviewers( 1348cfff0d5SPatrick Williams args: argparse.Namespace, data: Dict[str, OwnersData] 1358cfff0d5SPatrick Williams) -> None: 136ca1b89ebSPatrick Williams matcher = CommitMatch(args, data) 1376cef255aSPatrick Williams 1386cef255aSPatrick Williams # Print in `git push refs/for/branch%<reviewers>` format. 1396cef255aSPatrick Williams if args.push_args: 1406cef255aSPatrick Williams result = [] 141ca1b89ebSPatrick Williams for o in sorted(matcher.owners): 1426cef255aSPatrick Williams # Gerrit uses 'r' for the required reviewers (owners). 1436cef255aSPatrick Williams result.append(f"r={o}") 144ca1b89ebSPatrick Williams for r in sorted(matcher.reviewers): 1456cef255aSPatrick Williams # Gerrit uses 'cc' for the optional reviewers. 1466cef255aSPatrick Williams result.append(f"cc={r}") 1476cef255aSPatrick Williams print(",".join(result)) 1486cef255aSPatrick Williams # Print as Gerrit Add Reviewers POST format. 1496cef255aSPatrick Williams # https://gerrit.openbmc.org/Documentation/rest-api-changes.html#add-reviewer 1506cef255aSPatrick Williams else: 15129986f19SPatrick Williams 15229986f19SPatrick Williams def review_js(reviewer: str, state: str) -> str: 15329986f19SPatrick Williams return json.dumps( 15429986f19SPatrick Williams { 15529986f19SPatrick Williams "reviewer": reviewer, 15629986f19SPatrick Williams "state": state, 15729986f19SPatrick Williams "notify": "NONE", 15829986f19SPatrick Williams "notify_details": {"TO": {"accounts": [reviewer]}}, 15929986f19SPatrick Williams } 16029986f19SPatrick Williams ) 16129986f19SPatrick Williams 162ca1b89ebSPatrick Williams for o in sorted(matcher.owners): 16329986f19SPatrick Williams print(review_js(o, "REVIEWER")) 164ca1b89ebSPatrick Williams for r in sorted(matcher.reviewers): 16529986f19SPatrick Williams print(review_js(r, "CC")) 1666cef255aSPatrick Williams 1676cef255aSPatrick Williams 1686cef255aSPatrick Williamsdef main() -> None: 1696cef255aSPatrick Williams parser = argparse.ArgumentParser() 1706cef255aSPatrick Williams parser.add_argument( 1716cef255aSPatrick Williams "-p", "--path", default=".", help="Root path to analyse" 1726cef255aSPatrick Williams ) 1736cef255aSPatrick Williams subparsers = parser.add_subparsers() 1746cef255aSPatrick Williams 1756cef255aSPatrick Williams parser_reviewers = subparsers.add_parser( 1766cef255aSPatrick Williams "reviewers", help="Generate List of Reviewers" 1776cef255aSPatrick Williams ) 1786cef255aSPatrick Williams parser_reviewers.add_argument( 1796cef255aSPatrick Williams "--push-args", 18047b59dc8SPatrick Williams default=False, 18129986f19SPatrick Williams action="store_true", 1826cef255aSPatrick Williams help="Format as git push options", 1836cef255aSPatrick Williams ) 184ca1b89ebSPatrick Williams parser_reviewers.add_argument( 185ca1b89ebSPatrick Williams "--commit", 186ca1b89ebSPatrick Williams default="HEAD", 187ca1b89ebSPatrick Williams help="Commit(s) to match against", 188ca1b89ebSPatrick Williams ) 1896cef255aSPatrick Williams parser_reviewers.set_defaults(func=subcmd_reviewers) 1906cef255aSPatrick Williams 1916cef255aSPatrick Williams args = parser.parse_args() 1926cef255aSPatrick Williams 1938cfff0d5SPatrick Williams owners_files = git.bake("-C", args.path)( 1948cfff0d5SPatrick Williams "ls-files", "OWNERS", "**/OWNERS" 1958cfff0d5SPatrick Williams ).splitlines() 1968cfff0d5SPatrick Williams 1978cfff0d5SPatrick Williams files = {} 1988cfff0d5SPatrick Williams for f in owners_files: 1998cfff0d5SPatrick Williams file = YamlLoader.load(os.path.join(args.path, f)) 2008cfff0d5SPatrick Williams dirpath = os.path.dirname(f) 2018cfff0d5SPatrick Williams files[dirpath] = file 2028cfff0d5SPatrick Williams 2038cfff0d5SPatrick Williams args.func(args, files) 2046cef255aSPatrick Williams 2056cef255aSPatrick Williams 2066cef255aSPatrick Williamsif __name__ == "__main__": 2076cef255aSPatrick Williams main() 208