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
9*8cfff0d5SPatrick Williamsfrom typing import Dict, 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:
61*8cfff0d5SPatrick Williams    def __init__(
62*8cfff0d5SPatrick Williams        self, args: argparse.Namespace, owners: Dict[str, OwnersData]
63*8cfff0d5SPatrick Williams    ):
64ca1b89ebSPatrick Williams        files: Set[str] = set(
65ca1b89ebSPatrick Williams            git.bake("-C", args.path)
66ca1b89ebSPatrick Williams            .show(args.commit, pretty="", name_only=True, _tty_out=False)
67ca1b89ebSPatrick Williams            .splitlines()
68ca1b89ebSPatrick Williams        )
696cef255aSPatrick Williams
70*8cfff0d5SPatrick Williams        root_owners = owners[""]
716cef255aSPatrick Williams
72*8cfff0d5SPatrick Williams        self.owners: Set[str] = set()
73*8cfff0d5SPatrick Williams        self.reviewers: Set[str] = set()
74*8cfff0d5SPatrick Williams
75*8cfff0d5SPatrick Williams        for f in files:
76*8cfff0d5SPatrick Williams            path = f
77*8cfff0d5SPatrick Williams
78*8cfff0d5SPatrick Williams            while True:
79*8cfff0d5SPatrick Williams                path = os.path.dirname(path)
80*8cfff0d5SPatrick Williams
81*8cfff0d5SPatrick Williams                if path not in owners:
82*8cfff0d5SPatrick Williams                    if not path:
83*8cfff0d5SPatrick Williams                        break
84*8cfff0d5SPatrick Williams                    continue
85*8cfff0d5SPatrick Williams
86*8cfff0d5SPatrick Williams                local_owners = owners[path]
87*8cfff0d5SPatrick Williams
88*8cfff0d5SPatrick Williams                self.owners = self.owners.union(
89*8cfff0d5SPatrick Williams                    local_owners.get("owners") or []
90*8cfff0d5SPatrick Williams                )
91*8cfff0d5SPatrick Williams                self.reviewers = self.reviewers.union(
92*8cfff0d5SPatrick Williams                    local_owners.get("reviewers") or []
93*8cfff0d5SPatrick Williams                )
94*8cfff0d5SPatrick Williams
95*8cfff0d5SPatrick Williams                rel_file = os.path.relpath(f, path)
96*8cfff0d5SPatrick Williams
97*8cfff0d5SPatrick Williams                for e in local_owners.get("matchers", None) or []:
98ca1b89ebSPatrick Williams                    if "exact" in e:
99*8cfff0d5SPatrick Williams                        self.__exact(rel_file, e)
100ca1b89ebSPatrick Williams                    elif "partial_regex" in e:
101*8cfff0d5SPatrick Williams                        self.__partial_regex(rel_file, e)
102ca1b89ebSPatrick Williams                    elif "regex" in e:
103*8cfff0d5SPatrick Williams                        self.__regex(rel_file, e)
104ca1b89ebSPatrick Williams                    elif "suffix" in e:
105*8cfff0d5SPatrick Williams                        self.__suffix(rel_file, e)
106*8cfff0d5SPatrick Williams
107*8cfff0d5SPatrick Williams                if not path:
108*8cfff0d5SPatrick Williams                    break
109ca1b89ebSPatrick Williams
110ca1b89ebSPatrick Williams        self.reviewers = self.reviewers.difference(self.owners)
111ca1b89ebSPatrick Williams
112ca1b89ebSPatrick Williams    def __add_entry(self, entry: MatchEntry) -> None:
113ca1b89ebSPatrick Williams        self.owners = self.owners.union(entry.get("owners") or [])
114ca1b89ebSPatrick Williams        self.reviewers = self.reviewers.union(entry.get("reviewers") or [])
115ca1b89ebSPatrick Williams
116*8cfff0d5SPatrick Williams    def __exact(self, file: str, entry: MatchEntry) -> None:
117*8cfff0d5SPatrick Williams        if file == entry["exact"]:
118ca1b89ebSPatrick Williams            self.__add_entry(entry)
119ca1b89ebSPatrick Williams
120*8cfff0d5SPatrick Williams    def __partial_regex(self, file: str, entry: MatchEntry) -> None:
121*8cfff0d5SPatrick Williams        if re.search(entry["partial_regex"], file):
122ca1b89ebSPatrick Williams            self.__add_entry(entry)
123ca1b89ebSPatrick Williams
124*8cfff0d5SPatrick Williams    def __regex(self, file: str, entry: MatchEntry) -> None:
125*8cfff0d5SPatrick Williams        if re.fullmatch(entry["regex"], file):
126ca1b89ebSPatrick Williams            self.__add_entry(entry)
127ca1b89ebSPatrick Williams
128*8cfff0d5SPatrick Williams    def __suffix(self, file: str, entry: MatchEntry) -> None:
129*8cfff0d5SPatrick Williams        if os.path.splitext(file)[1] == entry["suffix"]:
130ca1b89ebSPatrick Williams            self.__add_entry(entry)
1316cef255aSPatrick Williams
1326cef255aSPatrick Williams
1336cef255aSPatrick Williams# The subcommand to get the reviewers.
134*8cfff0d5SPatrick Williamsdef subcmd_reviewers(
135*8cfff0d5SPatrick Williams    args: argparse.Namespace, data: Dict[str, OwnersData]
136*8cfff0d5SPatrick Williams) -> None:
137ca1b89ebSPatrick Williams    matcher = CommitMatch(args, data)
1386cef255aSPatrick Williams
1396cef255aSPatrick Williams    # Print in `git push refs/for/branch%<reviewers>` format.
1406cef255aSPatrick Williams    if args.push_args:
1416cef255aSPatrick Williams        result = []
142ca1b89ebSPatrick Williams        for o in sorted(matcher.owners):
1436cef255aSPatrick Williams            # Gerrit uses 'r' for the required reviewers (owners).
1446cef255aSPatrick Williams            result.append(f"r={o}")
145ca1b89ebSPatrick Williams        for r in sorted(matcher.reviewers):
1466cef255aSPatrick Williams            # Gerrit uses 'cc' for the optional reviewers.
1476cef255aSPatrick Williams            result.append(f"cc={r}")
1486cef255aSPatrick Williams        print(",".join(result))
1496cef255aSPatrick Williams    # Print as Gerrit Add Reviewers POST format.
1506cef255aSPatrick Williams    # https://gerrit.openbmc.org/Documentation/rest-api-changes.html#add-reviewer
1516cef255aSPatrick Williams    else:
15229986f19SPatrick Williams
15329986f19SPatrick Williams        def review_js(reviewer: str, state: str) -> str:
15429986f19SPatrick Williams            return json.dumps(
15529986f19SPatrick Williams                {
15629986f19SPatrick Williams                    "reviewer": reviewer,
15729986f19SPatrick Williams                    "state": state,
15829986f19SPatrick Williams                    "notify": "NONE",
15929986f19SPatrick Williams                    "notify_details": {"TO": {"accounts": [reviewer]}},
16029986f19SPatrick Williams                }
16129986f19SPatrick Williams            )
16229986f19SPatrick Williams
163ca1b89ebSPatrick Williams        for o in sorted(matcher.owners):
16429986f19SPatrick Williams            print(review_js(o, "REVIEWER"))
165ca1b89ebSPatrick Williams        for r in sorted(matcher.reviewers):
16629986f19SPatrick Williams            print(review_js(r, "CC"))
1676cef255aSPatrick Williams
1686cef255aSPatrick Williams
1696cef255aSPatrick Williamsdef main() -> None:
1706cef255aSPatrick Williams    parser = argparse.ArgumentParser()
1716cef255aSPatrick Williams    parser.add_argument(
1726cef255aSPatrick Williams        "-p", "--path", default=".", help="Root path to analyse"
1736cef255aSPatrick Williams    )
1746cef255aSPatrick Williams    subparsers = parser.add_subparsers()
1756cef255aSPatrick Williams
1766cef255aSPatrick Williams    parser_reviewers = subparsers.add_parser(
1776cef255aSPatrick Williams        "reviewers", help="Generate List of Reviewers"
1786cef255aSPatrick Williams    )
1796cef255aSPatrick Williams    parser_reviewers.add_argument(
1806cef255aSPatrick Williams        "--push-args",
18147b59dc8SPatrick Williams        default=False,
18229986f19SPatrick Williams        action="store_true",
1836cef255aSPatrick Williams        help="Format as git push options",
1846cef255aSPatrick Williams    )
185ca1b89ebSPatrick Williams    parser_reviewers.add_argument(
186ca1b89ebSPatrick Williams        "--commit",
187ca1b89ebSPatrick Williams        default="HEAD",
188ca1b89ebSPatrick Williams        help="Commit(s) to match against",
189ca1b89ebSPatrick Williams    )
1906cef255aSPatrick Williams    parser_reviewers.set_defaults(func=subcmd_reviewers)
1916cef255aSPatrick Williams
1926cef255aSPatrick Williams    args = parser.parse_args()
1936cef255aSPatrick Williams
194*8cfff0d5SPatrick Williams    owners_files = git.bake("-C", args.path)(
195*8cfff0d5SPatrick Williams        "ls-files", "OWNERS", "**/OWNERS"
196*8cfff0d5SPatrick Williams    ).splitlines()
197*8cfff0d5SPatrick Williams
198*8cfff0d5SPatrick Williams    files = {}
199*8cfff0d5SPatrick Williams    for f in owners_files:
200*8cfff0d5SPatrick Williams        file = YamlLoader.load(os.path.join(args.path, f))
201*8cfff0d5SPatrick Williams        dirpath = os.path.dirname(f)
202*8cfff0d5SPatrick Williams        files[dirpath] = file
203*8cfff0d5SPatrick Williams
204*8cfff0d5SPatrick Williams    args.func(args, files)
2056cef255aSPatrick Williams
2066cef255aSPatrick Williams
2076cef255aSPatrick Williamsif __name__ == "__main__":
2086cef255aSPatrick Williams    main()
209