blob: ed4cca6f7240a4776bc1a955997017805cad5705 [file] [log] [blame]
Simon Glass3db916d2020-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
Simon Glass2112d072020-10-29 21:46:38 -06006and commented on. Provides a way to display review tags and comments.
7Allows creation of a new branch based on the old but with the review tags
8collected from patchwork.
Simon Glass3db916d2020-10-29 21:46:35 -06009"""
10
11import collections
12import concurrent.futures
13from itertools import repeat
Simon Glassd0a0a582020-10-29 21:46:36 -060014
15import pygit2
Simon Glass3db916d2020-10-29 21:46:35 -060016import requests
17
Simon Glass131444f2023-02-23 18:18:04 -070018from u_boot_pylib import terminal
19from u_boot_pylib import tout
Simon Glass232eefd2025-04-29 07:22:14 -060020from patman import patchstream
21from patman import patchwork
22from patman.patchstream import PatchStream
Simon Glass3db916d2020-10-29 21:46:35 -060023
Simon Glass3db916d2020-10-29 21:46:35 -060024
25def to_int(vals):
26 """Convert a list of strings into integers, using 0 if not an integer
27
28 Args:
29 vals (list): List of strings
30
31 Returns:
32 list: List of integers, one for each input string
33 """
34 out = [int(val) if val.isdigit() else 0 for val in vals]
35 return out
36
Simon Glass2112d072020-10-29 21:46:38 -060037
Simon Glass49b92292025-04-29 07:22:18 -060038def process_reviews(content, comment_data, base_rtags):
39 """Process and return review data
40
41 Args:
42 content (str): Content text of the patch itself - see pwork.get_patch()
43 comment_data (list of dict): Comments for the patch - see
44 pwork._get_patch_comments()
45 base_rtags (dict): base review tags (before any comments)
46 key: Response tag (e.g. 'Reviewed-by')
47 value: Set of people who gave that response, each a name/email
48 string
49
50 Return: tuple:
51 dict: new review tags (noticed since the base_rtags)
52 key: Response tag (e.g. 'Reviewed-by')
53 value: Set of people who gave that response, each a name/email
54 string
55 list of patchwork.Review: reviews received on the patch
56 """
57 pstrm = patchstream.PatchStream.process_text(content, True)
58 rtags = collections.defaultdict(set)
59 for response, people in pstrm.commit.rtags.items():
60 rtags[response].update(people)
61
62 reviews = []
63 for comment in comment_data:
64 pstrm = patchstream.PatchStream.process_text(comment['content'], True)
65 if pstrm.snippets:
66 submitter = comment['submitter']
67 person = f"{submitter['name']} <{submitter['email']}>"
68 reviews.append(patchwork.Review(person, pstrm.snippets))
69 for response, people in pstrm.commit.rtags.items():
70 rtags[response].update(people)
71
72 # Find the tags that are not in the commit
73 new_rtags = collections.defaultdict(set)
74 for tag, people in rtags.items():
75 for who in people:
76 is_new = (tag not in base_rtags or
77 who not in base_rtags[tag])
78 if is_new:
79 new_rtags[tag].add(who)
80 return new_rtags, reviews
81
82
Simon Glass3db916d2020-10-29 21:46:35 -060083def compare_with_series(series, patches):
84 """Compare a list of patches with a series it came from
85
86 This prints any problems as warnings
87
88 Args:
89 series (Series): Series to compare against
90 patches (:type: list of Patch): list of Patch objects to compare with
91
92 Returns:
93 tuple
94 dict:
95 key: Commit number (0...n-1)
96 value: Patch object for that commit
97 dict:
98 key: Patch number (0...n-1)
99 value: Commit object for that patch
100 """
101 # Check the names match
102 warnings = []
103 patch_for_commit = {}
104 all_patches = set(patches)
105 for seq, cmt in enumerate(series.commits):
106 pmatch = [p for p in all_patches if p.subject == cmt.subject]
107 if len(pmatch) == 1:
108 patch_for_commit[seq] = pmatch[0]
109 all_patches.remove(pmatch[0])
110 elif len(pmatch) > 1:
111 warnings.append("Multiple patches match commit %d ('%s'):\n %s" %
112 (seq + 1, cmt.subject,
113 '\n '.join([p.subject for p in pmatch])))
114 else:
115 warnings.append("Cannot find patch for commit %d ('%s')" %
116 (seq + 1, cmt.subject))
117
118
119 # Check the names match
120 commit_for_patch = {}
121 all_commits = set(series.commits)
122 for seq, patch in enumerate(patches):
123 cmatch = [c for c in all_commits if c.subject == patch.subject]
124 if len(cmatch) == 1:
125 commit_for_patch[seq] = cmatch[0]
126 all_commits.remove(cmatch[0])
127 elif len(cmatch) > 1:
128 warnings.append("Multiple commits match patch %d ('%s'):\n %s" %
129 (seq + 1, patch.subject,
130 '\n '.join([c.subject for c in cmatch])))
131 else:
132 warnings.append("Cannot find commit for patch %d ('%s')" %
133 (seq + 1, patch.subject))
134
135 return patch_for_commit, commit_for_patch, warnings
136
Simon Glassf9b03cf2020-11-03 13:54:14 -0700137def call_rest_api(url, subpath):
Simon Glass3db916d2020-10-29 21:46:35 -0600138 """Call the patchwork API and return the result as JSON
139
140 Args:
Simon Glassf9b03cf2020-11-03 13:54:14 -0700141 url (str): URL of patchwork server, e.g. 'https://patchwork.ozlabs.org'
Simon Glass3db916d2020-10-29 21:46:35 -0600142 subpath (str): URL subpath to use
143
144 Returns:
145 dict: Json result
146
147 Raises:
148 ValueError: the URL could not be read
149 """
Simon Glassf9b03cf2020-11-03 13:54:14 -0700150 full_url = '%s/api/1.2/%s' % (url, subpath)
151 response = requests.get(full_url)
Simon Glass3db916d2020-10-29 21:46:35 -0600152 if response.status_code != 200:
Simon Glassf9b03cf2020-11-03 13:54:14 -0700153 raise ValueError("Could not read URL '%s'" % full_url)
Simon Glass3db916d2020-10-29 21:46:35 -0600154 return response.json()
155
Simon Glass27280f42025-04-29 07:22:17 -0600156def collect_patches(series_id, url, rest_api=call_rest_api):
Simon Glass3db916d2020-10-29 21:46:35 -0600157 """Collect patch information about a series from patchwork
158
159 Uses the Patchwork REST API to collect information provided by patchwork
160 about the status of each patch.
161
162 Args:
Simon Glass3db916d2020-10-29 21:46:35 -0600163 series_id (str): Patch series ID number
Simon Glassf9b03cf2020-11-03 13:54:14 -0700164 url (str): URL of patchwork server, e.g. 'https://patchwork.ozlabs.org'
Simon Glass3db916d2020-10-29 21:46:35 -0600165 rest_api (function): API function to call to access Patchwork, for
166 testing
167
168 Returns:
Simon Glass27280f42025-04-29 07:22:17 -0600169 list of Patch: List of patches sorted by sequence number
Simon Glass3db916d2020-10-29 21:46:35 -0600170
171 Raises:
172 ValueError: if the URL could not be read or the web page does not follow
173 the expected structure
174 """
Simon Glassf9b03cf2020-11-03 13:54:14 -0700175 data = rest_api(url, 'series/%s/' % series_id)
Simon Glass3db916d2020-10-29 21:46:35 -0600176
177 # Get all the rows, which are patches
178 patch_dict = data['patches']
179 count = len(patch_dict)
Simon Glass3db916d2020-10-29 21:46:35 -0600180
181 patches = []
182
183 # Work through each row (patch) one at a time, collecting the information
184 warn_count = 0
185 for pw_patch in patch_dict:
Simon Glass232eefd2025-04-29 07:22:14 -0600186 patch = patchwork.Patch(pw_patch['id'])
Simon Glass3db916d2020-10-29 21:46:35 -0600187 patch.parse_subject(pw_patch['name'])
188 patches.append(patch)
189 if warn_count > 1:
Simon Glass011f1b32022-01-29 14:14:15 -0700190 tout.warning(' (total of %d warnings)' % warn_count)
Simon Glass3db916d2020-10-29 21:46:35 -0600191
192 # Sort patches by patch number
193 patches = sorted(patches, key=lambda x: x.seq)
194 return patches
195
Simon Glassf9b03cf2020-11-03 13:54:14 -0700196def find_new_responses(new_rtag_list, review_list, seq, cmt, patch, url,
Simon Glass2112d072020-10-29 21:46:38 -0600197 rest_api=call_rest_api):
Simon Glass3db916d2020-10-29 21:46:35 -0600198 """Find new rtags collected by patchwork that we don't know about
199
200 This is designed to be run in parallel, once for each commit/patch
201
202 Args:
203 new_rtag_list (list): New rtags are written to new_rtag_list[seq]
204 list, each a dict:
205 key: Response tag (e.g. 'Reviewed-by')
206 value: Set of people who gave that response, each a name/email
207 string
Simon Glass2112d072020-10-29 21:46:38 -0600208 review_list (list): New reviews are written to review_list[seq]
209 list, each a
210 List of reviews for the patch, each a Review
Simon Glass3db916d2020-10-29 21:46:35 -0600211 seq (int): Position in new_rtag_list to update
212 cmt (Commit): Commit object for this commit
213 patch (Patch): Corresponding Patch object for this patch
Simon Glassf9b03cf2020-11-03 13:54:14 -0700214 url (str): URL of patchwork server, e.g. 'https://patchwork.ozlabs.org'
Simon Glass3db916d2020-10-29 21:46:35 -0600215 rest_api (function): API function to call to access Patchwork, for
216 testing
217 """
218 if not patch:
219 return
220
221 # Get the content for the patch email itself as well as all comments
Simon Glassf9b03cf2020-11-03 13:54:14 -0700222 data = rest_api(url, 'patches/%s/' % patch.id)
Simon Glass49b92292025-04-29 07:22:18 -0600223 comment_data = rest_api(url, 'patches/%s/comments/' % patch.id)
Simon Glass3db916d2020-10-29 21:46:35 -0600224
Simon Glass49b92292025-04-29 07:22:18 -0600225 new_rtags, reviews = process_reviews(data['content'], comment_data,
226 cmt.rtags)
Simon Glass3db916d2020-10-29 21:46:35 -0600227 new_rtag_list[seq] = new_rtags
Simon Glass2112d072020-10-29 21:46:38 -0600228 review_list[seq] = reviews
Simon Glass3db916d2020-10-29 21:46:35 -0600229
230def show_responses(rtags, indent, is_new):
231 """Show rtags collected
232
233 Args:
234 rtags (dict): review tags to show
235 key: Response tag (e.g. 'Reviewed-by')
236 value: Set of people who gave that response, each a name/email string
237 indent (str): Indentation string to write before each line
238 is_new (bool): True if this output should be highlighted
239
240 Returns:
241 int: Number of review tags displayed
242 """
243 col = terminal.Color()
244 count = 0
Simon Glass2112d072020-10-29 21:46:38 -0600245 for tag in sorted(rtags.keys()):
246 people = rtags[tag]
247 for who in sorted(people):
Simon Glass02811582022-01-29 14:14:18 -0700248 terminal.tprint(indent + '%s %s: ' % ('+' if is_new else ' ', tag),
Simon Glass3db916d2020-10-29 21:46:35 -0600249 newline=False, colour=col.GREEN, bright=is_new)
Simon Glass02811582022-01-29 14:14:18 -0700250 terminal.tprint(who, colour=col.WHITE, bright=is_new)
Simon Glass3db916d2020-10-29 21:46:35 -0600251 count += 1
252 return count
253
Simon Glassd0a0a582020-10-29 21:46:36 -0600254def create_branch(series, new_rtag_list, branch, dest_branch, overwrite,
255 repo=None):
256 """Create a new branch with review tags added
257
258 Args:
259 series (Series): Series object for the existing branch
260 new_rtag_list (list): List of review tags to add, one for each commit,
261 each a dict:
262 key: Response tag (e.g. 'Reviewed-by')
263 value: Set of people who gave that response, each a name/email
264 string
265 branch (str): Existing branch to update
266 dest_branch (str): Name of new branch to create
267 overwrite (bool): True to force overwriting dest_branch if it exists
268 repo (pygit2.Repository): Repo to use (use None unless testing)
269
270 Returns:
271 int: Total number of review tags added across all commits
272
273 Raises:
274 ValueError: if the destination branch name is the same as the original
275 branch, or it already exists and @overwrite is False
276 """
277 if branch == dest_branch:
278 raise ValueError(
279 'Destination branch must not be the same as the original branch')
280 if not repo:
281 repo = pygit2.Repository('.')
282 count = len(series.commits)
283 new_br = repo.branches.get(dest_branch)
284 if new_br:
285 if not overwrite:
286 raise ValueError("Branch '%s' already exists (-f to overwrite)" %
287 dest_branch)
288 new_br.delete()
289 if not branch:
290 branch = 'HEAD'
291 target = repo.revparse_single('%s~%d' % (branch, count))
292 repo.branches.local.create(dest_branch, target)
293
294 num_added = 0
295 for seq in range(count):
296 parent = repo.branches.get(dest_branch)
297 cherry = repo.revparse_single('%s~%d' % (branch, count - seq - 1))
298
299 repo.merge_base(cherry.oid, parent.target)
300 base_tree = cherry.parents[0].tree
301
302 index = repo.merge_trees(base_tree, parent, cherry)
303 tree_id = index.write_tree(repo)
304
305 lines = []
306 if new_rtag_list[seq]:
307 for tag, people in new_rtag_list[seq].items():
308 for who in people:
309 lines.append('%s: %s' % (tag, who))
310 num_added += 1
311 message = patchstream.insert_tags(cherry.message.rstrip(),
312 sorted(lines))
313
314 repo.create_commit(
315 parent.name, cherry.author, cherry.committer, message, tree_id,
316 [parent.target])
317 return num_added
318
Simon Glass27280f42025-04-29 07:22:17 -0600319def check_status(series, series_id, url, rest_api=call_rest_api):
Simon Glass3db916d2020-10-29 21:46:35 -0600320 """Check the status of a series on Patchwork
321
322 This finds review tags and comments for a series in Patchwork, displaying
323 them to show what is new compared to the local series.
324
325 Args:
326 series (Series): Series object for the existing branch
327 series_id (str): Patch series ID number
Simon Glassf9b03cf2020-11-03 13:54:14 -0700328 url (str): URL of patchwork server, e.g. 'https://patchwork.ozlabs.org'
Simon Glass3db916d2020-10-29 21:46:35 -0600329 rest_api (function): API function to call to access Patchwork, for
330 testing
Simon Glass27280f42025-04-29 07:22:17 -0600331
332 Return:
333 tuple:
334 list of Patch: List of patches sorted by sequence number
335 dict: Patches for commit
336 key: Commit number (0...n-1)
337 value: Patch object for that commit
338 list of dict: review tags:
339 key: Response tag (e.g. 'Reviewed-by')
340 value: Set of people who gave that response, each a name/email
341 string
342 list for each patch, each a:
343 list of Review objects for the patch
Simon Glass3db916d2020-10-29 21:46:35 -0600344 """
Simon Glass27280f42025-04-29 07:22:17 -0600345 patches = collect_patches(series_id, url, rest_api)
Simon Glass3db916d2020-10-29 21:46:35 -0600346 count = len(series.commits)
347 new_rtag_list = [None] * count
Simon Glass2112d072020-10-29 21:46:38 -0600348 review_list = [None] * count
Simon Glass3db916d2020-10-29 21:46:35 -0600349
350 patch_for_commit, _, warnings = compare_with_series(series, patches)
351 for warn in warnings:
Simon Glass011f1b32022-01-29 14:14:15 -0700352 tout.warning(warn)
Simon Glass3db916d2020-10-29 21:46:35 -0600353
354 patch_list = [patch_for_commit.get(c) for c in range(len(series.commits))]
355
356 with concurrent.futures.ThreadPoolExecutor(max_workers=16) as executor:
357 futures = executor.map(
Simon Glass2112d072020-10-29 21:46:38 -0600358 find_new_responses, repeat(new_rtag_list), repeat(review_list),
Simon Glassf9b03cf2020-11-03 13:54:14 -0700359 range(count), series.commits, patch_list, repeat(url),
360 repeat(rest_api))
Simon Glass3db916d2020-10-29 21:46:35 -0600361 for fresponse in futures:
362 if fresponse:
363 raise fresponse.exception()
Simon Glass27280f42025-04-29 07:22:17 -0600364 return patches, patch_for_commit, new_rtag_list, review_list
Simon Glass3db916d2020-10-29 21:46:35 -0600365
Simon Glass27280f42025-04-29 07:22:17 -0600366
367def check_patch_count(num_commits, num_patches):
368 """Check the number of commits and patches agree
369
370 Args:
371 num_commits (int): Number of commits
372 num_patches (int): Number of patches
373 """
374 if num_patches != num_commits:
375 tout.warning(f'Warning: Patchwork reports {num_patches} patches, '
376 f'series has {num_commits}')
377
378
379def do_show_status(series, patch_for_commit, show_comments, new_rtag_list,
380 review_list, col):
Simon Glass3db916d2020-10-29 21:46:35 -0600381 num_to_add = 0
382 for seq, cmt in enumerate(series.commits):
383 patch = patch_for_commit.get(seq)
384 if not patch:
385 continue
Simon Glass02811582022-01-29 14:14:18 -0700386 terminal.tprint('%3d %s' % (patch.seq, patch.subject[:50]),
Simon Glass3db916d2020-10-29 21:46:35 -0600387 colour=col.BLUE)
388 cmt = series.commits[seq]
389 base_rtags = cmt.rtags
390 new_rtags = new_rtag_list[seq]
391
392 indent = ' ' * 2
393 show_responses(base_rtags, indent, False)
394 num_to_add += show_responses(new_rtags, indent, True)
Simon Glass2112d072020-10-29 21:46:38 -0600395 if show_comments:
396 for review in review_list[seq]:
Simon Glass02811582022-01-29 14:14:18 -0700397 terminal.tprint('Review: %s' % review.meta, colour=col.RED)
Simon Glass2112d072020-10-29 21:46:38 -0600398 for snippet in review.snippets:
399 for line in snippet:
400 quoted = line.startswith('>')
Simon Glass02811582022-01-29 14:14:18 -0700401 terminal.tprint(' %s' % line,
Simon Glass2112d072020-10-29 21:46:38 -0600402 colour=col.MAGENTA if quoted else None)
Simon Glass02811582022-01-29 14:14:18 -0700403 terminal.tprint()
Simon Glass27280f42025-04-29 07:22:17 -0600404 return num_to_add
405
406
407def show_status(series, branch, dest_branch, force, patches, patch_for_commit,
408 show_comments, new_rtag_list, review_list, test_repo=None):
409 """Show status to the user and allow a branch to be written
410
411 Args:
412 series (Series): Series object for the existing branch
413 branch (str): Existing branch to update, or None
414 dest_branch (str): Name of new branch to create, or None
415 force (bool): True to force overwriting dest_branch if it exists
416 patches (list of Patch): Patches sorted by sequence number
417 patch_for_commit (dict): Patches for commit
418 key: Commit number (0...n-1)
419 value: Patch object for that commit
420 show_comments (bool): True to show patch comments
421 new_rtag_list (list of dict) review tags for each patch:
422 key: Response tag (e.g. 'Reviewed-by')
423 value: Set of people who gave that response, each a name/email
424 string
425 review_list (list of list): list for each patch, each a:
426 list of Review objects for the patch
427 test_repo (pygit2.Repository): Repo to use (use None unless testing)
428 """
429 col = terminal.Color()
430 check_patch_count(len(series.commits), len(patches))
431 num_to_add = do_show_status(series, patch_for_commit, show_comments,
432 new_rtag_list, review_list, col)
Simon Glass3db916d2020-10-29 21:46:35 -0600433
Simon Glass02811582022-01-29 14:14:18 -0700434 terminal.tprint("%d new response%s available in patchwork%s" %
Simon Glassd0a0a582020-10-29 21:46:36 -0600435 (num_to_add, 's' if num_to_add != 1 else '',
436 '' if dest_branch
437 else ' (use -d to write them to a new branch)'))
438
439 if dest_branch:
Simon Glass27280f42025-04-29 07:22:17 -0600440 num_added = create_branch(series, new_rtag_list, branch, dest_branch,
441 force, test_repo)
Simon Glass02811582022-01-29 14:14:18 -0700442 terminal.tprint(
Simon Glassd0a0a582020-10-29 21:46:36 -0600443 "%d response%s added from patchwork into new branch '%s'" %
444 (num_added, 's' if num_added != 1 else '', dest_branch))
Simon Glass27280f42025-04-29 07:22:17 -0600445
446
447def check_and_show_status(series, link, branch, dest_branch, force,
448 show_comments, url, rest_api=call_rest_api,
449 test_repo=None):
450 """Read the series status from patchwork and show it to the user
451
452 Args:
453 series (Series): Series object for the existing branch
454 link (str): Patch series ID number
455 branch (str): Existing branch to update, or None
456 dest_branch (str): Name of new branch to create, or None
457 force (bool): True to force overwriting dest_branch if it exists
458 show_comments (bool): True to show patch comments
459 url (str): URL of patchwork server, e.g. 'https://patchwork.ozlabs.org'
460 rest_api (function): API function to call to access Patchwork, for
461 testing
462 test_repo (pygit2.Repository): Repo to use (use None unless testing)
463 """
464 patches, patch_for_commit, new_rtag_list, review_list = check_status(
465 series, link, url, rest_api)
466 show_status(series, branch, dest_branch, force, patches, patch_for_commit,
467 show_comments, new_rtag_list, review_list, test_repo)