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
9ca1b89ebSPatrick Williamsfrom typing import 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:
61ca1b89ebSPatrick Williams    def __init__(self, args: argparse.Namespace, owners: OwnersData):
62ca1b89ebSPatrick Williams        files: Set[str] = set(
63ca1b89ebSPatrick Williams            git.bake("-C", args.path)
64ca1b89ebSPatrick Williams            .show(args.commit, pretty="", name_only=True, _tty_out=False)
65ca1b89ebSPatrick Williams            .splitlines()
66ca1b89ebSPatrick Williams        )
676cef255aSPatrick Williams
68ca1b89ebSPatrick Williams        self.owners: Set[str] = set(owners.get("owners") or [])
69ca1b89ebSPatrick Williams        self.reviewers: Set[str] = set(owners.get("reviewers") or [])
706cef255aSPatrick Williams
71*47b59dc8SPatrick Williams        for e in owners.get("matchers", None) or []:
72ca1b89ebSPatrick Williams            if "exact" in e:
73ca1b89ebSPatrick Williams                self.__exact(files, e)
74ca1b89ebSPatrick Williams            elif "partial_regex" in e:
75ca1b89ebSPatrick Williams                self.__partial_regex(files, e)
76ca1b89ebSPatrick Williams            elif "regex" in e:
77ca1b89ebSPatrick Williams                self.__regex(files, e)
78ca1b89ebSPatrick Williams            elif "suffix" in e:
79ca1b89ebSPatrick Williams                self.__suffix(files, e)
80ca1b89ebSPatrick Williams
81ca1b89ebSPatrick Williams        self.reviewers = self.reviewers.difference(self.owners)
82ca1b89ebSPatrick Williams
83ca1b89ebSPatrick Williams    def __add_entry(self, entry: MatchEntry) -> None:
84ca1b89ebSPatrick Williams        self.owners = self.owners.union(entry.get("owners") or [])
85ca1b89ebSPatrick Williams        self.reviewers = self.reviewers.union(entry.get("reviewers") or [])
86ca1b89ebSPatrick Williams
87ca1b89ebSPatrick Williams    def __exact(self, files: Set[str], entry: MatchEntry) -> None:
88ca1b89ebSPatrick Williams        for f in files:
89ca1b89ebSPatrick Williams            if f == entry["exact"]:
90ca1b89ebSPatrick Williams                self.__add_entry(entry)
91ca1b89ebSPatrick Williams
92ca1b89ebSPatrick Williams    def __partial_regex(self, files: Set[str], entry: MatchEntry) -> None:
93ca1b89ebSPatrick Williams        for f in files:
94ca1b89ebSPatrick Williams            if re.search(entry["partial_regex"], f):
95ca1b89ebSPatrick Williams                self.__add_entry(entry)
96ca1b89ebSPatrick Williams
97ca1b89ebSPatrick Williams    def __regex(self, files: Set[str], entry: MatchEntry) -> None:
98ca1b89ebSPatrick Williams        for f in files:
99ca1b89ebSPatrick Williams            if re.fullmatch(entry["regex"], f):
100ca1b89ebSPatrick Williams                self.__add_entry(entry)
101ca1b89ebSPatrick Williams
102ca1b89ebSPatrick Williams    def __suffix(self, files: Set[str], entry: MatchEntry) -> None:
103ca1b89ebSPatrick Williams        for f in files:
104ca1b89ebSPatrick Williams            if os.path.splitext(f)[1] == entry["suffix"]:
105ca1b89ebSPatrick Williams                self.__add_entry(entry)
1066cef255aSPatrick Williams
1076cef255aSPatrick Williams
1086cef255aSPatrick Williams# The subcommand to get the reviewers.
1096cef255aSPatrick Williamsdef subcmd_reviewers(args: argparse.Namespace, data: OwnersData) -> None:
110ca1b89ebSPatrick Williams    matcher = CommitMatch(args, data)
1116cef255aSPatrick Williams
1126cef255aSPatrick Williams    # Print in `git push refs/for/branch%<reviewers>` format.
1136cef255aSPatrick Williams    if args.push_args:
1146cef255aSPatrick Williams        result = []
115ca1b89ebSPatrick Williams        for o in sorted(matcher.owners):
1166cef255aSPatrick Williams            # Gerrit uses 'r' for the required reviewers (owners).
1176cef255aSPatrick Williams            result.append(f"r={o}")
118ca1b89ebSPatrick Williams        for r in sorted(matcher.reviewers):
1196cef255aSPatrick Williams            # Gerrit uses 'cc' for the optional reviewers.
1206cef255aSPatrick Williams            result.append(f"cc={r}")
1216cef255aSPatrick Williams        print(",".join(result))
1226cef255aSPatrick Williams    # Print as Gerrit Add Reviewers POST format.
1236cef255aSPatrick Williams    # https://gerrit.openbmc.org/Documentation/rest-api-changes.html#add-reviewer
1246cef255aSPatrick Williams    else:
125ca1b89ebSPatrick Williams        for o in sorted(matcher.owners):
1266cef255aSPatrick Williams            print(json.dumps({"reviewer": o, "state": "REVIEWER"}))
127ca1b89ebSPatrick Williams        for r in sorted(matcher.reviewers):
1286cef255aSPatrick Williams            print(json.dumps({"reviewer": r, "state": "CC"}))
1296cef255aSPatrick Williams
1306cef255aSPatrick Williams
1316cef255aSPatrick Williamsdef main() -> None:
1326cef255aSPatrick Williams    parser = argparse.ArgumentParser()
1336cef255aSPatrick Williams    parser.add_argument(
1346cef255aSPatrick Williams        "-p", "--path", default=".", help="Root path to analyse"
1356cef255aSPatrick Williams    )
1366cef255aSPatrick Williams    subparsers = parser.add_subparsers()
1376cef255aSPatrick Williams
1386cef255aSPatrick Williams    parser_reviewers = subparsers.add_parser(
1396cef255aSPatrick Williams        "reviewers", help="Generate List of Reviewers"
1406cef255aSPatrick Williams    )
1416cef255aSPatrick Williams    parser_reviewers.add_argument(
1426cef255aSPatrick Williams        "--push-args",
143*47b59dc8SPatrick Williams        default=False,
144*47b59dc8SPatrick Williams        action='store_true',
1456cef255aSPatrick Williams        help="Format as git push options",
1466cef255aSPatrick Williams    )
147ca1b89ebSPatrick Williams    parser_reviewers.add_argument(
148ca1b89ebSPatrick Williams        "--commit",
149ca1b89ebSPatrick Williams        default="HEAD",
150ca1b89ebSPatrick Williams        help="Commit(s) to match against",
151ca1b89ebSPatrick Williams    )
1526cef255aSPatrick Williams    parser_reviewers.set_defaults(func=subcmd_reviewers)
1536cef255aSPatrick Williams
1546cef255aSPatrick Williams    args = parser.parse_args()
1556cef255aSPatrick Williams
1566cef255aSPatrick Williams    file = YamlLoader.load(args.path + "/OWNERS")
1576cef255aSPatrick Williams    args.func(args, file)
1586cef255aSPatrick Williams
1596cef255aSPatrick Williams
1606cef255aSPatrick Williamsif __name__ == "__main__":
1616cef255aSPatrick Williams    main()
162