blob: 2b2a0aa4585badcbd1e2c3efc1746080fb253b39 [file]
"""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)
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()