| # 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') |