1# SPDX-License-Identifier: GPL-2.0+
2#
3# Copyright 2025 Google LLC
4#
5"""Handles the 'series' subcommand
6"""
7
8import asyncio
9from collections import OrderedDict, defaultdict
10
11import pygit2
12
13from u_boot_pylib import cros_subprocess
14from u_boot_pylib import gitutil
15from u_boot_pylib import terminal
16from u_boot_pylib import tout
17
18from patman import patchstream
19from patman import cser_helper
20from patman.cser_helper import AUTOLINK, oid
21from patman import send
22from patman import status
23
24
25class Cseries(cser_helper.CseriesHelper):
26    """Database with information about series
27
28    This class handles database read/write as well as operations in a git
29    directory to update series information.
30    """
31    def __init__(self, topdir=None, colour=terminal.COLOR_IF_TERMINAL):
32        """Set up a new Cseries
33
34        Args:
35            topdir (str): Top-level directory of the repo
36            colour (terminal.enum): Whether to enable ANSI colour or not
37        """
38        super().__init__(topdir, colour)
39
40    def add(self, branch_name, desc=None, mark=False, allow_unmarked=False,
41            end=None, force_version=False, dry_run=False):
42        """Add a series (or new version of a series) to the database
43
44        Args:
45            branch_name (str): Name of branch to sync, or None for current one
46            desc (str): Description to use, or None to use the series subject
47            mark (str): True to mark each commit with a change ID
48            allow_unmarked (str): True to not require each commit to be marked
49            end (str): Add only commits up to but exclu
50            force_version (bool): True if ignore a Series-version tag that
51                doesn't match its branch name
52            dry_run (bool): True to do a dry run
53        """
54        name, ser, version, msg = self.prep_series(branch_name, end)
55        tout.info(f"Adding series '{ser.name}' v{version}: mark {mark} "
56                  f'allow_unmarked {allow_unmarked}')
57        if msg:
58            tout.info(msg)
59        if desc is None:
60            if not ser.cover:
61                raise ValueError(f"Branch '{name}' has no cover letter - "
62                                 'please provide description')
63            desc = ser['cover'][0]
64
65        ser = self._handle_mark(name, ser, version, mark, allow_unmarked,
66                                force_version, dry_run)
67        link = ser.get_link_for_version(version)
68
69        msg = 'Added'
70        added = False
71        series_id = self.db.series_find_by_name(ser.name)
72        if not series_id:
73            series_id = self.db.series_add(ser.name, desc)
74            added = True
75            msg += f" series '{ser.name}'"
76
77        if version not in self._get_version_list(series_id):
78            svid = self.db.ser_ver_add(series_id, version, link)
79            msg += f" v{version}"
80            if not added:
81                msg += f" to existing series '{ser.name}'"
82            added = True
83
84            self._add_series_commits(ser, svid)
85            count = len(ser.commits)
86            msg += f" ({count} commit{'s' if count > 1 else ''})"
87        if not added:
88            tout.info(f"Series '{ser.name}' v{version} already exists")
89            msg = None
90        elif not dry_run:
91            self.commit()
92        else:
93            self.rollback()
94            series_id = None
95        ser.desc = desc
96        ser.idnum = series_id
97
98        if msg:
99            tout.info(msg)
100        if dry_run:
101            tout.info('Dry run completed')
102
103    def decrement(self, series, dry_run=False):
104        """Decrement a series to the previous version and delete the branch
105
106        Args:
107            series (str): Name of series to use, or None to use current branch
108            dry_run (bool): True to do a dry run
109        """
110        ser = self._parse_series(series)
111        if not ser.idnum:
112            raise ValueError(f"Series '{ser.name}' not found in database")
113
114        max_vers = self._series_max_version(ser.idnum)
115        if max_vers < 2:
116            raise ValueError(f"Series '{ser.name}' only has one version")
117
118        tout.info(f"Removing series '{ser.name}' v{max_vers}")
119
120        new_max = max_vers - 1
121
122        repo = pygit2.init_repository(self.gitdir)
123        if not dry_run:
124            name = self._get_branch_name(ser.name, new_max)
125            branch = repo.lookup_branch(name)
126            try:
127                repo.checkout(branch)
128            except pygit2.errors.GitError:
129                tout.warning(f"Failed to checkout branch {name}")
130                raise
131
132            del_name = f'{ser.name}{max_vers}'
133            del_branch = repo.lookup_branch(del_name)
134            branch_oid = del_branch.peel(pygit2.enums.ObjectType.COMMIT).oid
135            del_branch.delete()
136            print(f"Deleted branch '{del_name}' {oid(branch_oid)}")
137
138        self.db.ser_ver_remove(ser.idnum, max_vers)
139        if not dry_run:
140            self.commit()
141        else:
142            self.rollback()
143
144    def increment(self, series_name, dry_run=False):
145        """Increment a series to the next version and create a new branch
146
147        Args:
148            series_name (str): Name of series to use, or None to use current
149                branch
150            dry_run (bool): True to do a dry run
151        """
152        ser = self._parse_series(series_name)
153        if not ser.idnum:
154            raise ValueError(f"Series '{ser.name}' not found in database")
155
156        max_vers = self._series_max_version(ser.idnum)
157
158        branch_name = self._get_branch_name(ser.name, max_vers)
159        on_branch = gitutil.get_branch(self.gitdir) == branch_name
160        svid = self.get_series_svid(ser.idnum, max_vers)
161        pwc = self.get_pcommit_dict(svid)
162        count = len(pwc.values())
163        series = patchstream.get_metadata(branch_name, 0, count,
164                                          git_dir=self.gitdir)
165        tout.info(f"Increment '{ser.name}' v{max_vers}: {count} patches")
166
167        # Create a new branch
168        vers = max_vers + 1
169        new_name = self._join_name_version(ser.name, vers)
170
171        self.update_series(branch_name, series, max_vers, new_name, dry_run,
172                            add_vers=vers, switch=on_branch)
173
174        old_svid = self.get_series_svid(ser.idnum, max_vers)
175        pcd = self.get_pcommit_dict(old_svid)
176
177        svid = self.db.ser_ver_add(ser.idnum, vers)
178        self.db.pcommit_add_list(svid, pcd.values())
179        if not dry_run:
180            self.commit()
181        else:
182            self.rollback()
183
184        # repo.head.set_target(amended)
185        tout.info(f'Added new branch {new_name}')
186        if dry_run:
187            tout.info('Dry run completed')
188
189    def link_set(self, series_name, version, link, update_commit):
190        """Add / update a series-links link for a series
191
192        Args:
193            series_name (str): Name of series to use, or None to use current
194                branch
195            version (int): Version number, or None to detect from name
196            link (str): Patchwork link-string for the series
197            update_commit (bool): True to update the current commit with the
198                link
199        """
200        ser, version = self._parse_series_and_version(series_name, version)
201        self._ensure_version(ser, version)
202
203        self._set_link(ser.idnum, ser.name, version, link, update_commit)
204        self.commit()
205        tout.info(f"Setting link for series '{ser.name}' v{version} to {link}")
206
207    def link_get(self, series, version):
208        """Get the patchwork link for a version of a series
209
210        Args:
211            series (str): Name of series to use, or None to use current branch
212            version (int): Version number or None for current
213
214        Return:
215            str: Patchwork link as a string, e.g. '12325'
216        """
217        ser, version = self._parse_series_and_version(series, version)
218        self._ensure_version(ser, version)
219        return self.db.ser_ver_get_link(ser.idnum, version)
220
221    def link_search(self, pwork, series, version):
222        """Search patch for the link for a series
223
224        Returns either the single match, or None, in which case the second part
225        of the tuple is filled in
226
227        Args:
228            pwork (Patchwork): Patchwork object to use
229            series (str): Series name to search for, or None for current series
230                that is checked out
231            version (int): Version to search for, or None for current version
232                detected from branch name
233
234        Returns:
235            tuple:
236                int: ID of the series found, or None
237                list of possible matches, or None, each a dict:
238                    'id': series ID
239                    'name': series name
240                str: series name
241                int: series version
242                str: series description
243        """
244        _, ser, version, _, _, _, _, _ = self._get_patches(series, version)
245
246        if not ser.desc:
247            raise ValueError(f"Series '{ser.name}' has an empty description")
248
249        pws, options = self.loop.run_until_complete(pwork.find_series(
250            ser, version))
251        return pws, options, ser.name, version, ser.desc
252
253    def link_auto(self, pwork, series, version, update_commit, wait_s=0):
254        """Automatically find a series link by looking in patchwork
255
256        Args:
257            pwork (Patchwork): Patchwork object to use
258            series (str): Series name to search for, or None for current series
259                that is checked out
260            version (int): Version to search for, or None for current version
261                detected from branch name
262            update_commit (bool): True to update the current commit with the
263                link
264            wait_s (int): Number of seconds to wait for the autolink to succeed
265        """
266        start = self.get_time()
267        stop = start + wait_s
268        sleep_time = 5
269        while True:
270            pws, options, name, version, desc = self.link_search(
271                pwork, series, version)
272            if pws:
273                if wait_s:
274                    tout.info('Link completed after '
275                              f'{self.get_time() - start} seconds')
276                break
277
278            print(f"Possible matches for '{name}' v{version} desc '{desc}':")
279            print('  Link  Version  Description')
280            for opt in options:
281                print(f"{opt['id']:6}  {opt['version']:7}  {opt['name']}")
282            if not wait_s or self.get_time() > stop:
283                delay = f' after {wait_s} seconds' if wait_s else ''
284                raise ValueError(f"Cannot find series '{desc}{delay}'")
285
286            self.sleep(sleep_time)
287
288        self.link_set(name, version, pws, update_commit)
289
290    def link_auto_all(self, pwork, update_commit, link_all_versions,
291                      replace_existing, dry_run, show_summary=True):
292        """Automatically find a series link by looking in patchwork
293
294        Args:
295            pwork (Patchwork): Patchwork object to use
296            update_commit (bool): True to update the current commit with the
297                link
298            link_all_versions (bool): True to sync all versions of a series,
299                False to sync only the latest version
300            replace_existing (bool): True to sync a series even if it already
301                has a link
302            dry_run (bool): True to do a dry run
303            show_summary (bool): True to show a summary of how things went
304
305        Return:
306            OrderedDict of summary info:
307                key (int): ser_ver ID
308                value (AUTOLINK): result of autolinking on this ser_ver
309        """
310        sdict = self.db.series_get_dict_by_id()
311        all_ser_vers = self._get_autolink_dict(sdict, link_all_versions)
312
313        # Get rid of things without a description
314        valid = {}
315        state = {}
316        no_desc = 0
317        not_found = 0
318        updated = 0
319        failed = 0
320        already = 0
321        for svid, (ser_id, name, version, link, desc) in all_ser_vers.items():
322            if link and not replace_existing:
323                state[svid] = f'already:{link}'
324                already += 1
325            elif desc:
326                valid[svid] = ser_id, version, link, desc
327            else:
328                no_desc += 1
329                state[svid] = 'missing description'
330
331        results, requests = self.loop.run_until_complete(
332            pwork.find_series_list(valid))
333
334        for svid, ser_id, link, _ in results:
335            if link:
336                version = all_ser_vers[svid][2]
337                if self._set_link(ser_id, sdict[ser_id].name, version,
338                                  link, update_commit, dry_run=dry_run):
339                    updated += 1
340                    state[svid] = f'linked:{link}'
341                else:
342                    failed += 1
343                    state[svid] = 'failed'
344            else:
345                not_found += 1
346                state[svid] = 'not found'
347
348        # Create a summary sorted by name and version
349        summary = OrderedDict()
350        for svid in sorted(all_ser_vers, key=lambda k: all_ser_vers[k][1:2]):
351            _, name, version, link, ser = all_ser_vers[svid]
352            summary[svid] = AUTOLINK(name, version, link, ser.desc,
353                                     state[svid])
354
355        if show_summary:
356            msg = f'{updated} series linked'
357            if already:
358                msg += f', {already} already linked'
359            if not_found:
360                msg += f', {not_found} not found'
361            if no_desc:
362                msg += f', {no_desc} missing description'
363            if failed:
364                msg += f', {failed} updated failed'
365            tout.info(msg + f' ({requests} requests)')
366
367            tout.info('')
368            tout.info(f"{'Name':15}  Version  {'Description':40}  Result")
369            border = f"{'-' * 15}  -------  {'-' * 40}  {'-' * 15}"
370            tout.info(border)
371            for name, version, link, desc, state in summary.values():
372                bright = True
373                if state.startswith('already'):
374                    col = self.col.GREEN
375                    bright = False
376                elif state.startswith('linked'):
377                    col = self.col.MAGENTA
378                else:
379                    col = self.col.RED
380                col_state = self.col.build(col, state, bright)
381                tout.info(f"{name:16.16} {version:7}  {desc or '':40.40}  "
382                          f'{col_state}')
383            tout.info(border)
384        if dry_run:
385            tout.info('Dry run completed')
386
387        return summary
388
389    def series_list(self):
390        """List all series
391
392        Lines all series along with their description, number of patches
393        accepted and  the available versions
394        """
395        sdict = self.db.series_get_dict()
396        print(f"{'Name':15}  {'Description':40}  Accepted  Versions")
397        border = f"{'-' * 15}  {'-' * 40}  --------  {'-' * 15}"
398        print(border)
399        for name in sorted(sdict):
400            ser = sdict[name]
401            versions = self._get_version_list(ser.idnum)
402            stat = self._series_get_version_stats(
403                ser.idnum, self._series_max_version(ser.idnum))[0]
404
405            vlist = ' '.join([str(ver) for ver in sorted(versions)])
406
407            print(f'{name:16.16} {ser.desc:41.41} {stat.rjust(8)}  {vlist}')
408        print(border)
409
410    def list_patches(self, series, version, show_commit=False,
411                     show_patch=False):
412        """List patches in a series
413
414        Args:
415            series (str): Name of series to use, or None to use current branch
416            version (int): Version number, or None to detect from name
417            show_commit (bool): True to show the commit and diffstate
418            show_patch (bool): True to show the patch
419        """
420        branch, series, version, pwc, name, _, cover_id, num_comments = (
421            self._get_patches(series, version))
422        with terminal.pager():
423            state_totals = defaultdict(int)
424            self._list_patches(branch, pwc, series, name, cover_id,
425                               num_comments, show_commit, show_patch, True,
426                               state_totals)
427
428    def mark(self, in_name, allow_marked=False, dry_run=False):
429        """Add Change-Id tags to a series
430
431        Args:
432            in_name (str): Name of the series to unmark
433            allow_marked (bool): Allow commits to be (already) marked
434            dry_run (bool): True to do a dry run, restoring the original tree
435                afterwards
436
437        Return:
438            pygit.oid: oid of the new branch
439        """
440        name, ser, _, _ = self.prep_series(in_name)
441        tout.info(f"Marking series '{name}': allow_marked {allow_marked}")
442
443        if not allow_marked:
444            bad = []
445            for cmt in ser.commits:
446                if cmt.change_id:
447                    bad.append(cmt)
448            if bad:
449                print(f'{len(bad)} commit(s) already have marks')
450                for cmt in bad:
451                    print(f' - {oid(cmt.hash)} {cmt.subject}')
452                raise ValueError(
453                    f'Marked commits {len(bad)}/{len(ser.commits)}')
454        new_oid = self._mark_series(in_name, ser, dry_run=dry_run)
455
456        if dry_run:
457            tout.info('Dry run completed')
458        return new_oid
459
460    def unmark(self, name, allow_unmarked=False, dry_run=False):
461        """Remove Change-Id tags from a series
462
463        Args:
464            name (str): Name of the series to unmark
465            allow_unmarked (bool): Allow commits to be (already) unmarked
466            dry_run (bool): True to do a dry run, restoring the original tree
467                afterwards
468
469        Return:
470            pygit.oid: oid of the new branch
471        """
472        name, ser, _, _ = self.prep_series(name)
473        tout.info(
474            f"Unmarking series '{name}': allow_unmarked {allow_unmarked}")
475
476        if not allow_unmarked:
477            bad = []
478            for cmt in ser.commits:
479                if not cmt.change_id:
480                    bad.append(cmt)
481            if bad:
482                print(f'{len(bad)} commit(s) are missing marks')
483                for cmt in bad:
484                    print(f' - {oid(cmt.hash)} {cmt.subject}')
485                raise ValueError(
486                    f'Unmarked commits {len(bad)}/{len(ser.commits)}')
487        vals = None
488        for vals in self.process_series(name, ser, dry_run=dry_run):
489            if cser_helper.CHANGE_ID_TAG in vals.msg:
490                lines = vals.msg.splitlines()
491                updated = [line for line in lines
492                           if not line.startswith(cser_helper.CHANGE_ID_TAG)]
493                vals.msg = '\n'.join(updated)
494
495                tout.detail("   - removing mark")
496                vals.info = 'unmarked'
497            else:
498                vals.info = 'no mark'
499
500        if dry_run:
501            tout.info('Dry run completed')
502        return vals.oid
503
504    def open(self, pwork, name, version):
505        """Open the patchwork page for a series
506
507        Args:
508            pwork (Patchwork): Patchwork object to use
509            name (str): Name of series to open
510            version (str): Version number to open
511        """
512        ser, version = self._parse_series_and_version(name, version)
513        link = self.link_get(ser.name, version)
514        pwork.url = 'https://patchwork.ozlabs.org'
515        url = self.loop.run_until_complete(pwork.get_series_url(link))
516        print(f'Opening {url}')
517
518        # With Firefox, GTK produces lots of warnings, so suppress them
519        # Gtk-Message: 06:48:20.692: Failed to load module "xapp-gtk3-module"
520        # Gtk-Message: 06:48:20.692: Not loading module "atk-bridge": The
521        # functionality is provided by GTK natively. Please try to not load it.
522        # Gtk-Message: 06:48:20.692: Failed to load module "appmenu-gtk-module"
523        # Gtk-Message: 06:48:20.692: Failed to load module "appmenu-gtk-module"
524        # [262145, Main Thread] WARNING: GTK+ module /snap/firefox/5987/
525        #  gnome-platform/usr/lib/gtk-2.0/modules/libcanberra-gtk-module.so
526        #  cannot be loaded.
527        # GTK+ 2.x symbols detected. Using GTK+ 2.x and GTK+ 3 in the same
528        #  process #  is not supported.: 'glib warning', file /build/firefox/
529        #  parts/firefox/build/toolkit/xre/nsSigHandlers.cpp:201
530        #
531        # (firefox_firefox:262145): Gtk-WARNING **: 06:48:20.728: GTK+ module
532        #  /snap/firefox/5987/gnome-platform/usr/lib/gtk-2.0/modules/
533        #  libcanberra-gtk-module.so cannot be loaded.
534        # GTK+ 2.x symbols detected. Using GTK+ 2.x and GTK+ 3 in the same
535        #  process is not supported.
536        # Gtk-Message: 06:48:20.728: Failed to load module
537        #  "canberra-gtk-module"
538        # [262145, Main Thread] WARNING: GTK+ module /snap/firefox/5987/
539        #  gnome-platform/usr/lib/gtk-2.0/modules/libcanberra-gtk-module.so
540        #  cannot be loaded.
541        # GTK+ 2.x symbols detected. Using GTK+ 2.x and GTK+ 3 in the same
542        #  process is not supported.: 'glib warning', file /build/firefox/
543        #  parts/firefox/build/toolkit/xre/nsSigHandlers.cpp:201
544        #
545        # (firefox_firefox:262145): Gtk-WARNING **: 06:48:20.729: GTK+ module
546        #   /snap/firefox/5987/gnome-platform/usr/lib/gtk-2.0/modules/
547        #   libcanberra-gtk-module.so cannot be loaded.
548        # GTK+ 2.x symbols detected. Using GTK+ 2.x and GTK+ 3 in the same
549        #  process is not supported.
550        # Gtk-Message: 06:48:20.729: Failed to load module
551        #  "canberra-gtk-module"
552        # ATTENTION: default value of option mesa_glthread overridden by
553        # environment.
554        cros_subprocess.Popen(['xdg-open', url])
555
556    def progress(self, series, show_all_versions, list_patches):
557        """Show progress information for all versions in a series
558
559        Args:
560            series (str): Name of series to use, or None to show progress for
561                all series
562            show_all_versions (bool): True to show all versions of a series,
563                False to show only the final version
564            list_patches (bool): True to list all patches for each series,
565                False to just show the series summary on a single line
566        """
567        with terminal.pager():
568            state_totals = defaultdict(int)
569            if series is not None:
570                _, _, need_scan = self._progress_one(
571                    self._parse_series(series), show_all_versions,
572                    list_patches, state_totals)
573                if need_scan:
574                    tout.warning(
575                        'Inconsistent commit-subject: Please use '
576                        "'patman series -s <branch> scan' to resolve this")
577                return
578
579            total_patches = 0
580            total_series = 0
581            sdict = self.db.series_get_dict()
582            border = None
583            total_need_scan = 0
584            if not list_patches:
585                print(self.col.build(
586                    self.col.MAGENTA,
587                    f"{'Name':16} {'Description':41} Count  {'Status'}"))
588                border = f"{'-' * 15}  {'-' * 40}  -----  {'-' * 15}"
589                print(border)
590            for name in sorted(sdict):
591                ser = sdict[name]
592                num_series, num_patches, need_scan = self._progress_one(
593                    ser, show_all_versions, list_patches, state_totals)
594                total_need_scan += need_scan
595                if list_patches:
596                    print()
597                total_series += num_series
598                total_patches += num_patches
599            if not list_patches:
600                print(border)
601                total = f'{total_series} series'
602                out = ''
603                for state, freq in state_totals.items():
604                    out += ' ' + self._build_col(state, f'{freq}:')[0]
605                if total_need_scan:
606                    out = '*' + out[1:]
607
608                print(f"{total:15}  {'':40}  {total_patches:5} {out}")
609                if total_need_scan:
610                    tout.info(
611                        f'Series marked * ({total_need_scan}) have commit '
612                        'subjects which mismatch their patches and need to be '
613                        'scanned')
614
615    def project_set(self, pwork, name, quiet=False):
616        """Set the name of the project
617
618        Args:
619            pwork (Patchwork): Patchwork object to use
620            name (str): Name of the project to use in patchwork
621            quiet (bool): True to skip writing the message
622        """
623        res = self.loop.run_until_complete(pwork.get_projects())
624        proj_id = None
625        link_name = None
626        for proj in res:
627            if proj['name'] == name:
628                proj_id = proj['id']
629                link_name = proj['link_name']
630        if not proj_id:
631            raise ValueError(f"Unknown project name '{name}'")
632        self.db.settings_update(name, proj_id, link_name)
633        self.commit()
634        if not quiet:
635            tout.info(f"Project '{name}' patchwork-ID {proj_id} "
636                      f'link-name {link_name}')
637
638    def project_get(self):
639        """Get the details of the project
640
641        Returns:
642            tuple or None if there are no settings:
643                name (str): Project name, e.g. 'U-Boot'
644                proj_id (int): Patchworks project ID for this project
645                link_name (str): Patchwork's link-name for the project
646        """
647        return self.db.settings_get()
648
649    def remove(self, name, dry_run=False):
650        """Remove a series from the database
651
652        Args:
653            name (str): Name of series to remove, or None to use current one
654            dry_run (bool): True to do a dry run
655        """
656        ser = self._parse_series(name)
657        name = ser.name
658        if not ser.idnum:
659            raise ValueError(f"No such series '{name}'")
660
661        self.db.ser_ver_remove(ser.idnum, None)
662        if not dry_run:
663            self.commit()
664        else:
665            self.rollback()
666
667        self.commit()
668        tout.info(f"Removed series '{name}'")
669        if dry_run:
670            tout.info('Dry run completed')
671
672    def rename(self, series, name, dry_run=False):
673        """Rename a series
674
675        Renames a series and changes the name of any branches which match
676        versions present in the database
677
678        Args:
679            series (str): Name of series to use, or None to use current branch
680            name (str): new name to use (must not include version number)
681            dry_run (bool): True to do a dry run
682        """
683        old_ser, _ = self._parse_series_and_version(series, None)
684        if not old_ser.idnum:
685            raise ValueError(f"Series '{old_ser.name}' not found in database")
686        if old_ser.name != series:
687            raise ValueError(f"Invalid series name '{series}': "
688                             'did you use the branch name?')
689        chk, _ = cser_helper.split_name_version(name)
690        if chk != name:
691            raise ValueError(
692                f"Invalid series name '{name}': did you use the branch name?")
693        if chk == old_ser.name:
694            raise ValueError(
695                f"Cannot rename series '{old_ser.name}' to itself")
696        if self.get_series_by_name(name):
697            raise ValueError(f"Cannot rename: series '{name}' already exists")
698
699        versions = self._get_version_list(old_ser.idnum)
700        missing = []
701        exists = []
702        todo = {}
703        for ver in versions:
704            ok = True
705            old_branch = self._get_branch_name(old_ser.name, ver)
706            if not gitutil.check_branch(old_branch, self.gitdir):
707                missing.append(old_branch)
708                ok = False
709
710            branch = self._get_branch_name(name, ver)
711            if gitutil.check_branch(branch, self.gitdir):
712                exists.append(branch)
713                ok = False
714
715            if ok:
716                todo[ver] = [old_branch, branch]
717
718        if missing or exists:
719            msg = 'Cannot rename'
720            if missing:
721                msg += f": branches missing: {', '.join(missing)}"
722            if exists:
723                msg += f": branches exist: {', '.join(exists)}"
724            raise ValueError(msg)
725
726        for old_branch, branch in todo.values():
727            tout.info(f"Renaming branch '{old_branch}' to '{branch}'")
728            if not dry_run:
729                gitutil.rename_branch(old_branch, branch, self.gitdir)
730
731        # Change the series name; nothing needs to change in ser_ver
732        self.db.series_set_name(old_ser.idnum, name)
733
734        if not dry_run:
735            self.commit()
736        else:
737            self.rollback()
738
739        tout.info(f"Renamed series '{series}' to '{name}'")
740        if dry_run:
741            tout.info('Dry run completed')
742
743    def scan(self, branch_name, mark=False, allow_unmarked=False, end=None,
744             dry_run=False):
745        """Scan a branch and make updates to the database if it has changed
746
747        Args:
748            branch_name (str): Name of branch to sync, or None for current one
749            mark (str): True to mark each commit with a change ID
750            allow_unmarked (str): True to not require each commit to be marked
751            end (str): Add only commits up to but exclu
752            dry_run (bool): True to do a dry run
753        """
754        def _show_item(oper, seq, subject):
755            col = None
756            if oper == '+':
757                col = self.col.GREEN
758            elif oper == '-':
759                col = self.col.RED
760            out = self.col.build(col, subject) if col else subject
761            tout.info(f'{oper} {seq:3} {out}')
762
763        name, ser, version, msg = self.prep_series(branch_name, end)
764        svid = self.get_ser_ver(ser.idnum, version).idnum
765        pcdict = self.get_pcommit_dict(svid)
766
767        tout.info(
768            f"Syncing series '{name}' v{version}: mark {mark} "
769            f'allow_unmarked {allow_unmarked}')
770        if msg:
771            tout.info(msg)
772
773        ser = self._handle_mark(name, ser, version, mark, allow_unmarked,
774                                False, dry_run)
775
776        # First check for new patches that are not in the database
777        to_add = dict(enumerate(ser.commits))
778        for pcm in pcdict.values():
779            tout.debug(f'pcm {pcm.subject}')
780            i = self._find_matched_commit(to_add, pcm)
781            if i is not None:
782                del to_add[i]
783
784        # Now check for patches in the database that are not in the branch
785        to_remove = dict(enumerate(pcdict.values()))
786        for cmt in ser.commits:
787            tout.debug(f'cmt {cmt.subject}')
788            i = self._find_matched_patch(to_remove, cmt)
789            if i is not None:
790                del to_remove[i]
791
792        for seq, cmt in enumerate(ser.commits):
793            if seq in to_remove:
794                _show_item('-', seq, to_remove[seq].subject)
795                del to_remove[seq]
796            if seq in to_add:
797                _show_item('+', seq, to_add[seq].subject)
798                del to_add[seq]
799            else:
800                _show_item(' ', seq, cmt.subject)
801        seq = len(ser.commits)
802        for cmt in to_add.items():
803            _show_item('+', seq, cmt.subject)
804            seq += 1
805        for seq, pcm in to_remove.items():
806            _show_item('+', seq, pcm.subject)
807
808        self.db.pcommit_delete(svid)
809        self._add_series_commits(ser, svid)
810        if not dry_run:
811            self.commit()
812        else:
813            self.rollback()
814            tout.info('Dry run completed')
815
816    def send(self, pwork, name, autolink, autolink_wait, args):
817        """Send out a series
818
819        Args:
820            pwork (Patchwork): Patchwork object to use
821            name (str): Series name to search for, or None for current series
822                that is checked out
823            autolink (bool): True to auto-link the series after sending
824            args (argparse.Namespace): 'send' arguments provided
825            autolink_wait (int): Number of seconds to wait for the autolink to
826                succeed
827        """
828        ser, version = self._parse_series_and_version(name, None)
829        if not ser.idnum:
830            raise ValueError(f"Series '{ser.name}' not found in database")
831
832        args.branch = self._get_branch_name(ser.name, version)
833        likely_sent = send.send(args, git_dir=self.gitdir, cwd=self.topdir)
834
835        if likely_sent and autolink:
836            print(f'Autolinking with Patchwork ({autolink_wait} seconds)')
837            self.link_auto(pwork, name, version, True, wait_s=autolink_wait)
838
839    def archive(self, series):
840        """Archive a series
841
842        Args:
843            series (str): Name of series to use, or None to use current branch
844        """
845        ser = self._parse_series(series, include_archived=True)
846        if not ser.idnum:
847            raise ValueError(f"Series '{ser.name}' not found in database")
848
849        svlist = self.db.ser_ver_get_for_series(ser.idnum)
850
851        # Figure out the tags we will create
852        tag_info = {}
853        now = self.get_now()
854        now_str = now.strftime('%d%b%y').lower()
855        for svi in svlist:
856            name = self._get_branch_name(ser.name, svi.version)
857            if not gitutil.check_branch(name, git_dir=self.gitdir):
858                raise ValueError(f"No branch named '{name}'")
859            tag_info[svi.version] = [svi.idnum, name, f'{name}-{now_str}']
860
861        # Create the tags
862        repo = pygit2.init_repository(self.gitdir)
863        for _, (idnum, name, tag_name) in tag_info.items():
864            commit = repo.revparse_single(name)
865            repo.create_tag(tag_name, commit.hex,
866                            pygit2.enums.ObjectType.COMMIT,
867                            commit.author, commit.message)
868
869        # Update the database
870        for idnum, name, tag_name in tag_info.values():
871            self.db.ser_ver_set_archive_tag(idnum, tag_name)
872
873        # Delete the branches
874        for idnum, name, tag_name in tag_info.values():
875            # Detach HEAD from the branch if pointing to this branch
876            commit = repo.revparse_single(name)
877            if repo.head.target == commit.oid:
878                repo.set_head(commit.oid)
879
880            repo.branches.delete(name)
881
882        self.db.series_set_archived(ser.idnum, True)
883        self.commit()
884
885    def unarchive(self, series):
886        """Unarchive a series
887
888        Args:
889            series (str): Name of series to use, or None to use current branch
890        """
891        ser = self._parse_series(series, include_archived=True)
892        if not ser.idnum:
893            raise ValueError(f"Series '{ser.name}' not found in database")
894        self.db.series_set_archived(ser.idnum, False)
895
896        svlist = self.db.ser_ver_get_for_series(ser.idnum)
897
898        # Collect the tags
899        repo = pygit2.init_repository(self.gitdir)
900        tag_info = {}
901        for svi in svlist:
902            name = self._get_branch_name(ser.name, svi.version)
903            target = repo.revparse_single(svi.archive_tag)
904            tag_info[svi.idnum] = name, svi.archive_tag, target
905
906        # Make sure the branches don't exist
907        for name, tag_name, tag in tag_info.values():
908            if name in repo.branches:
909                raise ValueError(
910                    f"Cannot restore branch '{name}': already exists")
911
912        # Recreate the branches
913        for name, tag_name, tag in tag_info.values():
914            target = repo.get(tag.target)
915            repo.branches.create(name, target)
916
917        # Delete the tags
918        for name, tag_name, tag in tag_info.values():
919            repo.references.delete(f'refs/tags/{tag_name}')
920
921        # Update the database
922        for idnum, (name, tag_name, tag) in tag_info.items():
923            self.db.ser_ver_set_archive_tag(idnum, None)
924
925        self.commit()
926
927    def status(self, pwork, series, version, show_comments,
928               show_cover_comments=False):
929        """Show the series status from patchwork
930
931        Args:
932            pwork (Patchwork): Patchwork object to use
933            series (str): Name of series to use, or None to use current branch
934            version (int): Version number, or None to detect from name
935            show_comments (bool): Show all comments on each patch
936            show_cover_comments (bool): Show all comments on the cover letter
937        """
938        branch, series, version, _, _, link, _, _ = self._get_patches(
939            series, version)
940        if not link:
941            raise ValueError(
942                f"Series '{series.name}' v{version} has no patchwork link: "
943                f"Try 'patman series -s {branch} autolink'")
944        status.check_and_show_status(
945            series, link, branch, None, False, show_comments,
946            show_cover_comments, pwork, self.gitdir)
947
948    def summary(self, series):
949        """Show summary information for all series
950
951        Args:
952            series (str): Name of series to use
953        """
954        print(f"{'Name':17}  Status  Description")
955        print(f"{'-' * 17}  {'-' * 6}  {'-' * 30}")
956        if series is not None:
957            self._summary_one(self._parse_series(series))
958            return
959
960        sdict = self.db.series_get_dict()
961        for ser in sdict.values():
962            self._summary_one(ser)
963
964    def gather(self, pwork, series, version, show_comments,
965               show_cover_comments, gather_tags, dry_run=False):
966        """Gather any new tags from Patchwork, optionally showing comments
967
968        Args:
969            pwork (Patchwork): Patchwork object to use
970            series (str): Name of series to use, or None to use current branch
971            version (int): Version number, or None to detect from name
972            show_comments (bool): True to show the comments on each patch
973            show_cover_comments (bool): True to show the comments on the cover
974                letter
975            gather_tags (bool): True to gather review/test tags
976            dry_run (bool): True to do a dry run (database is not updated)
977        """
978        ser, version = self._parse_series_and_version(series, version)
979        self._ensure_version(ser, version)
980        svid, link = self._get_series_svid_link(ser.idnum, version)
981        if not link:
982            raise ValueError(
983                "No patchwork link is available: use 'patman series autolink'")
984        tout.info(
985            f"Updating series '{ser.name}' version {version} "
986            f"from link '{link}'")
987
988        loop = asyncio.get_event_loop()
989        with pwork.collect_stats() as stats:
990            cover, patches = loop.run_until_complete(self._gather(
991                pwork, link, show_cover_comments))
992
993        with terminal.pager():
994            updated, updated_cover = self._sync_one(
995                svid, ser.name, version, show_comments, show_cover_comments,
996                gather_tags, cover, patches, dry_run)
997            tout.info(f"{updated} patch{'es' if updated != 1 else ''}"
998                      f"{' and cover letter' if updated_cover else ''} "
999                      f'updated ({stats.request_count} requests)')
1000
1001            if not dry_run:
1002                self.commit()
1003            else:
1004                self.rollback()
1005                tout.info('Dry run completed')
1006
1007    def gather_all(self, pwork, show_comments, show_cover_comments,
1008                   sync_all_versions, gather_tags, dry_run=False):
1009        to_fetch, missing = self._get_fetch_dict(sync_all_versions)
1010
1011        loop = asyncio.get_event_loop()
1012        result, requests = loop.run_until_complete(self._do_series_sync_all(
1013                pwork, to_fetch))
1014
1015        with terminal.pager():
1016            tot_updated = 0
1017            tot_cover = 0
1018            add_newline = False
1019            for (svid, sync), (cover, patches) in zip(to_fetch.items(),
1020                                                      result):
1021                if add_newline:
1022                    tout.info('')
1023                tout.info(f"Syncing '{sync.series_name}' v{sync.version}")
1024                updated, updated_cover = self._sync_one(
1025                    svid, sync.series_name, sync.version, show_comments,
1026                    show_cover_comments, gather_tags, cover, patches, dry_run)
1027                tot_updated += updated
1028                tot_cover += updated_cover
1029                add_newline = gather_tags
1030
1031            tout.info('')
1032            tout.info(
1033                f"{tot_updated} patch{'es' if tot_updated != 1 else ''} and "
1034                f"{tot_cover} cover letter{'s' if tot_cover != 1 else ''} "
1035                f'updated, {missing} missing '
1036                f"link{'s' if missing != 1 else ''} ({requests} requests)")
1037            if not dry_run:
1038                self.commit()
1039            else:
1040                self.rollback()
1041                tout.info('Dry run completed')
1042
1043    def upstream_add(self, name, url):
1044        """Add a new upstream tree
1045
1046        Args:
1047            name (str): Name of the tree
1048            url (str): URL for the tree
1049        """
1050        self.db.upstream_add(name, url)
1051        self.commit()
1052
1053    def upstream_list(self):
1054        """List the upstream repos
1055
1056        Shows a list of the repos, obtained from the database
1057        """
1058        udict = self.get_upstream_dict()
1059
1060        for name, items in udict.items():
1061            url, is_default = items
1062            default = 'default' if is_default else ''
1063            print(f'{name:15.15} {default:8} {url}')
1064
1065    def upstream_set_default(self, name):
1066        """Set the default upstream target
1067
1068        Args:
1069            name (str): Name of the upstream remote to set as default, or None
1070                for none
1071        """
1072        self.db.upstream_set_default(name)
1073        self.commit()
1074
1075    def upstream_get_default(self):
1076        """Get the default upstream target
1077
1078        Return:
1079            str: Name of the upstream remote to set as default, or None if none
1080        """
1081        return self.db.upstream_get_default()
1082
1083    def upstream_delete(self, name):
1084        """Delete an upstream target
1085
1086        Args:
1087            name (str): Name of the upstream remote to delete
1088        """
1089        self.db.upstream_delete(name)
1090        self.commit()
1091
1092    def version_remove(self, name, version, dry_run=False):
1093        """Remove a version of a series from the database
1094
1095        Args:
1096            name (str): Name of series to remove, or None to use current one
1097            version (int): Version number to remove
1098            dry_run (bool): True to do a dry run
1099        """
1100        ser, version = self._parse_series_and_version(name, version)
1101        name = ser.name
1102
1103        versions = self._ensure_version(ser, version)
1104
1105        if versions == [version]:
1106            raise ValueError(
1107                f"Series '{ser.name}' only has one version: remove the series")
1108
1109        self.db.ser_ver_remove(ser.idnum, version)
1110        if not dry_run:
1111            self.commit()
1112        else:
1113            self.rollback()
1114
1115        tout.info(f"Removed version {version} from series '{name}'")
1116        if dry_run:
1117            tout.info('Dry run completed')
1118
1119    def version_change(self, name, version, new_version, dry_run=False):
1120        """Change a version of a series to be a different version
1121
1122        Args:
1123            name (str): Name of series to remove, or None to use current one
1124            version (int): Version number to change
1125            new_version (int): New version
1126            dry_run (bool): True to do a dry run
1127        """
1128        ser, version = self._parse_series_and_version(name, version)
1129        name = ser.name
1130
1131        versions = self._ensure_version(ser, version)
1132        vstr = list(map(str, versions))
1133        if version not in versions:
1134            raise ValueError(
1135                f"Series '{ser.name}' does not have v{version}: "
1136                f"{' '.join(vstr)}")
1137
1138        if not new_version:
1139            raise ValueError('Please provide a new version number')
1140
1141        if new_version in versions:
1142            raise ValueError(
1143                f"Series '{ser.name}' already has a v{new_version}: "
1144                f"{' '.join(vstr)}")
1145
1146        new_name = self._join_name_version(ser.name, new_version)
1147
1148        svid = self.get_series_svid(ser.idnum, version)
1149        pwc = self.get_pcommit_dict(svid)
1150        count = len(pwc.values())
1151        series = patchstream.get_metadata(name, 0, count, git_dir=self.gitdir)
1152
1153        self.update_series(name, series, version, new_name, dry_run,
1154                            add_vers=new_version, switch=True)
1155        self.db.ser_ver_set_version(svid, new_version)
1156
1157        if not dry_run:
1158            self.commit()
1159        else:
1160            self.rollback()
1161
1162        tout.info(f"Changed version {version} in series '{ser.name}' "
1163                  f"to {new_version} named '{new_name}'")
1164        if dry_run:
1165            tout.info('Dry run completed')
1166