Patchwork pwclient: Add heuristics to find a whole series of patches

login
register
mail settings
Submitter Doug Anderson
Date Dec. 4, 2012, 12:12 a.m.
Message ID <1354579921-27260-1-git-send-email-dianders@chromium.org>
Download mbox | patch
Permalink /patch/203511/
State Superseded
Headers show

Comments

Doug Anderson - Dec. 4, 2012, 12:12 a.m.
Add a new filter option '-r' that attempts to list all patches in a
series.  Since there's no built-in way in patman to do this, we use
some heuristics to try to find the series.

Signed-off-by: Doug Anderson <dianders@chromium.org>

---
 apps/patchwork/bin/pwclient |  128 +++++++++++++++++++++++++++++++++++++++++-
 1 files changed, 124 insertions(+), 4 deletions(-)

Patch

diff --git a/apps/patchwork/bin/pwclient b/apps/patchwork/bin/pwclient
index 9588615..0c03ff5 100755
--- a/apps/patchwork/bin/pwclient
+++ b/apps/patchwork/bin/pwclient
@@ -23,11 +23,14 @@  import os
 import sys
 import xmlrpclib
 import getopt
+import re
 import string
+import time
 import tempfile
 import subprocess
 import base64
 import ConfigParser
+import collections
 
 # Default Patchwork remote XML-RPC server URL
 # This script will check the PW_XMLRPC_URL environment variable
@@ -79,6 +82,57 @@  class Filter:
         """Return human-readable description of the filter."""
         return str(self.d)
 
+class Patch(object):
+    """Nicer representation of a patch from the server."""
+
+    def __init__(self, patch_dict):
+        """Patch constructor.
+
+        @patch_dict: The dictionary version of the patch.
+        """
+        # Make it easy to compare times of patches by getting an int.
+        self.time = time.mktime(time.strptime(patch_dict["date"],
+                                              "%Y-%m-%d %H:%M:%S"))
+
+        self.version, self.part_num, self.num_parts = \
+            self._parse_patch_name(patch_dict["name"])
+
+        # Add a few things to make it easier...
+        self.id = patch_dict["id"]
+        self.project_id = patch_dict["project_id"]
+        self.name = patch_dict["name"]
+        self.submitter_id = patch_dict["submitter_id"]
+
+        # Keep the dict in case we need anything else...
+        self.dict = patch_dict
+
+    @staticmethod
+    def _parse_patch_name(name):
+        """Parse a patch name into version, part_num, num_parts.
+
+        @name: The patch name.
+        @return: (version, part_num, num_parts)
+        """
+        mo = re.match(r"\[v(\d*),(\d*)/(\d*)\]", name)
+        if mo:
+            return mo.groups()
+
+        mo = re.match(r"\[(\d*)/(\d*)\]", name)
+        if mo:
+            return (1, mo.groups()[0], mo.groups()[1])
+
+        mo = re.match(r"\[v(\d*)]", name)
+        if mo:
+            return (mo.groups()[0], 1, 1)
+
+        return (1, 1, 1)
+
+    def __str__(self):
+        return str(self.dict)
+
+    def __repr__(self):
+        return repr(self.dict)
+
 class BasicHTTPAuthTransport(xmlrpclib.SafeTransport):
 
     def __init__(self, username = None, password = None, use_https = False):
@@ -128,7 +182,8 @@  def usage():
         -w <who>      : Filter by submitter (name, e-mail substring search)
         -d <who>      : Filter by delegate (name, e-mail substring search)
         -n <max #>    : Restrict number of results
-        -m <messageid>: Filter by Message-Id\n""")
+        -m <messageid>: Filter by Message-Id
+        -r <ID>       : Filter by patches in the same series as <ID>\n""")
     sys.stderr.write("""\nActions that take an ID argument can also be \
 invoked with:
         -h <hash>     : Lookup by patch hash\n""")
@@ -162,6 +217,57 @@  def person_ids_by_name(rpc, name):
     people = rpc.person_list(name, 0)
     return map(lambda x: x['id'], people)
 
