Patrick Williams | 6cef255 | 2022-05-29 15:35:17 -0500 | [diff] [blame] | 1 | #!/bin/env python3 |
| 2 | import argparse |
| 3 | import json |
Patrick Williams | ca1b89e | 2022-06-19 20:53:43 -0500 | [diff] [blame] | 4 | import os |
| 5 | import re |
Patrick Williams | 6cef255 | 2022-05-29 15:35:17 -0500 | [diff] [blame] | 6 | import yaml |
| 7 | |
Patrick Williams | ca1b89e | 2022-06-19 20:53:43 -0500 | [diff] [blame] | 8 | from sh import git # type: ignore |
Patrick Williams | 8cfff0d | 2022-07-20 10:42:31 -0500 | [diff] [blame] | 9 | from typing import Dict, List, Set, TypedDict, Optional |
Patrick Williams | 6cef255 | 2022-05-29 15:35:17 -0500 | [diff] [blame] | 10 | from yaml.loader import SafeLoader |
| 11 | |
| 12 | # A list of Gerrit users (email addresses). |
Patrick Williams | ca1b89e | 2022-06-19 20:53:43 -0500 | [diff] [blame] | 13 | # Some OWNERS files have empty lists for 'owners' or 'reviewers', which |
| 14 | # results in a None type for the value. |
| 15 | UsersList = Optional[List[str]] |
Patrick Williams | 6cef255 | 2022-05-29 15:35:17 -0500 | [diff] [blame] | 16 | |
| 17 | # A YAML node with an extra line number. |
| 18 | class NumberedNode(TypedDict): |
| 19 | line_number: int |
| 20 | |
| 21 | |
Patrick Williams | ca1b89e | 2022-06-19 20:53:43 -0500 | [diff] [blame] | 22 | class 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 | |
Patrick Williams | 6cef255 | 2022-05-29 15:35:17 -0500 | [diff] [blame] | 31 | # The root YAML node of an OWNERS file |
| 32 | class OwnersData(NumberedNode, TypedDict, total=False): |
| 33 | owners: UsersList |
| 34 | reviewers: UsersList |
Patrick Williams | ca1b89e | 2022-06-19 20:53:43 -0500 | [diff] [blame] | 35 | matchers: List[MatchEntry] |
Patrick Williams | 6cef255 | 2022-05-29 15:35:17 -0500 | [diff] [blame] | 36 | |
| 37 | |
| 38 | # A YAML loader that adds the start line number onto each node (for |
| 39 | # later linting support) |
| 40 | class 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. |
Patrick Williams | 6cef255 | 2022-05-29 15:35:17 -0500 | [diff] [blame] | 60 | class CommitMatch: |
Patrick Williams | 8cfff0d | 2022-07-20 10:42:31 -0500 | [diff] [blame] | 61 | def __init__( |
| 62 | self, args: argparse.Namespace, owners: Dict[str, OwnersData] |
| 63 | ): |
Patrick Williams | ca1b89e | 2022-06-19 20:53:43 -0500 | [diff] [blame] | 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 | ) |
Patrick Williams | 6cef255 | 2022-05-29 15:35:17 -0500 | [diff] [blame] | 69 | |
Patrick Williams | 8cfff0d | 2022-07-20 10:42:31 -0500 | [diff] [blame] | 70 | root_owners = owners[""] |
Patrick Williams | 6cef255 | 2022-05-29 15:35:17 -0500 | [diff] [blame] | 71 | |
Patrick Williams | 8cfff0d | 2022-07-20 10:42:31 -0500 | [diff] [blame] | 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 |
Patrick Williams | ca1b89e | 2022-06-19 20:53:43 -0500 | [diff] [blame] | 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 | |
Patrick Williams | 8cfff0d | 2022-07-20 10:42:31 -0500 | [diff] [blame] | 116 | def __exact(self, file: str, entry: MatchEntry) -> None: |
| 117 | if file == entry["exact"]: |
| 118 | self.__add_entry(entry) |
Patrick Williams | ca1b89e | 2022-06-19 20:53:43 -0500 | [diff] [blame] | 119 | |
Patrick Williams | 8cfff0d | 2022-07-20 10:42:31 -0500 | [diff] [blame] | 120 | def __partial_regex(self, file: str, entry: MatchEntry) -> None: |
| 121 | if re.search(entry["partial_regex"], file): |
| 122 | self.__add_entry(entry) |
Patrick Williams | ca1b89e | 2022-06-19 20:53:43 -0500 | [diff] [blame] | 123 | |
Patrick Williams | 8cfff0d | 2022-07-20 10:42:31 -0500 | [diff] [blame] | 124 | def __regex(self, file: str, entry: MatchEntry) -> None: |
| 125 | if re.fullmatch(entry["regex"], file): |
| 126 | self.__add_entry(entry) |
Patrick Williams | ca1b89e | 2022-06-19 20:53:43 -0500 | [diff] [blame] | 127 | |
Patrick Williams | 8cfff0d | 2022-07-20 10:42:31 -0500 | [diff] [blame] | 128 | def __suffix(self, file: str, entry: MatchEntry) -> None: |
| 129 | if os.path.splitext(file)[1] == entry["suffix"]: |
| 130 | self.__add_entry(entry) |
Patrick Williams | 6cef255 | 2022-05-29 15:35:17 -0500 | [diff] [blame] | 131 | |
| 132 | |
| 133 | # The subcommand to get the reviewers. |
Patrick Williams | 8cfff0d | 2022-07-20 10:42:31 -0500 | [diff] [blame] | 134 | def subcmd_reviewers( |
| 135 | args: argparse.Namespace, data: Dict[str, OwnersData] |
| 136 | ) -> None: |
Patrick Williams | ca1b89e | 2022-06-19 20:53:43 -0500 | [diff] [blame] | 137 | matcher = CommitMatch(args, data) |
Patrick Williams | 6cef255 | 2022-05-29 15:35:17 -0500 | [diff] [blame] | 138 | |
| 139 | # Print in `git push refs/for/branch%<reviewers>` format. |
| 140 | if args.push_args: |
| 141 | result = [] |
Patrick Williams | ca1b89e | 2022-06-19 20:53:43 -0500 | [diff] [blame] | 142 | for o in sorted(matcher.owners): |
Patrick Williams | 6cef255 | 2022-05-29 15:35:17 -0500 | [diff] [blame] | 143 | # Gerrit uses 'r' for the required reviewers (owners). |
| 144 | result.append(f"r={o}") |
Patrick Williams | ca1b89e | 2022-06-19 20:53:43 -0500 | [diff] [blame] | 145 | for r in sorted(matcher.reviewers): |
Patrick Williams | 6cef255 | 2022-05-29 15:35:17 -0500 | [diff] [blame] | 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: |
Patrick Williams | 29986f1 | 2022-07-19 10:46:07 -0500 | [diff] [blame] | 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 | |
Patrick Williams | ca1b89e | 2022-06-19 20:53:43 -0500 | [diff] [blame] | 163 | for o in sorted(matcher.owners): |
Patrick Williams | 29986f1 | 2022-07-19 10:46:07 -0500 | [diff] [blame] | 164 | print(review_js(o, "REVIEWER")) |
Patrick Williams | ca1b89e | 2022-06-19 20:53:43 -0500 | [diff] [blame] | 165 | for r in sorted(matcher.reviewers): |
Patrick Williams | 29986f1 | 2022-07-19 10:46:07 -0500 | [diff] [blame] | 166 | print(review_js(r, "CC")) |
Patrick Williams | 6cef255 | 2022-05-29 15:35:17 -0500 | [diff] [blame] | 167 | |
| 168 | |
| 169 | def 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", |
Patrick Williams | 47b59dc | 2022-07-18 16:58:42 -0500 | [diff] [blame] | 181 | default=False, |
Patrick Williams | 29986f1 | 2022-07-19 10:46:07 -0500 | [diff] [blame] | 182 | action="store_true", |
Patrick Williams | 6cef255 | 2022-05-29 15:35:17 -0500 | [diff] [blame] | 183 | help="Format as git push options", |
| 184 | ) |
Patrick Williams | ca1b89e | 2022-06-19 20:53:43 -0500 | [diff] [blame] | 185 | parser_reviewers.add_argument( |
| 186 | "--commit", |
| 187 | default="HEAD", |
| 188 | help="Commit(s) to match against", |
| 189 | ) |
Patrick Williams | 6cef255 | 2022-05-29 15:35:17 -0500 | [diff] [blame] | 190 | parser_reviewers.set_defaults(func=subcmd_reviewers) |
| 191 | |
| 192 | args = parser.parse_args() |
| 193 | |
Patrick Williams | 8cfff0d | 2022-07-20 10:42:31 -0500 | [diff] [blame] | 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) |
Patrick Williams | 6cef255 | 2022-05-29 15:35:17 -0500 | [diff] [blame] | 205 | |
| 206 | |
| 207 | if __name__ == "__main__": |
| 208 | main() |