blob: f41b2d4c77645b441c46dc6f7d4914a6ca0385ec [file] [log] [blame]
Simon Glassdc6df972020-10-29 21:46:35 -06001# SPDX-License-Identifier: GPL-2.0+
2#
3# Copyright 2020 Google LLC
4#
5"""Talks to the patchwork service to figure out what patches have been reviewed
6and commented on.
7"""
8
9import collections
10import concurrent.futures
11from itertools import repeat
12import re
13import requests
14
15from patman.patchstream import PatchStream
16from patman import terminal
17from patman import tout
18
19# Patches which are part of a multi-patch series are shown with a prefix like
20# [prefix, version, sequence], for example '[RFC, v2, 3/5]'. All but the last
21# part is optional. This decodes the string into groups. For single patches
22# the [] part is not present:
23# Groups: (ignore, ignore, ignore, prefix, version, sequence, subject)
24RE_PATCH = re.compile(r'(\[(((.*),)?(.*),)?(.*)\]\s)?(.*)$')
25
26# This decodes the sequence string into a patch number and patch count
27RE_SEQ = re.compile(r'(\d+)/(\d+)')
28
29def to_int(vals):
30 """Convert a list of strings into integers, using 0 if not an integer
31
32 Args:
33 vals (list): List of strings
34
35 Returns:
36 list: List of integers, one for each input string
37 """
38 out = [int(val) if val.isdigit() else 0 for val in vals]
39 return out
40
41
42class Patch(dict):
43 """Models a patch in patchwork
44
45 This class records information obtained from patchwork
46
47 Some of this information comes from the 'Patch' column:
48
49 [RFC,v2,1/3] dm: Driver and uclass changes for tiny-dm
50
51 This shows the prefix, version, seq, count and subject.
52
53 The other properties come from other columns in the display.
54
55 Properties:
56 pid (str): ID of the patch (typically an integer)
57 seq (int): Sequence number within series (1=first) parsed from sequence
58 string
59 count (int): Number of patches in series, parsed from sequence string
60 raw_subject (str): Entire subject line, e.g.
61 "[1/2,v2] efi_loader: Sort header file ordering"
62 prefix (str): Prefix string or None (e.g. 'RFC')
63 version (str): Version string or None (e.g. 'v2')
64 raw_subject (str): Raw patch subject
65 subject (str): Patch subject with [..] part removed (same as commit
66 subject)
67 """
68 def __init__(self, pid):
69 super().__init__()
70 self.id = pid # Use 'id' to match what the Rest API provides
71 self.seq = None
72 self.count = None
73 self.prefix = None
74 self.version = None
75 self.raw_subject = None
76 self.subject = None
77
78 # These make us more like a dictionary
79 def __setattr__(self, name, value):
80 self[name] = value
81
82 def __getattr__(self, name):
83 return self[name]
84
85 def __hash__(self):
86 return hash(frozenset(self.items()))
87
88 def __str__(self):
89 return self.raw_subject
90
91 def parse_subject(self, raw_subject):
92 """Parse the subject of a patch into its component parts
93
94 See RE_PATCH for details. The parsed info is placed into seq, count,
95 prefix, version, subject
96
97 Args:
98 raw_subject (str): Subject string to parse
99
100 Raises:
101 ValueError: the subject cannot be parsed
102 """
103 self.raw_subject = raw_subject.strip()
104 mat = RE_PATCH.search(raw_subject.strip())
105 if not mat:
106 raise ValueError("Cannot parse subject '%s'" % raw_subject)
107 self.prefix, self.version, seq_info, self.subject = mat.groups()[3:]
108 mat_seq = RE_SEQ.match(seq_info) if seq_info else False
109 if mat_seq is None:
110 self.version = seq_info
111 seq_info = None
112 if self.version and not self.version.startswith('v'):
113 self.prefix = self.version
114 self.version = None
115 if seq_info:
116 if mat_seq:
117 self.seq = int(mat_seq.group(1))
118 self.count = int(mat_seq.group(2))
119 else:
120 self.seq = 1
121 self.count = 1
122
123def compare_with_series(series, patches):
124 """Compare a list of patches with a series it came from
125
126 This prints any problems as warnings
127
128 Args:
129 series (Series): Series to compare against
130 patches (:type: list of Patch): list of Patch objects to compare with
131
132 Returns:
133 tuple
134 dict:
135 key: Commit number (0...n-1)
136 value: Patch object for that commit
137 dict:
138 key: Patch number (0...n-1)
139 value: Commit object for that patch
140 """
141 # Check the names match
142 warnings = []
143 patch_for_commit = {}
144 all_patches = set(patches)
145 for seq, cmt in enumerate(series.commits):
146 pmatch = [p for p in all_patches if p.subject == cmt.subject]
147 if len(pmatch) == 1:
148 patch_for_commit[seq] = pmatch[0]
149 all_patches.remove(pmatch[0])
150 elif len(pmatch) > 1:
151 warnings.append("Multiple patches match commit %d ('%s'):\n %s" %
152 (seq + 1, cmt.subject,
153 '\n '.join([p.subject for p in pmatch])))
154 else:
155 warnings.append("Cannot find patch for commit %d ('%s')" %
156 (seq + 1, cmt.subject))
157
158
159 # Check the names match
160 commit_for_patch = {}
161 all_commits = set(series.commits)
162 for seq, patch in enumerate(patches):
163 cmatch = [c for c in all_commits if c.subject == patch.subject]
164 if len(cmatch) == 1:
165 commit_for_patch[seq] = cmatch[0]
166 all_commits.remove(cmatch[0])
167 elif len(cmatch) > 1:
168 warnings.append("Multiple commits match patch %d ('%s'):\n %s" %
169 (seq + 1, patch.subject,
170 '\n '.join([c.subject for c in cmatch])))
171 else:
172 warnings.append("Cannot find commit for patch %d ('%s')" %
173 (seq + 1, patch.subject))
174
175 return patch_for_commit, commit_for_patch, warnings
176
177def call_rest_api(subpath):
178 """Call the patchwork API and return the result as JSON
179
180 Args:
181 subpath (str): URL subpath to use
182
183 Returns:
184 dict: Json result
185
186 Raises:
187 ValueError: the URL could not be read
188 """
189 url = 'https://patchwork.ozlabs.org/api/1.2/%s' % subpath
190 response = requests.get(url)
191 if response.status_code != 200:
192 raise ValueError("Could not read URL '%s'" % url)
193 return response.json()
194
195def collect_patches(series, series_id, rest_api=call_rest_api):
196 """Collect patch information about a series from patchwork
197
198 Uses the Patchwork REST API to collect information provided by patchwork
199 about the status of each patch.
200
201 Args:
202 series (Series): Series object corresponding to the local branch
203 containing the series
204 series_id (str): Patch series ID number
205 rest_api (function): API function to call to access Patchwork, for
206 testing
207
208 Returns:
209 list: List of patches sorted by sequence number, each a Patch object
210
211 Raises:
212 ValueError: if the URL could not be read or the web page does not follow
213 the expected structure
214 """
215 data = rest_api('series/%s/' % series_id)
216
217 # Get all the rows, which are patches
218 patch_dict = data['patches']
219 count = len(patch_dict)
220 num_commits = len(series.commits)
221 if count != num_commits:
222 tout.Warning('Warning: Patchwork reports %d patches, series has %d' %
223 (count, num_commits))
224
225 patches = []
226
227 # Work through each row (patch) one at a time, collecting the information
228 warn_count = 0
229 for pw_patch in patch_dict:
230 patch = Patch(pw_patch['id'])
231 patch.parse_subject(pw_patch['name'])
232 patches.append(patch)
233 if warn_count > 1:
234 tout.Warning(' (total of %d warnings)' % warn_count)
235
236 # Sort patches by patch number
237 patches = sorted(patches, key=lambda x: x.seq)
238 return patches
239
240def find_new_responses(new_rtag_list, seq, cmt, patch, rest_api=call_rest_api):
241 """Find new rtags collected by patchwork that we don't know about
242
243 This is designed to be run in parallel, once for each commit/patch
244
245 Args:
246 new_rtag_list (list): New rtags are written to new_rtag_list[seq]
247 list, each a dict:
248 key: Response tag (e.g. 'Reviewed-by')
249 value: Set of people who gave that response, each a name/email
250 string
251 seq (int): Position in new_rtag_list to update
252 cmt (Commit): Commit object for this commit
253 patch (Patch): Corresponding Patch object for this patch
254 rest_api (function): API function to call to access Patchwork, for
255 testing
256 """
257 if not patch:
258 return
259
260 # Get the content for the patch email itself as well as all comments
261 data = rest_api('patches/%s/' % patch.id)
262 pstrm = PatchStream.process_text(data['content'], True)
263
264 rtags = collections.defaultdict(set)
265 for response, people in pstrm.commit.rtags.items():
266 rtags[response].update(people)
267
268 data = rest_api('patches/%s/comments/' % patch.id)
269
270 for comment in data:
271 pstrm = PatchStream.process_text(comment['content'], True)
272 for response, people in pstrm.commit.rtags.items():
273 rtags[response].update(people)
274
275 # Find the tags that are not in the commit
276 new_rtags = collections.defaultdict(set)
277 base_rtags = cmt.rtags
278 for tag, people in rtags.items():
279 for who in people:
280 is_new = (tag not in base_rtags or
281 who not in base_rtags[tag])
282 if is_new:
283 new_rtags[tag].add(who)
284 new_rtag_list[seq] = new_rtags
285
286def show_responses(rtags, indent, is_new):
287 """Show rtags collected
288
289 Args:
290 rtags (dict): review tags to show
291 key: Response tag (e.g. 'Reviewed-by')
292 value: Set of people who gave that response, each a name/email string
293 indent (str): Indentation string to write before each line
294 is_new (bool): True if this output should be highlighted
295
296 Returns:
297 int: Number of review tags displayed
298 """
299 col = terminal.Color()
300 count = 0
301 for tag, people in rtags.items():
302 for who in people:
303 terminal.Print(indent + '%s %s: ' % ('+' if is_new else ' ', tag),
304 newline=False, colour=col.GREEN, bright=is_new)
305 terminal.Print(who, colour=col.WHITE, bright=is_new)
306 count += 1
307 return count
308
309def check_patchwork_status(series, series_id, rest_api=call_rest_api):
310 """Check the status of a series on Patchwork
311
312 This finds review tags and comments for a series in Patchwork, displaying
313 them to show what is new compared to the local series.
314
315 Args:
316 series (Series): Series object for the existing branch
317 series_id (str): Patch series ID number
318 rest_api (function): API function to call to access Patchwork, for
319 testing
320 """
321 patches = collect_patches(series, series_id, rest_api)
322 col = terminal.Color()
323 count = len(series.commits)
324 new_rtag_list = [None] * count
325
326 patch_for_commit, _, warnings = compare_with_series(series, patches)
327 for warn in warnings:
328 tout.Warning(warn)
329
330 patch_list = [patch_for_commit.get(c) for c in range(len(series.commits))]
331
332 with concurrent.futures.ThreadPoolExecutor(max_workers=16) as executor:
333 futures = executor.map(
334 find_new_responses, repeat(new_rtag_list), range(count),
335 series.commits, patch_list, repeat(rest_api))
336 for fresponse in futures:
337 if fresponse:
338 raise fresponse.exception()
339
340 num_to_add = 0
341 for seq, cmt in enumerate(series.commits):
342 patch = patch_for_commit.get(seq)
343 if not patch:
344 continue
345 terminal.Print('%3d %s' % (patch.seq, patch.subject[:50]),
346 colour=col.BLUE)
347 cmt = series.commits[seq]
348 base_rtags = cmt.rtags
349 new_rtags = new_rtag_list[seq]
350
351 indent = ' ' * 2
352 show_responses(base_rtags, indent, False)
353 num_to_add += show_responses(new_rtags, indent, True)
354
355 terminal.Print("%d new response%s available in patchwork" %
356 (num_to_add, 's' if num_to_add != 1 else ''))