[Concept,20/29] patman: Add 'series find' to search by subject fragment

Message ID 20260501110040.1874719-21-sjg@u-boot.org
State New
Headers
Series patman: Review-flow improvements and shared helpers |

Commit Message

Simon Glass May 1, 2026, 11 a.m. UTC
  From: Simon Glass <sjg@chromium.org>

Add a database-only command that searches for series whose cover
letter title, per-version description, or patch subjects match a
given substring. Multiple matches are shown with the series name,
description, and which version/patch matched.

This helps locate a series when only a fragment of the subject is
remembered, without needing to query patchwork.

Signed-off-by: Simon Glass <sjg@chromium.org>
---

 tools/patman/cmdline.py      |  6 +++++
 tools/patman/control.py      |  2 ++
 tools/patman/cseries.py      | 43 +++++++++++++++++++++++++++++
 tools/patman/database.py     | 40 +++++++++++++++++++++++++++
 tools/patman/patman.rst      | 16 ++++++++++-
 tools/patman/review.py       |  4 +--
 tools/patman/test_cseries.py | 52 +++++++++++++++++++++++++++++++++++-
 7 files changed, 158 insertions(+), 5 deletions(-)
  

Patch

diff --git a/tools/patman/cmdline.py b/tools/patman/cmdline.py
index fa7493bbfa8..dbd0309090d 100644
--- a/tools/patman/cmdline.py
+++ b/tools/patman/cmdline.py
@@ -229,6 +229,7 @@  def add_series_subparser(subparsers):
                                    help='Manage series of patches')
     series.defaults_cmds = [
         ['set-link', 'fred'],
+        ['find', 'dummy'],
     ]
     series.add_argument(
         '-n', '--dry-run', action='store_true', dest='dry_run', default=False,
@@ -292,6 +293,11 @@  def add_series_subparser(subparsers):
     _add_show_comments(sall)
     _add_show_cover_comments(sall)
 
+    find = series_subparsers.add_parser(
+        'find', help='Search for series by subject fragment')
+    find.add_argument('query', help='Text to search for')
+    _add_archived(find)
+
     series_subparsers.add_parser('get-link')
     series_subparsers.add_parser('inc')
     info = series_subparsers.add_parser('info')
diff --git a/tools/patman/control.py b/tools/patman/control.py
index 14d74028ae9..ebe47f96481 100644
--- a/tools/patman/control.py
+++ b/tools/patman/control.py
@@ -201,6 +201,8 @@  def do_series(args, test_db=None, pwork=None, cser=None):
                                dry_run=args.dry_run, show_summary=True)
         elif args.subcmd == 'dec':
             cser.decrement(args.series, args.dry_run)
+        elif args.subcmd == 'find':
+            cser.series_find(args.query, args.include_archived)
         elif args.subcmd == 'info':
             cser.show_info(args.series,
                            show_reviews=getattr(args, 'reviews', None))
diff --git a/tools/patman/cseries.py b/tools/patman/cseries.py
index 893e1b4d212..443fe324c37 100644
--- a/tools/patman/cseries.py
+++ b/tools/patman/cseries.py
@@ -492,6 +492,49 @@  class Cseries(cser_helper.CseriesHelper):
                   f'{ups:2}  {vlist}')
         print(border)
 
