@@ -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')
@@ -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))
@@ -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
@@ -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
@@ -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
@@ -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())
@@ -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