summaryrefslogtreecommitdiff
path: root/tools/patman/status.py
diff options
context:
space:
mode:
Diffstat (limited to 'tools/patman/status.py')
-rw-r--r--tools/patman/status.py405
1 files changed, 405 insertions, 0 deletions
diff --git a/tools/patman/status.py b/tools/patman/status.py
new file mode 100644
index 00000000000..967fef3ad6e
--- /dev/null
+++ b/tools/patman/status.py
@@ -0,0 +1,405 @@
+# SPDX-License-Identifier: GPL-2.0+
+#
+# Copyright 2020 Google LLC
+#
+"""Talks to the patchwork service to figure out what patches have been reviewed
+and commented on. Provides a way to display review tags and comments.
+Allows creation of a new branch based on the old but with the review tags
+collected from patchwork.
+"""
+
+import asyncio
+from collections import defaultdict
+import concurrent.futures
+from itertools import repeat
+
+import aiohttp
+import pygit2
+
+from u_boot_pylib import terminal
+from u_boot_pylib import tout
+from patman import patchstream
+from patman import patchwork
+
+
+def process_reviews(content, comment_data, base_rtags):
+ """Process and return review data
+
+ Args:
+ content (str): Content text of the patch itself - see pwork.get_patch()
+ comment_data (list of dict): Comments for the patch - see
+ pwork._get_patch_comments()
+ base_rtags (dict): base review tags (before any comments)
+ key: Response tag (e.g. 'Reviewed-by')
+ value: Set of people who gave that response, each a name/email
+ string
+
+ Return: tuple:
+ dict: new review tags (noticed since the base_rtags)
+ key: Response tag (e.g. 'Reviewed-by')
+ value: Set of people who gave that response, each a name/email
+ string
+ list of patchwork.Review: reviews received on the patch
+ """
+ pstrm = patchstream.PatchStream.process_text(content, True)
+ rtags = defaultdict(set)
+ for response, people in pstrm.commit.rtags.items():
+ rtags[response].update(people)
+
+ reviews = []
+ for comment in comment_data:
+ pstrm = patchstream.PatchStream.process_text(comment['content'], True)
+ if pstrm.snippets:
+ submitter = comment['submitter']
+ person = f"{submitter['name']} <{submitter['email']}>"
+ reviews.append(patchwork.Review(person, pstrm.snippets))
+ for response, people in pstrm.commit.rtags.items():
+ rtags[response].update(people)
+
+ # Find the tags that are not in the commit
+ new_rtags = defaultdict(set)
+ for tag, people in rtags.items():
+ for who in people:
+ is_new = (tag not in base_rtags or
+ who not in base_rtags[tag])
+ if is_new:
+ new_rtags[tag].add(who)
+ return new_rtags, reviews
+
+
+def compare_with_series(series, patches):
+ """Compare a list of patches with a series it came from
+
+ This prints any problems as warnings
+
+ Args:
+ series (Series): Series to compare against
+ patches (list of Patch): list of Patch objects to compare with
+
+ Returns:
+ tuple
+ dict:
+ key: Commit number (0...n-1)
+ value: Patch object for that commit
+ dict:
+ key: Patch number (0...n-1)
+ value: Commit object for that patch
+ """
+ # Check the names match
+ warnings = []
+ patch_for_commit = {}
+ all_patches = set(patches)
+ for seq, cmt in enumerate(series.commits):
+ pmatch = [p for p in all_patches if p.subject == cmt.subject]
+ if len(pmatch) == 1:
+ patch_for_commit[seq] = pmatch[0]
+ all_patches.remove(pmatch[0])
+ elif len(pmatch) > 1:
+ warnings.append("Multiple patches match commit %d ('%s'):\n %s" %
+ (seq + 1, cmt.subject,
+ '\n '.join([p.subject for p in pmatch])))
+ else:
+ warnings.append("Cannot find patch for commit %d ('%s')" %
+ (seq + 1, cmt.subject))
+
+ # Check the names match
+ commit_for_patch = {}
+ all_commits = set(series.commits)
+ for seq, patch in enumerate(patches):
+ cmatch = [c for c in all_commits if c.subject == patch.subject]
+ if len(cmatch) == 1:
+ commit_for_patch[seq] = cmatch[0]
+ all_commits.remove(cmatch[0])
+ elif len(cmatch) > 1:
+ warnings.append("Multiple commits match patch %d ('%s'):\n %s" %
+ (seq + 1, patch.subject,
+ '\n '.join([c.subject for c in cmatch])))
+ else:
+ warnings.append("Cannot find commit for patch %d ('%s')" %
+ (seq + 1, patch.subject))
+
+ return patch_for_commit, commit_for_patch, warnings
+
+
+def show_responses(col, rtags, indent, is_new):
+ """Show rtags collected
+
+ Args:
+ col (terminal.Colour): Colour object to use
+ rtags (dict): review tags to show
+ key: Response tag (e.g. 'Reviewed-by')
+ value: Set of people who gave that response, each a name/email string
+ indent (str): Indentation string to write before each line
+ is_new (bool): True if this output should be highlighted
+
+ Returns:
+ int: Number of review tags displayed
+ """
+ count = 0
+ for tag in sorted(rtags.keys()):
+ people = rtags[tag]
+ for who in sorted(people):
+ terminal.tprint(indent + '%s %s: ' % ('+' if is_new else ' ', tag),
+ newline=False, colour=col.GREEN, bright=is_new,
+ col=col)
+ terminal.tprint(who, colour=col.WHITE, bright=is_new, col=col)
+ count += 1
+ return count
+
+def create_branch(series, new_rtag_list, branch, dest_branch, overwrite,
+ repo=None):
+ """Create a new branch with review tags added
+
+ Args:
+ series (Series): Series object for the existing branch
+ new_rtag_list (list): List of review tags to add, one for each commit,
+ each a dict:
+ key: Response tag (e.g. 'Reviewed-by')
+ value: Set of people who gave that response, each a name/email
+ string
+ branch (str): Existing branch to update
+ dest_branch (str): Name of new branch to create
+ overwrite (bool): True to force overwriting dest_branch if it exists
+ repo (pygit2.Repository): Repo to use (use None unless testing)
+
+ Returns:
+ int: Total number of review tags added across all commits
+
+ Raises:
+ ValueError: if the destination branch name is the same as the original
+ branch, or it already exists and @overwrite is False
+ """
+ if branch == dest_branch:
+ raise ValueError(
+ 'Destination branch must not be the same as the original branch')
+ if not repo:
+ repo = pygit2.Repository('.')
+ count = len(series.commits)
+ new_br = repo.branches.get(dest_branch)
+ if new_br:
+ if not overwrite:
+ raise ValueError("Branch '%s' already exists (-f to overwrite)" %
+ dest_branch)
+ new_br.delete()
+ if not branch:
+ branch = 'HEAD'
+ target = repo.revparse_single('%s~%d' % (branch, count))
+ repo.branches.local.create(dest_branch, target)
+
+ num_added = 0
+ for seq in range(count):
+ parent = repo.branches.get(dest_branch)
+ cherry = repo.revparse_single('%s~%d' % (branch, count - seq - 1))
+
+ repo.merge_base(cherry.oid, parent.target)
+ base_tree = cherry.parents[0].tree
+
+ index = repo.merge_trees(base_tree, parent, cherry)
+ tree_id = index.write_tree(repo)
+
+ lines = []
+ if new_rtag_list[seq]:
+ for tag, people in new_rtag_list[seq].items():
+ for who in people:
+ lines.append('%s: %s' % (tag, who))
+ num_added += 1
+ message = patchstream.insert_tags(cherry.message.rstrip(),
+ sorted(lines))
+
+ repo.create_commit(
+ parent.name, cherry.author, cherry.committer, message, tree_id,
+ [parent.target])
+ return num_added
+
+
+def check_patch_count(num_commits, num_patches):
+ """Check the number of commits and patches agree
+
+ Args:
+ num_commits (int): Number of commits
+ num_patches (int): Number of patches
+ """
+ if num_patches != num_commits:
+ tout.warning(f'Warning: Patchwork reports {num_patches} patches, '
+ f'series has {num_commits}')
+
+
+def do_show_status(series, cover, patches, show_comments, show_cover_comments,
+ col, warnings_on_stderr=True):
+ """Check the status of a series on Patchwork
+
+ This finds review tags and comments for a series in Patchwork, displaying
+ them to show what is new compared to the local series.
+
+ Args:
+ series (Series): Series object for the existing branch
+ cover (COVER): Cover letter info, or None if none
+ patches (list of Patch): Patches sorted by sequence number
+ show_comments (bool): True to show the comments on each patch
+ show_cover_comments (bool): True to show the comments on the
+ letter
+ col (terminal.Colour): Colour object
+
+ Return: tuple:
+ int: Number of new review tags to add
+ list: List of review tags to add, one item for each commit, each a
+ dict:
+ key: Response tag (e.g. 'Reviewed-by')
+ value: Set of people who gave that response, each a name/email
+ string
+ """
+ compare = []
+ for pw_patch in patches:
+ patch = patchwork.Patch(pw_patch.id)
+ patch.parse_subject(pw_patch.series_data['name'])
+ compare.append(patch)
+
+ count = len(series.commits)
+ new_rtag_list = [None] * count
+ review_list = [None] * count
+
+ with terminal.pager():
+ patch_for_commit, _, warnings = compare_with_series(series, compare)
+ for warn in warnings:
+ tout.do_output(tout.WARNING if warnings_on_stderr else tout.INFO,
+ warn)
+
+ for seq, pw_patch in enumerate(patches):
+ compare[seq].patch = pw_patch
+
+ for i in range(count):
+ pat = patch_for_commit.get(i)
+ if pat:
+ patch_data = pat.patch.data
+ comment_data = pat.patch.comments
+ new_rtag_list[i], review_list[i] = process_reviews(
+ patch_data['content'], comment_data,
+ series.commits[i].rtags)
+ num_to_add = _do_show_status(
+ series, cover, patch_for_commit, show_comments,
+ show_cover_comments, new_rtag_list, review_list, col)
+
+ return num_to_add, new_rtag_list
+
+
+def _do_show_status(series, cover, patch_for_commit, show_comments,
+ show_cover_comments, new_rtag_list, review_list, col):
+ if cover and show_cover_comments:
+ terminal.tprint(f'Cov {cover.name}', colour=col.BLACK, col=col,
+ bright=False, back=col.YELLOW)
+ for seq, comment in enumerate(cover.comments):
+ submitter = comment['submitter']
+ person = '%s <%s>' % (submitter['name'], submitter['email'])
+ terminal.tprint(f"From: {person}: {comment['date']}",
+ colour=col.RED, col=col)
+ print(comment['content'])
+ print()
+
+ num_to_add = 0
+ for seq, cmt in enumerate(series.commits):
+ patch = patch_for_commit.get(seq)
+ if not patch:
+ continue
+ terminal.tprint('%3d %s' % (patch.seq, patch.subject[:50]),
+ colour=col.YELLOW, col=col)
+ cmt = series.commits[seq]
+ base_rtags = cmt.rtags
+ new_rtags = new_rtag_list[seq]
+
+ indent = ' ' * 2
+ show_responses(col, base_rtags, indent, False)
+ num_to_add += show_responses(col, new_rtags, indent, True)
+ if show_comments:
+ for review in review_list[seq]:
+ terminal.tprint('Review: %s' % review.meta, colour=col.RED,
+ col=col)
+ for snippet in review.snippets:
+ for line in snippet:
+ quoted = line.startswith('>')
+ terminal.tprint(
+ f' {line}',
+ colour=col.MAGENTA if quoted else None, col=col)
+ terminal.tprint()
+ return num_to_add
+
+
+def show_status(series, branch, dest_branch, force, cover, patches,
+ show_comments, show_cover_comments, test_repo=None):
+ """Check the status of a series on Patchwork
+
+ This finds review tags and comments for a series in Patchwork, displaying
+ them to show what is new compared to the local series.
+
+ Args:
+ client (aiohttp.ClientSession): Session to use
+ series (Series): Series object for the existing branch
+ branch (str): Existing branch to update, or None
+ dest_branch (str): Name of new branch to create, or None
+ force (bool): True to force overwriting dest_branch if it exists
+ cover (COVER): Cover letter info, or None if none
+ patches (list of Patch): Patches sorted by sequence number
+ show_comments (bool): True to show the comments on each patch
+ show_cover_comments (bool): True to show the comments on the letter
+ test_repo (pygit2.Repository): Repo to use (use None unless testing)
+ """
+ col = terminal.Color()
+ check_patch_count(len(series.commits), len(patches))
+ num_to_add, new_rtag_list = do_show_status(
+ series, cover, patches, show_comments, show_cover_comments, col)
+
+ if not dest_branch and num_to_add:
+ msg = ' (use -d to write them to a new branch)'
+ else:
+ msg = ''
+ terminal.tprint(
+ f"{num_to_add} new response{'s' if num_to_add != 1 else ''} "
+ f'available in patchwork{msg}')
+
+ if dest_branch:
+ num_added = create_branch(series, new_rtag_list, branch,
+ dest_branch, force, test_repo)
+ terminal.tprint(
+ f"{num_added} response{'s' if num_added != 1 else ''} added "
+ f"from patchwork into new branch '{dest_branch}'")
+
+
+async def check_status(link, pwork, read_comments=False,
+ read_cover_comments=False):
+ """Set up an HTTP session and get the required state
+
+ Args:
+ link (str): Patch series ID number
+ pwork (Patchwork): Patchwork object to use for reading
+ read_comments (bool): True to read comments and state for each patch
+
+ Return: tuple:
+ COVER object, or None if none or not read_cover_comments
+ list of PATCH objects
+ """
+ async with aiohttp.ClientSession() as client:
+ return await pwork.series_get_state(client, link, read_comments,
+ read_cover_comments)
+
+
+def check_and_show_status(series, link, branch, dest_branch, force,
+ show_comments, show_cover_comments, pwork,
+ test_repo=None):
+ """Read the series status from patchwork and show it to the user
+
+ Args:
+ series (Series): Series object for the existing branch
+ link (str): Patch series ID number
+ branch (str): Existing branch to update, or None
+ dest_branch (str): Name of new branch to create, or None
+ force (bool): True to force overwriting dest_branch if it exists
+ show_comments (bool): True to show the comments on each patch
+ show_cover_comments (bool): True to show the comments on the letter
+ pwork (Patchwork): Patchwork object to use for reading
+ test_repo (pygit2.Repository): Repo to use (use None unless testing)
+ """
+ loop = asyncio.get_event_loop()
+ cover, patches = loop.run_until_complete(check_status(
+ link, pwork, True, show_cover_comments))
+
+ show_status(series, branch, dest_branch, force, cover, patches,
+ show_comments, show_cover_comments, test_repo=test_repo)