ARM: dts: at91: sama5d2_icp: fix i2c eeprom compatible
[platform/kernel/u-boot.git] / tools / patman / status.py
1 # 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
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.
9 """
10
11 import collections
12 import concurrent.futures
13 from itertools import repeat
14 import re
15
16 import pygit2
17 import requests
18
19 from patman import patchstream
20 from patman.patchstream import PatchStream
21 from patman import terminal
22 from patman import tout
23
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)?(.*)$')
30
31 # This decodes the sequence string into a patch number and patch count
32 RE_SEQ = re.compile(r'(\d+)/(\d+)')
33
34 def to_int(vals):
35     """Convert a list of strings into integers, using 0 if not an integer
36
37     Args:
38         vals (list): List of strings
39
40     Returns:
41         list: List of integers, one for each input string
42     """
43     out = [int(val) if val.isdigit() else 0 for val in vals]
44     return out
45
46
47 class Patch(dict):
48     """Models a patch in patchwork
49
50     This class records information obtained from patchwork
51
52     Some of this information comes from the 'Patch' column:
53
54         [RFC,v2,1/3] dm: Driver and uclass changes for tiny-dm
55
56     This shows the prefix, version, seq, count and subject.
57
58     The other properties come from other columns in the display.
59
60     Properties:
61         pid (str): ID of the patch (typically an integer)
62         seq (int): Sequence number within series (1=first) parsed from sequence
63             string
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
71             subject)
72     """
73     def __init__(self, pid):
74         super().__init__()
75         self.id = pid  # Use 'id' to match what the Rest API provides
76         self.seq = None
77         self.count = None
78         self.prefix = None
79         self.version = None
80         self.raw_subject = None
81         self.subject = None
82
83     # These make us more like a dictionary
84     def __setattr__(self, name, value):
85         self[name] = value
86
87     def __getattr__(self, name):
88         return self[name]
89
90     def __hash__(self):
91         return hash(frozenset(self.items()))
92
93     def __str__(self):
94         return self.raw_subject
95
96     def parse_subject(self, raw_subject):
97         """Parse the subject of a patch into its component parts
98
99         See RE_PATCH for details. The parsed info is placed into seq, count,
100         prefix, version, subject
101
102         Args:
103             raw_subject (str): Subject string to parse
104
105         Raises:
106             ValueError: the subject cannot be parsed
107         """
108         self.raw_subject = raw_subject.strip()
109         mat = RE_PATCH.search(raw_subject.strip())
110         if not mat:
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
114         if mat_seq is None:
115             self.version = seq_info
116             seq_info = None
117         if self.version and not self.version.startswith('v'):
118             self.prefix = self.version
119             self.version = None
120         if seq_info:
121             if mat_seq:
122                 self.seq = int(mat_seq.group(1))
123                 self.count = int(mat_seq.group(2))
124         else:
125             self.seq = 1
126             self.count = 1
127
128
129 class Review:
130     """Represents a single review email collected in Patchwork
131
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
134     text.
135     """
136     def __init__(self, meta, snippets):
137         """Create new Review object
138
139         Args:
140             meta (str): Text containing review author and date
141             snippets (list): List of snippets in th review, each a list of text
142                 lines
143         """
144         self.meta = ' : '.join([line for line in meta.splitlines() if line])
145         self.snippets = snippets
146
147 def compare_with_series(series, patches):
148     """Compare a list of patches with a series it came from
149
150     This prints any problems as warnings
151
152     Args:
153         series (Series): Series to compare against
154         patches (:type: list of Patch): list of Patch objects to compare with
155
156     Returns:
157         tuple
158             dict:
159                 key: Commit number (0...n-1)
160                 value: Patch object for that commit
161             dict:
162                 key: Patch number  (0...n-1)
163                 value: Commit object for that patch
164     """
165     # Check the names match
166     warnings = []
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]
171         if len(pmatch) == 1:
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])))
178         else:
179             warnings.append("Cannot find patch for commit %d ('%s')" %
180                             (seq + 1, cmt.subject))
181
182
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]
188         if len(cmatch) == 1:
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])))
195         else:
196             warnings.append("Cannot find commit for patch %d ('%s')" %
197                             (seq + 1, patch.subject))
198
199     return patch_for_commit, commit_for_patch, warnings
200
201 def call_rest_api(subpath):
202     """Call the patchwork API and return the result as JSON
203
204     Args:
205         subpath (str): URL subpath to use
206
207     Returns:
208         dict: Json result
209
210     Raises:
211         ValueError: the URL could not be read
212     """
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()
218
219 def collect_patches(series, series_id, rest_api=call_rest_api):
220     """Collect patch information about a series from patchwork
221
222     Uses the Patchwork REST API to collect information provided by patchwork
223     about the status of each patch.
224
225     Args:
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
230             testing
231
232     Returns:
233         list: List of patches sorted by sequence number, each a Patch object
234
235     Raises:
236         ValueError: if the URL could not be read or the web page does not follow
237             the expected structure
238     """
239     data = rest_api('series/%s/' % series_id)
240
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))
248
249     patches = []
250
251     # Work through each row (patch) one at a time, collecting the information
252     warn_count = 0
253     for pw_patch in patch_dict:
254         patch = Patch(pw_patch['id'])
255         patch.parse_subject(pw_patch['name'])
256         patches.append(patch)
257     if warn_count > 1:
258         tout.Warning('   (total of %d warnings)' % warn_count)
259
260     # Sort patches by patch number
261     patches = sorted(patches, key=lambda x: x.seq)
262     return patches
263
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
267
268     This is designed to be run in parallel, once for each commit/patch
269
270     Args:
271         new_rtag_list (list): New rtags are written to new_rtag_list[seq]
272             list, each a dict:
273                 key: Response tag (e.g. 'Reviewed-by')
274                 value: Set of people who gave that response, each a name/email
275                     string
276         review_list (list): New reviews are written to review_list[seq]
277             list, each a
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
283             testing
284     """
285     if not patch:
286         return
287
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)
291
292     rtags = collections.defaultdict(set)
293     for response, people in pstrm.commit.rtags.items():
294         rtags[response].update(people)
295
296     data = rest_api('patches/%s/comments/' % patch.id)
297
298     reviews = []
299     for comment in data:
300         pstrm = PatchStream.process_text(comment['content'], True)
301         if pstrm.snippets:
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)
307
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():
312         for who in people:
313             is_new = (tag not in base_rtags or
314                       who not in base_rtags[tag])
315             if is_new:
316                 new_rtags[tag].add(who)
317     new_rtag_list[seq] = new_rtags
318     review_list[seq] = reviews
319
320 def show_responses(rtags, indent, is_new):
321     """Show rtags collected
322
323     Args:
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
329
330     Returns:
331         int: Number of review tags displayed
332     """
333     col = terminal.Color()
334     count = 0
335     for tag in sorted(rtags.keys()):
336         people = rtags[tag]
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)
341             count += 1
342     return count
343
344 def create_branch(series, new_rtag_list, branch, dest_branch, overwrite,
345                   repo=None):
346     """Create a new branch with review tags added
347
348     Args:
349         series (Series): Series object for the existing branch
350         new_rtag_list (list): List of review tags to add, one for each commit,
351                 each a dict:
352             key: Response tag (e.g. 'Reviewed-by')
353             value: Set of people who gave that response, each a name/email
354                 string
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)
359
360     Returns:
361         int: Total number of review tags added across all commits
362
363     Raises:
364         ValueError: if the destination branch name is the same as the original
365             branch, or it already exists and @overwrite is False
366     """
367     if branch == dest_branch:
368         raise ValueError(
369             'Destination branch must not be the same as the original branch')
370     if not repo:
371         repo = pygit2.Repository('.')
372     count = len(series.commits)
373     new_br = repo.branches.get(dest_branch)
374     if new_br:
375         if not overwrite:
376             raise ValueError("Branch '%s' already exists (-f to overwrite)" %
377                              dest_branch)
378         new_br.delete()
379     if not branch:
380         branch = 'HEAD'
381     target = repo.revparse_single('%s~%d' % (branch, count))
382     repo.branches.local.create(dest_branch, target)
383
384     num_added = 0
385     for seq in range(count):
386         parent = repo.branches.get(dest_branch)
387         cherry = repo.revparse_single('%s~%d' % (branch, count - seq - 1))
388
389         repo.merge_base(cherry.oid, parent.target)
390         base_tree = cherry.parents[0].tree
391
392         index = repo.merge_trees(base_tree, parent, cherry)
393         tree_id = index.write_tree(repo)
394
395         lines = []
396         if new_rtag_list[seq]:
397             for tag, people in new_rtag_list[seq].items():
398                 for who in people:
399                     lines.append('%s: %s' % (tag, who))
400                     num_added += 1
401         message = patchstream.insert_tags(cherry.message.rstrip(),
402                                           sorted(lines))
403
404         repo.create_commit(
405             parent.name, cherry.author, cherry.committer, message, tree_id,
406             [parent.target])
407     return num_added
408
409 def check_patchwork_status(series, series_id, branch, dest_branch, force,
410                            show_comments, rest_api=call_rest_api,
411                            test_repo=None):
412     """Check the status of a series on Patchwork
413
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.
416
417     Args:
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
425             testing
426         test_repo (pygit2.Repository): Repo to use (use None unless testing)
427     """
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
433
434     patch_for_commit, _, warnings = compare_with_series(series, patches)
435     for warn in warnings:
436         tout.Warning(warn)
437
438     patch_list = [patch_for_commit.get(c) for c in range(len(series.commits))]
439
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:
445         if fresponse:
446             raise fresponse.exception()
447
448     num_to_add = 0
449     for seq, cmt in enumerate(series.commits):
450         patch = patch_for_commit.get(seq)
451         if not patch:
452             continue
453         terminal.Print('%3d %s' % (patch.seq, patch.subject[:50]),
454                        colour=col.BLUE)
455         cmt = series.commits[seq]
456         base_rtags = cmt.rtags
457         new_rtags = new_rtag_list[seq]
458
459         indent = ' ' * 2
460         show_responses(base_rtags, indent, False)
461         num_to_add += show_responses(new_rtags, indent, True)
462         if show_comments:
463             for review in review_list[seq]:
464                 terminal.Print('Review: %s' % review.meta, colour=col.RED)
465                 for snippet in review.snippets:
466                     for line in snippet:
467                         quoted = line.startswith('>')
468                         terminal.Print('    %s' % line,
469                                        colour=col.MAGENTA if quoted else None)
470                     terminal.Print()
471
472     terminal.Print("%d new response%s available in patchwork%s" %
473                    (num_to_add, 's' if num_to_add != 1 else '',
474                     '' if dest_branch
475                     else ' (use -d to write them to a new branch)'))
476
477     if dest_branch:
478         num_added = create_branch(series, new_rtag_list, branch,
479                                   dest_branch, force, test_repo)
480         terminal.Print(
481             "%d response%s added from patchwork into new branch '%s'" %
482             (num_added, 's' if num_added != 1 else '', dest_branch))