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