1#!/bin/env python3 2import argparse 3import json 4import os 5import re 6import yaml 7 8from sh import git # type: ignore 9from typing import List, Set, TypedDict, Optional 10from yaml.loader import SafeLoader 11 12# A list of Gerrit users (email addresses). 13# Some OWNERS files have empty lists for 'owners' or 'reviewers', which 14# results in a None type for the value. 15UsersList = Optional[List[str]] 16 17# A YAML node with an extra line number. 18class NumberedNode(TypedDict): 19 line_number: int 20 21 22class MatchEntry(TypedDict, total=False): 23 suffix: str 24 regex: str 25 partial_regex: str 26 exact: str 27 owners: UsersList 28 reviewers: UsersList 29 30 31# The root YAML node of an OWNERS file 32class OwnersData(NumberedNode, TypedDict, total=False): 33 owners: UsersList 34 reviewers: UsersList 35 matchers: List[MatchEntry] 36 37 38# A YAML loader that adds the start line number onto each node (for 39# later linting support) 40class YamlLoader(SafeLoader): 41 def construct_mapping( 42 self, node: yaml.nodes.Node, deep: bool = False 43 ) -> NumberedNode: 44 mapping: NumberedNode = super(YamlLoader, self).construct_mapping( 45 node, deep=deep 46 ) # type: ignore 47 mapping["line_number"] = node.start_mark.line + 1 48 return mapping 49 50 # Load a file and return the OwnersData. 51 @staticmethod 52 def load(file: str) -> OwnersData: 53 data: OwnersData 54 with open(file, "r") as f: 55 data = yaml.load(f, Loader=YamlLoader) 56 return data 57 58 59# Class to match commit information with OWNERS files. 60class CommitMatch: 61 def __init__(self, args: argparse.Namespace, owners: OwnersData): 62 files: Set[str] = set( 63 git.bake("-C", args.path) 64 .show(args.commit, pretty="", name_only=True, _tty_out=False) 65 .splitlines() 66 ) 67 68 self.owners: Set[str] = set(owners.get("owners") or []) 69 self.reviewers: Set[str] = set(owners.get("reviewers") or []) 70 71 for e in owners.get("matchers", []): 72 if "exact" in e: 73 self.__exact(files, e) 74 elif "partial_regex" in e: 75 self.__partial_regex(files, e) 76 elif "regex" in e: 77 self.__regex(files, e) 78 elif "suffix" in e: 79 self.__suffix(files, e) 80 81 self.reviewers = self.reviewers.difference(self.owners) 82 83 def __add_entry(self, entry: MatchEntry) -> None: 84 self.owners = self.owners.union(entry.get("owners") or []) 85 self.reviewers = self.reviewers.union(entry.get("reviewers") or []) 86 87 def __exact(self, files: Set[str], entry: MatchEntry) -> None: 88 for f in files: 89 if f == entry["exact"]: 90 self.__add_entry(entry) 91 92 def __partial_regex(self, files: Set[str], entry: MatchEntry) -> None: 93 for f in files: 94 if re.search(entry["partial_regex"], f): 95 self.__add_entry(entry) 96 97 def __regex(self, files: Set[str], entry: MatchEntry) -> None: 98 for f in files: 99 if re.fullmatch(entry["regex"], f): 100 self.__add_entry(entry) 101 102 def __suffix(self, files: Set[str], entry: MatchEntry) -> None: 103 for f in files: 104 if os.path.splitext(f)[1] == entry["suffix"]: 105 self.__add_entry(entry) 106 107 108# The subcommand to get the reviewers. 109def subcmd_reviewers(args: argparse.Namespace, data: OwnersData) -> None: 110 matcher = CommitMatch(args, data) 111 112 # Print in `git push refs/for/branch%<reviewers>` format. 113 if args.push_args: 114 result = [] 115 for o in sorted(matcher.owners): 116 # Gerrit uses 'r' for the required reviewers (owners). 117 result.append(f"r={o}") 118 for r in sorted(matcher.reviewers): 119 # Gerrit uses 'cc' for the optional reviewers. 120 result.append(f"cc={r}") 121 print(",".join(result)) 122 # Print as Gerrit Add Reviewers POST format. 123 # https://gerrit.openbmc.org/Documentation/rest-api-changes.html#add-reviewer 124 else: 125 for o in sorted(matcher.owners): 126 print(json.dumps({"reviewer": o, "state": "REVIEWER"})) 127 for r in sorted(matcher.reviewers): 128 print(json.dumps({"reviewer": r, "state": "CC"})) 129 130 131def main() -> None: 132 parser = argparse.ArgumentParser() 133 parser.add_argument( 134 "-p", "--path", default=".", help="Root path to analyse" 135 ) 136 subparsers = parser.add_subparsers() 137 138 parser_reviewers = subparsers.add_parser( 139 "reviewers", help="Generate List of Reviewers" 140 ) 141 parser_reviewers.add_argument( 142 "--push-args", 143 action=argparse.BooleanOptionalAction, 144 help="Format as git push options", 145 ) 146 parser_reviewers.add_argument( 147 "--commit", 148 default="HEAD", 149 help="Commit(s) to match against", 150 ) 151 parser_reviewers.set_defaults(func=subcmd_reviewers) 152 153 args = parser.parse_args() 154 155 file = YamlLoader.load(args.path + "/OWNERS") 156 args.func(args, file) 157 158 159if __name__ == "__main__": 160 main() 161