This is the main class for dealing with series, across branches and the database.
Signed-off-by: Simon Glass <s...@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