1## @file
2#  Retrieves the people to request review from on submission of a commit.
3#
4#  Copyright (c) 2019, Linaro Ltd. All rights reserved.<BR>
5#
6#  SPDX-License-Identifier: BSD-2-Clause-Patent
7#
8
9from __future__ import print_function
10from collections import defaultdict
11from collections import OrderedDict
12import argparse
13import os
14import re
15import SetupGit
16
17EXPRESSIONS = {
18    'exclude':    re.compile(r'^X:\s*(?P<exclude>.*?)\r*$'),
19    'file':       re.compile(r'^F:\s*(?P<file>.*?)\r*$'),
20    'list':       re.compile(r'^L:\s*(?P<list>.*?)\r*$'),
21    'maintainer': re.compile(r'^M:\s*(?P<maintainer>.*<.*?>)\r*$'),
22    'reviewer':   re.compile(r'^R:\s*(?P<reviewer>.*?)\r*$'),
23    'status':     re.compile(r'^S:\s*(?P<status>.*?)\r*$'),
24    'tree':       re.compile(r'^T:\s*(?P<tree>.*?)\r*$'),
25    'webpage':    re.compile(r'^W:\s*(?P<webpage>.*?)\r*$')
26}
27
28def printsection(section):
29    """Prints out the dictionary describing a Maintainers.txt section."""
30    print('===')
31    for key in section.keys():
32        print("Key: %s" % key)
33        for item in section[key]:
34            print('  %s' % item)
35
36def pattern_to_regex(pattern):
37    """Takes a string containing regular UNIX path wildcards
38       and returns a string suitable for matching with regex."""
39
40    pattern = pattern.replace('.', r'\.')
41    pattern = pattern.replace('?', r'.')
42    pattern = pattern.replace('*', r'.*')
43
44    if pattern.endswith('/'):
45        pattern += r'.*'
46    elif pattern.endswith('.*'):
47        pattern = pattern[:-2]
48        pattern += r'(?!.*?/.*?)'
49
50    return pattern
51
52def path_in_section(path, section):
53    """Returns True of False indicating whether the path is covered by
54       the current section."""
55    if not 'file' in section:
56        return False
57
58    for pattern in section['file']:
59        regex = pattern_to_regex(pattern)
60
61        match = re.match(regex, path)
62        if match:
63            # Check if there is an exclude pattern that applies
64            for pattern in section['exclude']:
65                regex = pattern_to_regex(pattern)
66
67                match = re.match(regex, path)
68                if match:
69                    return False
70
71            return True
72
73    return False
74
75def get_section_maintainers(path, section):
76    """Returns a list with email addresses to any M: and R: entries
77       matching the provided path in the provided section."""
78    maintainers = []
79    lists = []
80    nowarn_status = ['Supported', 'Maintained']
81
82    if path_in_section(path, section):
83        for status in section['status']:
84            if status not in nowarn_status:
85                print('WARNING: Maintained status for "%s" is \'%s\'!' % (path, status))
86        for address in section['maintainer'], section['reviewer']:
87            # Convert to list if necessary
88            if isinstance(address, list):
89                maintainers += address
90            else:
91                lists += [address]
92        for address in section['list']:
93            # Convert to list if necessary
94            if isinstance(address, list):
95                lists += address
96            else:
97                lists += [address]
98
99    return maintainers, lists
100
101def get_maintainers(path, sections, level=0):
102    """For 'path', iterates over all sections, returning maintainers
103       for matching ones."""
104    maintainers = []
105    lists = []
106    for section in sections:
107        tmp_maint, tmp_lists = get_section_maintainers(path, section)
108        if tmp_maint:
109            maintainers += tmp_maint
110        if tmp_lists:
111            lists += tmp_lists
112
113    if not maintainers:
114        # If no match found, look for match for (nonexistent) file
115        # REPO.working_dir/<default>
116        print('"%s": no maintainers found, looking for default' % path)
117        if level == 0:
118            maintainers = get_maintainers('<default>', sections, level=level + 1)
119        else:
120            print("No <default> maintainers set for project.")
121        if not maintainers:
122            return None
123
124    return maintainers + lists
125
126def parse_maintainers_line(line):
127    """Parse one line of Maintainers.txt, returning any match group and its key."""
128    for key, expression in EXPRESSIONS.items():
129        match = expression.match(line)
130        if match:
131            return key, match.group(key)
132    return None, None
133
134def parse_maintainers_file(filename):
135    """Parse the Maintainers.txt from top-level of repo and
136       return a list containing dictionaries of all sections."""
137    with open(filename, 'r') as text:
138        line = text.readline()
139        sectionlist = []
140        section = defaultdict(list)
141        while line:
142            key, value = parse_maintainers_line(line)
143            if key and value:
144                section[key].append(value)
145
146            line = text.readline()
147            # If end of section (end of file, or non-tag line encountered)...
148            if not key or not value or not line:
149                # ...if non-empty, append section to list.
150                if section:
151                    sectionlist.append(section.copy())
152                    section.clear()
153
154        return sectionlist
155
156def get_modified_files(repo, args):
157    """Returns a list of the files modified by the commit specified in 'args'."""
158    commit = repo.commit(args.commit)
159    return commit.stats.files
160
161if __name__ == '__main__':
162    PARSER = argparse.ArgumentParser(
163        description='Retrieves information on who to cc for review on a given commit')
164    PARSER.add_argument('commit',
165                        action="store",
166                        help='git revision to examine (default: HEAD)',
167                        nargs='?',
168                        default='HEAD')
169    PARSER.add_argument('-l', '--lookup',
170                        help='Find section matches for path LOOKUP',
171                        required=False)
172    ARGS = PARSER.parse_args()
173
174    REPO = SetupGit.locate_repo()
175
176    CONFIG_FILE = os.path.join(REPO.working_dir, 'Maintainers.txt')
177
178    SECTIONS = parse_maintainers_file(CONFIG_FILE)
179
180    if ARGS.lookup:
181        FILES = [ARGS.lookup]
182    else:
183        FILES = get_modified_files(REPO, ARGS)
184
185    ADDRESSES = []
186
187    for file in FILES:
188        print(file)
189        addresslist = get_maintainers(file, SECTIONS)
190        if addresslist:
191            ADDRESSES += addresslist
192
193    for address in list(OrderedDict.fromkeys(ADDRESSES)):
194        print('  %s' % address)
195