+    def series_find(self, query, include_archived=False):
+        """Search for series by subject fragment
+
+        Args:
+            query (str): Text to search for in series/version/patch
+                subjects
+            include_archived (bool): True to include archived series
+        """
+        col = self.col
+        rows = self.db.series_search(query, include_archived)
+        if not rows:
+            tout.notice(f"No series match '{query}'")
+            return
+
+        # Deduplicate: for each (series_id, version), keep the best match
+        # priority: series > version > patch
+        priority = {'series': 0, 'version': 1, 'patch': 2}
+        best = {}
+        for sid, name, desc, version, link, mtype, mtext in rows:
+            key = (sid, version)
+            prev = best.get(key)
+            if prev is None or priority[mtype] < priority[prev[5]]:
+                best[key] = (sid, name, desc, version, link, mtype, mtext)
+
+        with terminal.pager():
+            terminal.tprint(f"{len(best)} match(es) for '{query}':",
+                            colour=col.WHITE, col=col)
+            last_sid = None
+            for key in sorted(best,
+                              key=lambda k: (best[k][1], best[k][3])):
+                sid, name, desc, version, link, mtype, mtext = best[key]
+                if sid != last_sid:
+                    terminal.tprint('')
+                    terminal.tprint(f'{name}', colour=col.YELLOW,
+                                    col=col)
+                    terminal.tprint(f'  {desc or "(no description)"}',
+                                    col=col)
+                    last_sid = sid
+                link_str = link or '(no link)'
+                terminal.tprint(f'  v{version} [{link_str}]',
+                                colour=col.BLUE, col=col, newline=False)
+                terminal.tprint(f' {mtype}: {mtext}', col=col)
+
     def list_patches(self, series, version, show_commit=False,
                      show_patch=False):
         """List patches in a series
diff --git a/tools/patman/database.py b/tools/patman/database.py
index 7f1771cbf11..2d6e27ef6e7 100644
--- a/tools/patman/database.py
+++ b/tools/patman/database.py
@@ -1452,6 +1452,46 @@  class Database:  # pylint:disable=R0904
             'WHERE sv.link = ?', (str(link),))
         return res.fetchone()
 
+    def series_search(self, query, include_archived=False):
+        """Search for series by subject fragment
+
+        Matches series against the given query string in three places:
+         - Series description (cover letter title)
+         - ser_ver description (per-version title)
+         - Patch subjects (pcommit.subject)
+
+        Args:
+            query (str): Text to search for (case-insensitive substring)
+            include_archived (bool): True to include archived series
+
+        Return:
+            list of tuple: (series_id, name, desc, version, link, match_type,
+                match_text) where match_type is 'series', 'version' or
+                'patch', ordered by series name and version
+        """
+        pat = f'%{query}%'
+        cond = '' if include_archived else ' AND s.archived = 0'
+
+        sql = (
+            "SELECT s.id, s.name, s.desc, sv.version, sv.link, "
+            "'series' AS mtype, s.desc AS mtext "
+            'FROM series s JOIN ser_ver sv ON sv.series_id = s.id '
+            f'WHERE s.desc LIKE ?{cond} '
+            'UNION '
+            "SELECT s.id, s.name, s.desc, sv.version, sv.link, "
+            "'version' AS mtype, sv.desc AS mtext "
+            'FROM series s JOIN ser_ver sv ON sv.series_id = s.id '
+            f'WHERE sv.desc LIKE ?{cond} '
+            'UNION '
+            "SELECT s.id, s.name, s.desc, sv.version, sv.link, "
+            "'patch' AS mtype, pc.subject AS mtext "
+            'FROM series s JOIN ser_ver sv ON sv.series_id = s.id '
+            'JOIN pcommit pc ON pc.svid = sv.id '
+            f'WHERE pc.subject LIKE ?{cond} '
+            'ORDER BY 2, 4')
+        res = self.execute(sql, (pat, pat, pat))
+        return res.fetchall()
+
     def series_find_review_by_name(self, name):
         """Find a review series by its description
 
diff --git a/tools/patman/patman.rst b/tools/patman/patman.rst
index 532db22745f..718078cb882 100644
--- a/tools/patman/patman.rst
+++ b/tools/patman/patman.rst
@@ -745,11 +745,25 @@  Here is a short overview of the available subcommands:
         removing that version from the data. If you use this comment on branch
         'video3' Patman will delete version 3 and branch 'video3'.
 
+    find
+        Search the database for series matching a subject fragment.
+        Matches against the series description, per-version description,
+        and individual patch subjects. Use ``-A`` to include archived
+        series. Example::
+
+            patman series find 'fs loader'
+
     get-link
         Shows the Patchwork link for a series/version
 
+    info
+        Show detailed information about a series, including each
+        version's link, description, patches and any stored reviews.
+        Use ``-r`` to include review text.
+
     ls
