16cef255aSPatrick Williams#!/bin/env python3 26cef255aSPatrick Williamsimport argparse 36cef255aSPatrick Williamsimport json 4ca1b89ebSPatrick Williamsimport os 5ca1b89ebSPatrick Williamsimport re 66cef255aSPatrick Williamsimport yaml 76cef255aSPatrick Williams 8ca1b89ebSPatrick Williamsfrom sh import git # type: ignore 9*8cfff0d5SPatrick Williamsfrom typing import Dict, List, Set, TypedDict, Optional 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 176cef255aSPatrick Williams# A YAML node with an extra line number. 186cef255aSPatrick Williamsclass NumberedNode(TypedDict): 196cef255aSPatrick Williams line_number: int 206cef255aSPatrick Williams 216cef255aSPatrick Williams 22ca1b89ebSPatrick Williamsclass MatchEntry(TypedDict, total=False): 23ca1b89ebSPatrick Williams suffix: str 24ca1b89ebSPatrick Williams regex: str 25ca1b89ebSPatrick Williams partial_regex: str 26ca1b89ebSPatrick Williams exact: str 27ca1b89ebSPatrick Williams owners: UsersList 28ca1b89ebSPatrick Williams reviewers: UsersList 29ca1b89ebSPatrick Williams 30ca1b89ebSPatrick Williams 316cef255aSPatrick Williams# The root YAML node of an OWNERS file 326cef255aSPatrick Williamsclass OwnersData(NumberedNode, TypedDict, total=False): 336cef255aSPatrick Williams owners: UsersList 346cef255aSPatrick Williams reviewers: UsersList 35ca1b89ebSPatrick Williams matchers: List[MatchEntry] 366cef255aSPatrick Williams 376cef255aSPatrick Williams 386cef255aSPatrick Williams# A YAML loader that adds the start line number onto each node (for 396cef255aSPatrick Williams# later linting support) 406cef255aSPatrick Williamsclass YamlLoader(SafeLoader): 416cef255aSPatrick Williams def construct_mapping( 426cef255aSPatrick Williams self, node: yaml.nodes.Node, deep: bool = False 436cef255aSPatrick Williams ) -> NumberedNode: 446cef255aSPatrick Williams mapping: NumberedNode = super(YamlLoader, self).construct_mapping( 456cef255aSPatrick Williams node, deep=deep 466cef255aSPatrick Williams ) # type: ignore 476cef255aSPatrick Williams mapping["line_number"] = node.start_mark.line + 1 486cef255aSPatrick Williams return mapping 496cef255aSPatrick Williams 506cef255aSPatrick Williams # Load a file and return the OwnersData. 516cef255aSPatrick Williams @staticmethod 526cef255aSPatrick Williams def load(file: str) -> OwnersData: 536cef255aSPatrick Williams data: OwnersData 546cef255aSPatrick Williams with open(file, "r") as f: 556cef255aSPatrick Williams data = yaml.load(f, Loader=YamlLoader) 566cef255aSPatrick Williams return data 576cef255aSPatrick Williams 586cef255aSPatrick Williams 596cef255aSPatrick Williams# Class to match commit information with OWNERS files. 606cef255aSPatrick Williamsclass CommitMatch: 61*8cfff0d5SPatrick Williams def __init__( 62*8cfff0d5SPatrick Williams self, args: argparse.Namespace, owners: Dict[str, OwnersData] 63*8cfff0d5SPatrick Williams ): 64ca1b89ebSPatrick Williams files: Set[str] = set( 65ca1b89ebSPatrick Williams git.bake("-C", args.path) 66ca1b89ebSPatrick Williams .show(args.commit, pretty="", name_only=True, _tty_out=False) 67ca1b89ebSPatrick Williams .splitlines() 68ca1b89ebSPatrick Williams ) 696cef255aSPatrick Williams 70*8cfff0d5SPatrick Williams root_owners = owners[""] 716cef255aSPatrick Williams 72*8cfff0d5SPatrick Williams self.owners: Set[str] = set() 73*8cfff0d5SPatrick Williams self.reviewers: Set[str] = set() 74*8cfff0d5SPatrick Williams 75*8cfff0d5SPatrick Williams for f in files: 76*8cfff0d5SPatrick Williams path = f 77*8cfff0d5SPatrick Williams 78*8cfff0d5SPatrick Williams while True: 79*8cfff0d5SPatrick Williams path = os.path.dirname(path) 80*8cfff0d5SPatrick Williams 81*8cfff0d5SPatrick Williams if path not in owners: 82*8cfff0d5SPatrick Williams if not path: 83*8cfff0d5SPatrick Williams break 84*8cfff0d5SPatrick Williams continue 85*8cfff0d5SPatrick Williams 86*8cfff0d5SPatrick Williams local_owners = owners[path] 87*8cfff0d5SPatrick Williams 88*8cfff0d5SPatrick Williams self.owners = self.owners.union( 89*8cfff0d5SPatrick Williams local_owners.get("owners") or [] 90*8cfff0d5SPatrick Williams ) 91*8cfff0d5SPatrick Williams self.reviewers = self.reviewers.union( 92*8cfff0d5SPatrick Williams local_owners.get("reviewers") or [] 93*8cfff0d5SPatrick Williams ) 94*8cfff0d5SPatrick Williams 95*8cfff0d5SPatrick Williams rel_file = os.path.relpath(f, path) 96*8cfff0d5SPatrick Williams 97*8cfff0d5SPatrick Williams for e in local_owners.get("matchers", None) or []: 98ca1b89ebSPatrick Williams if "exact" in e: 99*8cfff0d5SPatrick Williams self.__exact(rel_file, e) 100ca1b89ebSPatrick Williams elif "partial_regex" in e: 101*8cfff0d5SPatrick Williams self.__partial_regex(rel_file, e) 102ca1b89ebSPatrick Williams elif "regex" in e: 103*8cfff0d5SPatrick Williams self.__regex(rel_file, e) 104ca1b89ebSPatrick Williams elif "suffix" in e: 105*8cfff0d5SPatrick Williams self.__suffix(rel_file, e) 106*8cfff0d5SPatrick Williams 107*8cfff0d5SPatrick Williams if not path: 108*8cfff0d5SPatrick Williams break 109ca1b89ebSPatrick Williams 110ca1b89ebSPatrick Williams self.reviewers = self.reviewers.difference(self.owners) 111ca1b89ebSPatrick Williams 112ca1b89ebSPatrick Williams def __add_entry(self, entry: MatchEntry) -> None: 113ca1b89ebSPatrick Williams self.owners = self.owners.union(entry.get("owners") or []) 114ca1b89ebSPatrick Williams self.reviewers = self.reviewers.union(entry.get("reviewers") or []) 115ca1b89ebSPatrick Williams 116*8cfff0d5SPatrick Williams def __exact(self, file: str, entry: MatchEntry) -> None: 117*8cfff0d5SPatrick Williams if file == entry["exact"]: 118ca1b89ebSPatrick Williams self.__add_entry(entry) 119ca1b89ebSPatrick Williams 120*8cfff0d5SPatrick Williams def __partial_regex(self, file: str, entry: MatchEntry) -> None: 121*8cfff0d5SPatrick Williams if re.search(entry["partial_regex"], file): 122ca1b89ebSPatrick Williams self.__add_entry(entry) 123ca1b89ebSPatrick Williams 124*8cfff0d5SPatrick Williams def __regex(self, file: str, entry: MatchEntry) -> None: 125*8cfff0d5SPatrick Williams if re.fullmatch(entry["regex"], file): 126ca1b89ebSPatrick Williams self.__add_entry(entry) 127ca1b89ebSPatrick Williams 128*8cfff0d5SPatrick Williams def __suffix(self, file: str, entry: MatchEntry) -> None: 129*8cfff0d5SPatrick Williams if os.path.splitext(file)[1] == entry["suffix"]: 130ca1b89ebSPatrick Williams self.__add_entry(entry) 1316cef255aSPatrick Williams 1326cef255aSPatrick Williams 1336cef255aSPatrick Williams# The subcommand to get the reviewers. 134*8cfff0d5SPatrick Williamsdef subcmd_reviewers( 135*8cfff0d5SPatrick Williams args: argparse.Namespace, data: Dict[str, OwnersData] 136*8cfff0d5SPatrick Williams) -> None: 137ca1b89ebSPatrick Williams matcher = CommitMatch(args, data) 1386cef255aSPatrick Williams 1396cef255aSPatrick Williams # Print in `git push refs/for/branch%<reviewers>` format. 1406cef255aSPatrick Williams if args.push_args: 1416cef255aSPatrick Williams result = [] 142ca1b89ebSPatrick Williams for o in sorted(matcher.owners): 1436cef255aSPatrick Williams # Gerrit uses 'r' for the required reviewers (owners). 1446cef255aSPatrick Williams result.append(f"r={o}") 145ca1b89ebSPatrick Williams for r in sorted(matcher.reviewers): 1466cef255aSPatrick Williams # Gerrit uses 'cc' for the optional reviewers. 1476cef255aSPatrick Williams result.append(f"cc={r}") 1486cef255aSPatrick Williams print(",".join(result)) 1496cef255aSPatrick Williams # Print as Gerrit Add Reviewers POST format. 1506cef255aSPatrick Williams # https://gerrit.openbmc.org/Documentation/rest-api-changes.html#add-reviewer 1516cef255aSPatrick Williams else: 15229986f19SPatrick Williams 15329986f19SPatrick Williams def review_js(reviewer: str, state: str) -> str: 15429986f19SPatrick Williams return json.dumps( 15529986f19SPatrick Williams { 15629986f19SPatrick Williams "reviewer": reviewer, 15729986f19SPatrick Williams "state": state, 15829986f19SPatrick Williams "notify": "NONE", 15929986f19SPatrick Williams "notify_details": {"TO": {"accounts": [reviewer]}}, 16029986f19SPatrick Williams } 16129986f19SPatrick Williams ) 16229986f19SPatrick Williams 163ca1b89ebSPatrick Williams for o in sorted(matcher.owners): 16429986f19SPatrick Williams print(review_js(o, "REVIEWER")) 165ca1b89ebSPatrick Williams for r in sorted(matcher.reviewers): 16629986f19SPatrick Williams print(review_js(r, "CC")) 1676cef255aSPatrick Williams 1686cef255aSPatrick Williams 1696cef255aSPatrick Williamsdef main() -> None: 1706cef255aSPatrick Williams parser = argparse.ArgumentParser() 1716cef255aSPatrick Williams parser.add_argument( 1726cef255aSPatrick Williams "-p", "--path", default=".", help="Root path to analyse" 1736cef255aSPatrick Williams ) 1746cef255aSPatrick Williams subparsers = parser.add_subparsers() 1756cef255aSPatrick Williams 1766cef255aSPatrick Williams parser_reviewers = subparsers.add_parser( 1776cef255aSPatrick Williams "reviewers", help="Generate List of Reviewers" 1786cef255aSPatrick Williams ) 1796cef255aSPatrick Williams parser_reviewers.add_argument( 1806cef255aSPatrick Williams "--push-args", 18147b59dc8SPatrick Williams default=False, 18229986f19SPatrick Williams action="store_true", 1836cef255aSPatrick Williams help="Format as git push options", 1846cef255aSPatrick Williams ) 185ca1b89ebSPatrick Williams parser_reviewers.add_argument( 186ca1b89ebSPatrick Williams "--commit", 187ca1b89ebSPatrick Williams default="HEAD", 188ca1b89ebSPatrick Williams help="Commit(s) to match against", 189ca1b89ebSPatrick Williams ) 1906cef255aSPatrick Williams parser_reviewers.set_defaults(func=subcmd_reviewers) 1916cef255aSPatrick Williams 1926cef255aSPatrick Williams args = parser.parse_args() 1936cef255aSPatrick Williams 194*8cfff0d5SPatrick Williams owners_files = git.bake("-C", args.path)( 195*8cfff0d5SPatrick Williams "ls-files", "OWNERS", "**/OWNERS" 196*8cfff0d5SPatrick Williams ).splitlines() 197*8cfff0d5SPatrick Williams 198*8cfff0d5SPatrick Williams files = {} 199*8cfff0d5SPatrick Williams for f in owners_files: 200*8cfff0d5SPatrick Williams file = YamlLoader.load(os.path.join(args.path, f)) 201*8cfff0d5SPatrick Williams dirpath = os.path.dirname(f) 202*8cfff0d5SPatrick Williams files[dirpath] = file 203*8cfff0d5SPatrick Williams 204*8cfff0d5SPatrick Williams args.func(args, files) 2056cef255aSPatrick Williams 2066cef255aSPatrick Williams 2076cef255aSPatrick Williamsif __name__ == "__main__": 2086cef255aSPatrick Williams main() 209