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>.*>)')
20PATCH_START = re.compile(r'^From [0-9a-f]{40}')
21
22
23def get_args():
24    parser = argparse.ArgumentParser(description='Print the maintainers for '
25                                     'the given source files or directories; '
26                                     'or for the files modified by a patch or '
27                                     'a pull request. '
28                                     '(With -m) Check if a patch or pull '
29                                     'request is properly Acked/Reviewed for '
30                                     'merging.')
31    parser.add_argument('-m', '--merge-check', action='store_true',
32                        help='use Reviewed-by: and Acked-by: tags found in '
33                        'patches to prevent display of information for all '
34                        'the approved paths.')
35    parser.add_argument('-p', '--show-paths', action='store_true',
36                        help='show all paths that are not approved.')
37    parser.add_argument('-s', '--strict', action='store_true',
38                        help='stricter conditions for patch approval check: '
39                        'subsystem "THE REST" is ignored for paths that '
40                        'match some other subsystem.')
41    parser.add_argument('arg', nargs='*', help='file or patch')
42    parser.add_argument('-f', '--file', action='append',
43                        help='treat following argument as a file path, not '
44                        'a patch.')
45    parser.add_argument('-g', '--github-pr', action='append', type=int,
46                        help='Github pull request ID. The script will '
47                        'download the patchset from Github to a temporary '
48                        'file and process it.')
49    parser.add_argument('-r', '--release-to', action='store_true',
50                        help='show all the recipients to be used in release '
51                        'announcement emails (i.e., maintainers and reviewers)'
52                        'and exit.')
53    return parser.parse_args()
54
55
56def check_cwd():
57    cwd = os.getcwd()
58    parent = os.path.dirname(os.path.realpath(__file__)) + "/../"
59    if (os.path.realpath(cwd) != os.path.realpath(parent)):
60        print("Error: this script must be run from the top-level of the "
61              "optee_os tree")
62        exit(1)
63
64
65# Parse MAINTAINERS and return a dictionary of subsystems such as:
66# {'Subsystem name': {'R': ['foo', 'bar'], 'S': ['Maintained'],
67#                     'F': [ 'path1', 'path2' ]}, ...}
68def parse_maintainers():
69    subsystems = {}
70    check_cwd()
71    with open("MAINTAINERS", "r") as f:
72        start_found = False
73        ss = {}
74        name = ''
75        for line in f:
76            line = line.strip()
77            if not line:
78                continue
79            if not start_found:
80                if line.startswith("----------"):
81                    start_found = True
82                continue
83
84            if line[1] == ':':
85                letter = line[0]
86                if (not ss.get(letter)):
87                    ss[letter] = []
88                ss[letter].append(line[3:])
89            else:
90                if name:
91                    subsystems[name] = ss
92                name = line
93                ss = {}
94        if name:
95            subsystems[name] = ss
96
97    return subsystems
98
99
100# If @patchset is a patchset files and contains 2 patches or more, write
101# individual patches to temporary files and return the paths.
102# Otherwise return [].
103def split_patchset(patchset):
104    psname = os.path.basename(patchset).replace('.', '_')
105    patchnum = 0
106    of = None
107    ret = []
108    f = None
109    try:
110        f = open(patchset, "r")
111    except OSError:
112        return []
113    for line in f:
114        match = re.search(PATCH_START, line)
115        if match:
116            # New patch found: create new file
117            patchnum += 1
118            prefix = "{}_{}_".format(patchnum, psname)
119            of = tempfile.NamedTemporaryFile(mode="w", prefix=prefix,
120                                             suffix=".patch",
121                                             delete=False)
122            ret.append(of.name)
123        if of:
124            of.write(line)
125    if len(ret) >= 2:
126        return ret
127    if len(ret) == 1:
128        os.remove(ret[0])
129    return []
130
131
132# If @path is a patch file, returns the paths touched by the patch as well
133# as the content of the review/ack tags
134def get_paths_from_patch(patch):
135    paths = []
136    approvers = []
137    try:
138        with open(patch, "r") as f:
139            for line in f:
140                match = re.search(DIFF_GIT_RE, line)
141                if match:
142                    p = match.group('path')
143                    if p not in paths:
144                        paths.append(p)
145                    continue
146                match = re.search(REVIEWED_RE, line)
147                if match:
148                    a = match.group('approver')
149                    if a not in approvers:
150                        approvers.append(a)
151                    continue
152                match = re.search(ACKED_RE, line)
153                if match:
154                    a = match.group('approver')
155                    if a not in approvers:
156                        approvers.append(a)
157                    continue
158    except Exception:
159        pass
160    return (paths, approvers)
161
162
163# Does @path match @pattern?
164# @pattern has the syntax defined in the Linux MAINTAINERS file -- mostly a
165# shell glob pattern, except that a trailing slash means a directory and
166# everything below. Matching can easily be done by converting to a regexp.
167def match_pattern(path, pattern):
168    # Append a trailing slash if path is an existing directory, so that it
169    # matches F: entries such as 'foo/bar/'
170    if not path.endswith('/') and os.path.isdir(path):
171        path += '/'
172    rep = "^" + pattern
173    rep = rep.replace('*', '[^/]+')
174    rep = rep.replace('?', '[^/]')
175    if rep.endswith('/'):
176        rep += '.*'
177    rep += '$'
178    return not not re.match(rep, path)
179
180
181def get_subsystems_for_path(subsystems, path, strict):
182    found = {}
183    for key in subsystems:
184        def inner():
185            excluded = subsystems[key].get('X')
186            if excluded:
187                for pattern in excluded:
188                    if match_pattern(path, pattern):
189                        return  # next key
190            included = subsystems[key].get('F')
191            if not included:
192                return  # next key
193            for pattern in included:
194                if match_pattern(path, pattern):
195                    found[key] = subsystems[key]
196        inner()
197    if strict and len(found) > 1:
198        found.pop('THE REST', None)
199    return found
200
201
202def get_ss_maintainers(subsys):
203    return subsys.get('M') or []
204
205
206def get_ss_reviewers(subsys):
207    return subsys.get('R') or []
208
209
210def get_ss_approvers(ss):
211    return get_ss_maintainers(ss) + get_ss_reviewers(ss)
212
213
214def approvers_have_approved(approved_by, approvers):
215    for n in approvers:
216        # Ignore anything after the email (Github ID...)
217        n = n.split('>', 1)[0]
218        for m in approved_by:
219            m = m.split('>', 1)[0]
220            if n == m:
221                return True
222    return False
223
224
225def download(pr):
226    url = "https://github.com/OP-TEE/optee_os/pull/{}.patch".format(pr)
227    f = tempfile.NamedTemporaryFile(mode="wb", prefix="pr{}_".format(pr),
228                                    suffix=".patch", delete=False)
229    print("Downloading {}...".format(url), end='', flush=True)
230    f.write(urlopen(url).read())
231    print(" Done.")
232    return f.name
233
234
235def show_release_to():
236    check_cwd()
237    with open("MAINTAINERS", "r") as f:
238        emails = sorted(set(re.findall(r'[RM]:\t(.*[\w]*<[\w\.-]+@[\w\.-]+>)',
239                                       f.read())))
240    print(*emails, sep=', ')
241
242
243def main():
244    global args
245
246    args = get_args()
247
248    if args.release_to:
249        show_release_to()
250        return
251
252    all_subsystems = parse_maintainers()
253    paths = []
254    arglist = []
255    downloads = []
256    split_patches = []
257
258    for pr in args.github_pr or []:
259        downloads += [download(pr)]
260
261    for arg in args.arg + downloads:
262        if os.path.exists(arg):
263            patches = split_patchset(arg)
264            if patches:
265                split_patches += patches
266                continue
267        arglist.append(arg)
268
269    for arg in arglist + split_patches:
270        patch_paths = []
271        approved_by = []
272        if os.path.exists(arg):
273            # Try to parse as a patch
274            (patch_paths, approved_by) = get_paths_from_patch(arg)
275        if not patch_paths:
276            # Not a patch, consider the path itself
277            # as_posix() cleans the path a little bit (suppress leading ./ and
278            # duplicate slashes...)
279            patch_paths = [PurePath(arg).as_posix()]
280        for path in patch_paths:
281            approved = False
282            if args.merge_check:
283                ss_for_path = get_subsystems_for_path(all_subsystems, path,
284                                                      args.strict)
285                for key in ss_for_path:
286                    ss_approvers = get_ss_approvers(ss_for_path[key])
287                    if approvers_have_approved(approved_by, ss_approvers):
288                        approved = True
289            if not approved:
290                paths += [path]
291
292    for f in downloads + split_patches:
293        os.remove(f)
294
295    if args.file:
296        paths += args.file
297
298    if (args.show_paths):
299        print(paths)
300
301    ss = {}
302    for path in paths:
303        ss.update(get_subsystems_for_path(all_subsystems, path, args.strict))
304    for key in ss:
305        ss_name = key[:50] + (key[50:] and '...')
306        for name in ss[key].get('M') or []:
307            print("{} (maintainer:{})".format(name, ss_name))
308        for name in ss[key].get('R') or []:
309            print("{} (reviewer:{})".format(name, ss_name))
310
311
312if __name__ == "__main__":
313    main()
314