summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--tools/patman/README34
-rw-r--r--tools/patman/control.py46
-rw-r--r--tools/patman/func_test.py315
-rwxr-xr-xtools/patman/main.py18
-rw-r--r--tools/patman/status.py356
-rw-r--r--tools/patman/terminal.py21
6 files changed, 784 insertions, 6 deletions
diff --git a/tools/patman/README b/tools/patman/README
index 6664027ed7..46b8e251ca 100644
--- a/tools/patman/README
+++ b/tools/patman/README
@@ -11,6 +11,8 @@ This tool is a Python script which:
- Runs the patches through checkpatch.pl and its own checks
- Optionally emails them out to selected people
+It also shows review tags from Patchwork so you can update your local patches.
+
It is intended to automate patch creation and make it a less
error-prone process. It is useful for U-Boot and Linux work so far,
since it uses the checkpatch.pl script.
@@ -352,6 +354,38 @@ These people will get the cover letter even if they are not on the To/Cc
list for any of the patches.
+Patchwork Integration
+=====================
+
+Patman has a very basic integration with Patchwork. If you point patman to
+your series on patchwork it can show you what new reviews have appears since
+you sent your series.
+
+To set this up, add a Series-link tag to one of the commits in your series
+(see above).
+
+Then you can type
+
+ patman status
+
+and patman will show you each patch and what review tags have been collected,
+for example:
+
+...
+ 21 x86: mtrr: Update the command to use the new mtrr
+ Reviewed-by: Wolfgang Wallner <wolfgang.wallner@br-automation.com>
+ + Reviewed-by: Bin Meng <bmeng.cn@gmail.com>
+ 22 x86: mtrr: Restructure so command execution is in
+ Reviewed-by: Wolfgang Wallner <wolfgang.wallner@br-automation.com>
+ + Reviewed-by: Bin Meng <bmeng.cn@gmail.com>
+...
+
+This shows that patch 21 and 22 were sent out with one review but have since
+attracted another review each. If the series needs changes, you can update
+these commits with the new review tag before sending the next version of the
+series.
+
+
Example Work Flow
=================
diff --git a/tools/patman/control.py b/tools/patman/control.py
index 6555a4018a..7a5469add1 100644
--- a/tools/patman/control.py
+++ b/tools/patman/control.py
@@ -175,3 +175,49 @@ def send(args):
its_a_go, args.ignore_bad_tags, args.add_maintainers,
args.limit, args.dry_run, args.in_reply_to, args.thread,
args.smtp_server)
+
+def patchwork_status(branch, count, start, end):
+ """Check the status of patches in patchwork
+
+ This finds the series in patchwork using the Series-link tag, checks for new
+ comments / review tags and displays them
+
+ Args:
+ branch (str): Branch to create patches from (None = current)
+ count (int): Number of patches to produce, or -1 to produce patches for
+ the current branch back to the upstream commit
+ start (int): Start partch to use (0=first / top of branch)
+ end (int): End patch to use (0=last one in series, 1=one before that,
+ etc.)
+
+ Raises:
+ ValueError: if the branch has no Series-link value
+ """
+ if count == -1:
+ # Work out how many patches to send if we can
+ count = (gitutil.CountCommitsToBranch(branch) - start)
+
+ series = patchstream.get_metadata(branch, start, count - end)
+ warnings = 0
+ for cmt in series.commits:
+ if cmt.warn:
+ print('%d warnings for %s:' % (len(cmt.warn), cmt.hash))
+ for warn in cmt.warn:
+ print('\t', warn)
+ warnings += 1
+ print
+ if warnings:
+ raise ValueError('Please fix warnings before running status')
+ links = series.get('links')
+ if not links:
+ raise ValueError("Branch has no Series-links value")
+
+ # Find the link without a version number (we don't support versions yet)
+ found = [link for link in links.split() if not ':' in link]
+ if not found:
+ raise ValueError('Series-links has no current version (without :)')
+
+ # Import this here to avoid failing on other commands if the dependencies
+ # are not present
+ from patman import status
+ status.check_patchwork_status(series, found[0])
diff --git a/tools/patman/func_test.py b/tools/patman/func_test.py
index cce3905c09..722844e15d 100644
--- a/tools/patman/func_test.py
+++ b/tools/patman/func_test.py
@@ -13,10 +13,13 @@ import sys
import tempfile
import unittest
+
+from patman.commit import Commit
from patman import control
from patman import gitutil
from patman import patchstream
from patman.patchstream import PatchStream
+from patman.series import Series
from patman import settings
from patman import terminal
from patman import tools
@@ -25,6 +28,7 @@ from patman.test_util import capture_sys_output
try:
import pygit2
HAVE_PYGIT2 = True
+ from patman import status
except ModuleNotFoundError:
HAVE_PYGIT2 = False
@@ -36,6 +40,8 @@ class TestFunctional(unittest.TestCase):
fred = 'Fred Bloggs <f.bloggs@napier.net>'
joe = 'Joe Bloggs <joe@napierwallies.co.nz>'
mary = 'Mary Bloggs <mary@napierwallies.co.nz>'
+ commits = None
+ patches = None
def setUp(self):
self.tmpdir = tempfile.mkdtemp(prefix='patman.')
@@ -44,6 +50,7 @@ class TestFunctional(unittest.TestCase):
def tearDown(self):
shutil.rmtree(self.tmpdir)
+ terminal.SetPrintTestMode(False)
@staticmethod
def _get_path(fname):
@@ -607,3 +614,311 @@ diff --git a/lib/efi_loader/efi_memory.c b/lib/efi_loader/efi_memory.c
str(exc.exception))
finally:
os.chdir(orig_dir)
+
+ @staticmethod
+ def _fake_patchwork(subpath):
+ """Fake Patchwork server for the function below
+
+ This handles accessing a series, providing a list consisting of a
+ single patch
+ """
+ re_series = re.match(r'series/(\d*)/$', subpath)
+ if re_series:
+ series_num = re_series.group(1)
+ if series_num == '1234':
+ return {'patches': [
+ {'id': '1', 'name': 'Some patch'}]}
+ raise ValueError('Fake Patchwork does not understand: %s' % subpath)
+
+ @unittest.skipIf(not HAVE_PYGIT2, 'Missing python3-pygit2')
+ def testStatusMismatch(self):
+ """Test Patchwork patches not matching the series"""
+ series = Series()
+
+ with capture_sys_output() as (_, err):
+ status.collect_patches(series, 1234, self._fake_patchwork)
+ self.assertIn('Warning: Patchwork reports 1 patches, series has 0',
+ err.getvalue())
+
+ @unittest.skipIf(not HAVE_PYGIT2, 'Missing python3-pygit2')
+ def testStatusReadPatch(self):
+ """Test handling a single patch in Patchwork"""
+ series = Series()
+ series.commits = [Commit('abcd')]
+
+ patches = status.collect_patches(series, 1234, self._fake_patchwork)
+ self.assertEqual(1, len(patches))
+ patch = patches[0]
+ self.assertEqual('1', patch.id)
+ self.assertEqual('Some patch', patch.raw_subject)
+
+ @unittest.skipIf(not HAVE_PYGIT2, 'Missing python3-pygit2')
+ def testParseSubject(self):
+ """Test parsing of the patch subject"""
+ patch = status.Patch('1')
+
+ # Simple patch not in a series
+ patch.parse_subject('Testing')
+ self.assertEqual('Testing', patch.raw_subject)
+ self.assertEqual('Testing', patch.subject)
+ self.assertEqual(1, patch.seq)
+ self.assertEqual(1, patch.count)
+ self.assertEqual(None, patch.prefix)
+ self.assertEqual(None, patch.version)
+
+ # First patch in a series
+ patch.parse_subject('[1/2] Testing')
+ self.assertEqual('[1/2] Testing', patch.raw_subject)
+ self.assertEqual('Testing', patch.subject)
+ self.assertEqual(1, patch.seq)
+ self.assertEqual(2, patch.count)
+ self.assertEqual(None, patch.prefix)
+ self.assertEqual(None, patch.version)
+
+ # Second patch in a series
+ patch.parse_subject('[2/2] Testing')
+ self.assertEqual('Testing', patch.subject)
+ self.assertEqual(2, patch.seq)
+ self.assertEqual(2, patch.count)
+ self.assertEqual(None, patch.prefix)
+ self.assertEqual(None, patch.version)
+
+ # RFC patch
+ patch.parse_subject('[RFC,3/7] Testing')
+ self.assertEqual('Testing', patch.subject)
+ self.assertEqual(3, patch.seq)
+ self.assertEqual(7, patch.count)
+ self.assertEqual('RFC', patch.prefix)
+ self.assertEqual(None, patch.version)
+
+ # Version patch
+ patch.parse_subject('[v2,3/7] Testing')
+ self.assertEqual('Testing', patch.subject)
+ self.assertEqual(3, patch.seq)
+ self.assertEqual(7, patch.count)
+ self.assertEqual(None, patch.prefix)
+ self.assertEqual('v2', patch.version)
+
+ # All fields
+ patch.parse_subject('[RESEND,v2,3/7] Testing')
+ self.assertEqual('Testing', patch.subject)
+ self.assertEqual(3, patch.seq)
+ self.assertEqual(7, patch.count)
+ self.assertEqual('RESEND', patch.prefix)
+ self.assertEqual('v2', patch.version)
+
+ # RFC only
+ patch.parse_subject('[RESEND] Testing')
+ self.assertEqual('Testing', patch.subject)
+ self.assertEqual(1, patch.seq)
+ self.assertEqual(1, patch.count)
+ self.assertEqual('RESEND', patch.prefix)
+ self.assertEqual(None, patch.version)
+
+ @unittest.skipIf(not HAVE_PYGIT2, 'Missing python3-pygit2')
+ def testCompareSeries(self):
+ """Test operation of compare_with_series()"""
+ commit1 = Commit('abcd')
+ commit1.subject = 'Subject 1'
+ commit2 = Commit('ef12')
+ commit2.subject = 'Subject 2'
+ commit3 = Commit('3456')
+ commit3.subject = 'Subject 2'
+
+ patch1 = status.Patch('1')
+ patch1.subject = 'Subject 1'
+ patch2 = status.Patch('2')
+ patch2.subject = 'Subject 2'
+ patch3 = status.Patch('3')
+ patch3.subject = 'Subject 2'
+
+ series = Series()
+ series.commits = [commit1]
+ patches = [patch1]
+ patch_for_commit, commit_for_patch, warnings = (
+ status.compare_with_series(series, patches))
+ self.assertEqual(1, len(patch_for_commit))
+ self.assertEqual(patch1, patch_for_commit[0])
+ self.assertEqual(1, len(commit_for_patch))
+ self.assertEqual(commit1, commit_for_patch[0])
+
+ series.commits = [commit1]
+ patches = [patch1, patch2]
+ patch_for_commit, commit_for_patch, warnings = (
+ status.compare_with_series(series, patches))
+ self.assertEqual(1, len(patch_for_commit))
+ self.assertEqual(patch1, patch_for_commit[0])
+ self.assertEqual(1, len(commit_for_patch))
+ self.assertEqual(commit1, commit_for_patch[0])
+ self.assertEqual(["Cannot find commit for patch 2 ('Subject 2')"],
+ warnings)
+
+ series.commits = [commit1, commit2]
+ patches = [patch1]
+ patch_for_commit, commit_for_patch, warnings = (
+ status.compare_with_series(series, patches))
+ self.assertEqual(1, len(patch_for_commit))
+ self.assertEqual(patch1, patch_for_commit[0])
+ self.assertEqual(1, len(commit_for_patch))
+ self.assertEqual(commit1, commit_for_patch[0])
+ self.assertEqual(["Cannot find patch for commit 2 ('Subject 2')"],
+ warnings)
+
+ series.commits = [commit1, commit2, commit3]
+ patches = [patch1, patch2]
+ patch_for_commit, commit_for_patch, warnings = (
+ status.compare_with_series(series, patches))
+ self.assertEqual(2, len(patch_for_commit))
+ self.assertEqual(patch1, patch_for_commit[0])
+ self.assertEqual(patch2, patch_for_commit[1])
+ self.assertEqual(1, len(commit_for_patch))
+ self.assertEqual(commit1, commit_for_patch[0])
+ self.assertEqual(["Cannot find patch for commit 3 ('Subject 2')",
+ "Multiple commits match patch 2 ('Subject 2'):\n"
+ ' Subject 2\n Subject 2'],
+ warnings)
+
+ series.commits = [commit1, commit2]
+ patches = [patch1, patch2, patch3]
+ patch_for_commit, commit_for_patch, warnings = (
+ status.compare_with_series(series, patches))
+ self.assertEqual(1, len(patch_for_commit))
+ self.assertEqual(patch1, patch_for_commit[0])
+ self.assertEqual(2, len(commit_for_patch))
+ self.assertEqual(commit1, commit_for_patch[0])
+ self.assertEqual(["Multiple patches match commit 2 ('Subject 2'):\n"
+ ' Subject 2\n Subject 2',
+ "Cannot find commit for patch 3 ('Subject 2')"],
+ warnings)
+
+ def _fake_patchwork2(self, subpath):
+ """Fake Patchwork server for the function below
+
+ This handles accessing series, patches and comments, providing the data
+ in self.patches to the caller
+ """
+ re_series = re.match(r'series/(\d*)/$', subpath)
+ re_patch = re.match(r'patches/(\d*)/$', subpath)
+ re_comments = re.match(r'patches/(\d*)/comments/$', subpath)
+ if re_series:
+ series_num = re_series.group(1)
+ if series_num == '1234':
+ return {'patches': self.patches}
+ elif re_patch:
+ patch_num = int(re_patch.group(1))
+ patch = self.patches[patch_num - 1]
+ return patch
+ elif re_comments:
+ patch_num = int(re_comments.group(1))
+ patch = self.patches[patch_num - 1]
+ return patch.comments
+ raise ValueError('Fake Patchwork does not understand: %s' % subpath)
+
+ @unittest.skipIf(not HAVE_PYGIT2, 'Missing python3-pygit2')
+ def testFindNewResponses(self):
+ """Test operation of find_new_responses()"""
+ commit1 = Commit('abcd')
+ commit1.subject = 'Subject 1'
+ commit2 = Commit('ef12')
+ commit2.subject = 'Subject 2'
+
+ patch1 = status.Patch('1')
+ patch1.parse_subject('[1/2] Subject 1')
+ patch1.name = patch1.raw_subject
+ patch1.content = 'This is my patch content'
+ comment1a = {'content': 'Reviewed-by: %s\n' % self.joe}
+
+ patch1.comments = [comment1a]
+
+ patch2 = status.Patch('2')
+ patch2.parse_subject('[2/2] Subject 2')
+ patch2.name = patch2.raw_subject
+ patch2.content = 'Some other patch content'
+ comment2a = {
+ 'content': 'Reviewed-by: %s\nTested-by: %s\n' %
+ (self.mary, self.leb)}
+ comment2b = {'content': 'Reviewed-by: %s' % self.fred}
+ patch2.comments = [comment2a, comment2b]
+
+ # This test works by setting up commits and patch for use by the fake
+ # Rest API function _fake_patchwork2(). It calls various functions in
+ # the status module after setting up tags in the commits, checking that
+ # things behaves as expected
+ self.commits = [commit1, commit2]
+ self.patches = [patch1, patch2]
+ count = 2
+ new_rtag_list = [None] * count
+
+ # Check that the tags are picked up on the first patch
+ status.find_new_responses(new_rtag_list, 0, commit1, patch1,
+ self._fake_patchwork2)
+ self.assertEqual(new_rtag_list[0], {'Reviewed-by': {self.joe}})
+
+ # Now the second patch
+ status.find_new_responses(new_rtag_list, 1, commit2, patch2,
+ self._fake_patchwork2)
+ self.assertEqual(new_rtag_list[1], {
+ 'Reviewed-by': {self.mary, self.fred},
+ 'Tested-by': {self.leb}})
+
+ # Now add some tags to the commit, which means they should not appear as
+ # 'new' tags when scanning comments
+ new_rtag_list = [None] * count
+ commit1.rtags = {'Reviewed-by': {self.joe}}
+ status.find_new_responses(new_rtag_list, 0, commit1, patch1,
+ self._fake_patchwork2)
+ self.assertEqual(new_rtag_list[0], {})
+
+ # For the second commit, add Ed and Fred, so only Mary should be left
+ commit2.rtags = {
+ 'Tested-by': {self.leb},
+ 'Reviewed-by': {self.fred}}
+ status.find_new_responses(new_rtag_list, 1, commit2, patch2,
+ self._fake_patchwork2)
+ self.assertEqual(new_rtag_list[1], {'Reviewed-by': {self.mary}})
+
+ # Check that the output patches expectations:
+ # 1 Subject 1
+ # Reviewed-by: Joe Bloggs <joe@napierwallies.co.nz>
+ # 2 Subject 2
+ # Tested-by: Lord Edmund Blackaddër <weasel@blackadder.org>
+ # Reviewed-by: Fred Bloggs <f.bloggs@napier.net>
+ # + Reviewed-by: Mary Bloggs <mary@napierwallies.co.nz>
+ # 1 new response available in patchwork
+
+ series = Series()
+ series.commits = [commit1, commit2]
+ terminal.SetPrintTestMode()
+ status.check_patchwork_status(series, '1234', self._fake_patchwork2)
+ lines = iter(terminal.GetPrintTestLines())
+ col = terminal.Color()
+ self.assertEqual(terminal.PrintLine(' 1 Subject 1', col.BLUE),
+ next(lines))
+ self.assertEqual(
+ terminal.PrintLine(' Reviewed-by: ', col.GREEN, newline=False,
+ bright=False),
+ next(lines))
+ self.assertEqual(terminal.PrintLine(self.joe, col.WHITE, bright=False),
+ next(lines))
+
+ self.assertEqual(terminal.PrintLine(' 2 Subject 2', col.BLUE),
+ next(lines))
+ self.assertEqual(
+ terminal.PrintLine(' Tested-by: ', col.GREEN, newline=False,
+ bright=False),
+ next(lines))
+ self.assertEqual(terminal.PrintLine(self.leb, col.WHITE, bright=False),
+ next(lines))
+ self.assertEqual(
+ terminal.PrintLine(' Reviewed-by: ', col.GREEN, newline=False,
+ bright=False),
+ next(lines))
+ self.assertEqual(terminal.PrintLine(self.fred, col.WHITE, bright=False),
+ next(lines))
+ self.assertEqual(
+ terminal.PrintLine(' + Reviewed-by: ', col.GREEN, newline=False),
+ next(lines))
+ self.assertEqual(terminal.PrintLine(self.mary, col.WHITE),
+ next(lines))
+ self.assertEqual(terminal.PrintLine(
+ '1 new response available in patchwork', None), next(lines))
diff --git a/tools/patman/main.py b/tools/patman/main.py
index d1a43c44fc..7f4ae1125a 100755
--- a/tools/patman/main.py
+++ b/tools/patman/main.py
@@ -90,6 +90,10 @@ test_parser.add_argument('testname', type=str, default=None, nargs='?',
help="Specify the test to run")
AddCommonArgs(test_parser)
+status = subparsers.add_parser('status',
+ help='Check status of patches in patchwork')
+AddCommonArgs(status)
+
# Parse options twice: first to get the project and second to handle
# defaults properly (which depends on project).
argv = sys.argv[1:]
@@ -157,3 +161,17 @@ elif args.cmd == 'send':
else:
control.send(args)
+
+# Check status of patches in patchwork
+elif args.cmd == 'status':
+ ret_code = 0
+ try:
+ control.patchwork_status(args.branch, args.count, args.start, args.end)
+ except Exception as e:
+ terminal.Print('patman: %s: %s' % (type(e).__name__, e),
+ colour=terminal.Color.RED)
+ if args.debug:
+ print()
+ traceback.print_exc()
+ ret_code = 1
+ sys.exit(ret_code)
diff --git a/tools/patman/status.py b/tools/patman/status.py
new file mode 100644
index 0000000000..f41b2d4c77
--- /dev/null
+++ b/tools/patman/status.py
@@ -0,0 +1,356 @@
+# 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.
+"""
+
+import collections
+import concurrent.futures
+from itertools import repeat
+import re
+import requests
+
+from patman.patchstream import PatchStream
+from patman import terminal
+from patman import tout
+
+# Patches which are part of a multi-patch series are shown with a prefix like
+# [prefix, version, sequence], for example '[RFC, v2, 3/5]'. All but the last
+# part is optional. This decodes the string into groups. For single patches
+# the [] part is not present:
+# Groups: (ignore, ignore, ignore, prefix, version, sequence, subject)
+RE_PATCH = re.compile(r'(\[(((.*),)?(.*),)?(.*)\]\s)?(.*)$')
+
+# This decodes the sequence string into a patch number and patch count
+RE_SEQ = re.compile(r'(\d+)/(\d+)')
+
+def to_int(vals):
+ """Convert a list of strings into integers, using 0 if not an integer
+
+ Args:
+ vals (list): List of strings
+
+ Returns:
+ list: List of integers, one for each input string
+ """
+ out = [int(val) if val.isdigit() else 0 for val in vals]
+ return out
+
+
+class Patch(dict):
+ """Models a patch in patchwork
+
+ This class records information obtained from patchwork
+
+ Some of this information comes from the 'Patch' column:
+
+ [RFC,v2,1/3] dm: Driver and uclass changes for tiny-dm
+
+ This shows the prefix, version, seq, count and subject.
+
+ The other properties come from other columns in the display.
+
+ Properties:
+ pid (str): ID of the patch (typically an integer)
+ seq (int): Sequence number within series (1=first) parsed from sequence
+ string
+ count (int): Number of patches in series, parsed from sequence string
+ raw_subject (str): Entire subject line, e.g.
+ "[1/2,v2] efi_loader: Sort header file ordering"
+ prefix (str): Prefix string or None (e.g. 'RFC')
+ version (str): Version string or None (e.g. 'v2')
+ raw_subject (str): Raw patch subject
+ subject (str): Patch subject with [..] part removed (same as commit
+ subject)
+ """
+ def __init__(self, pid):
+ super().__init__()
+ self.id = pid # Use 'id' to match what the Rest API provides
+ self.seq = None
+ self.count = None
+ self.prefix = None
+ self.version = None
+ self.raw_subject = None
+ self.subject = None
+
+ # These make us more like a dictionary
+ def __setattr__(self, name, value):
+ self[name] = value
+
+ def __getattr__(self, name):
+ return self[name]
+
+ def __hash__(self):
+ return hash(frozenset(self.items()))
+
+ def __str__(self):
+ return self.raw_subject
+
+ def parse_subject(self, raw_subject):
+ """Parse the subject of a patch into its component parts
+
+ See RE_PATCH for details. The parsed info is placed into seq, count,
+ prefix, version, subject
+
+ Args:
+ raw_subject (str): Subject string to parse
+
+ Raises:
+ ValueError: the subject cannot be parsed
+ """
+ self.raw_subject = raw_subject.strip()
+ mat = RE_PATCH.search(raw_subject.strip())
+ if not mat:
+ raise ValueError("Cannot parse subject '%s'" % raw_subject)
+ self.prefix, self.version, seq_info, self.subject = mat.groups()[3:]
+ mat_seq = RE_SEQ.match(seq_info) if seq_info else False
+ if mat_seq is None:
+ self.version = seq_info
+ seq_info = None
+ if self.version and not self.version.startswith('v'):
+ self.prefix = self.version
+ self.version = None
+ if seq_info:
+ if mat_seq:
+ self.seq = int(mat_seq.group(1))
+ self.count = int(mat_seq.group(2))
+ else:
+ self.seq = 1
+ self.count = 1
+
+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 (:type: 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 call_rest_api(subpath):
+ """Call the patchwork API and return the result as JSON
+
+ Args:
+ subpath (str): URL subpath to use
+
+ Returns:
+ dict: Json result
+
+ Raises:
+ ValueError: the URL could not be read
+ """
+ url = 'https://patchwork.ozlabs.org/api/1.2/%s' % subpath
+ response = requests.get(url)
+ if response.status_code != 200:
+ raise ValueError("Could not read URL '%s'" % url)
+ return response.json()
+
+def collect_patches(series, series_id, rest_api=call_rest_api):
+ """Collect patch information about a series from patchwork
+
+ Uses the Patchwork REST API to collect information provided by patchwork
+ about the status of each patch.
+
+ Args:
+ series (Series): Series object corresponding to the local branch
+ containing the series
+ series_id (str): Patch series ID number
+ rest_api (function): API function to call to access Patchwork, for
+ testing
+
+ Returns:
+ list: List of patches sorted by sequence number, each a Patch object
+
+ Raises:
+ ValueError: if the URL could not be read or the web page does not follow
+ the expected structure
+ """
+ data = rest_api('series/%s/' % series_id)
+
+ # Get all the rows, which are patches
+ patch_dict = data['patches']
+ count = len(patch_dict)
+ num_commits = len(series.commits)
+ if count != num_commits:
+ tout.Warning('Warning: Patchwork reports %d patches, series has %d' %
+ (count, num_commits))
+
+ patches = []
+
+ # Work through each row (patch) one at a time, collecting the information
+ warn_count = 0
+ for pw_patch in patch_dict:
+ patch = Patch(pw_patch['id'])
+ patch.parse_subject(pw_patch['name'])
+ patches.append(patch)
+ if warn_count > 1:
+ tout.Warning(' (total of %d warnings)' % warn_count)
+
+ # Sort patches by patch number
+ patches = sorted(patches, key=lambda x: x.seq)
+ return patches
+
+def find_new_responses(new_rtag_list, seq, cmt, patch, rest_api=call_rest_api):
+ """Find new rtags collected by patchwork that we don't know about
+
+ This is designed to be run in parallel, once for each commit/patch
+
+ Args:
+ new_rtag_list (list): New rtags are written to new_rtag_list[seq]
+ list, each a dict:
+ key: Response tag (e.g. 'Reviewed-by')
+ value: Set of people who gave that response, each a name/email
+ string
+ seq (int): Position in new_rtag_list to update
+ cmt (Commit): Commit object for this commit
+ patch (Patch): Corresponding Patch object for this patch
+ rest_api (function): API function to call to access Patchwork, for
+ testing
+ """
+ if not patch:
+ return
+
+ # Get the content for the patch email itself as well as all comments
+ data = rest_api('patches/%s/' % patch.id)
+ pstrm = PatchStream.process_text(data['content'], True)
+
+ rtags = collections.defaultdict(set)
+ for response, people in pstrm.commit.rtags.items():
+ rtags[response].update(people)
+
+ data = rest_api('patches/%s/comments/' % patch.id)
+
+ for comment in data:
+ pstrm = PatchStream.process_text(comment['content'], True)
+ for response, people in pstrm.commit.rtags.items():
+ rtags[response].update(people)
+
+ # Find the tags that are not in the commit
+ new_rtags = collections.defaultdict(set)
+ base_rtags = cmt.rtags
+ 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)
+ new_rtag_list[seq] = new_rtags
+
+def show_responses(rtags, indent, is_new):
+ """Show rtags collected
+
+ Args:
+ 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
+ """
+ col = terminal.Color()
+ count = 0
+ for tag, people in rtags.items():
+ for who in people:
+ terminal.Print(indent + '%s %s: ' % ('+' if is_new else ' ', tag),
+ newline=False, colour=col.GREEN, bright=is_new)
+ terminal.Print(who, colour=col.WHITE, bright=is_new)
+ count += 1
+ return count
+
+def check_patchwork_status(series, series_id, rest_api=call_rest_api):
+ """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
+ series_id (str): Patch series ID number
+ rest_api (function): API function to call to access Patchwork, for
+ testing
+ """
+ patches = collect_patches(series, series_id, rest_api)
+ col = terminal.Color()
+ count = len(series.commits)
+ new_rtag_list = [None] * count
+
+ patch_for_commit, _, warnings = compare_with_series(series, patches)
+ for warn in warnings:
+ tout.Warning(warn)
+
+ patch_list = [patch_for_commit.get(c) for c in range(len(series.commits))]
+
+ with concurrent.futures.ThreadPoolExecutor(max_workers=16) as executor:
+ futures = executor.map(
+ find_new_responses, repeat(new_rtag_list), range(count),
+ series.commits, patch_list, repeat(rest_api))
+ for fresponse in futures:
+ if fresponse:
+ raise fresponse.exception()
+
+ num_to_add = 0
+ for seq, cmt in enumerate(series.commits):
+ patch = patch_for_commit.get(seq)
+ if not patch:
+ continue
+ terminal.Print('%3d %s' % (patch.seq, patch.subject[:50]),
+ colour=col.BLUE)
+ cmt = series.commits[seq]
+ base_rtags = cmt.rtags
+ new_rtags = new_rtag_list[seq]
+
+ indent = ' ' * 2
+ show_responses(base_rtags, indent, False)
+ num_to_add += show_responses(new_rtags, indent, True)
+
+ terminal.Print("%d new response%s available in patchwork" %
+ (num_to_add, 's' if num_to_add != 1 else ''))
diff --git a/tools/patman/terminal.py b/tools/patman/terminal.py
index 60dbce3ce1..9be03b3a6f 100644
--- a/tools/patman/terminal.py
+++ b/tools/patman/terminal.py
@@ -34,14 +34,22 @@ class PrintLine:
newline: True to output a newline after the text
colour: Text colour to use
"""
- def __init__(self, text, newline, colour):
+ def __init__(self, text, colour, newline=True, bright=True):
self.text = text
self.newline = newline
self.colour = colour
+ self.bright = bright
+
+ def __eq__(self, other):
+ return (self.text == other.text and
+ self.newline == other.newline and
+ self.colour == other.colour and
+ self.bright == other.bright)
def __str__(self):
- return 'newline=%s, colour=%s, text=%s' % (self.newline, self.colour,
- self.text)
+ return ("newline=%s, colour=%s, bright=%d, text='%s'" %
+ (self.newline, self.colour, self.bright, self.text))
+
def CalcAsciiLen(text):
"""Calculate the length of a string, ignoring any ANSI sequences
@@ -136,7 +144,7 @@ def Print(text='', newline=True, colour=None, limit_to_line=False, bright=True):
global last_print_len
if print_test_mode:
- print_test_list.append(PrintLine(text, newline, colour))
+ print_test_list.append(PrintLine(text, colour, newline, bright))
else:
if colour:
col = Color()
@@ -159,11 +167,12 @@ def PrintClear():
print('\r%s\r' % (' '* last_print_len), end='', flush=True)
last_print_len = None
-def SetPrintTestMode():
+def SetPrintTestMode(enable=True):
"""Go into test mode, where all printing is recorded"""
global print_test_mode
- print_test_mode = True
+ print_test_mode = enable
+ GetPrintTestLines()
def GetPrintTestLines():
"""Get a list of all lines output through Print()