1 # SPDX-License-Identifier: GPL-2.0+
3 # Copyright 2020 Google LLC
5 """Talks to the patchwork service to figure out what patches have been reviewed
6 and commented on. Provides a way to display review tags and comments.
7 Allows creation of a new branch based on the old but with the review tags
8 collected from patchwork.
12 import concurrent.futures
13 from itertools import repeat
19 from patman import patchstream
20 from patman.patchstream import PatchStream
21 from patman import terminal
22 from patman import tout
24 # Patches which are part of a multi-patch series are shown with a prefix like
25 # [prefix, version, sequence], for example '[RFC, v2, 3/5]'. All but the last
26 # part is optional. This decodes the string into groups. For single patches
27 # the [] part is not present:
28 # Groups: (ignore, ignore, ignore, prefix, version, sequence, subject)
29 RE_PATCH = re.compile(r'(\[(((.*),)?(.*),)?(.*)\]\s)?(.*)$')
31 # This decodes the sequence string into a patch number and patch count
32 RE_SEQ = re.compile(r'(\d+)/(\d+)')
35 """Convert a list of strings into integers, using 0 if not an integer
38 vals (list): List of strings
41 list: List of integers, one for each input string
43 out = [int(val) if val.isdigit() else 0 for val in vals]
48 """Models a patch in patchwork
50 This class records information obtained from patchwork
52 Some of this information comes from the 'Patch' column:
54 [RFC,v2,1/3] dm: Driver and uclass changes for tiny-dm
56 This shows the prefix, version, seq, count and subject.
58 The other properties come from other columns in the display.
61 pid (str): ID of the patch (typically an integer)
62 seq (int): Sequence number within series (1=first) parsed from sequence
64 count (int): Number of patches in series, parsed from sequence string
65 raw_subject (str): Entire subject line, e.g.
66 "[1/2,v2] efi_loader: Sort header file ordering"
67 prefix (str): Prefix string or None (e.g. 'RFC')
68 version (str): Version string or None (e.g. 'v2')
69 raw_subject (str): Raw patch subject
70 subject (str): Patch subject with [..] part removed (same as commit
73 def __init__(self, pid):
75 self.id = pid # Use 'id' to match what the Rest API provides
80 self.raw_subject = None
83 # These make us more like a dictionary
84 def __setattr__(self, name, value):
87 def __getattr__(self, name):
91 return hash(frozenset(self.items()))
94 return self.raw_subject
96 def parse_subject(self, raw_subject):
97 """Parse the subject of a patch into its component parts
99 See RE_PATCH for details. The parsed info is placed into seq, count,
100 prefix, version, subject
103 raw_subject (str): Subject string to parse
106 ValueError: the subject cannot be parsed
108 self.raw_subject = raw_subject.strip()
109 mat = RE_PATCH.search(raw_subject.strip())
111 raise ValueError("Cannot parse subject '%s'" % raw_subject)
112 self.prefix, self.version, seq_info, self.subject = mat.groups()[3:]
113 mat_seq = RE_SEQ.match(seq_info) if seq_info else False
115 self.version = seq_info
117 if self.version and not self.version.startswith('v'):
118 self.prefix = self.version
122 self.seq = int(mat_seq.group(1))
123 self.count = int(mat_seq.group(2))
130 """Represents a single review email collected in Patchwork
132 Patches can attract multiple reviews. Each consists of an author/date and
133 a variable number of 'snippets', which are groups of quoted and unquoted
136 def __init__(self, meta, snippets):
137 """Create new Review object
140 meta (str): Text containing review author and date
141 snippets (list): List of snippets in th review, each a list of text
144 self.meta = ' : '.join([line for line in meta.splitlines() if line])
145 self.snippets = snippets
147 def compare_with_series(series, patches):
148 """Compare a list of patches with a series it came from
150 This prints any problems as warnings
153 series (Series): Series to compare against
154 patches (:type: list of Patch): list of Patch objects to compare with
159 key: Commit number (0...n-1)
160 value: Patch object for that commit
162 key: Patch number (0...n-1)
163 value: Commit object for that patch
165 # Check the names match
167 patch_for_commit = {}
168 all_patches = set(patches)
169 for seq, cmt in enumerate(series.commits):
170 pmatch = [p for p in all_patches if p.subject == cmt.subject]
172 patch_for_commit[seq] = pmatch[0]
173 all_patches.remove(pmatch[0])
174 elif len(pmatch) > 1:
175 warnings.append("Multiple patches match commit %d ('%s'):\n %s" %
176 (seq + 1, cmt.subject,
177 '\n '.join([p.subject for p in pmatch])))
179 warnings.append("Cannot find patch for commit %d ('%s')" %
180 (seq + 1, cmt.subject))
183 # Check the names match
184 commit_for_patch = {}
185 all_commits = set(series.commits)
186 for seq, patch in enumerate(patches):
187 cmatch = [c for c in all_commits if c.subject == patch.subject]
189 commit_for_patch[seq] = cmatch[0]
190 all_commits.remove(cmatch[0])
191 elif len(cmatch) > 1:
192 warnings.append("Multiple commits match patch %d ('%s'):\n %s" %
193 (seq + 1, patch.subject,
194 '\n '.join([c.subject for c in cmatch])))
196 warnings.append("Cannot find commit for patch %d ('%s')" %
197 (seq + 1, patch.subject))
199 return patch_for_commit, commit_for_patch, warnings
201 def call_rest_api(subpath):
202 """Call the patchwork API and return the result as JSON
205 subpath (str): URL subpath to use
211 ValueError: the URL could not be read
213 url = 'https://patchwork.ozlabs.org/api/1.2/%s' % subpath
214 response = requests.get(url)
215 if response.status_code != 200:
216 raise ValueError("Could not read URL '%s'" % url)
217 return response.json()
219 def collect_patches(series, series_id, rest_api=call_rest_api):
220 """Collect patch information about a series from patchwork
222 Uses the Patchwork REST API to collect information provided by patchwork
223 about the status of each patch.
226 series (Series): Series object corresponding to the local branch
227 containing the series
228 series_id (str): Patch series ID number
229 rest_api (function): API function to call to access Patchwork, for
233 list: List of patches sorted by sequence number, each a Patch object
236 ValueError: if the URL could not be read or the web page does not follow
237 the expected structure
239 data = rest_api('series/%s/' % series_id)
241 # Get all the rows, which are patches
242 patch_dict = data['patches']
243 count = len(patch_dict)
244 num_commits = len(series.commits)
245 if count != num_commits:
246 tout.Warning('Warning: Patchwork reports %d patches, series has %d' %
247 (count, num_commits))
251 # Work through each row (patch) one at a time, collecting the information
253 for pw_patch in patch_dict:
254 patch = Patch(pw_patch['id'])
255 patch.parse_subject(pw_patch['name'])
256 patches.append(patch)
258 tout.Warning(' (total of %d warnings)' % warn_count)
260 # Sort patches by patch number
261 patches = sorted(patches, key=lambda x: x.seq)
264 def find_new_responses(new_rtag_list, review_list, seq, cmt, patch,
265 rest_api=call_rest_api):
266 """Find new rtags collected by patchwork that we don't know about
268 This is designed to be run in parallel, once for each commit/patch
271 new_rtag_list (list): New rtags are written to new_rtag_list[seq]
273 key: Response tag (e.g. 'Reviewed-by')
274 value: Set of people who gave that response, each a name/email
276 review_list (list): New reviews are written to review_list[seq]
278 List of reviews for the patch, each a Review
279 seq (int): Position in new_rtag_list to update
280 cmt (Commit): Commit object for this commit
281 patch (Patch): Corresponding Patch object for this patch
282 rest_api (function): API function to call to access Patchwork, for
288 # Get the content for the patch email itself as well as all comments
289 data = rest_api('patches/%s/' % patch.id)
290 pstrm = PatchStream.process_text(data['content'], True)
292 rtags = collections.defaultdict(set)
293 for response, people in pstrm.commit.rtags.items():
294 rtags[response].update(people)
296 data = rest_api('patches/%s/comments/' % patch.id)
300 pstrm = PatchStream.process_text(comment['content'], True)
302 submitter = comment['submitter']
303 person = '%s <%s>' % (submitter['name'], submitter['email'])
304 reviews.append(Review(person, pstrm.snippets))
305 for response, people in pstrm.commit.rtags.items():
306 rtags[response].update(people)
308 # Find the tags that are not in the commit
309 new_rtags = collections.defaultdict(set)
310 base_rtags = cmt.rtags
311 for tag, people in rtags.items():
313 is_new = (tag not in base_rtags or
314 who not in base_rtags[tag])
316 new_rtags[tag].add(who)
317 new_rtag_list[seq] = new_rtags
318 review_list[seq] = reviews
320 def show_responses(rtags, indent, is_new):
321 """Show rtags collected
324 rtags (dict): review tags to show
325 key: Response tag (e.g. 'Reviewed-by')
326 value: Set of people who gave that response, each a name/email string
327 indent (str): Indentation string to write before each line
328 is_new (bool): True if this output should be highlighted
331 int: Number of review tags displayed
333 col = terminal.Color()
335 for tag in sorted(rtags.keys()):
337 for who in sorted(people):
338 terminal.Print(indent + '%s %s: ' % ('+' if is_new else ' ', tag),
339 newline=False, colour=col.GREEN, bright=is_new)
340 terminal.Print(who, colour=col.WHITE, bright=is_new)
344 def create_branch(series, new_rtag_list, branch, dest_branch, overwrite,
346 """Create a new branch with review tags added
349 series (Series): Series object for the existing branch
350 new_rtag_list (list): List of review tags to add, one for each commit,
352 key: Response tag (e.g. 'Reviewed-by')
353 value: Set of people who gave that response, each a name/email
355 branch (str): Existing branch to update
356 dest_branch (str): Name of new branch to create
357 overwrite (bool): True to force overwriting dest_branch if it exists
358 repo (pygit2.Repository): Repo to use (use None unless testing)
361 int: Total number of review tags added across all commits
364 ValueError: if the destination branch name is the same as the original
365 branch, or it already exists and @overwrite is False
367 if branch == dest_branch:
369 'Destination branch must not be the same as the original branch')
371 repo = pygit2.Repository('.')
372 count = len(series.commits)
373 new_br = repo.branches.get(dest_branch)
376 raise ValueError("Branch '%s' already exists (-f to overwrite)" %
381 target = repo.revparse_single('%s~%d' % (branch, count))
382 repo.branches.local.create(dest_branch, target)
385 for seq in range(count):
386 parent = repo.branches.get(dest_branch)
387 cherry = repo.revparse_single('%s~%d' % (branch, count - seq - 1))
389 repo.merge_base(cherry.oid, parent.target)
390 base_tree = cherry.parents[0].tree
392 index = repo.merge_trees(base_tree, parent, cherry)
393 tree_id = index.write_tree(repo)
396 if new_rtag_list[seq]:
397 for tag, people in new_rtag_list[seq].items():
399 lines.append('%s: %s' % (tag, who))
401 message = patchstream.insert_tags(cherry.message.rstrip(),
405 parent.name, cherry.author, cherry.committer, message, tree_id,
409 def check_patchwork_status(series, series_id, branch, dest_branch, force,
410 show_comments, rest_api=call_rest_api,
412 """Check the status of a series on Patchwork
414 This finds review tags and comments for a series in Patchwork, displaying
415 them to show what is new compared to the local series.
418 series (Series): Series object for the existing branch
419 series_id (str): Patch series ID number
420 branch (str): Existing branch to update, or None
421 dest_branch (str): Name of new branch to create, or None
422 force (bool): True to force overwriting dest_branch if it exists
423 show_comments (bool): True to show the comments on each patch
424 rest_api (function): API function to call to access Patchwork, for
426 test_repo (pygit2.Repository): Repo to use (use None unless testing)
428 patches = collect_patches(series, series_id, rest_api)
429 col = terminal.Color()
430 count = len(series.commits)
431 new_rtag_list = [None] * count
432 review_list = [None] * count
434 patch_for_commit, _, warnings = compare_with_series(series, patches)
435 for warn in warnings:
438 patch_list = [patch_for_commit.get(c) for c in range(len(series.commits))]
440 with concurrent.futures.ThreadPoolExecutor(max_workers=16) as executor:
441 futures = executor.map(
442 find_new_responses, repeat(new_rtag_list), repeat(review_list),
443 range(count), series.commits, patch_list, repeat(rest_api))
444 for fresponse in futures:
446 raise fresponse.exception()
449 for seq, cmt in enumerate(series.commits):
450 patch = patch_for_commit.get(seq)
453 terminal.Print('%3d %s' % (patch.seq, patch.subject[:50]),
455 cmt = series.commits[seq]
456 base_rtags = cmt.rtags
457 new_rtags = new_rtag_list[seq]
460 show_responses(base_rtags, indent, False)
461 num_to_add += show_responses(new_rtags, indent, True)
463 for review in review_list[seq]:
464 terminal.Print('Review: %s' % review.meta, colour=col.RED)
465 for snippet in review.snippets:
467 quoted = line.startswith('>')
468 terminal.Print(' %s' % line,
469 colour=col.MAGENTA if quoted else None)
472 terminal.Print("%d new response%s available in patchwork%s" %
473 (num_to_add, 's' if num_to_add != 1 else '',
475 else ' (use -d to write them to a new branch)'))
478 num_added = create_branch(series, new_rtag_list, branch,
479 dest_branch, force, test_repo)
481 "%d response%s added from patchwork into new branch '%s'" %
482 (num_added, 's' if num_added != 1 else '', dest_branch))