+def patch_id_to_series(rpc, patch_id):
+    """Take a patch ID and return a list of patches in the same series.
+
+    This function uses the following heuristics to find patches in a series:
+    - It searches for all patches with the same submitter that the same version
+      number and same number of parts.
+    - It allows patches to span multiple projects (though they must all be on
+      the same patchwork server), though it prefers patches that are part of
+      the same project.  This handles cases where some parts in a series might
+      have only been sent to a topic project (like "linux-mmc").
+    - For each part number it finds the matching patch that has a date value
+      closest to the original patch.
+
+    It would be nice to use "Message-ID" and "In-Reply-To", but that's not
+    exported to the xmlrpc interface as far as I can tell.  :(
+
+    @patch_id: The patch ID that's part of the series.
+    @return: A list of patches in the series.
+    """
+    # Find this patch
+    patch = Patch(rpc.patch_get(patch_id))
+
+    # Get the all patches by the submitter, ignoring project.
+    filter = Filter()
+    filter.add("submitter_id", patch.submitter_id)
+    all_patches = [Patch(p) for p in rpc.patch_list(filter.d)]
+
+    # Whittle down--only those with matching version / num_parts.
+    key = (patch.version, patch.num_parts)
+    all_patches = [p for p in all_patches if (p.version, p.num_parts) == key]
+
+    # Organize by part_num.
+    by_part_num = collections.defaultdict(list)
+    for p in all_patches:
+        by_part_num[p.part_num].append(p)
+
+    # Find the part that's closest in time to ours for each part num.
+    final_list = []
+    for part_num, patch_list in sorted(by_part_num.iteritems()):
+        # Create a list of tuples to make sorting easier.  We want to find
+        # the patch that has the closet time.  If there's a tie then we want
+        # the patch that has the same project ID...
+        patch_list = [(abs(p.time - patch.time),
+                       abs(p.project_id - patch.project_id),
+                       p) for p in patch_list]
+
+        best = sorted(patch_list)[0][-1]
+        final_list.append(best)
+
+    return final_list
+
 def list_patches(patches):
     """Dump a list of patches to stdout."""
     print("%-5s %-12s %s" % ("ID", "State", "Name"))
@@ -169,9 +275,20 @@  def list_patches(patches):
     for patch in patches:
         print("%-5d %-12s %s" % (patch['id'], patch['state'], patch['name']))
 
-def action_list(rpc, filter, submitter_str, delegate_str):
+def action_list(rpc, filter, submitter_str, delegate_str, series_str):
     filter.resolve_ids(rpc)
 
+    if series_str != "":
+        try:
+            patch_id = int(series_str)
+        except:
+            sys.stderr.write("Invalid patch ID given\n")
+            sys.exit(1)
+
+        patches = patch_id_to_series(rpc, patch_id)
+        list_patches([patch.dict for patch in patches])
+        return
+
     if submitter_str != "":
         ids = person_ids_by_name(rpc, submitter_str)
         if len(ids) == 0:
@@ -320,7 +437,7 @@  auth_actions = ['update']
 
 def main():
     try:
-        opts, args = getopt.getopt(sys.argv[2:], 's:p:w:d:n:c:h:m:')
+        opts, args = getopt.getopt(sys.argv[2:], 's:p:w:d:n:c:h:m:r:')
     except getopt.GetoptError, err:
         print str(err)
         usage()
@@ -337,6 +454,7 @@  def main():
     project_str = ""
     commit_str = ""
     state_str = ""
+    series_str = ""
     hash_str = ""
     msgid_str = ""
     url = DEFAULT_URL
@@ -354,6 +472,8 @@  def main():
     for name, value in opts:
         if name == '-s':
             state_str = value
+        elif name == '-r':
+            series_str = value
         elif name == '-p':
             project_str = value
         elif name == '-w':
@@ -424,7 +544,7 @@  def main():
     if action == 'list' or action == 'search':
         if len(args) > 0:
             filt.add("name__icontains", args[0])
-        action_list(rpc, filt, submitter_str, delegate_str)
+        action_list(rpc, filt, submitter_str, delegate_str, series_str)
 
     elif action.startswith('project'):
         action_projects(rpc)