blob: acae84058d9c99ab290490feca937ccba5dcfec3 [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
9from typing import 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 Williamsca1b89e2022-06-19 20:53:43 -050061 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 )
Patrick Williams6cef2552022-05-29 15:35:17 -050067
Patrick Williamsca1b89e2022-06-19 20:53:43 -050068 self.owners: Set[str] = set(owners.get("owners") or [])
69 self.reviewers: Set[str] = set(owners.get("reviewers") or [])
Patrick Williams6cef2552022-05-29 15:35:17 -050070
Patrick Williamsca1b89e2022-06-19 20:53:43 -050071 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)
Patrick Williams6cef2552022-05-29 15:35:17 -0500106
107
108# The subcommand to get the reviewers.
109def subcmd_reviewers(args: argparse.Namespace, data: OwnersData) -> None:
Patrick Williamsca1b89e2022-06-19 20:53:43 -0500110 matcher = CommitMatch(args, data)
Patrick Williams6cef2552022-05-29 15:35:17 -0500111
112 # Print in `git push refs/for/branch%<reviewers>` format.
113 if args.push_args:
114 result = []
Patrick Williamsca1b89e2022-06-19 20:53:43 -0500115 for o in sorted(matcher.owners):
Patrick Williams6cef2552022-05-29 15:35:17 -0500116 # Gerrit uses 'r' for the required reviewers (owners).
117 result.append(f"r={o}")
Patrick Williamsca1b89e2022-06-19 20:53:43 -0500118 for r in sorted(matcher.reviewers):
Patrick Williams6cef2552022-05-29 15:35:17 -0500119 # 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:
Patrick Williamsca1b89e2022-06-19 20:53:43 -0500125 for o in sorted(matcher.owners):
Patrick Williams6cef2552022-05-29 15:35:17 -0500126 print(json.dumps({"reviewer": o, "state": "REVIEWER"}))
Patrick Williamsca1b89e2022-06-19 20:53:43 -0500127 for r in sorted(matcher.reviewers):
Patrick Williams6cef2552022-05-29 15:35:17 -0500128 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 )
Patrick Williamsca1b89e2022-06-19 20:53:43 -0500146 parser_reviewers.add_argument(
147 "--commit",
148 default="HEAD",
149 help="Commit(s) to match against",
150 )
Patrick Williams6cef2552022-05-29 15:35:17 -0500151 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()