16cef255aSPatrick Williams#!/bin/env python3
26cef255aSPatrick Williamsimport argparse
36cef255aSPatrick Williamsimport json
4*ca1b89ebSPatrick Williamsimport os
5*ca1b89ebSPatrick Williamsimport re
66cef255aSPatrick Williamsimport yaml
76cef255aSPatrick Williams
8*ca1b89ebSPatrick Williamsfrom sh import git  # type: ignore
9*ca1b89ebSPatrick Williamsfrom typing import List, Set, TypedDict, Optional
106cef255aSPatrick Williamsfrom yaml.loader import SafeLoader
116cef255aSPatrick Williams
126cef255aSPatrick Williams# A list of Gerrit users (email addresses).
13*ca1b89ebSPatrick Williams#   Some OWNERS files have empty lists for 'owners' or 'reviewers', which
14*ca1b89ebSPatrick Williams#   results in a None type for the value.
15*ca1b89ebSPatrick 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
22*ca1b89ebSPatrick Williamsclass MatchEntry(TypedDict, total=False):
23*ca1b89ebSPatrick Williams    suffix: str
24*ca1b89ebSPatrick Williams    regex: str
25*ca1b89ebSPatrick Williams    partial_regex: str
26*ca1b89ebSPatrick Williams    exact: str
27*ca1b89ebSPatrick Williams    owners: UsersList
28*ca1b89ebSPatrick Williams    reviewers: UsersList
29*ca1b89ebSPatrick Williams
30*ca1b89ebSPatrick 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
35*ca1b89ebSPatrick 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:
61*ca1b89ebSPatrick Williams    def __init__(self, args: argparse.Namespace, owners: OwnersData):
62*ca1b89ebSPatrick Williams        files: Set[str] = set(
63*ca1b89ebSPatrick Williams            git.bake("-C", args.path)
64*ca1b89ebSPatrick Williams            .show(args.commit, pretty="", name_only=True, _tty_out=False)
65*ca1b89ebSPatrick Williams            .splitlines()
66*ca1b89ebSPatrick Williams        )
676cef255aSPatrick Williams
68*ca1b89ebSPatrick Williams        self.owners: Set[str] = set(owners.get("owners") or [])
69*ca1b89ebSPatrick Williams        self.reviewers: Set[str] = set(owners.get("reviewers") or [])
706cef255aSPatrick Williams
71*ca1b89ebSPatrick Williams        for e in owners.get("matchers", []):
72*ca1b89ebSPatrick Williams            if "exact" in e:
73*ca1b89ebSPatrick Williams                self.__exact(files, e)
74*ca1b89ebSPatrick Williams            elif "partial_regex" in e:
75*ca1b89ebSPatrick Williams                self.__partial_regex(files, e)
76*ca1b89ebSPatrick Williams            elif "regex" in e:
77*ca1b89ebSPatrick Williams                self.__regex(files, e)
78*ca1b89ebSPatrick Williams            elif "suffix" in e:
79*ca1b89ebSPatrick Williams                self.__suffix(files, e)
80*ca1b89ebSPatrick Williams
81*ca1b89ebSPatrick Williams        self.reviewers = self.reviewers.difference(self.owners)
82*ca1b89ebSPatrick Williams
83*ca1b89ebSPatrick Williams    def __add_entry(self, entry: MatchEntry) -> None:
84*ca1b89ebSPatrick Williams        self.owners = self.owners.union(entry.get("owners") or [])
85*ca1b89ebSPatrick Williams        self.reviewers = self.reviewers.union(entry.get("reviewers") or [])
86*ca1b89ebSPatrick Williams
87*ca1b89ebSPatrick Williams    def __exact(self, files: Set[str], entry: MatchEntry) -> None:
88*ca1b89ebSPatrick Williams        for f in files:
89*ca1b89ebSPatrick Williams            if f == entry["exact"]:
90*ca1b89ebSPatrick Williams                self.__add_entry(entry)
91*ca1b89ebSPatrick Williams
92*ca1b89ebSPatrick Williams    def __partial_regex(self, files: Set[str], entry: MatchEntry) -> None:
93*ca1b89ebSPatrick Williams        for f in files:
94*ca1b89ebSPatrick Williams            if re.search(entry["partial_regex"], f):
95*ca1b89ebSPatrick Williams                self.__add_entry(entry)
96*ca1b89ebSPatrick Williams
97*ca1b89ebSPatrick Williams    def __regex(self, files: Set[str], entry: MatchEntry) -> None:
98*ca1b89ebSPatrick Williams        for f in files:
99*ca1b89ebSPatrick Williams            if re.fullmatch(entry["regex"], f):
100*ca1b89ebSPatrick Williams                self.__add_entry(entry)
101*ca1b89ebSPatrick Williams
102*ca1b89ebSPatrick Williams    def __suffix(self, files: Set[str], entry: MatchEntry) -> None:
103*ca1b89ebSPatrick Williams        for f in files:
104*ca1b89ebSPatrick Williams            if os.path.splitext(f)[1] == entry["suffix"]:
105*ca1b89ebSPatrick 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:
110*ca1b89ebSPatrick 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 = []
115*ca1b89ebSPatrick Williams        for o in sorted(matcher.owners):
1166cef255aSPatrick Williams            # Gerrit uses 'r' for the required reviewers (owners).
1176cef255aSPatrick Williams            result.append(f"r={o}")
118*ca1b89ebSPatrick 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:
125*ca1b89ebSPatrick Williams        for o in sorted(matcher.owners):
1266cef255aSPatrick Williams            print(json.dumps({"reviewer": o, "state": "REVIEWER"}))
127*ca1b89ebSPatrick 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",
1436cef255aSPatrick Williams        action=argparse.BooleanOptionalAction,
1446cef255aSPatrick Williams        help="Format as git push options",
1456cef255aSPatrick Williams    )
146*ca1b89ebSPatrick Williams    parser_reviewers.add_argument(
147*ca1b89ebSPatrick Williams        "--commit",
148*ca1b89ebSPatrick Williams        default="HEAD",
149*ca1b89ebSPatrick Williams        help="Commit(s) to match against",
150*ca1b89ebSPatrick Williams    )
1516cef255aSPatrick Williams    parser_reviewers.set_defaults(func=subcmd_reviewers)
1526cef255aSPatrick Williams
1536cef255aSPatrick Williams    args = parser.parse_args()
1546cef255aSPatrick Williams
1556cef255aSPatrick Williams    file = YamlLoader.load(args.path + "/OWNERS")
1566cef255aSPatrick Williams    args.func(args, file)
1576cef255aSPatrick Williams
1586cef255aSPatrick Williams
1596cef255aSPatrick Williamsif __name__ == "__main__":
1606cef255aSPatrick Williams    main()
161