diff options
Diffstat (limited to 'tools/patman/cser_helper.py')
| -rw-r--r-- | tools/patman/cser_helper.py | 1524 | 
1 files changed, 1524 insertions, 0 deletions
| diff --git a/tools/patman/cser_helper.py b/tools/patman/cser_helper.py new file mode 100644 index 00000000000..2841fcd9c20 --- /dev/null +++ b/tools/patman/cser_helper.py @@ -0,0 +1,1524 @@ +# SPDX-License-Identifier: GPL-2.0+ +# +# Copyright 2025 Simon Glass <sjg@chromium.org> +# +"""Helper functions for handling the 'series' subcommand +""" + +import asyncio +from collections import OrderedDict, defaultdict, namedtuple +from datetime import datetime +import hashlib +import os +import re +import sys +import time +from types import SimpleNamespace + +import aiohttp +import pygit2 +from pygit2.enums import CheckoutStrategy + +from u_boot_pylib import gitutil +from u_boot_pylib import terminal +from u_boot_pylib import tout + +from patman import patchstream +from patman.database import Database, Pcommit, SerVer +from patman import patchwork +from patman.series import Series +from patman import status + + +# Tag to use for Change IDs +CHANGE_ID_TAG = 'Change-Id' + +# Length of hash to display +HASH_LEN = 10 + +# Shorter version of some states, to save horizontal space +SHORTEN_STATE = { +    'handled-elsewhere': 'elsewhere', +    'awaiting-upstream': 'awaiting', +    'not-applicable': 'n/a', +    'changes-requested': 'changes', +} + +# Summary info returned from Cseries.link_auto_all() +AUTOLINK = namedtuple('autolink', 'name,version,link,desc,result') + + +def oid(oid_val): +    """Convert a hash string into a shortened hash + +    The number of hex digits git uses for showing hashes depends on the size of +    the repo. For the purposes of showing hashes to the user in lists, we use a +    fixed value for now + +    Args: +        str or Pygit2.oid: Hash value to shorten + +    Return: +        str: Shortened hash +    """ +    return str(oid_val)[:HASH_LEN] + + +def split_name_version(in_name): +    """Split a branch name into its series name and its version + +    For example: +        'series' returns ('series', 1) +        'series3' returns ('series', 3) +    Args: +        in_name (str): Name to parse + +    Return: +        tuple: +            str: series name +            int: series version, or None if there is none in in_name +    """ +    m_ver = re.match(r'([^0-9]*)(\d*)', in_name) +    version = None +    if m_ver: +        name = m_ver.group(1) +        if m_ver.group(2): +            version = int(m_ver.group(2)) +    else: +        name = in_name +    return name, version + + +class CseriesHelper: +    """Helper functions for Cseries + +    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 CseriesHelper + +        Args: +            topdir (str): Top-level directory of the repo +            colour (terminal.enum): Whether to enable ANSI colour or not + +        Properties: +            gitdir (str): Git directory (typically topdir + '/.git') +            db (Database): Database handler +            col (terminal.Colour): Colour object +            _fake_time (float): Holds the current fake time for tests, in +                seconds +            _fake_sleep (func): Function provided by a test; called to fake a +                'time.sleep()' call and take whatever action it wants to take. +                The only argument is the (Float) time to sleep for; it returns +                nothing +            loop (asyncio event loop): Loop used for Patchwork operations +        """ +        self.topdir = topdir +        self.gitdir = None +        self.db = None +        self.col = terminal.Color(colour) +        self._fake_time = None +        self._fake_sleep = None +        self.fake_now = None +        self.loop = asyncio.get_event_loop() + +    def open_database(self): +        """Open the database ready for use""" +        if not self.topdir: +            self.topdir = gitutil.get_top_level() +            if not self.topdir: +                raise ValueError('No git repo detected in current directory') +        self.gitdir = os.path.join(self.topdir, '.git') +        fname = f'{self.topdir}/.patman.db' + +        # For the first instance, start it up with the expected schema +        self.db, is_new = Database.get_instance(fname) +        if is_new: +            self.db.start() +        else: +            # If a previous test has already checked the schema, just open it +            self.db.open_it() + +    def close_database(self): +        """Close the database""" +        if self.db: +            self.db.close() + +    def commit(self): +        """Commit changes to the database""" +        self.db.commit() + +    def rollback(self): +        """Roll back changes to the database""" +        self.db.rollback() + +    def set_fake_time(self, fake_sleep): +        """Setup the fake timer + +        Args: +            fake_sleep (func(float)): Function to call to fake a sleep +        """ +        self._fake_time = 0 +        self._fake_sleep = fake_sleep + +    def inc_fake_time(self, inc_s): +        """Increment the fake time + +        Args: +            inc_s (float): Amount to increment the fake time by +        """ +        self._fake_time += inc_s + +    def get_time(self): +        """Get the current time, fake or real + +        This function should always be used to read the time so that faking the +        time works correctly in tests. + +        Return: +            float: Fake time, if time is being faked, else real time +        """ +        if self._fake_time is not None: +            return self._fake_time +        return time.monotonic() + +    def sleep(self, time_s): +        """Sleep for a while + +        This function should always be used to sleep so that faking the time +        works correctly in tests. + +        Args: +            time_s (float): Amount of seconds to sleep for +        """ +        print(f'Sleeping for {time_s} seconds') +        if self._fake_time is not None: +            self._fake_sleep(time_s) +        else: +            time.sleep(time_s) + +    def get_now(self): +        """Get the time now + +        This function should always be used to read the datetime, so that +        faking the time works correctly in tests + +        Return: +            DateTime object +        """ +        if self.fake_now: +            return self.fake_now +        return datetime.now() + +    def get_ser_ver_list(self): +        """Get a list of patchwork entries from the database + +        Return: +            list of SER_VER +        """ +        return self.db.ser_ver_get_list() + +    def get_ser_ver_dict(self): +        """Get a dict of patchwork entries from the database + +        Return: dict contain all records: +            key (int): ser_ver id +            value (SER_VER): Information about one ser_ver record +        """ +        svlist = self.get_ser_ver_list() +        svdict = {} +        for sver in svlist: +            svdict[sver.idnum] = sver +        return svdict + +    def get_upstream_dict(self): +        """Get a list of upstream entries from the database + +        Return: +            OrderedDict: +                key (str): upstream name +                value (str): url +        """ +        return self.db.upstream_get_dict() + +    def get_pcommit_dict(self, find_svid=None): +        """Get a dict of pcommits entries from the database + +        Args: +            find_svid (int): If not None, finds the records associated with a +                particular series and version + +        Return: +            OrderedDict: +                key (int): record ID if find_svid is None, else seq +                value (PCOMMIT): record data +        """ +        pcdict = OrderedDict() +        for rec in self.db.pcommit_get_list(find_svid): +            if find_svid is not None: +                pcdict[rec.seq] = rec +            else: +                pcdict[rec.idnum] = rec +        return pcdict + +    def _get_series_info(self, idnum): +        """Get information for a series from the database + +        Args: +            idnum (int): Series ID to look up + +        Return: tuple: +            str: Series name +            str: Series description + +        Raises: +            ValueError: Series is not found +        """ +        return self.db.series_get_info(idnum) + +    def prep_series(self, name, end=None): +        """Prepare to work with a series + +        Args: +            name (str): Branch name with version appended, e.g. 'fix2' +            end (str or None): Commit to end at, e.g. 'my_branch~16'. Only +                commits up to that are processed. None to process commits up to +                the upstream branch + +        Return: tuple: +            str: Series name, e.g. 'fix' +            Series: Collected series information, including name +            int: Version number, e.g. 2 +            str: Message to show +        """ +        ser, version = self._parse_series_and_version(name, None) +        if not name: +            name = self._get_branch_name(ser.name, version) + +        # First check we have a branch with this name +        if not gitutil.check_branch(name, git_dir=self.gitdir): +            raise ValueError(f"No branch named '{name}'") + +        count = gitutil.count_commits_to_branch(name, self.gitdir, end) +        if not count: +            raise ValueError('Cannot detect branch automatically: ' +                             'Perhaps use -U <upstream-commit> ?') + +        series = patchstream.get_metadata(name, 0, count, git_dir=self.gitdir) +        self._copy_db_fields_to(series, ser) +        msg = None +        if end: +            repo = pygit2.init_repository(self.gitdir) +            target = repo.revparse_single(end) +            first_line = target.message.splitlines()[0] +            msg = f'Ending before {oid(target.id)} {first_line}' + +        return name, series, version, msg + +    def _copy_db_fields_to(self, series, in_series): +        """Copy over fields used by Cseries from one series to another + +        This copes desc, idnum and name + +        Args: +            series (Series): Series to copy to +            in_series (Series): Series to copy from +        """ +        series.desc = in_series.desc +        series.idnum = in_series.idnum +        series.name = in_series.name + +    def _handle_mark(self, branch_name, in_series, version, mark, +                     allow_unmarked, force_version, dry_run): +        """Handle marking a series, checking for unmarked commits, etc. + +        Args: +            branch_name (str): Name of branch to sync, or None for current one +            in_series (Series): Series object +            version (int): branch version, e.g. 2 for 'mychange2' +            mark (bool): True to mark each commit with a change ID +            allow_unmarked (str): True to not require each commit to be marked +            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 + +        Returns: +            Series: New series object, if the series was marked; +                copy_db_fields_to() is used to copy fields over + +        Raises: +            ValueError: Series being unmarked when it should be marked, etc. +        """ +        series = in_series +        if 'version' in series and int(series.version) != version: +            msg = (f"Series name '{branch_name}' suggests version {version} " +                   f"but Series-version tag indicates {series.version}") +            if not force_version: +                raise ValueError(msg + ' (see --force-version)') + +            tout.warning(msg) +            tout.warning(f'Updating Series-version tag to version {version}') +            self.update_series(branch_name, series, int(series.version), +                                new_name=None, dry_run=dry_run, +                                add_vers=version) + +            # Collect the commits again, as the hashes have changed +            series = patchstream.get_metadata(branch_name, 0, +                                              len(series.commits), +                                              git_dir=self.gitdir) +            self._copy_db_fields_to(series, in_series) + +        if mark: +            add_oid = self._mark_series(branch_name, series, dry_run=dry_run) + +            # Collect the commits again, as the hashes have changed +            series = patchstream.get_metadata(add_oid, 0, len(series.commits), +                                              git_dir=self.gitdir) +            self._copy_db_fields_to(series, in_series) + +        bad_count = 0 +        for commit in series.commits: +            if not commit.change_id: +                bad_count += 1 +        if bad_count and not allow_unmarked: +            raise ValueError( +                f'{bad_count} commit(s) are unmarked; please use -m or -M') + +        return series + +    def _add_series_commits(self, series, svid): +        """Add a commits from a series into the database + +        Args: +            series (Series): Series containing commits to add +            svid (int): ser_ver-table ID to use for each commit +        """ +        to_add = [Pcommit(None, seq, commit.subject, None, commit.change_id, +                          None, None, None) +                  for seq, commit in enumerate(series.commits)] + +        self.db.pcommit_add_list(svid, to_add) + +    def get_series_by_name(self, name, include_archived=False): +        """Get a Series object from the database by name + +        Args: +            name (str): Name of series to get +            include_archived (bool): True to search in archives series + +        Return: +            Series: Object containing series info, or None if none +        """ +        idnum = self.db.series_find_by_name(name, include_archived) +        if not idnum: +            return None +        name, desc = self.db.series_get_info(idnum) + +        return Series.from_fields(idnum, name, desc) + +    def _get_branch_name(self, name, version): +        """Get the branch name for a particular version + +        Args: +            name (str): Base name of branch +            version (int): Version number to use +        """ +        return name + (f'{version}' if version > 1 else '') + +    def _ensure_version(self, ser, version): +        """Ensure that a version exists in a series + +        Args: +            ser (Series): Series information, with idnum and name used here +            version (int): Version to check + +        Returns: +            list of int: List of versions +        """ +        versions = self._get_version_list(ser.idnum) +        if version not in versions: +            raise ValueError( +                f"Series '{ser.name}' does not have a version {version}") +        return versions + +    def _set_link(self, ser_id, name, version, link, update_commit, +                  dry_run=False): +        """Add / update a series-links link for a series + +        Args: +            ser_id (int): Series ID number +            name (str): Series name (used to find the branch) +            version (int): Version number (used to update the database) +            link (str): Patchwork link-string for the series +            update_commit (bool): True to update the current commit with the +                link +            dry_run (bool): True to do a dry run + +        Return: +            bool: True if the database was update, False if the ser_id or +                version was not found +        """ +        if update_commit: +            branch_name = self._get_branch_name(name, version) +            _, ser, max_vers, _ = self.prep_series(branch_name) +            self.update_series(branch_name, ser, max_vers, add_vers=version, +                                dry_run=dry_run, add_link=link) +        if link is None: +            link = '' +        updated = 1 if self.db.ser_ver_set_link(ser_id, version, link) else 0 +        if dry_run: +            self.rollback() +        else: +            self.commit() + +        return updated + +    def _get_autolink_dict(self, sdict, link_all_versions): +        """Get a dict of ser_vers to fetch, along with their patchwork links + +        Note that this returns items that already have links, as well as those +        without links + +        Args: +            sdict: +                key: series ID +                value: Series with idnum, name and desc filled out +            link_all_versions (bool): True to sync all versions of a series, +                False to sync only the latest version + +        Return: tuple: +            dict: +                key (int): svid +                value (tuple): +                   int: series ID +                   str: series name +                   int: series version +                   str: patchwork link for the series, or None if none +                   desc: cover-letter name / series description +        """ +        svdict = self.get_ser_ver_dict() +        to_fetch = {} + +        if link_all_versions: +            for svinfo in self.get_ser_ver_list(): +                ser = sdict[svinfo.series_id] + +                pwc = self.get_pcommit_dict(svinfo.idnum) +                count = len(pwc) +                branch = self._join_name_version(ser.name, svinfo.version) +                series = patchstream.get_metadata(branch, 0, count, +                                                  git_dir=self.gitdir) +                self._copy_db_fields_to(series, ser) + +                to_fetch[svinfo.idnum] = (svinfo.series_id, series.name, +                                          svinfo.version, svinfo.link, series) +        else: +            # Find the maximum version for each series +            max_vers = self._series_all_max_versions() + +            # Get a list of links to fetch +            for svid, ser_id, version in max_vers: +                svinfo = svdict[svid] +                ser = sdict[ser_id] + +                pwc = self.get_pcommit_dict(svid) +                count = len(pwc) +                branch = self._join_name_version(ser.name, version) +                series = patchstream.get_metadata(branch, 0, count, +                                                  git_dir=self.gitdir) +                self._copy_db_fields_to(series, ser) + +                to_fetch[svid] = (ser_id, series.name, version, svinfo.link, +                                  series) +        return to_fetch + +    def _get_version_list(self, idnum): +        """Get a list of the versions available for a series + +        Args: +            idnum (int): ID of series to look up + +        Return: +            str: List of versions +        """ +        if idnum is None: +            raise ValueError('Unknown series idnum') +        return self.db.series_get_version_list(idnum) + +    def _join_name_version(self, in_name, version): +        """Convert a series name plus a version into a branch name + +        For example: +            ('series', 1) returns 'series' +            ('series', 3) returns 'series3' + +        Args: +            in_name (str): Series name +            version (int): Version number + +        Return: +            str: associated branch name +        """ +        if version == 1: +            return in_name +        return f'{in_name}{version}' + +    def _parse_series(self, name, include_archived=False): +        """Parse the name of a series, or detect it from the current branch + +        Args: +            name (str or None): name of series +            include_archived (bool): True to search in archives series + +        Return: +            Series: New object with the name set; idnum is also set if the +                series exists in the database +        """ +        if not name: +            name = gitutil.get_branch(self.gitdir) +        name, _ = split_name_version(name) +        ser = self.get_series_by_name(name, include_archived) +        if not ser: +            ser = Series() +            ser.name = name +        return ser + +    def _parse_series_and_version(self, in_name, in_version): +        """Parse name and version of a series, or detect from current branch + +        Figures out the name from in_name, or if that is None, from the current +            branch. + +        Uses the version in_version, or if that is None, uses the int at the +        end of the name (e.g. 'series' is version 1, 'series4' is version 4) + +        Args: +            in_name (str or None): name of series +            in_version (str or None): version of series + +        Return: +            tuple: +                Series: New object with the name set; idnum is also set if the +                    series exists in the database +                int: Series version-number detected from the name +                    (e.g. 'fred' is version 1, 'fred2' is version 2) +        """ +        name = in_name +        if not name: +            name = gitutil.get_branch(self.gitdir) +            if not name: +                raise ValueError('No branch detected: please use -s <series>') +        name, version = split_name_version(name) +        if not name: +            raise ValueError(f"Series name '{in_name}' cannot be a number, " +                             f"use '<name><version>'") +        if in_version: +            if version and version != in_version: +                tout.warning( +                    f"Version mismatch: -V has {in_version} but branch name " +                    f'indicates {version}') +            version = in_version +        if not version: +            version = 1 +        if version > 99: +            raise ValueError(f"Version {version} exceeds 99") +        ser = self.get_series_by_name(name) +        if not ser: +            ser = Series() +            ser.name = name +        return ser, version + +    def _series_get_version_stats(self, idnum, vers): +        """Get the stats for a series + +        Args: +            idnum (int): ID number of series to process +            vers (int): Version number to process + +        Return: +            tuple: +                str: Status string, '<accepted>/<count>' +                OrderedDict: +                    key (int): record ID if find_svid is None, else seq +                    value (PCOMMIT): record data +        """ +        svid, link = self._get_series_svid_link(idnum, vers) +        pwc = self.get_pcommit_dict(svid) +        count = len(pwc.values()) +        if link: +            accepted = 0 +            for pcm in pwc.values(): +                accepted += pcm.state == 'accepted' +        else: +            accepted = '-' +        return f'{accepted}/{count}', pwc + +    def get_series_svid(self, series_id, version): +        """Get the patchwork ID of a series version + +        Args: +            series_id (int): id of the series to look up +            version (int): version number to look up + +        Return: +            str: link found + +        Raises: +            ValueError: No matching series found +        """ +        return self._get_series_svid_link(series_id, version)[0] + +    def _get_series_svid_link(self, series_id, version): +        """Get the patchwork ID of a series version + +        Args: +            series_id (int): series ID to look up +            version (int): version number to look up + +        Return: +            tuple: +                int: record id +                str: link +        """ +        recs = self.get_ser_ver(series_id, version) +        return recs.idnum, recs.link + +    def get_ser_ver(self, series_id, version): +        """Get the patchwork details for a series version + +        Args: +            series_id (int): series ID to look up +            version (int): version number to look up + +        Return: +            SER_VER: Requested information + +        Raises: +            ValueError: There is no matching idnum/version +        """ +        return self.db.ser_ver_get_for_series(series_id, version) + +    def _prepare_process(self, name, count, new_name=None, quiet=False): +        """Get ready to process all commits in a branch + +        Args: +            name (str): Name of the branch to process +            count (int): Number of commits +            new_name (str or None): New name, if a new branch is to be created +            quiet (bool): True to avoid output (used for testing) + +        Return: tuple: +            pygit2.repo: Repo to use +            pygit2.oid: Upstream commit, onto which commits should be added +            Pygit2.branch: Original branch, for later use +            str: (Possibly new) name of branch to process +            list of Commit: commits to process, in order +            pygit2.Reference: Original head before processing started +        """ +        upstream_guess = gitutil.get_upstream(self.gitdir, name)[0] + +        tout.debug(f"_process_series name '{name}' new_name '{new_name}' " +                   f"upstream_guess '{upstream_guess}'") +        dirty = gitutil.check_dirty(self.gitdir, self.topdir) +        if dirty: +            raise ValueError( +                f"Modified files exist: use 'git status' to check: " +                f'{dirty[:5]}') +        repo = pygit2.init_repository(self.gitdir) + +        commit = None +        upstream_name = None +        if upstream_guess: +            try: +                upstream = repo.lookup_reference(upstream_guess) +                upstream_name = upstream.name +                commit = upstream.peel(pygit2.enums.ObjectType.COMMIT) +            except KeyError: +                pass +            except pygit2.repository.InvalidSpecError as exc: +                print(f"Error '{exc}'") +        if not upstream_name: +            upstream_name = f'{name}~{count}' +            commit = repo.revparse_single(upstream_name) + +        branch = repo.lookup_branch(name) +        if not quiet: +            tout.info( +                f'Checking out upstream commit {upstream_name}: ' +                f'{oid(commit.oid)}') + +        old_head = repo.head +        if old_head.shorthand == name: +            old_head = None +        else: +            old_head = repo.head + +        if new_name: +            name = new_name +        repo.set_head(commit.oid) + +        commits = [] +        cmt = repo.get(branch.target) +        for _ in range(count): +            commits.append(cmt) +            cmt = cmt.parents[0] + +        return (repo, repo.head, branch, name, commit, list(reversed(commits)), +                old_head) + +    def _pick_commit(self, repo, cmt): +        """Apply a commit to the source tree, without committing it + +        _prepare_process() must be called before starting to pick commits + +        This function must be called before _finish_commit() + +        Note that this uses a cherry-pick method, creating a new tree_id each +        time, so can make source-code changes + +        Args: +            repo (pygit2.repo): Repo to use +            cmt (Commit): Commit to apply + +        Return: tuple: +            tree_id (pygit2.oid): Oid of index with source-changes applied +            commit (pygit2.oid): Old commit being cherry-picked +        """ +        tout.detail(f"- adding {oid(cmt.hash)} {cmt}") +        repo.cherrypick(cmt.hash) +        if repo.index.conflicts: +            raise ValueError('Conflicts detected') + +        tree_id = repo.index.write_tree() +        cherry = repo.get(cmt.hash) +        tout.detail(f"cherry {oid(cherry.oid)}") +        return tree_id, cherry + +    def _finish_commit(self, repo, tree_id, commit, cur, msg=None): +        """Complete a commit + +        This must be called after _pick_commit(). + +        Args: +            repo (pygit2.repo): Repo to use +            tree_id (pygit2.oid): Oid of index with source-changes applied; if +                None then the existing commit.tree_id is used +            commit (pygit2.oid): Old commit being cherry-picked +            cur (pygit2.reference): Reference to parent to use for the commit +            msg (str): Commit subject and message; None to use commit.message +        """ +        if msg is None: +            msg = commit.message +        if not tree_id: +            tree_id = commit.tree_id +        repo.create_commit('HEAD', commit.author, commit.committer, +                           msg, tree_id, [cur.target]) +        return repo.head + +    def _finish_process(self, repo, branch, name, cur, old_head, new_name=None, +                        switch=False, dry_run=False, quiet=False): +        """Finish processing commits + +        Args: +            repo (pygit2.repo): Repo to use +            branch (pygit2.branch): Branch returned by _prepare_process() +            name (str): Name of the branch to process +            new_name (str or None): New name, if a new branch is being created +            switch (bool): True to switch to the new branch after processing; +                otherwise HEAD remains at the original branch, as amended +            dry_run (bool): True to do a dry run, restoring the original tree +                afterwards +            quiet (bool): True to avoid output (used for testing) + +        Return: +            pygit2.reference: Final commit after everything is completed +        """ +        repo.state_cleanup() + +        # Update the branch +        target = repo.revparse_single('HEAD') +        if not quiet: +            tout.info(f'Updating branch {name} from {oid(branch.target)} to ' +                      f'{str(target.oid)[:HASH_LEN]}') +        if dry_run: +            if new_name: +                repo.head.set_target(branch.target) +            else: +                branch_oid = branch.peel(pygit2.enums.ObjectType.COMMIT).oid +                repo.head.set_target(branch_oid) +            repo.head.set_target(branch.target) +            repo.set_head(branch.name) +        else: +            if new_name: +                new_branch = repo.branches.create(new_name, target) +                if branch.upstream: +                    new_branch.upstream = branch.upstream +                branch = new_branch +            else: +                branch.set_target(cur.target) +            repo.set_head(branch.name) +        if old_head: +            if not switch: +                repo.set_head(old_head.name) +        return target + +    def make_change_id(self, commit): +        """Make a Change ID for a commit + +        This is similar to the gerrit script: +        git var GIT_COMMITTER_IDENT ; echo "$refhash" ; cat "README"; } +            | git hash-object --stdin) + +        Args: +            commit (pygit2.commit): Commit to process + +        Return: +            Change ID in hex format +        """ +        sig = commit.committer +        val = hashlib.sha1() +        to_hash = f'{sig.name} <{sig.email}> {sig.time} {sig.offset}' +        val.update(to_hash.encode('utf-8')) +        val.update(str(commit.tree_id).encode('utf-8')) +        val.update(commit.message.encode('utf-8')) +        return val.hexdigest() + +    def _filter_commits(self, name, series, seq_to_drop): +        """Filter commits to drop one + +        This function rebases the current branch, dropping a single commit, +        thus changing the resulting code in the tree. + +        Args: +            name (str): Name of the branch to process +            series (Series): Series object +            seq_to_drop (int): Commit sequence to drop; commits are numbered +                from 0, which is the one after the upstream branch, to +                count - 1 +        """ +        count = len(series.commits) +        (repo, cur, branch, name, commit, _, _) = self._prepare_process( +            name, count, quiet=True) +        repo.checkout_tree(commit, strategy=CheckoutStrategy.FORCE | +                           CheckoutStrategy.RECREATE_MISSING) +        repo.set_head(commit.oid) +        for seq, cmt in enumerate(series.commits): +            if seq != seq_to_drop: +                tree_id, cherry = self._pick_commit(repo, cmt) +                cur = self._finish_commit(repo, tree_id, cherry, cur) +        self._finish_process(repo, branch, name, cur, None, quiet=True) + +    def process_series(self, name, series, new_name=None, switch=False, +                       dry_run=False): +        """Rewrite a series commit messages, leaving code alone + +        This uses a 'vals' namespace to pass things to the controlling +        function. + +        Each time _process_series() yields, it sets up: +            commit (Commit): The pygit2 commit that is being processed +            msg (str): Commit message, which can be modified +            info (str): Initially empty; the controlling function can add a +                short message here which will be shown to the user +            final (bool): True if this is the last commit to apply +            seq (int): Current sequence number in the commits to apply (0,,n-1) + +            It also sets git HEAD at the commit before this commit being +            processed + +        The function can change msg and info, e.g. to add or remove tags from +        the commit. + +        Args: +            name (str): Name of the branch to process +            series (Series): Series object +            new_name (str or None): New name, if a new branch is to be created +            switch (bool): True to switch to the new branch after processing; +                otherwise HEAD remains at the original branch, as amended +            dry_run (bool): True to do a dry run, restoring the original tree +                afterwards + +        Return: +            pygit.oid: oid of the new branch +        """ +        count = len(series.commits) +        repo, cur, branch, name, _, commits, old_head = self._prepare_process( +            name, count, new_name) +        vals = SimpleNamespace() +        vals.final = False +        tout.info(f"Processing {count} commits from branch '{name}'") + +        # Record the message lines +        lines = [] +        for seq, cmt in enumerate(series.commits): +            commit = commits[seq] +            vals.commit = commit +            vals.msg = commit.message +            vals.info = '' +            vals.final = seq == len(series.commits) - 1 +            vals.seq = seq +            yield vals + +            cur = self._finish_commit(repo, None, commit, cur, vals.msg) +            lines.append([vals.info.strip(), +                          f'{oid(cmt.hash)} as {oid(cur.target)} {cmt}']) + +        max_len = max(len(info) for info, rest in lines) + 1 +        for info, rest in lines: +            if info: +                info += ':' +            tout.info(f'- {info.ljust(max_len)} {rest}') +        target = self._finish_process(repo, branch, name, cur, old_head, +                                      new_name, switch, dry_run) +        vals.oid = target.oid + +    def _mark_series(self, name, series, dry_run=False): +        """Mark a series with Change-Id tags + +        Args: +            name (str): Name of the series to mark +            series (Series): Series object +            dry_run (bool): True to do a dry run, restoring the original tree +                afterwards + +        Return: +            pygit.oid: oid of the new branch +        """ +        vals = None +        for vals in self.process_series(name, series, dry_run=dry_run): +            if CHANGE_ID_TAG not in vals.msg: +                change_id = self.make_change_id(vals.commit) +                vals.msg = vals.msg + f'\n{CHANGE_ID_TAG}: {change_id}' +                tout.detail("   - adding mark") +                vals.info = 'marked' +            else: +                vals.info = 'has mark' + +        return vals.oid + +    def update_series(self, branch_name, series, max_vers, new_name=None, +                       dry_run=False, add_vers=None, add_link=None, +                       add_rtags=None, switch=False): +        """Rewrite a series to update the Series-version/Series-links lines + +        This updates the series in git; it does not update the database + +        Args: +            branch_name (str): Name of the branch to process +            series (Series): Series object +            max_vers (int): Version number of the series being updated +            new_name (str or None): New name, if a new branch is to be created +            dry_run (bool): True to do a dry run, restoring the original tree +                afterwards +            add_vers (int or None): Version number to add to the series, if any +            add_link (str or None): Link to add to the series, if any +            add_rtags (list of dict): List of review tags to add, one item for +                    each commit, each a dict: +                key: Response tag (e.g. 'Reviewed-by') +                value: Set of people who gave that response, each a name/email +                    string +            switch (bool): True to switch to the new branch after processing; +                otherwise HEAD remains at the original branch, as amended + +        Return: +            pygit.oid: oid of the new branch +        """ +        def _do_version(): +            if add_vers: +                if add_vers == 1: +                    vals.info += f'rm v{add_vers} ' +                else: +                    vals.info += f'add v{add_vers} ' +                    out.append(f'Series-version: {add_vers}') + +        def _do_links(new_links): +            if add_link: +                if 'add' not in vals.info: +                    vals.info += 'add ' +                vals.info += f"links '{new_links}' " +            else: +                vals.info += f"upd links '{new_links}' " +            out.append(f'Series-links: {new_links}') + +        added_version = False +        added_link = False +        for vals in self.process_series(branch_name, series, new_name, switch, +                                         dry_run): +            out = [] +            for line in vals.msg.splitlines(): +                m_ver = re.match('Series-version:(.*)', line) +                m_links = re.match('Series-links:(.*)', line) +                if m_ver and add_vers: +                    if ('version' in series and +                            int(series.version) != max_vers): +                        tout.warning( +                            f'Branch {branch_name}: Series-version tag ' +                            f'{series.version} does not match expected ' +                            f'version {max_vers}') +                    _do_version() +                    added_version = True +                elif m_links: +                    links = series.get_links(m_links.group(1), max_vers) +                    if add_link: +                        links[max_vers] = add_link +                    _do_links(series.build_links(links)) +                    added_link = True +                else: +                    out.append(line) +            if vals.final: +                if not added_version and add_vers and add_vers > 1: +                    _do_version() +                if not added_link and add_link: +                    _do_links(f'{max_vers}:{add_link}') + +            vals.msg = '\n'.join(out) + '\n' +            if add_rtags and add_rtags[vals.seq]: +                lines = [] +                for tag, people in add_rtags[vals.seq].items(): +                    for who in people: +                        lines.append(f'{tag}: {who}') +                vals.msg = patchstream.insert_tags(vals.msg.rstrip(), +                                                   sorted(lines)) +                vals.info += (f'added {len(lines)} ' +                              f"tag{'' if len(lines) == 1 else 's'}") + +    def _build_col(self, state, prefix='', base_str=None): +        """Build a patch-state string with colour + +        Args: +            state (str): State to colourise (also indicates the colour to use) +            prefix (str): Prefix string to also colourise +            base_str (str or None): String to show instead of state, or None to +                show state + +        Return: +            str: String with ANSI colour characters +        """ +        bright = True +        if state == 'accepted': +            col = self.col.GREEN +        elif state == 'awaiting-upstream': +            bright = False +            col = self.col.GREEN +        elif state in ['changes-requested']: +            col = self.col.CYAN +        elif state in ['rejected', 'deferred', 'not-applicable', 'superseded', +                       'handled-elsewhere']: +            col = self.col.RED +        elif not state: +            state = 'unknown' +            col = self.col.MAGENTA +        else: +            # under-review, rfc, needs-review-ack +            col = self.col.WHITE +        out = base_str or SHORTEN_STATE.get(state, state) +        pad = ' ' * (10 - len(out)) +        col_state = self.col.build(col, prefix + out, bright) +        return col_state, pad + +    def _get_patches(self, series, version): +        """Get a Series object containing the 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 + +        Return: tuple: +            str: Name of branch, e.g. 'mary2' +            Series: Series object containing the commits and idnum, desc, name +            int: Version number of series, e.g. 2 +            OrderedDict: +                key (int): record ID if find_svid is None, else seq +                value (PCOMMIT): record data +            str: series name (for this version) +            str: patchwork link +            str: cover_id +            int: cover_num_comments +        """ +        ser, version = self._parse_series_and_version(series, version) +        if not ser.idnum: +            raise ValueError(f"Unknown series '{series}'") +        self._ensure_version(ser, version) +        svinfo = self.get_ser_ver(ser.idnum, version) +        pwc = self.get_pcommit_dict(svinfo.idnum) + +        count = len(pwc) +        branch = self._join_name_version(ser.name, version) +        series = patchstream.get_metadata(branch, 0, count, +                                          git_dir=self.gitdir) +        self._copy_db_fields_to(series, ser) + +        return (branch, series, version, pwc, svinfo.name, svinfo.link, +                svinfo.cover_id, svinfo.cover_num_comments) + +    def _list_patches(self, branch, pwc, series, desc, cover_id, num_comments, +                      show_commit, show_patch, list_patches, state_totals): +        """List patches along with optional status info + +        Args: +            branch (str): Branch name        if self.show_progress +            pwc (dict): pcommit records: +                key (int): seq +                value (PCOMMIT): Record from database +            series (Series): Series to show, or None to just use the database +            desc (str): Series title +            cover_id (int): Cover-letter ID +            num_comments (int): The number of comments on the cover letter +            show_commit (bool): True to show the commit and diffstate +            show_patch (bool): True to show the patch +            list_patches (bool): True to list all patches for each series, +                False to just show the series summary on a single line +            state_totals (dict): Holds totals for each state across all patches +                key (str): state name +                value (int): Number of patches in that state + +        Return: +            bool: True if OK, False if any commit subjects don't match their +                patchwork subjects +        """ +        lines = [] +        states = defaultdict(int) +        count = len(pwc) +        ok = True +        for seq, item in enumerate(pwc.values()): +            if series: +                cmt = series.commits[seq] +                if cmt.subject != item.subject: +                    ok = False + +            col_state, pad = self._build_col(item.state) +            patch_id = item.patch_id if item.patch_id else '' +            if item.num_comments: +                comments = str(item.num_comments) +            elif item.num_comments is None: +                comments = '-' +            else: +                comments = '' + +            if show_commit or show_patch: +                subject = self.col.build(self.col.BLACK, item.subject, +                                         bright=False, back=self.col.YELLOW) +            else: +                subject = item.subject + +            line = (f'{seq:3} {col_state}{pad} {comments.rjust(3)} ' +                    f'{patch_id:7} {oid(cmt.hash)} {subject}') +            lines.append(line) +            states[item.state] += 1 +        out = '' +        for state, freq in states.items(): +            out += ' ' + self._build_col(state, f'{freq}:')[0] +            state_totals[state] += freq +        name = '' +        if not list_patches: +            name = desc or series.desc +            name = self.col.build(self.col.YELLOW, name[:41].ljust(41)) +            if not ok: +                out = '*' + out[1:] +            print(f"{branch:16} {name} {len(pwc):5} {out}") +            return ok +        print(f"Branch '{branch}' (total {len(pwc)}):{out}{name}") + +        print(self.col.build( +            self.col.MAGENTA, +            f"Seq State      Com PatchId {'Commit'.ljust(HASH_LEN)} Subject")) + +        comments = '' if num_comments is None else str(num_comments) +        if desc or comments or cover_id: +            cov = 'Cov' if cover_id else '' +            print(self.col.build( +                self.col.WHITE, +                f"{cov:14} {comments.rjust(3)} {cover_id or '':7}            " +                f'{desc or series.desc}', +                bright=False)) +        for seq in range(count): +            line = lines[seq] +            print(line) +            if show_commit or show_patch: +                print() +                cmt = series.commits[seq] if series else '' +                msg = gitutil.show_commit( +                    cmt.hash, show_commit, True, show_patch, +                    colour=self.col.enabled(), git_dir=self.gitdir) +                sys.stdout.write(msg) +                if seq != count - 1: +                    print() +                    print() + +        return ok + +    def _find_matched_commit(self, commits, pcm): +        """Find a commit in a list of possible matches + +        Args: +            commits (dict of Commit): Possible matches +                key (int): sequence number of patch (from 0) +                value (Commit): Commit object +            pcm (PCOMMIT): Patch to check + +        Return: +            int: Sequence number of matching commit, or None if not found +        """ +        for seq, cmt in commits.items(): +            tout.debug(f"- match subject: '{cmt.subject}'") +            if pcm.subject == cmt.subject: +                return seq +        return None + +    def _find_matched_patch(self, patches, cmt): +        """Find a patch in a list of possible matches + +        Args: +            patches: dict of ossible matches +                key (int): sequence number of patch +                value (PCOMMIT): patch +            cmt (Commit): Commit to check + +        Return: +            int: Sequence number of matching patch, or None if not found +        """ +        for seq, pcm in patches.items(): +            tout.debug(f"- match subject: '{pcm.subject}'") +            if cmt.subject == pcm.subject: +                return seq +        return None + +    def _sync_one(self, svid, series_name, version, show_comments, +                  show_cover_comments, gather_tags, cover, patches, dry_run): +        """Sync one series to the database + +        Args: +            svid (int): Ser/ver ID +            cover (dict or None): Cover letter from patchwork, with keys: +                id (int): Cover-letter ID in patchwork +                num_comments (int): Number of comments +                name (str): Cover-letter name +            patches (list of Patch): Patches in the series +        """ +        pwc = self.get_pcommit_dict(svid) +        if gather_tags: +            count = len(pwc) +            branch = self._join_name_version(series_name, version) +            series = patchstream.get_metadata(branch, 0, count, +                                              git_dir=self.gitdir) + +            _, new_rtag_list = status.do_show_status( +                series, cover, patches, show_comments, show_cover_comments, +                self.col, warnings_on_stderr=False) +            self.update_series(branch, series, version, None, dry_run, +                                add_rtags=new_rtag_list) + +        updated = 0 +        for seq, item in enumerate(pwc.values()): +            if seq >= len(patches): +                continue +            patch = patches[seq] +            if patch.id: +                if self.db.pcommit_update( +                    Pcommit(item.idnum, seq, None, None, None, patch.state, +                            patch.id, len(patch.comments))): +                    updated += 1 +        if cover: +            info = SerVer(svid, None, None, None, cover.id, +                           cover.num_comments, cover.name, None) +        else: +            info = SerVer(svid, None, None, None, None, None, patches[0].name, +                           None) +        self.db.ser_ver_set_info(info) + +        return updated, 1 if cover else 0 + +    async def _gather(self, pwork, link, show_cover_comments): +        """Sync the series status from patchwork + +        Creates a new client sesion and calls _sync() + +        Args: +            pwork (Patchwork): Patchwork object to use +            link (str): Patchwork link for the series +            show_cover_comments (bool): True to show the comments on the cover +                letter + +        Return: tuple: +            COVER object, or None if none or not read_cover_comments +            list of PATCH objects +        """ +        async with aiohttp.ClientSession() as client: +            return await pwork.series_get_state(client, link, True, +                                                show_cover_comments) + +    def _get_fetch_dict(self, sync_all_versions): +        """Get a dict of ser_vers to fetch, along with their patchwork links + +        Args: +            sync_all_versions (bool): True to sync all versions of a series, +                False to sync only the latest version + +        Return: tuple: +            dict: things to fetch +                key (int): svid +                value (str): patchwork link for the series +            int: number of series which are missing a link +        """ +        missing = 0 +        svdict = self.get_ser_ver_dict() +        sdict = self.db.series_get_dict_by_id() +        to_fetch = {} + +        if sync_all_versions: +            for svinfo in self.get_ser_ver_list(): +                ser_ver = svdict[svinfo.idnum] +                if svinfo.link: +                    to_fetch[svinfo.idnum] = patchwork.STATE_REQ( +                        svinfo.link, svinfo.series_id, +                        sdict[svinfo.series_id].name, svinfo.version, False, +                        False) +                else: +                    missing += 1 +        else: +            # Find the maximum version for each series +            max_vers = self._series_all_max_versions() + +            # Get a list of links to fetch +            for svid, series_id, version in max_vers: +                ser_ver = svdict[svid] +                if series_id not in sdict: +                    # skip archived item +                    continue +                if ser_ver.link: +                    to_fetch[svid] = patchwork.STATE_REQ( +                        ser_ver.link, series_id, sdict[series_id].name, +                        version, False, False) +                else: +                    missing += 1 + +        # order by series name, version +        ordered = OrderedDict() +        for svid in sorted( +                to_fetch, +                key=lambda k: (to_fetch[k].series_name, to_fetch[k].version)): +            sync = to_fetch[svid] +            ordered[svid] = sync + +        return ordered, missing + +    async def _sync_all(self, client, pwork, to_fetch): +        """Sync all series status from patchwork + +        Args: +            pwork (Patchwork): Patchwork object to use +            sync_all_versions (bool): True to sync all versions of a series, +                False to sync only the latest version +            gather_tags (bool): True to gather review/test tags + +        Return: list of tuple: +            COVER object, or None if none or not read_cover_comments +            list of PATCH objects +        """ +        with pwork.collect_stats() as stats: +            tasks = [pwork.series_get_state(client, sync.link, True, True) +                     for sync in to_fetch.values() if sync.link] +            result = await asyncio.gather(*tasks) +        return result, stats.request_count + +    async def _do_series_sync_all(self, pwork, to_fetch): +        async with aiohttp.ClientSession() as client: +            return await self._sync_all(client, pwork, to_fetch) + +    def _progress_one(self, ser, show_all_versions, list_patches, +                      state_totals): +        """Show progress information for all versions in a series + +        Args: +            ser (Series): Series to use +            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 +            state_totals (dict): Holds totals for each state across all patches +                key (str): state name +                value (int): Number of patches in that state + +        Return: tuple +            int: Number of series shown +            int: Number of patches shown +            int: Number of version which need a 'scan' +        """ +        max_vers = self._series_max_version(ser.idnum) +        name, desc = self._get_series_info(ser.idnum) +        coloured = self.col.build(self.col.BLACK, desc, bright=False, +                                  back=self.col.YELLOW) +        versions = self._get_version_list(ser.idnum) +        vstr = list(map(str, versions)) + +        if list_patches: +            print(f"{name}: {coloured} (versions: {' '.join(vstr)})") +        add_blank_line = False +        total_series = 0 +        total_patches = 0 +        need_scan = 0 +        for ver in versions: +            if not show_all_versions and ver != max_vers: +                continue +            if add_blank_line: +                print() +            _, pwc = self._series_get_version_stats(ser.idnum, ver) +            count = len(pwc) +            branch = self._join_name_version(ser.name, ver) +            series = patchstream.get_metadata(branch, 0, count, +                                              git_dir=self.gitdir) +            svinfo = self.get_ser_ver(ser.idnum, ver) +            self._copy_db_fields_to(series, ser) + +            ok = self._list_patches( +                branch, pwc, series, svinfo.name, svinfo.cover_id, +                svinfo.cover_num_comments, False, False, list_patches, +                state_totals) +            if not ok: +                need_scan += 1 +            add_blank_line = list_patches +            total_series += 1 +            total_patches += count +        return total_series, total_patches, need_scan + +    def _summary_one(self, ser): +        """Show summary information for the latest version in a series + +        Args: +            series (str): Name of series to use, or None to show progress for +                all series +        """ +        max_vers = self._series_max_version(ser.idnum) +        name, desc = self._get_series_info(ser.idnum) +        stats, pwc = self._series_get_version_stats(ser.idnum, max_vers) +        states = {x.state for x in pwc.values()} +        state = 'accepted' +        for val in ['awaiting-upstream', 'changes-requested', 'rejected', +                    'deferred', 'not-applicable', 'superseded', +                    'handled-elsewhere']: +            if val in states: +                state = val +        state_str, pad = self._build_col(state, base_str=name) +        print(f"{state_str}{pad}  {stats.rjust(6)}  {desc}") + +    def _series_max_version(self, idnum): +        """Find the latest version of a series + +        Args: +            idnum (int): Series ID to look up + +        Return: +            int: maximum version +        """ +        return self.db.series_get_max_version(idnum) + +    def _series_all_max_versions(self): +        """Find the latest version of all series + +        Return: list of: +            int: ser_ver ID +            int: series ID +            int: Maximum version +        """ +        return self.db.series_get_all_max_versions() | 
