[PATCH 11/25] patman: Add a Cseries class

Simon Glass sjg at chromium.org
Sat May 10 13:05:04 CEST 2025


This is the main class for dealing with series, across branches and the
database.

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

 tools/patman/__init__.py |    2 +-
 tools/patman/cseries.py  | 1165 ++++++++++++++++++++++++++++++++++++++
 2 files changed, 1166 insertions(+), 1 deletion(-)
 create mode 100644 tools/patman/cseries.py

diff --git a/tools/patman/__init__.py b/tools/patman/__init__.py
index 83ac4eabf80..c83fc1234ea 100644
--- a/tools/patman/__init__.py
+++ b/tools/patman/__init__.py
@@ -1,7 +1,7 @@
 # SPDX-License-Identifier: GPL-2.0+
 
 __all__ = [
-    'checkpatch', 'cmdline', 'commit', 'control', 'cser_helper',
+    'checkpatch', 'cmdline', 'commit', 'control', 'cser_helper', 'cseries',
     'database', 'func_test', 'get_maintainer', '__main__', 'patchstream',
     'patchwork', 'project',
     'send', 'series', 'settings', 'setup', 'status', 'test_checkpatch',
diff --git a/tools/patman/cseries.py b/tools/patman/cseries.py
new file mode 100644
index 00000000000..bcbc4963cea
--- /dev/null
+++ b/tools/patman/cseries.py
@@ -0,0 +1,1165 @@
+# SPDX-License-Identifier: GPL-2.0+
+#
+# Copyright 2025 Google LLC
+#
+"""Handles the 'series' subcommand
+"""
+
+import asyncio
+from collections import OrderedDict, defaultdict
+
+import pygit2
+
+from u_boot_pylib import cros_subprocess
+from u_boot_pylib import gitutil
+from u_boot_pylib import terminal
+from u_boot_pylib import tout
+
+from patman import patchstream
+from patman import cser_helper
+from patman.cser_helper import AUTOLINK, oid
+from patman import send
+from patman import status
+
+
+class Cseries(cser_helper.CseriesHelper):
+    """Database with information about series
+
+    This class handles database read/write as well as operations in a git
+    directory to update series information.
+    """
+    def __init__(self, topdir=None, colour=terminal.COLOR_IF_TERMINAL):
+        """Set up a new Cseries
+
+        Args:
+            topdir (str): Top-level directory of the repo
+            colour (terminal.enum): Whether to enable ANSI colour or not
+        """
+        super().__init__(topdir, colour)
+
+    def add(self, branch_name, desc=None, mark=False, allow_unmarked=False,
+            end=None, force_version=False, dry_run=False):
+        """Add a series (or new version of a series) to the database
+
+        Args:
+            branch_name (str): Name of branch to sync, or None for current one
+            desc (str): Description to use, or None to use the series subject
+            mark (str): True to mark each commit with a change ID
+            allow_unmarked (str): True to not require each commit to be marked
+            end (str): Add only commits up to but exclu
+            force_version (bool): True if ignore a Series-version tag that
+                doesn't match its branch name
+            dry_run (bool): True to do a dry run
+        """
+        name, ser, version, msg = self.prep_series(branch_name, end)
+        tout.info(f"Adding series '{ser.name}' v{version}: mark {mark} "
+                  f'allow_unmarked {allow_unmarked}')
+        if msg:
+            tout.info(msg)
+        if desc is None:
+            if not ser.cover:
+                raise ValueError(f"Branch '{name}' has no cover letter - "
+                                 'please provide description')
+            desc = ser['cover'][0]
+
+        ser = self._handle_mark(name, ser, version, mark, allow_unmarked,
+                                force_version, dry_run)
+        link = ser.get_link_for_version(version)
+
+        msg = 'Added'
+        added = False
+        series_id = self.db.series_find_by_name(ser.name)
+        if not series_id:
+            series_id = self.db.series_add(ser.name, desc)
+            added = True
+            msg += f" series '{ser.name}'"
+
+        if version not in self._get_version_list(series_id):
+            svid = self.db.ser_ver_add(series_id, version, link)
+            msg += f" v{version}"
+            if not added:
+                msg += f" to existing series '{ser.name}'"
+            added = True
+
+            self._add_series_commits(ser, svid)
+            count = len(ser.commits)
+            msg += f" ({count} commit{'s' if count > 1 else ''})"
+        if not added:
+            tout.info(f"Series '{ser.name}' v{version} already exists")
+            msg = None
+        elif not dry_run:
+            self.commit()
+        else:
+            self.rollback()
+            series_id = None
+        ser.desc = desc
+        ser.idnum = series_id
+
+        if msg:
+            tout.info(msg)
+        if dry_run:
+            tout.info('Dry run completed')
+
+    def decrement(self, series, dry_run=False):
+        """Decrement a series to the previous version and delete the branch
+
+        Args:
+            series (str): Name of series to use, or None to use current branch
+            dry_run (bool): True to do a dry run
+        """
+        ser = self._parse_series(series)
+        if not ser.idnum:
+            raise ValueError(f"Series '{ser.name}' not found in database")
+
+        max_vers = self._series_max_version(ser.idnum)
+        if max_vers < 2:
+            raise ValueError(f"Series '{ser.name}' only has one version")
+
+        tout.info(f"Removing series '{ser.name}' v{max_vers}")
+
+        new_max = max_vers - 1
+
+        repo = pygit2.init_repository(self.gitdir)
+        if not dry_run:
+            name = self._get_branch_name(ser.name, new_max)
+            branch = repo.lookup_branch(name)
+            try:
+                repo.checkout(branch)
+            except pygit2.errors.GitError:
+                tout.warning(f"Failed to checkout branch {name}")
+                raise
+
+            del_name = f'{ser.name}{max_vers}'
+            del_branch = repo.lookup_branch(del_name)
+            branch_oid = del_branch.peel(pygit2.enums.ObjectType.COMMIT).oid
+            del_branch.delete()
+            print(f"Deleted branch '{del_name}' {oid(branch_oid)}")
+
+        self.db.ser_ver_remove(ser.idnum, max_vers)
+        if not dry_run:
+            self.commit()
+        else:
+            self.rollback()
+
+    def increment(self, series_name, dry_run=False):
+        """Increment a series to the next version and create a new branch
+
+        Args:
+            series_name (str): Name of series to use, or None to use current
+                branch
+            dry_run (bool): True to do a dry run
+        """
+        ser = self._parse_series(series_name)
+        if not ser.idnum:
+            raise ValueError(f"Series '{ser.name}' not found in database")
+
+        max_vers = self._series_max_version(ser.idnum)
+
+        branch_name = self._get_branch_name(ser.name, max_vers)
+        on_branch = gitutil.get_branch(self.gitdir) == branch_name
+        svid = self.get_series_svid(ser.idnum, max_vers)
+        pwc = self.get_pcommit_dict(svid)
+        count = len(pwc.values())
+        series = patchstream.get_metadata(branch_name, 0, count,
+                                          git_dir=self.gitdir)
+        tout.info(f"Increment '{ser.name}' v{max_vers}: {count} patches")
+
+        # Create a new branch
+        vers = max_vers + 1
+        new_name = self._join_name_version(ser.name, vers)
+
+        self.update_series(branch_name, series, max_vers, new_name, dry_run,
+                            add_vers=vers, switch=on_branch)
+
+        old_svid = self.get_series_svid(ser.idnum, max_vers)
+        pcd = self.get_pcommit_dict(old_svid)
+
+        svid = self.db.ser_ver_add(ser.idnum, vers)
+        self.db.pcommit_add_list(svid, pcd.values())
+        if not dry_run:
+            self.commit()
+        else:
+            self.rollback()
+
+        # repo.head.set_target(amended)
+        tout.info(f'Added new branch {new_name}')
+        if dry_run:
+            tout.info('Dry run completed')
+
+    def link_set(self, series_name, version, link, update_commit):
+        """Add / update a series-links link for a series
+
+        Args:
+            series_name (str): Name of series to use, or None to use current
+                branch
+            version (int): Version number, or None to detect from name
+            link (str): Patchwork link-string for the series
+            update_commit (bool): True to update the current commit with the
+                link
+        """
+        ser, version = self._parse_series_and_version(series_name, version)
+        self._ensure_version(ser, version)
+
+        self._set_link(ser.idnum, ser.name, version, link, update_commit)
+        self.commit()
+        tout.info(f"Setting link for series '{ser.name}' v{version} to {link}")
+
+    def link_get(self, series, version):
+        """Get the patchwork link for a version of a series
+
+        Args:
+            series (str): Name of series to use, or None to use current branch
+            version (int): Version number or None for current
+
+        Return:
+            str: Patchwork link as a string, e.g. '12325'
+        """
+        ser, version = self._parse_series_and_version(series, version)
+        self._ensure_version(ser, version)
+        return self.db.ser_ver_get_link(ser.idnum, version)
+
+    def link_search(self, pwork, series, version):
+        """Search patch for the link for a series
+
+        Returns either the single match, or None, in which case the second part
+        of the tuple is filled in
+
+        Args:
+            pwork (Patchwork): Patchwork object to use
+            series (str): Series name to search for, or None for current series
+                that is checked out
+            version (int): Version to search for, or None for current version
+                detected from branch name
+
+        Returns:
+            tuple:
+                int: ID of the series found, or None
+                list of possible matches, or None, each a dict:
+                    'id': series ID
+                    'name': series name
+                str: series name
+                int: series version
+                str: series description
+        """
+        _, ser, version, _, _, _, _, _ = self._get_patches(series, version)
+
+        if not ser.desc:
+            raise ValueError(f"Series '{ser.name}' has an empty description")
+
+        pws, options = self.loop.run_until_complete(pwork.find_series(
+            ser, version))
+        return pws, options, ser.name, version, ser.desc
+
+    def link_auto(self, pwork, series, version, update_commit, wait_s=0):
+        """Automatically find a series link by looking in patchwork
+
+        Args:
+            pwork (Patchwork): Patchwork object to use
+            series (str): Series name to search for, or None for current series
+                that is checked out
+            version (int): Version to search for, or None for current version
+                detected from branch name
+            update_commit (bool): True to update the current commit with the
+                link
+            wait_s (int): Number of seconds to wait for the autolink to succeed
+        """
+        start = self.get_time()
+        stop = start + wait_s
+        sleep_time = 5
+        while True:
+            pws, options, name, version, desc = self.link_search(
+                pwork, series, version)
+            if pws:
+                if wait_s:
+                    tout.info('Link completed after '
+                              f'{self.get_time() - start} seconds')
+                break
+
+            print(f"Possible matches for '{name}' v{version} desc '{desc}':")
+            print('  Link  Version  Description')
+            for opt in options:
+                print(f"{opt['id']:6}  {opt['version']:7}  {opt['name']}")
+            if not wait_s or self.get_time() > stop:
+                delay = f' after {wait_s} seconds' if wait_s else ''
+                raise ValueError(f"Cannot find series '{desc}{delay}'")
+
+            self.sleep(sleep_time)
+
+        self.link_set(name, version, pws, update_commit)
+
+    def link_auto_all(self, pwork, update_commit, link_all_versions,
+                      replace_existing, dry_run, show_summary=True):
+        """Automatically find a series link by looking in patchwork
+
+        Args:
+            pwork (Patchwork): Patchwork object to use
+            update_commit (bool): True to update the current commit with the
+                link
+            link_all_versions (bool): True to sync all versions of a series,
+                False to sync only the latest version
+            replace_existing (bool): True to sync a series even if it already
+                has a link
+            dry_run (bool): True to do a dry run
+            show_summary (bool): True to show a summary of how things went
+
+        Return:
+            OrderedDict of summary info:
+                key (int): ser_ver ID
+                value (AUTOLINK): result of autolinking on this ser_ver
+        """
+        sdict = self.db.series_get_dict_by_id()
+        all_ser_vers = self._get_autolink_dict(sdict, link_all_versions)
+
+        # Get rid of things without a description
+        valid = {}
+        state = {}
+        no_desc = 0
+        not_found = 0
+        updated = 0
+        failed = 0
+        already = 0
+        for svid, (ser_id, name, version, link, desc) in all_ser_vers.items():
+            if link and not replace_existing:
+                state[svid] = f'already:{link}'
+                already += 1
+            elif desc:
+                valid[svid] = ser_id, version, link, desc
+            else:
+                no_desc += 1
+                state[svid] = 'missing description'
+
+        results, requests = self.loop.run_until_complete(
+            pwork.find_series_list(valid))
+
+        for svid, ser_id, link, _ in results:
+            if link:
+                version = all_ser_vers[svid][2]
+                if self._set_link(ser_id, sdict[ser_id].name, version,
+                                  link, update_commit, dry_run=dry_run):
+                    updated += 1
+                    state[svid] = f'linked:{link}'
+                else:
+                    failed += 1
+                    state[svid] = 'failed'
+            else:
+                not_found += 1
+                state[svid] = 'not found'
+
+        # Create a summary sorted by name and version
+        summary = OrderedDict()
+        for svid in sorted(all_ser_vers, key=lambda k: all_ser_vers[k][1:2]):
+            _, name, version, link, ser = all_ser_vers[svid]
+            summary[svid] = AUTOLINK(name, version, link, ser.desc,
+                                     state[svid])
+
+        if show_summary:
+            msg = f'{updated} series linked'
+            if already:
+                msg += f', {already} already linked'
+            if not_found:
+                msg += f', {not_found} not found'
+            if no_desc:
+                msg += f', {no_desc} missing description'
+            if failed:
+                msg += f', {failed} updated failed'
+            tout.info(msg + f' ({requests} requests)')
+
+            tout.info('')
+            tout.info(f"{'Name':15}  Version  {'Description':40}  Result")
+            border = f"{'-' * 15}  -------  {'-' * 40}  {'-' * 15}"
+            tout.info(border)
+            for name, version, link, desc, state in summary.values():
+                bright = True
+                if state.startswith('already'):
+                    col = self.col.GREEN
+                    bright = False
+                elif state.startswith('linked'):
+                    col = self.col.MAGENTA
+                else:
+                    col = self.col.RED
+                col_state = self.col.build(col, state, bright)
+                tout.info(f"{name:16.16} {version:7}  {desc or '':40.40}  "
+                          f'{col_state}')
+            tout.info(border)
+        if dry_run:
+            tout.info('Dry run completed')
+
+        return summary
+
+    def series_list(self):
+        """List all series
+
+        Lines all series along with their description, number of patches
+        accepted and  the available versions
+        """
+        sdict = self.db.series_get_dict()
+        print(f"{'Name':15}  {'Description':40}  Accepted  Versions")
+        border = f"{'-' * 15}  {'-' * 40}  --------  {'-' * 15}"
+        print(border)
+        for name in sorted(sdict):
+            ser = sdict[name]
+            versions = self._get_version_list(ser.idnum)
+            stat = self._series_get_version_stats(
+                ser.idnum, self._series_max_version(ser.idnum))[0]
+
+            vlist = ' '.join([str(ver) for ver in sorted(versions)])
+
+            print(f'{name:16.16} {ser.desc:41.41} {stat.rjust(8)}  {vlist}')
+        print(border)
+
+    def list_patches(self, series, version, show_commit=False,
+                     show_patch=False):
+        """List patches in a series
+
+        Args:
+            series (str): Name of series to use, or None to use current branch
+            version (int): Version number, or None to detect from name
+            show_commit (bool): True to show the commit and diffstate
+            show_patch (bool): True to show the patch
+        """
+        branch, series, version, pwc, name, _, cover_id, num_comments = (
+            self._get_patches(series, version))
+        with terminal.pager():
+            state_totals = defaultdict(int)
+            self._list_patches(branch, pwc, series, name, cover_id,
+                               num_comments, show_commit, show_patch, True,
+                               state_totals)
+
+    def mark(self, in_name, allow_marked=False, dry_run=False):
+        """Add Change-Id tags to a series
+
+        Args:
+            in_name (str): Name of the series to unmark
+            allow_marked (bool): Allow commits to be (already) marked
+            dry_run (bool): True to do a dry run, restoring the original tree
+                afterwards
+
+        Return:
+            pygit.oid: oid of the new branch
+        """
+        name, ser, _, _ = self.prep_series(in_name)
+        tout.info(f"Marking series '{name}': allow_marked {allow_marked}")
+
+        if not allow_marked:
+            bad = []
+            for cmt in ser.commits:
+                if cmt.change_id:
+                    bad.append(cmt)
+            if bad:
+                print(f'{len(bad)} commit(s) already have marks')
+                for cmt in bad:
+                    print(f' - {oid(cmt.hash)} {cmt.subject}')
+                raise ValueError(
+                    f'Marked commits {len(bad)}/{len(ser.commits)}')
+        new_oid = self._mark_series(in_name, ser, dry_run=dry_run)
+
+        if dry_run:
+            tout.info('Dry run completed')
+        return new_oid
+
+    def unmark(self, name, allow_unmarked=False, dry_run=False):
+        """Remove Change-Id tags from a series
+
+        Args:
+            name (str): Name of the series to unmark
+            allow_unmarked (bool): Allow commits to be (already) unmarked
+            dry_run (bool): True to do a dry run, restoring the original tree
+                afterwards
+
+        Return:
+            pygit.oid: oid of the new branch
+        """
+        name, ser, _, _ = self.prep_series(name)
+        tout.info(
+            f"Unmarking series '{name}': allow_unmarked {allow_unmarked}")
+
+        if not allow_unmarked:
+            bad = []
+            for cmt in ser.commits:
+                if not cmt.change_id:
+                    bad.append(cmt)
+            if bad:
+                print(f'{len(bad)} commit(s) are missing marks')
+                for cmt in bad:
+                    print(f' - {oid(cmt.hash)} {cmt.subject}')
+                raise ValueError(
+                    f'Unmarked commits {len(bad)}/{len(ser.commits)}')
+        vals = None
+        for vals in self.process_series(name, ser, dry_run=dry_run):
+            if cser_helper.CHANGE_ID_TAG in vals.msg:
+                lines = vals.msg.splitlines()
+                updated = [line for line in lines
+                           if not line.startswith(cser_helper.CHANGE_ID_TAG)]
+                vals.msg = '\n'.join(updated)
+
+                tout.detail("   - removing mark")
+                vals.info = 'unmarked'
+            else:
+                vals.info = 'no mark'
+
+        if dry_run:
+            tout.info('Dry run completed')
+        return vals.oid
+
+    def open(self, pwork, name, version):
+        """Open the patchwork page for a series
+
+        Args:
+            pwork (Patchwork): Patchwork object to use
+            name (str): Name of series to open
+            version (str): Version number to open
+        """
+        ser, version = self._parse_series_and_version(name, version)
+        link = self.link_get(ser.name, version)
+        pwork.url = 'https://patchwork.ozlabs.org'
+        url = self.loop.run_until_complete(pwork.get_series_url(link))
+        print(f'Opening {url}')
+
+        # With Firefox, GTK produces lots of warnings, so suppress them
+        # Gtk-Message: 06:48:20.692: Failed to load module "xapp-gtk3-module"
+        # Gtk-Message: 06:48:20.692: Not loading module "atk-bridge": The
+        # functionality is provided by GTK natively. Please try to not load it.
+        # Gtk-Message: 06:48:20.692: Failed to load module "appmenu-gtk-module"
+        # Gtk-Message: 06:48:20.692: Failed to load module "appmenu-gtk-module"
+        # [262145, Main Thread] WARNING: GTK+ module /snap/firefox/5987/
+        #  gnome-platform/usr/lib/gtk-2.0/modules/libcanberra-gtk-module.so
+        #  cannot be loaded.
+        # GTK+ 2.x symbols detected. Using GTK+ 2.x and GTK+ 3 in the same
+        #  process #  is not supported.: 'glib warning', file /build/firefox/
+        #  parts/firefox/build/toolkit/xre/nsSigHandlers.cpp:201
+        #
+        # (firefox_firefox:262145): Gtk-WARNING **: 06:48:20.728: GTK+ module
+        #  /snap/firefox/5987/gnome-platform/usr/lib/gtk-2.0/modules/
+        #  libcanberra-gtk-module.so cannot be loaded.
+        # GTK+ 2.x symbols detected. Using GTK+ 2.x and GTK+ 3 in the same
+        #  process is not supported.
+        # Gtk-Message: 06:48:20.728: Failed to load module
+        #  "canberra-gtk-module"
+        # [262145, Main Thread] WARNING: GTK+ module /snap/firefox/5987/
+        #  gnome-platform/usr/lib/gtk-2.0/modules/libcanberra-gtk-module.so
+        #  cannot be loaded.
+        # GTK+ 2.x symbols detected. Using GTK+ 2.x and GTK+ 3 in the same
+        #  process is not supported.: 'glib warning', file /build/firefox/
+        #  parts/firefox/build/toolkit/xre/nsSigHandlers.cpp:201
+        #
+        # (firefox_firefox:262145): Gtk-WARNING **: 06:48:20.729: GTK+ module
+        #   /snap/firefox/5987/gnome-platform/usr/lib/gtk-2.0/modules/
+        #   libcanberra-gtk-module.so cannot be loaded.
+        # GTK+ 2.x symbols detected. Using GTK+ 2.x and GTK+ 3 in the same
+        #  process is not supported.
+        # Gtk-Message: 06:48:20.729: Failed to load module
+        #  "canberra-gtk-module"
+        # ATTENTION: default value of option mesa_glthread overridden by
+        # environment.
+        cros_subprocess.Popen(['xdg-open', url])
+
+    def progress(self, series, show_all_versions, list_patches):
+        """Show progress information for all versions in a series
+
+        Args:
+            series (str): Name of series to use, or None to show progress for
+                all series
+            show_all_versions (bool): True to show all versions of a series,
+                False to show only the final version
+            list_patches (bool): True to list all patches for each series,
+                False to just show the series summary on a single line
+        """
+        with terminal.pager():
+            state_totals = defaultdict(int)
+            if series is not None:
+                _, _, need_scan = self._progress_one(
+                    self._parse_series(series), show_all_versions,
+                    list_patches, state_totals)
+                if need_scan:
+                    tout.warning(
+                        'Inconsistent commit-subject: Please use '
+                        "'patman series -s <branch> scan' to resolve this")
+                return
+
+            total_patches = 0
+            total_series = 0
+            sdict = self.db.series_get_dict()
+            border = None
+            total_need_scan = 0
+            if not list_patches:
+                print(self.col.build(
+                    self.col.MAGENTA,
+                    f"{'Name':16} {'Description':41} Count  {'Status'}"))
+                border = f"{'-' * 15}  {'-' * 40}  -----  {'-' * 15}"
+                print(border)
+            for name in sorted(sdict):
+                ser = sdict[name]
+                num_series, num_patches, need_scan = self._progress_one(
+                    ser, show_all_versions, list_patches, state_totals)
+                total_need_scan += need_scan
+                if list_patches:
+                    print()
+                total_series += num_series
+                total_patches += num_patches
+            if not list_patches:
+                print(border)
+                total = f'{total_series} series'
+                out = ''
+                for state, freq in state_totals.items():
+                    out += ' ' + self._build_col(state, f'{freq}:')[0]
+                if total_need_scan:
+                    out = '*' + out[1:]
+
+                print(f"{total:15}  {'':40}  {total_patches:5} {out}")
+                if total_need_scan:
+                    tout.info(
+                        f'Series marked * ({total_need_scan}) have commit '
+                        'subjects which mismatch their patches and need to be '
+                        'scanned')
+
+    def project_set(self, pwork, name, quiet=False):
+        """Set the name of the project
+
+        Args:
+            pwork (Patchwork): Patchwork object to use
+            name (str): Name of the project to use in patchwork
+            quiet (bool): True to skip writing the message
+        """
+        res = self.loop.run_until_complete(pwork.get_projects())
+        proj_id = None
+        link_name = None
+        for proj in res:
+            if proj['name'] == name:
+                proj_id = proj['id']
+                link_name = proj['link_name']
+        if not proj_id:
+            raise ValueError(f"Unknown project name '{name}'")
+        self.db.settings_update(name, proj_id, link_name)
+        self.commit()
+        if not quiet:
+            tout.info(f"Project '{name}' patchwork-ID {proj_id} "
+                      f'link-name {link_name}')
+
+    def project_get(self):
+        """Get the details of the project
+
+        Returns:
+            tuple or None if there are no settings:
+                name (str): Project name, e.g. 'U-Boot'
+                proj_id (int): Patchworks project ID for this project
+                link_name (str): Patchwork's link-name for the project
+        """
+        return self.db.settings_get()
+
+    def remove(self, name, dry_run=False):
+        """Remove a series from the database
+
+        Args:
+            name (str): Name of series to remove, or None to use current one
+            dry_run (bool): True to do a dry run
+        """
+        ser = self._parse_series(name)
+        name = ser.name
+        if not ser.idnum:
+            raise ValueError(f"No such series '{name}'")
+
+        self.db.ser_ver_remove(ser.idnum, None)
+        if not dry_run:
+            self.commit()
+        else:
+            self.rollback()
+
+        self.commit()
+        tout.info(f"Removed series '{name}'")
+        if dry_run:
+            tout.info('Dry run completed')
+
+    def rename(self, series, name, dry_run=False):
+        """Rename a series
+
+        Renames a series and changes the name of any branches which match
+        versions present in the database
+
+        Args:
+            series (str): Name of series to use, or None to use current branch
+            name (str): new name to use (must not include version number)
+            dry_run (bool): True to do a dry run
+        """
+        old_ser, _ = self._parse_series_and_version(series, None)
+        if not old_ser.idnum:
+            raise ValueError(f"Series '{old_ser.name}' not found in database")
+        if old_ser.name != series:
+            raise ValueError(f"Invalid series name '{series}': "
+                             'did you use the branch name?')
+        chk, _ = cser_helper.split_name_version(name)
+        if chk != name:
+            raise ValueError(
+                f"Invalid series name '{name}': did you use the branch name?")
+        if chk == old_ser.name:
+            raise ValueError(
+                f"Cannot rename series '{old_ser.name}' to itself")
+        if self.get_series_by_name(name):
+            raise ValueError(f"Cannot rename: series '{name}' already exists")
+
+        versions = self._get_version_list(old_ser.idnum)
+        missing = []
+        exists = []
+        todo = {}
+        for ver in versions:
+            ok = True
+            old_branch = self._get_branch_name(old_ser.name, ver)
+            if not gitutil.check_branch(old_branch, self.gitdir):
+                missing.append(old_branch)
+                ok = False
+
+            branch = self._get_branch_name(name, ver)
+            if gitutil.check_branch(branch, self.gitdir):
+                exists.append(branch)
+                ok = False
+
+            if ok:
+                todo[ver] = [old_branch, branch]
+
+        if missing or exists:
+            msg = 'Cannot rename'
+            if missing:
+                msg += f": branches missing: {', '.join(missing)}"
+            if exists:
+                msg += f": branches exist: {', '.join(exists)}"
+            raise ValueError(msg)
+
+        for old_branch, branch in todo.values():
+            tout.info(f"Renaming branch '{old_branch}' to '{branch}'")
+            if not dry_run:
+                gitutil.rename_branch(old_branch, branch, self.gitdir)
+
+        # Change the series name; nothing needs to change in ser_ver
+        self.db.series_set_name(old_ser.idnum, name)
+
+        if not dry_run:
+            self.commit()
+        else:
+            self.rollback()
+
+        tout.info(f"Renamed series '{series}' to '{name}'")
+        if dry_run:
+            tout.info('Dry run completed')
+
+    def scan(self, branch_name, mark=False, allow_unmarked=False, end=None,
+             dry_run=False):
+        """Scan a branch and make updates to the database if it has changed
+
+        Args:
+            branch_name (str): Name of branch to sync, or None for current one
+            mark (str): True to mark each commit with a change ID
+            allow_unmarked (str): True to not require each commit to be marked
+            end (str): Add only commits up to but exclu
+            dry_run (bool): True to do a dry run
+        """
+        def _show_item(oper, seq, subject):
+            col = None
+            if oper == '+':
+                col = self.col.GREEN
+            elif oper == '-':
+                col = self.col.RED
+            out = self.col.build(col, subject) if col else subject
+            tout.info(f'{oper} {seq:3} {out}')
+
+        name, ser, version, msg = self.prep_series(branch_name, end)
+        svid = self.get_ser_ver(ser.idnum, version).idnum
+        pcdict = self.get_pcommit_dict(svid)
+
+        tout.info(
+            f"Syncing series '{name}' v{version}: mark {mark} "
+            f'allow_unmarked {allow_unmarked}')
+        if msg:
+            tout.info(msg)
+
+        ser = self._handle_mark(name, ser, version, mark, allow_unmarked,
+                                False, dry_run)
+
+        # First check for new patches that are not in the database
+        to_add = dict(enumerate(ser.commits))
+        for pcm in pcdict.values():
+            tout.debug(f'pcm {pcm.subject}')
+            i = self._find_matched_commit(to_add, pcm)
+            if i is not None:
+                del to_add[i]
+
+        # Now check for patches in the database that are not in the branch
+        to_remove = dict(enumerate(pcdict.values()))
+        for cmt in ser.commits:
+            tout.debug(f'cmt {cmt.subject}')
+            i = self._find_matched_patch(to_remove, cmt)
+            if i is not None:
+                del to_remove[i]
+
+        for seq, cmt in enumerate(ser.commits):
+            if seq in to_remove:
+                _show_item('-', seq, to_remove[seq].subject)
+                del to_remove[seq]
+            if seq in to_add:
+                _show_item('+', seq, to_add[seq].subject)
+                del to_add[seq]
+            else:
+                _show_item(' ', seq, cmt.subject)
+        seq = len(ser.commits)
+        for cmt in to_add.items():
+            _show_item('+', seq, cmt.subject)
+            seq += 1
+        for seq, pcm in to_remove.items():
+            _show_item('+', seq, pcm.subject)
+
+        self.db.pcommit_delete(svid)
+        self._add_series_commits(ser, svid)
+        if not dry_run:
+            self.commit()
+        else:
+            self.rollback()
+            tout.info('Dry run completed')
+
+    def send(self, pwork, name, autolink, autolink_wait, args):
+        """Send out a series
+
+        Args:
+            pwork (Patchwork): Patchwork object to use
+            name (str): Series name to search for, or None for current series
+                that is checked out
+            autolink (bool): True to auto-link the series after sending
+            args (argparse.Namespace): 'send' arguments provided
+            autolink_wait (int): Number of seconds to wait for the autolink to
+                succeed
+        """
+        ser, version = self._parse_series_and_version(name, None)
+        if not ser.idnum:
+            raise ValueError(f"Series '{ser.name}' not found in database")
+
+        args.branch = self._get_branch_name(ser.name, version)
+        likely_sent = send.send(args, git_dir=self.gitdir, cwd=self.topdir)
+
+        if likely_sent and autolink:
+            print(f'Autolinking with Patchwork ({autolink_wait} seconds)')
+            self.link_auto(pwork, name, version, True, wait_s=autolink_wait)
+
+    def archive(self, series):
+        """Archive a series
+
+        Args:
+            series (str): Name of series to use, or None to use current branch
+        """
+        ser = self._parse_series(series, include_archived=True)
+        if not ser.idnum:
+            raise ValueError(f"Series '{ser.name}' not found in database")
+
+        svlist = self.db.ser_ver_get_for_series(ser.idnum)
+
+        # Figure out the tags we will create
+        tag_info = {}
+        now = self.get_now()
+        now_str = now.strftime('%d%b%y').lower()
+        for svi in svlist:
+            name = self._get_branch_name(ser.name, svi.version)
+            if not gitutil.check_branch(name, git_dir=self.gitdir):
+                raise ValueError(f"No branch named '{name}'")
+            tag_info[svi.version] = [svi.idnum, name, f'{name}-{now_str}']
+
+        # Create the tags
+        repo = pygit2.init_repository(self.gitdir)
+        for _, (idnum, name, tag_name) in tag_info.items():
+            commit = repo.revparse_single(name)
+            repo.create_tag(tag_name, commit.hex,
+                            pygit2.enums.ObjectType.COMMIT,
+                            commit.author, commit.message)
+
+        # Update the database
+        for idnum, name, tag_name in tag_info.values():
+            self.db.ser_ver_set_archive_tag(idnum, tag_name)
+
+        # Delete the branches
+        for idnum, name, tag_name in tag_info.values():
+            # Detach HEAD from the branch if pointing to this branch
+            commit = repo.revparse_single(name)
+            if repo.head.target == commit.oid:
+                repo.set_head(commit.oid)
+
+            repo.branches.delete(name)
+
+        self.db.series_set_archived(ser.idnum, True)
+        self.commit()
+
+    def unarchive(self, series):
+        """Unarchive a series
+
+        Args:
+            series (str): Name of series to use, or None to use current branch
+        """
+        ser = self._parse_series(series, include_archived=True)
+        if not ser.idnum:
+            raise ValueError(f"Series '{ser.name}' not found in database")
+        self.db.series_set_archived(ser.idnum, False)
+
+        svlist = self.db.ser_ver_get_for_series(ser.idnum)
+
+        # Collect the tags
+        repo = pygit2.init_repository(self.gitdir)
+        tag_info = {}
+        for svi in svlist:
+            name = self._get_branch_name(ser.name, svi.version)
+            target = repo.revparse_single(svi.archive_tag)
+            tag_info[svi.idnum] = name, svi.archive_tag, target
+
+        # Make sure the branches don't exist
+        for name, tag_name, tag in tag_info.values():
+            if name in repo.branches:
+                raise ValueError(
+                    f"Cannot restore branch '{name}': already exists")
+
+        # Recreate the branches
+        for name, tag_name, tag in tag_info.values():
+            target = repo.get(tag.target)
+            repo.branches.create(name, target)
+
+        # Delete the tags
+        for name, tag_name, tag in tag_info.values():
+            repo.references.delete(f'refs/tags/{tag_name}')
+
+        # Update the database
+        for idnum, (name, tag_name, tag) in tag_info.items():
+            self.db.ser_ver_set_archive_tag(idnum, None)
+
+        self.commit()
+
+    def status(self, pwork, series, version, show_comments,
+               show_cover_comments=False):
+        """Show the series status from patchwork
+
+        Args:
+            pwork (Patchwork): Patchwork object to use
+            series (str): Name of series to use, or None to use current branch
+            version (int): Version number, or None to detect from name
+            show_comments (bool): Show all comments on each patch
+            show_cover_comments (bool): Show all comments on the cover letter
+        """
+        branch, series, version, _, _, link, _, _ = self._get_patches(
+            series, version)
+        if not link:
+            raise ValueError(
+                f"Series '{series.name}' v{version} has no patchwork link: "
+                f"Try 'patman series -s {branch} autolink'")
+        status.check_and_show_status(
+            series, link, branch, None, False, show_comments,
+            show_cover_comments, pwork, self.gitdir)
+
+    def summary(self, series):
+        """Show summary information for all series
+
+        Args:
+            series (str): Name of series to use
+        """
+        print(f"{'Name':17}  Status  Description")
+        print(f"{'-' * 17}  {'-' * 6}  {'-' * 30}")
+        if series is not None:
+            self._summary_one(self._parse_series(series))
+            return
+
+        sdict = self.db.series_get_dict()
+        for ser in sdict.values():
+            self._summary_one(ser)
+
+    def gather(self, pwork, series, version, show_comments,
+               show_cover_comments, gather_tags, dry_run=False):
+        """Gather any new tags from Patchwork, optionally showing comments
+
+        Args:
+            pwork (Patchwork): Patchwork object to use
+            series (str): Name of series to use, or None to use current branch
+            version (int): Version number, or None to detect from name
+            show_comments (bool): True to show the comments on each patch
+            show_cover_comments (bool): True to show the comments on the cover
+                letter
+            gather_tags (bool): True to gather review/test tags
+            dry_run (bool): True to do a dry run (database is not updated)
+        """
+        ser, version = self._parse_series_and_version(series, version)
+        self._ensure_version(ser, version)
+        svid, link = self._get_series_svid_link(ser.idnum, version)
+        if not link:
+            raise ValueError(
+                "No patchwork link is available: use 'patman series autolink'")
+        tout.info(
+            f"Updating series '{ser.name}' version {version} "
+            f"from link '{link}'")
+
+        loop = asyncio.get_event_loop()
+        with pwork.collect_stats() as stats:
+            cover, patches = loop.run_until_complete(self._gather(
+                pwork, link, show_cover_comments))
+
+        with terminal.pager():
+            updated, updated_cover = self._sync_one(
+                svid, ser.name, version, show_comments, show_cover_comments,
+                gather_tags, cover, patches, dry_run)
+            tout.info(f"{updated} patch{'es' if updated != 1 else ''}"
+                      f"{' and cover letter' if updated_cover else ''} "
+                      f'updated ({stats.request_count} requests)')
+
+            if not dry_run:
+                self.commit()
+            else:
+                self.rollback()
+                tout.info('Dry run completed')
+
+    def gather_all(self, pwork, show_comments, show_cover_comments,
+                   sync_all_versions, gather_tags, dry_run=False):
+        to_fetch, missing = self._get_fetch_dict(sync_all_versions)
+
+        loop = asyncio.get_event_loop()
+        result, requests = loop.run_until_complete(self._do_series_sync_all(
+                pwork, to_fetch))
+
+        with terminal.pager():
+            tot_updated = 0
+            tot_cover = 0
+            add_newline = False
+            for (svid, sync), (cover, patches) in zip(to_fetch.items(),
+                                                      result):
+                if add_newline:
+                    tout.info('')
+                tout.info(f"Syncing '{sync.series_name}' v{sync.version}")
+                updated, updated_cover = self._sync_one(
+                    svid, sync.series_name, sync.version, show_comments,
+                    show_cover_comments, gather_tags, cover, patches, dry_run)
+                tot_updated += updated
+                tot_cover += updated_cover
+                add_newline = gather_tags
+
+            tout.info('')
+            tout.info(
+                f"{tot_updated} patch{'es' if tot_updated != 1 else ''} and "
+                f"{tot_cover} cover letter{'s' if tot_cover != 1 else ''} "
+                f'updated, {missing} missing '
+                f"link{'s' if missing != 1 else ''} ({requests} requests)")
+            if not dry_run:
+                self.commit()
+            else:
+                self.rollback()
+                tout.info('Dry run completed')
+
+    def upstream_add(self, name, url):
+        """Add a new upstream tree
+
+        Args:
+            name (str): Name of the tree
+            url (str): URL for the tree
+        """
+        self.db.upstream_add(name, url)
+        self.commit()
+
+    def upstream_list(self):
+        """List the upstream repos
+
+        Shows a list of the repos, obtained from the database
+        """
+        udict = self.get_upstream_dict()
+
+        for name, items in udict.items():
+            url, is_default = items
+            default = 'default' if is_default else ''
+            print(f'{name:15.15} {default:8} {url}')
+
+    def upstream_set_default(self, name):
+        """Set the default upstream target
+
+        Args:
+            name (str): Name of the upstream remote to set as default, or None
+                for none
+        """
+        self.db.upstream_set_default(name)
+        self.commit()
+
+    def upstream_get_default(self):
+        """Get the default upstream target
+
+        Return:
+            str: Name of the upstream remote to set as default, or None if none
+        """
+        return self.db.upstream_get_default()
+
+    def upstream_delete(self, name):
+        """Delete an upstream target
+
+        Args:
+            name (str): Name of the upstream remote to delete
+        """
+        self.db.upstream_delete(name)
+        self.commit()
+
+    def version_remove(self, name, version, dry_run=False):
+        """Remove a version of a series from the database
+
+        Args:
+            name (str): Name of series to remove, or None to use current one
+            version (int): Version number to remove
+            dry_run (bool): True to do a dry run
+        """
+        ser, version = self._parse_series_and_version(name, version)
+        name = ser.name
+
+        versions = self._ensure_version(ser, version)
+
+        if versions == [version]:
+            raise ValueError(
+                f"Series '{ser.name}' only has one version: remove the series")
+
+        self.db.ser_ver_remove(ser.idnum, version)
+        if not dry_run:
+            self.commit()
+        else:
+            self.rollback()
+
+        tout.info(f"Removed version {version} from series '{name}'")
+        if dry_run:
+            tout.info('Dry run completed')
+
+    def version_change(self, name, version, new_version, dry_run=False):
+        """Change a version of a series to be a different version
+
+        Args:
+            name (str): Name of series to remove, or None to use current one
+            version (int): Version number to change
+            new_version (int): New version
+            dry_run (bool): True to do a dry run
+        """
+        ser, version = self._parse_series_and_version(name, version)
+        name = ser.name
+
+        versions = self._ensure_version(ser, version)
+        vstr = list(map(str, versions))
+        if version not in versions:
+            raise ValueError(
+                f"Series '{ser.name}' does not have v{version}: "
+                f"{' '.join(vstr)}")
+
+        if not new_version:
+            raise ValueError('Please provide a new version number')
+
+        if new_version in versions:
+            raise ValueError(
+                f"Series '{ser.name}' already has a v{new_version}: "
+                f"{' '.join(vstr)}")
+
+        new_name = self._join_name_version(ser.name, new_version)
+
+        svid = self.get_series_svid(ser.idnum, version)
+        pwc = self.get_pcommit_dict(svid)
+        count = len(pwc.values())
+        series = patchstream.get_metadata(name, 0, count, git_dir=self.gitdir)
+
+        self.update_series(name, series, version, new_name, dry_run,
+                            add_vers=new_version, switch=True)
+        self.db.ser_ver_set_version(svid, new_version)
+
+        if not dry_run:
+            self.commit()
+        else:
+            self.rollback()
+
+        tout.info(f"Changed version {version} in series '{ser.name}' "
+                  f"to {new_version} named '{new_name}'")
+        if dry_run:
+            tout.info('Dry run completed')
-- 
2.43.0



More information about the U-Boot mailing list