| """Subcommand to process pending backports.""" | |
| import argparse | |
| import datetime | |
| import logging | |
| import traceback | |
| from dataclasses import dataclass | |
| from typing import Any | |
| from dev.release import changelog_news | |
| from dev.release.gh import GH_REACTION_THUMBS_DOWN, GitHub | |
| from dev.release.git import Git | |
| from dev.release.release_issue import ( | |
| RELEASE_TITLE_RE, | |
| add_backports_to_body, | |
| add_rc_task_to_body, | |
| add_sync_changelog_task_to_body, | |
| parse_backports, | |
| parse_checklist_state, | |
| update_task_in_body, | |
| ) | |
| from dev.release.utils import ( | |
| format_exception, | |
| get_latest_rc_tag, | |
| parse_pr_list, | |
| replace_version_next, | |
| ) | |
| logger = logging.getLogger(__name__) | |
| @dataclass | |
| class CherryPickAndUpdatePrsResult: | |
| # List of PR references that failed to cherry-pick. | |
| failed_prs: list[str] | |
| # The updated checklist body for the release tracking issue. | |
| body: str | |
| class ProcessBackports: | |
| """Class to process pending backports.""" | |
| def __init__(self, args, git: Git, gh: GitHub): | |
| self.args = args | |
| self.git = git | |
| self.gh = gh | |
| def _process_pr_commit_infos( | |
| self, pr_commit_infos, body, issue, dry_run | |
| ) -> tuple[list[str], dict[str, Any], list[str], list[str], str]: | |
| shas = [] | |
| sha_to_item = {} | |
| failed_prs = [] | |
| ignored_prs = [] | |
| for item in pr_commit_infos: | |
| if item.commit: | |
| sha = item.commit | |
| sha_to_item[sha] = item | |
| shas.append(sha) | |
| elif item.status in ("open-pr", "draft-pr"): | |
| logger.info("PR %s is open or draft. Ignoring.", item.pr_ref) | |
| ignored_prs.append(item.pr_ref) | |
| else: | |
| failed_prs.append(item.pr_ref) | |
| status_to_set = item.status or "error-unmerged-pr" | |
| if dry_run: | |
| logger.info( | |
| "[DRY RUN] Would update tracking issue checklist for" | |
| " unresolved PR %s to status=%s", | |
| item.pr_ref, | |
| status_to_set, | |
| ) | |
| else: | |
| logger.info( | |
| "Updating tracking issue checklist for unresolved PR %s...", | |
| item.pr_ref, | |
| ) | |
| try: | |
| body = update_task_in_body( | |
| body, | |
| item.pr_ref, | |
| checked=False, | |
| metadata={"status": status_to_set}, | |
| ) | |
| self.gh.update_issue_body(issue, body) | |
| except Exception as e: | |
| logger.error( | |
| "Failed to update tracking issue for unresolved PR %s: %s", | |
| item.pr_ref, | |
| format_exception(e), | |
| ) | |
| return shas, sha_to_item, failed_prs, ignored_prs, body | |
| def _cherry_pick_and_update_prs( | |
| self, | |
| sorted_shas, | |
| sha_to_item, | |
| body, | |
| issue, | |
| remote, | |
| dry_run, | |
| version, | |
| branch_name, | |
| next_rc_suffix, | |
| ) -> CherryPickAndUpdatePrsResult: | |
| failed_prs = [] | |
| for sha in sorted_shas: | |
| item = sha_to_item[sha] | |
| logger.info("Cherry-picking %s / %s...", item.pr_ref, sha) | |
| try: | |
| self.git.cherry_pick(sha) | |
| # Replace version markers FIRST | |
| logger.info("Replacing version markers for PR %s...", item.pr_ref) | |
| replace_version_next(version) | |
| # Perform news processing (merging news/ files into the changelog) | |
| logger.info( | |
| "Merging news fragments into changelog for PR %s...", | |
| item.pr_ref, | |
| ) | |
| release_date = datetime.date.today().strftime("%Y-%m-%d") | |
| changelog_news.update_changelog(version, release_date) | |
| # Stage changelog changes, news/ deletions, and version placeholder updates | |
| self.git.add_modified_and_deleted() | |
| # Amend cherry-pick commit to include news merging and deletions, | |
| # and reference the release tracking issue. | |
| logger.info("Amending cherry-pick commit for PR %s...", item.pr_ref) | |
| current_msg = self.git.get_commit_message("HEAD") | |
| new_msg = f"{current_msg.strip()}\n\nWork towards #{issue}" | |
| self.git.commit(new_msg, amend=True) | |
| if not dry_run: | |
| # Push amended commit | |
| self.git.push(remote, branch_name) | |
| new_sha = self.git.get_commit_sha("HEAD", short=True) | |
| metadata = { | |
| "status": "done", | |
| "rc": next_rc_suffix, | |
| "commit": new_sha, | |
| } | |
| logger.info( | |
| "Updating tracking issue checklist for PR %s...", | |
| item.pr_ref, | |
| ) | |
| try: | |
| body = update_task_in_body( | |
| body, item.pr_ref, checked=True, metadata=metadata | |
| ) | |
| self.gh.update_issue_body(issue, body) | |
| except Exception as e: | |
| logger.error( | |
| "Failed to update tracking issue for PR %s: %s", | |
| item.pr_ref, | |
| format_exception(e), | |
| ) | |
| logger.info( | |
| "Success: backported %s / %s to %s", | |
| item.pr_ref, | |
| sha, | |
| branch_name, | |
| ) | |
| else: | |
| logger.info( | |
| "[DRY RUN] Success: %s / %s can be backported without error.", | |
| item.pr_ref, | |
| sha, | |
| ) | |
| logger.info( | |
| "[DRY RUN] Would update tracking issue checklist for" | |
| " PR %s to status=done", | |
| item.pr_ref, | |
| ) | |
| except Exception as e: | |
| logger.error( | |
| "Conflict or error on %s: %s. Aborting.", | |
| sha, | |
| format_exception(e), | |
| ) | |
| try: | |
| self.git.cherry_pick_abort() | |
| except Exception: | |
| pass | |
| failed_prs.append(item.pr_ref) | |
| if dry_run: | |
| logger.info( | |
| "[DRY RUN] Would update tracking issue checklist for" | |
| " failed PR %s to status=error-merge-conflict", | |
| item.pr_ref, | |
| ) | |
| else: | |
| logger.info( | |
| "Updating tracking issue checklist for failed PR %s...", | |
| item.pr_ref, | |
| ) | |
| try: | |
| body = update_task_in_body( | |
| body, | |
| item.pr_ref, | |
| checked=False, | |
| metadata={"status": "error-merge-conflict"}, | |
| ) | |
| self.gh.update_issue_body(issue, body) | |
| logger.info( | |
| "Updated back port of %s to" | |
| " status=error-merge-conflict (unchecked)", | |
| item.pr_ref, | |
| ) | |
| except Exception as e: | |
| logger.error( | |
| "Failed to update tracking issue for failed PR %s: %s", | |
| item.pr_ref, | |
| format_exception(e), | |
| ) | |
| return CherryPickAndUpdatePrsResult( | |
| failed_prs=failed_prs, | |
| body=body, | |
| ) | |
| def run(self) -> int: | |
| """Executes the process-backports subcommand.""" | |
| args = self.args | |
| exit_code = 0 | |
| try: | |
| exit_code = self._run_internal() | |
| except Exception as e: | |
| logger.error("Unexpected error: %s", e) | |
| traceback.print_exc() | |
| exit_code = 1 | |
| if exit_code != 0 and args.triggering_comment: | |
| logger.info( | |
| "Reacting with thumbs-down to comment %s...", | |
| args.triggering_comment, | |
| ) | |
| try: | |
| self.gh.add_comment_reaction( | |
| args.triggering_comment, GH_REACTION_THUMBS_DOWN | |
| ) | |
| except Exception as e: | |
| logger.error("Failed to add reaction to comment: %s", e) | |
| return exit_code | |
| def _run_internal(self) -> int: | |
| """Internal implementation of process-backports.""" | |
| args = self.args | |
| body = self.gh.get_issue_body(args.issue) | |
| if args.add: | |
| items_to_add: list[dict[str, Any]] = [] | |
| for pr_ref in args.add: | |
| try: | |
| pr_num = self.gh.resolve_pr_number(pr_ref) | |
| items_to_add.append({"ref": f"#{pr_num}"}) | |
| except Exception as e: | |
| logger.warning( | |
| "PR ref '%s' is invalid: %s", | |
| pr_ref, | |
| format_exception(e), | |
| ) | |
| items_to_add.append( | |
| { | |
| "ref": pr_ref, | |
| "metadata": {"status": "error-invalid-pr"}, | |
| } | |
| ) | |
| logger.info( | |
| "Adding backports %s to tracking issue #%s...", | |
| items_to_add, | |
| args.issue, | |
| ) | |
| try: | |
| body = add_backports_to_body(body, items_to_add) | |
| for item in items_to_add: | |
| if ( | |
| "metadata" in item | |
| and item["metadata"].get("status") == "error-invalid-pr" | |
| ): | |
| continue | |
| pr_num = int(item["ref"].lstrip("#")) | |
| body = add_sync_changelog_task_to_body(body, pr_num) | |
| state = parse_checklist_state(body) | |
| rc_tags = state.get("rc_tags", {}) | |
| has_pending_rc = any( | |
| not task.checked and task.status != "done" | |
| for task in rc_tags.values() | |
| ) | |
| next_rc_num = max(rc_tags.keys()) + 1 if rc_tags else 0 | |
| if not has_pending_rc: | |
| logger.info( | |
| "No pending RC task found. Adding 'Tag RC%s' to checklist...", | |
| next_rc_num, | |
| ) | |
| body = add_rc_task_to_body(body, next_rc_num) | |
| except ValueError as e: | |
| logger.error("Error: %s", e) | |
| return 1 | |
| if not args.dry_run: | |
| self.gh.update_issue_body(args.issue, body) | |
| logger.info("Successfully updated tracking issue checklist.") | |
| else: | |
| logger.info( | |
| "[DRY RUN] Would update tracking issue checklist with new" | |
| " backports." | |
| ) | |
| if not has_pending_rc: | |
| logger.info( | |
| "[DRY RUN] Would add 'Tag RC%s' to checklist.", | |
| next_rc_num, | |
| ) | |
| items = parse_backports(body) | |
| pending_items = [ | |
| item | |
| for item in items | |
| if not item.checked and not item.status.startswith("error-") | |
| ] | |
| if not pending_items: | |
| logger.info("No pending backports found.") | |
| return 0 | |
| logger.info("Found %d pending backports to process.", len(pending_items)) | |
| # Determine branch name from issue title | |
| issue_title = self.gh.get_issue_title(args.issue) | |
| version_match = RELEASE_TITLE_RE.search(issue_title) | |
| if not version_match: | |
| logger.error("Could not parse version from issue title: %s", issue_title) | |
| return 1 | |
| version = version_match.group(1) | |
| branch_version = ".".join(version.split(".")[:2]) | |
| branch_name = f"release/{branch_version}" | |
| # Determine next RC tag to write to backport metadata | |
| self.git.fetch(args.remote, tags=True, force=True) | |
| latest_rc = get_latest_rc_tag(version, remote=args.remote) | |
| if not latest_rc: | |
| next_rc_suffix = "rc0" | |
| else: | |
| rc_num = int(latest_rc.split("-rc")[-1]) | |
| next_rc_suffix = f"rc{rc_num + 1}" | |
| # Resolve PRs to merge commits using gh helper. | |
| pr_commit_infos = self.gh.get_merge_commits_for_prs(pending_items) | |
| shas, sha_to_item, failed_prs, ignored_prs, body = ( | |
| self._process_pr_commit_infos( | |
| pr_commit_infos, body, args.issue, args.dry_run | |
| ) | |
| ) | |
| if not shas: | |
| logger.info("No valid merge commits to process.") | |
| if failed_prs: | |
| logger.error("Failed PRs:") | |
| for pr in failed_prs: | |
| logger.error("- %s", pr) | |
| return 1 | |
| return 0 | |
| # Verify workspace is clean before proceeding | |
| if self.git.status(): | |
| logger.error( | |
| "Git workspace is dirty. Please commit or stash changes" | |
| " before running backports." | |
| ) | |
| return 1 | |
| # Sort chronologically using git helper | |
| sorted_shas = self.git.sort_commits_chronologically(shas) | |
| self.git.fetch(args.remote) | |
| with self.git.checkout(branch_name, track_remote=args.remote): | |
| start_sha = self.git.get_commit_sha("HEAD") | |
| try: | |
| result = self._cherry_pick_and_update_prs( | |
| sorted_shas, | |
| sha_to_item, | |
| body, | |
| args.issue, | |
| args.remote, | |
| args.dry_run, | |
| version, | |
| branch_name, | |
| next_rc_suffix, | |
| ) | |
| failed_prs.extend(result.failed_prs) | |
| body = result.body | |
| finally: | |
| if args.dry_run: | |
| logger.info( | |
| "[DRY RUN] Resetting branch %s to %s", | |
| branch_name, | |
| start_sha, | |
| ) | |
| self.git.reset_hard(reset_to=start_sha) | |
| if failed_prs: | |
| logger.error("One or more cherry-picks/resolutions failed:") | |
| for pr in failed_prs: | |
| logger.error("- %s", pr) | |
| return 1 | |
| if args.dry_run: | |
| logger.info("Dry run completed successfully. No errors found.") | |
| else: | |
| logger.info("All backports successfully processed!") | |
| return 0 | |
| @classmethod | |
| def add_parser(cls, subparsers): | |
| """Adds parser for process-backports subcommand.""" | |
| parser = subparsers.add_parser( | |
| "process-backports", | |
| help="Cherry-pick pending backports listed in the tracking issue.", | |
| ) | |
| parser.add_argument( | |
| "--issue", | |
| type=int, | |
| required=True, | |
| help="The tracking issue number (required).", | |
| ) | |
| parser.add_argument( | |
| "--remote", | |
| type=str, | |
| required=True, | |
| help="The git remote to push changes to (required).", | |
| ) | |
| parser.add_argument( | |
| "--add", | |
| type=parse_pr_list, | |
| help="PR references (numbers, #numbers, or URLs, comma/space separated) to add before processing.", | |
| ) | |
| parser.add_argument( | |
| "--triggering-comment", | |
| type=int, | |
| help="The ID of the comment that triggered this run (optional).", | |
| ) | |
| parser.add_argument( | |
| "--dry-run", | |
| action=argparse.BooleanOptionalAction, | |
| default=True, | |
| help="Perform a dry run (default: True). Use --no-dry-run to actually execute.", | |
| ) | |
| parser.set_defaults(command=cls.run_from_args) | |
| @classmethod | |
| def run_from_args(cls, args): | |
| """Instantiates and runs the command from parsed args.""" | |
| git = Git(".") | |
| gh = GitHub() | |
| return cls(args, git, gh).run() |