xref: /optee_os/scripts/get_maintainer.py (revision 757331fc1216e0c1742c00123cc8c3349de3e884)
1#!/usr/bin/env python3
2#
3# Copyright (c) 2019, Linaro Limited
4#
5# SPDX-License-Identifier: BSD-2-Clause
6
7from pathlib import PurePath
8from urllib.request import urlopen
9
10import argparse
11import glob
12import os
13import re
14import tempfile
15
16
17DIFF_GIT_RE = re.compile(r'^diff --git a/(?P<path>.*) ')
18REVIEWED_RE = re.compile(r'^Reviewed-by: (?P<approver>.*>)')
19ACKED_RE = re.compile(r'^Acked-by: (?P<approver>.*>)')
20
21
22def get_args():
23    parser = argparse.ArgumentParser(description='Print the maintainers for '
24                                     'the given source files or directories; '
25                                     'or for the files modified by a patch or '
26                                     'a pull request. '
27                                     '(With -m) Check if a patch or pull '
28                                     'request is properly Acked/Reviewed for '
29                                     'merging.')
30    parser.add_argument('-m', '--merge-check', action='store_true',
31                        help='use Reviewed-by: and Acked-by: tags found in '
32                        'patches to prevent display of information for all '
33                        'the approved paths.')
34    parser.add_argument('-p', '--show-paths', action='store_true',
35                        help='show all paths that are not approved.')
36    parser.add_argument('-s', '--strict', action='store_true',
37                        help='stricter conditions for patch approval check: '
38                        'subsystem "THE REST" is ignored for paths that '
39                        'match some other subsystem.')
40    parser.add_argument('arg', nargs='*', help='file or patch')
41    parser.add_argument('-f', '--file', action='append',
42                        help='treat following argument as a file path, not '
43                        'a patch.')
44    parser.add_argument('-g', '--github-pr', action='append', type=int,
45                        help='Github pull request ID. The script will '
46                        'download the patchset from Github to a temporary '
47                        'file and process it.')
48    return parser.parse_args()
49
50
51# Parse MAINTAINERS and return a dictionary of subsystems such as:
52# {'Subsystem name': {'R': ['foo', 'bar'], 'S': ['Maintained'],
53#                     'F': [ 'path1', 'path2' ]}, ...}
54def parse_maintainers():
55    subsystems = {}
56    cwd = os.getcwd()
57    parent = os.path.dirname(os.path.realpath(__file__)) + "/../"
58    if (os.path.realpath(cwd) != os.path.realpath(parent)):
59        print("Error: this script must be run from the top-level of the "
60              "optee_os tree")
61        exit(1)
62    with open("MAINTAINERS", "r") as f:
63        start_found = False
64        ss = {}
65        name = ''
66        for line in f:
67            line = line.strip()
68            if not line:
69                continue
70            if not start_found:
71                if line.startswith("----------"):
72                    start_found = True
73                continue
74
75            if line[1] == ':':
76                letter = line[0]
77                if (not ss.get(letter)):
78                    ss[letter] = []
79                ss[letter].append(line[3:])
80            else:
81                if name:
82                    subsystems[name] = ss
83                name = line
84                ss = {}
85        if name:
86            subsystems[name] = ss
87
88    return subsystems
89
90
91# If @path is a patch file, returns the paths touched by the patch as well
92# as the content of the review/ack tags
93def get_paths_from_patchset(patch):
94    paths = []
95    approvers = []
96    try:
97        with open(patch, "r") as f:
98            for line in f:
99                match = re.search(DIFF_GIT_RE, line)
100                if match:
101                    p = match.group('path')
102                    if p not in paths:
103                        paths.append(p)
104                    continue
105                match = re.search(REVIEWED_RE, line)
106                if match:
107                    a = match.group('approver')
108                    if a not in approvers:
109                        approvers.append(a)
110                    continue
111                match = re.search(ACKED_RE, line)
112                if match:
113                    a = match.group('approver')
114                    if a not in approvers:
115                        approvers.append(a)
116                    continue
117    except Exception:
118        pass
119    return (paths, approvers)
120
121
122# Does @path match @pattern?
123# @pattern has the syntax defined in the Linux MAINTAINERS file -- mostly a
124# shell glob pattern, except that a trailing slash means a directory and
125# everything below. Matching can easily be done by converting to a regexp.
126def match_pattern(path, pattern):
127    # Append a trailing slash if path is an existing directory, so that it
128    # matches F: entries such as 'foo/bar/'
129    if not path.endswith('/') and os.path.isdir(path):
130        path += '/'
131    rep = "^" + pattern
132    rep = rep.replace('*', '[^/]+')
133    rep = rep.replace('?', '[^/]')
134    if rep.endswith('/'):
135        rep += '.*'
136    rep += '$'
137    return not not re.match(rep, path)
138
139
140def get_subsystems_for_path(subsystems, path, strict):
141    found = {}
142    for key in subsystems:
143        def inner():
144            excluded = subsystems[key].get('X')
145            if excluded:
146                for pattern in excluded:
147                    if match_pattern(path, pattern):
148                        return  # next key
149            included = subsystems[key].get('F')
150            if not included:
151                return  # next key
152            for pattern in included:
153                if match_pattern(path, pattern):
154                    found[key] = subsystems[key]
155        inner()
156    if strict and len(found) > 1:
157        found.pop('THE REST', None)
158    return found
159
160
161def get_ss_maintainers(subsys):
162    return subsys.get('M') or []
163
164
165def get_ss_reviewers(subsys):
166    return subsys.get('R') or []
167
168
169def get_ss_approvers(ss):
170    return get_ss_maintainers(ss) + get_ss_reviewers(ss)
171
172
173def approvers_have_approved(approved_by, approvers):
174    for n in approvers:
175        # Ignore anything after the email (Github ID...)
176        n = n.split('>', 1)[0]
177        for m in approved_by:
178            m = m.split('>', 1)[0]
179            if n == m:
180                return True
181    return False
182
183
184def download(pr):
185    url = "https://github.com/OP-TEE/optee_os/pull/{}.patch".format(pr)
186    f = tempfile.NamedTemporaryFile(mode="wb", prefix="pr{}_".format(pr),
187                                    suffix=".patch", delete=False)
188    print("Downloading {}...".format(url), end='', flush=True)
189    f.write(urlopen(url).read())
190    print(" Done.")
191    return f.name
192
193
194def main():
195    global args
196
197    args = get_args()
198    all_subsystems = parse_maintainers()
199    paths = []
200    downloads = []
201
202    for pr in args.github_pr or []:
203        downloads += [download(pr)]
204
205    for arg in args.arg + downloads:
206        patch_paths = []
207        approved_by = []
208        if os.path.exists(arg):
209            # Try to parse as a patch or patch set
210            (patch_paths, approved_by) = get_paths_from_patchset(arg)
211        if not patch_paths:
212            # Not a patch, consider the path itself
213            # as_posix() cleans the path a little bit (suppress leading ./ and
214            # duplicate slashes...)
215            patch_paths = [PurePath(arg).as_posix()]
216        for path in patch_paths:
217            approved = False
218            if args.merge_check:
219                ss_for_path = get_subsystems_for_path(all_subsystems, path,
220                                                      args.strict)
221                for key in ss_for_path:
222                    ss_approvers = get_ss_approvers(ss_for_path[key])
223                    if approvers_have_approved(approved_by, ss_approvers):
224                        approved = True
225            if not approved:
226                paths += [path]
227
228    for f in downloads:
229        os.remove(f)
230
231    if args.file:
232        paths += args.file
233
234    if (args.show_paths):
235        print(paths)
236
237    ss = {}
238    for path in paths:
239        ss.update(get_subsystems_for_path(all_subsystems, path, args.strict))
240    for key in ss:
241        ss_name = key[:50] + (key[50:] and '...')
242        for name in ss[key].get('M') or []:
243            print("{} (maintainer:{})".format(name, ss_name))
244        for name in ss[key].get('R') or []:
245            print("{} (reviewer:{})".format(name, ss_name))
246
247
248if __name__ == "__main__":
249    main()
250