1#!/bin/env python3 2import argparse 3import json 4import os 5import re 6import yaml 7 8from sh import git # type: ignore 9from typing import Dict, 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__( 62 self, args: argparse.Namespace, owners: Dict[str, OwnersData] 63 ): 64 files: Set[str] = set( 65 git.bake("-C", args.path) 66 .show(args.commit, pretty="", name_only=True, _tty_out=False) 67 .splitlines() 68 ) 69 70 root_owners = owners[""] 71 72 self.owners: Set[str] = set() 73 self.reviewers: Set[str] = set() 74 75 for f in files: 76 path = f 77 78 while True: 79 path = os.path.dirname(path) 80 81 if path not in owners: 82 if not path: 83 break 84 continue 85 86 local_owners = owners[path] 87 88 self.owners = self.owners.union( 89 local_owners.get("owners") or [] 90 ) 91 self.reviewers = self.reviewers.union( 92 local_owners.get("reviewers") or [] 93 ) 94 95 rel_file = os.path.relpath(f, path) 96 97 for e in local_owners.get("matchers", None) or []: 98 if "exact" in e: 99 self.__exact(rel_file, e) 100 elif "partial_regex" in e: 101 self.__partial_regex(rel_file, e) 102 elif "regex" in e: 103 self.__regex(rel_file, e) 104 elif "suffix" in e: 105 self.__suffix(rel_file, e) 106 107 if not path: 108 break 109 110 self.reviewers = self.reviewers.difference(self.owners) 111 112 def __add_entry(self, entry: MatchEntry) -> None: 113 self.owners = self.owners.union(entry.get("owners") or []) 114 self.reviewers = self.reviewers.union(entry.get("reviewers") or []) 115 116 def __exact(self, file: str, entry: MatchEntry) -> None: 117 if file == entry["exact"]: 118 self.__add_entry(entry) 119 120 def __partial_regex(self, file: str, entry: MatchEntry) -> None: 121 if re.search(entry["partial_regex"], file): 122 self.__add_entry(entry) 123 124 def __regex(self, file: str, entry: MatchEntry) -> None: 125 if re.fullmatch(entry["regex"], file): 126 self.__add_entry(entry) 127 128 def __suffix(self, file: str, entry: MatchEntry) -> None: 129 if os.path.splitext(file)[1] == entry["suffix"]: 130 self.__add_entry(entry) 131 132 133# The subcommand to get the reviewers. 134def subcmd_reviewers( 135 args: argparse.Namespace, data: Dict[str, OwnersData] 136) -> None: 137 matcher = CommitMatch(args, data) 138 139 # Print in `git push refs/for/branch%<reviewers>` format. 140 if args.push_args: 141 result = [] 142 for o in sorted(matcher.owners): 143 # Gerrit uses 'r' for the required reviewers (owners). 144 result.append(f"r={o}") 145 for r in sorted(matcher.reviewers): 146 # Gerrit uses 'cc' for the optional reviewers. 147 result.append(f"cc={r}") 148 print(",".join(result)) 149 # Print as Gerrit Add Reviewers POST format. 150 # https://gerrit.openbmc.org/Documentation/rest-api-changes.html#add-reviewer 151 else: 152 153 def review_js(reviewer: str, state: str) -> str: 154 return json.dumps( 155 { 156 "reviewer": reviewer, 157 "state": state, 158 "notify": "NONE", 159 "notify_details": {"TO": {"accounts": [reviewer]}}, 160 } 161 ) 162 163 for o in sorted(matcher.owners): 164 print(review_js(o, "REVIEWER")) 165 for r in sorted(matcher.reviewers): 166 print(review_js(r, "CC")) 167 168 169def main() -> None: 170 parser = argparse.ArgumentParser() 171 parser.add_argument( 172 "-p", "--path", default=".", help="Root path to analyse" 173 ) 174 subparsers = parser.add_subparsers() 175 176 parser_reviewers = subparsers.add_parser( 177 "reviewers", help="Generate List of Reviewers" 178 ) 179 parser_reviewers.add_argument( 180 "--push-args", 181 default=False, 182 action="store_true", 183 help="Format as git push options", 184 ) 185 parser_reviewers.add_argument( 186 "--commit", 187 default="HEAD", 188 help="Commit(s) to match against", 189 ) 190 parser_reviewers.set_defaults(func=subcmd_reviewers) 191 192 args = parser.parse_args() 193 194 owners_files = git.bake("-C", args.path)( 195 "ls-files", "OWNERS", "**/OWNERS" 196 ).splitlines() 197 198 files = {} 199 for f in owners_files: 200 file = YamlLoader.load(os.path.join(args.path, f)) 201 dirpath = os.path.dirname(f) 202 files[dirpath] = file 203 204 args.func(args, files) 205 206 207if __name__ == "__main__": 208 main() 209