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