1#!/bin/env python3
2import argparse
3import json
4import os
5import re
6import yaml
7
8from sh import git  # type: ignore
9from typing import 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__(self, args: argparse.Namespace, owners: OwnersData):
62        files: Set[str] = set(
63            git.bake("-C", args.path)
64            .show(args.commit, pretty="", name_only=True, _tty_out=False)
65            .splitlines()
66        )
67
68        self.owners: Set[str] = set(owners.get("owners") or [])
69        self.reviewers: Set[str] = set(owners.get("reviewers") or [])
70
71        for e in owners.get("matchers", []):
72            if "exact" in e:
73                self.__exact(files, e)
74            elif "partial_regex" in e:
75                self.__partial_regex(files, e)
76            elif "regex" in e:
77                self.__regex(files, e)
78            elif "suffix" in e:
79                self.__suffix(files, e)
80
81        self.reviewers = self.reviewers.difference(self.owners)
82
83    def __add_entry(self, entry: MatchEntry) -> None:
84        self.owners = self.owners.union(entry.get("owners") or [])
85        self.reviewers = self.reviewers.union(entry.get("reviewers") or [])
86
87    def __exact(self, files: Set[str], entry: MatchEntry) -> None:
88        for f in files:
89            if f == entry["exact"]:
90                self.__add_entry(entry)
91
92    def __partial_regex(self, files: Set[str], entry: MatchEntry) -> None:
93        for f in files:
94            if re.search(entry["partial_regex"], f):
95                self.__add_entry(entry)
96
97    def __regex(self, files: Set[str], entry: MatchEntry) -> None:
98        for f in files:
99            if re.fullmatch(entry["regex"], f):
100                self.__add_entry(entry)
101
102    def __suffix(self, files: Set[str], entry: MatchEntry) -> None:
103        for f in files:
104            if os.path.splitext(f)[1] == entry["suffix"]:
105                self.__add_entry(entry)
106
107
108# The subcommand to get the reviewers.
109def subcmd_reviewers(args: argparse.Namespace, data: OwnersData) -> None:
110    matcher = CommitMatch(args, data)
111
112    # Print in `git push refs/for/branch%<reviewers>` format.
113    if args.push_args:
114        result = []
115        for o in sorted(matcher.owners):
116            # Gerrit uses 'r' for the required reviewers (owners).
117            result.append(f"r={o}")
118        for r in sorted(matcher.reviewers):
119            # Gerrit uses 'cc' for the optional reviewers.
120            result.append(f"cc={r}")
121        print(",".join(result))
122    # Print as Gerrit Add Reviewers POST format.
123    # https://gerrit.openbmc.org/Documentation/rest-api-changes.html#add-reviewer
124    else:
125        for o in sorted(matcher.owners):
126            print(json.dumps({"reviewer": o, "state": "REVIEWER"}))
127        for r in sorted(matcher.reviewers):
128            print(json.dumps({"reviewer": r, "state": "CC"}))
129
130
131def main() -> None:
132    parser = argparse.ArgumentParser()
133    parser.add_argument(
134        "-p", "--path", default=".", help="Root path to analyse"
135    )
136    subparsers = parser.add_subparsers()
137
138    parser_reviewers = subparsers.add_parser(
139        "reviewers", help="Generate List of Reviewers"
140    )
141    parser_reviewers.add_argument(
142        "--push-args",
143        action=argparse.BooleanOptionalAction,
144        help="Format as git push options",
145    )
146    parser_reviewers.add_argument(
147        "--commit",
148        default="HEAD",
149        help="Commit(s) to match against",
150    )
151    parser_reviewers.set_defaults(func=subcmd_reviewers)
152
153    args = parser.parse_args()
154
155    file = YamlLoader.load(args.path + "/OWNERS")
156    args.func(args, file)
157
158
159if __name__ == "__main__":
160    main()
161