blob: 249ea5afc5a5c3f3fa9077b7cabb7b0d625dfe8a [file] [log] [blame]
Patrick Williams6cef2552022-05-29 15:35:17 -05001#!/bin/env python3
2import argparse
3import json
Patrick Williamsca1b89e2022-06-19 20:53:43 -05004import os
5import re
Patrick Williams6cef2552022-05-29 15:35:17 -05006import yaml
7
Patrick Williamsca1b89e2022-06-19 20:53:43 -05008from sh import git # type: ignore
Patrick Williams8cfff0d2022-07-20 10:42:31 -05009from typing import Dict, List, Set, TypedDict, Optional
Patrick Williams6cef2552022-05-29 15:35:17 -050010from yaml.loader import SafeLoader
11
12# A list of Gerrit users (email addresses).
Patrick Williamsca1b89e2022-06-19 20:53:43 -050013# Some OWNERS files have empty lists for 'owners' or 'reviewers', which
14# results in a None type for the value.
15UsersList = Optional[List[str]]
Patrick Williams6cef2552022-05-29 15:35:17 -050016
17# A YAML node with an extra line number.
18class NumberedNode(TypedDict):
19 line_number: int
20
21
Patrick Williamsca1b89e2022-06-19 20:53:43 -050022class 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
Patrick Williams6cef2552022-05-29 15:35:17 -050031# The root YAML node of an OWNERS file
32class OwnersData(NumberedNode, TypedDict, total=False):
33 owners: UsersList
34 reviewers: UsersList
Patrick Williamsca1b89e2022-06-19 20:53:43 -050035 matchers: List[MatchEntry]
Patrick Williams6cef2552022-05-29 15:35:17 -050036
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.
Patrick Williams6cef2552022-05-29 15:35:17 -050060class CommitMatch:
Patrick Williams8cfff0d2022-07-20 10:42:31 -050061 def __init__(
62 self, args: argparse.Namespace, owners: Dict[str, OwnersData]
63 ):
Patrick Williamsca1b89e2022-06-19 20:53:43 -050064 files: Set[str] = set(
65 git.bake("-C", args.path)
66 .show(args.commit, pretty="", name_only=True, _tty_out=False)
67 .splitlines()
68 )
Patrick Williams6cef2552022-05-29 15:35:17 -050069
Patrick Williams8cfff0d2022-07-20 10:42:31 -050070 root_owners = owners[""]
Patrick Williams6cef2552022-05-29 15:35:17 -050071
Patrick Williams8cfff0d2022-07-20 10:42:31 -050072 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
Patrick Williamsca1b89e2022-06-19 20:53:43 -0500109
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
Patrick Williams8cfff0d2022-07-20 10:42:31 -0500116 def __exact(self, file: str, entry: MatchEntry) -> None:
117 if file == entry["exact"]:
118 self.__add_entry(entry)
Patrick Williamsca1b89e2022-06-19 20:53:43 -0500119
Patrick Williams8cfff0d2022-07-20 10:42:31 -0500120 def __partial_regex(self, file: str, entry: MatchEntry) -> None:
121 if re.search(entry["partial_regex"], file):
122 self.__add_entry(entry)
Patrick Williamsca1b89e2022-06-19 20:53:43 -0500123
Patrick Williams8cfff0d2022-07-20 10:42:31 -0500124 def __regex(self, file: str, entry: MatchEntry) -> None:
125 if re.fullmatch(entry["regex"], file):
126 self.__add_entry(entry)
Patrick Williamsca1b89e2022-06-19 20:53:43 -0500127
Patrick Williams8cfff0d2022-07-20 10:42:31 -0500128 def __suffix(self, file: str, entry: MatchEntry) -> None:
129 if os.path.splitext(file)[1] == entry["suffix"]:
130 self.__add_entry(entry)
Patrick Williams6cef2552022-05-29 15:35:17 -0500131
132
133# The subcommand to get the reviewers.
Patrick Williams8cfff0d2022-07-20 10:42:31 -0500134def subcmd_reviewers(
135 args: argparse.Namespace, data: Dict[str, OwnersData]
136) -> None:
Patrick Williamsca1b89e2022-06-19 20:53:43 -0500137 matcher = CommitMatch(args, data)
Patrick Williams6cef2552022-05-29 15:35:17 -0500138
139 # Print in `git push refs/for/branch%<reviewers>` format.
140 if args.push_args:
141 result = []
Patrick Williamsca1b89e2022-06-19 20:53:43 -0500142 for o in sorted(matcher.owners):
Patrick Williams6cef2552022-05-29 15:35:17 -0500143 # Gerrit uses 'r' for the required reviewers (owners).
144 result.append(f"r={o}")
Patrick Williamsca1b89e2022-06-19 20:53:43 -0500145 for r in sorted(matcher.reviewers):
Patrick Williams6cef2552022-05-29 15:35:17 -0500146 # 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:
Patrick Williams29986f12022-07-19 10:46:07 -0500152
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
Patrick Williamsca1b89e2022-06-19 20:53:43 -0500163 for o in sorted(matcher.owners):
Patrick Williams29986f12022-07-19 10:46:07 -0500164 print(review_js(o, "REVIEWER"))
Patrick Williamsca1b89e2022-06-19 20:53:43 -0500165 for r in sorted(matcher.reviewers):
Patrick Williams29986f12022-07-19 10:46:07 -0500166 print(review_js(r, "CC"))
Patrick Williams6cef2552022-05-29 15:35:17 -0500167
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",
Patrick Williams47b59dc2022-07-18 16:58:42 -0500181 default=False,
Patrick Williams29986f12022-07-19 10:46:07 -0500182 action="store_true",
Patrick Williams6cef2552022-05-29 15:35:17 -0500183 help="Format as git push options",
184 )
Patrick Williamsca1b89e2022-06-19 20:53:43 -0500185 parser_reviewers.add_argument(
186 "--commit",
187 default="HEAD",
188 help="Commit(s) to match against",
189 )
Patrick Williams6cef2552022-05-29 15:35:17 -0500190 parser_reviewers.set_defaults(func=subcmd_reviewers)
191
192 args = parser.parse_args()
193
Patrick Williams8cfff0d2022-07-20 10:42:31 -0500194 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)
Patrick Williams6cef2552022-05-29 15:35:17 -0500205
206
207if __name__ == "__main__":
208 main()