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