-        Lists the series in the database
+        Lists the series in the database. Use ``-r`` to show only
+        review series (series fetched by ``patman review``).
 
     mark
         Mark a series with 'Change-Id' tags so that Patman can track patches
diff --git a/tools/patman/review.py b/tools/patman/review.py
index 76f3dbe274c..056333b5fe3 100644
--- a/tools/patman/review.py
+++ b/tools/patman/review.py
@@ -1144,10 +1144,8 @@  def search_patch(pwork, title):
 
     async def _query():
         query = quote_plus(title, safe=':')
-        subpath = (f'patches/?project={pwork.proj_id}&q={query}'
-                   '&order=-date&per_page=20')
         async with aiohttp.ClientSession() as client:
-            return await pwork._request(client, subpath)
+            return await pwork.search_patches(client, query)
 
     loop = asyncio.get_event_loop()
     results = loop.run_until_complete(_query())
diff --git a/tools/patman/test_cseries.py b/tools/patman/test_cseries.py
index ea1ac32badc..cb5724631ad 100644
--- a/tools/patman/test_cseries.py
+++ b/tools/patman/test_cseries.py
@@ -4461,7 +4461,6 @@  Date:   .*
         svid2 = cser.db.ser_ver_add(series_id, 2, desc='Second version desc')
 
         # Add patches to v1
-        from patman.database import Pcommit
         cser.db.pcommit_add_list(svid1, [
             Pcommit(idnum=None, seq=0, subject='Fix the widget',
                     svid=svid1, change_id=None, state=None,
@@ -4491,6 +4490,57 @@  Date:   .*
         self.assertIn('Second version desc', output)
         self.assertIn('Notes: Fixed review feedback', output)
 
+    def test_series_find(self):
+        """Test the series find command"""
+        cser = self.get_database()
+
+        # Create two series: one with patches matching 'widget', one with
+        # matching cover description, one with neither
+        alpha_id = cser.db.series_add('alpha', 'Widget subsystem refresh')
+        alpha_svid = cser.db.ser_ver_add(alpha_id, 1)
+        cser.db.pcommit_add_list(alpha_svid, [
+            Pcommit(idnum=None, seq=0, subject='Fix the widget',
+                    svid=alpha_svid, change_id=None, state=None,
+                    patch_id=None, num_comments=0)])
+
+        beta_id = cser.db.series_add('beta', 'Unrelated cleanup')
+        beta_svid = cser.db.ser_ver_add(beta_id, 1,
+                                         desc='Touch up the widget driver')
+        cser.db.pcommit_add_list(beta_svid, [
+            Pcommit(idnum=None, seq=0, subject='cleanup',
+                    svid=beta_svid, change_id=None, state=None,
+                    patch_id=None, num_comments=0)])
+
+        gamma_id = cser.db.series_add('gamma', 'Something different')
+        gamma_svid = cser.db.ser_ver_add(gamma_id, 1)
+        cser.db.pcommit_add_list(gamma_svid, [
+            Pcommit(idnum=None, seq=0, subject='other work',
+                    svid=gamma_svid, change_id=None, state=None,
+                    patch_id=None, num_comments=0)])
+        cser.commit()
+
+        # Match on cover-letter description and per-version description
+        with terminal.capture() as (out, _):
+            cser.series_find('widget')
+        output = out.getvalue()
+        self.assertIn('2 match(es)', output)
+        self.assertIn('alpha', output)
+        self.assertIn('beta', output)
+        self.assertNotIn('gamma', output)
+
+        # Match only on patch subject
+        with terminal.capture() as (out, _):
+            cser.series_find('Fix the')
+        output = out.getvalue()
+        self.assertIn('1 match(es)', output)
+        self.assertIn('alpha', output)
+
+        # No matches
+        with terminal.capture() as (out, _):
+            cser.series_find('nonexistent')
+        output = out.getvalue()
+        self.assertIn("No series match 'nonexistent'", output)
+
     # Series link used by the review tests
     REVIEW_LINK = 497923
     REVIEW_LINK_V2 = 497924