blob: 21d00b1994e2c8597ed3200b74914ce2307f4fa5 [file]
"""Subcommand to prepare backport tracking issue and verify cherry-picks."""
import argparse
import datetime
import re
from dataclasses import dataclass
from dev.release import changelog_news
from dev.release.gh import BACKPORT_LABEL, GitHub
from dev.release.git import Git
from dev.release.utils import determine_next_version
@dataclass
class BackportMetadata:
pr: str
from_minor: str
to_minor: str
def parse_backport_metadata(body) -> BackportMetadata:
"""Parses backport metadata from issue body."""
pr_match = re.search(r"^\s*\*\s*PR:\s*(\S+)", body, re.MULTILINE | re.IGNORECASE)
from_match = re.search(
r"^\s*\*\s*From version:\s*(\S+)", body, re.MULTILINE | re.IGNORECASE
)
to_match = re.search(
r"^\s*\*\s*To version:\s*(\S+)", body, re.MULTILINE | re.IGNORECASE
)
if not pr_match or not from_match or not to_match:
raise ValueError(
"Missing metadata in issue body. Need PR, From version, and To version."
)
return BackportMetadata(
pr=pr_match.group(1),
from_minor=from_match.group(1),
to_minor=to_match.group(1),
)
def get_target_branches(git, remote, from_minor, to_minor):
"""Identifies target release branches in the given range."""
branches = git.get_remote_branches(remote)
target_branches = []
# Parse version strings to compare (only major and minor)
from_v = [int(x) for x in from_minor.split(".")][:2]
to_v = [int(x) for x in to_minor.split(".")][:2]
for branch in branches:
match = re.match(r"^release/(\d+)\.(\d+)$", branch)
if match:
major = int(match.group(1))
minor = int(match.group(2))
v = [major, minor]
if v >= from_v and v <= to_v:
target_branches.append(branch)
# Sort branches by version
target_branches.sort(key=lambda b: [int(x) for x in b.split("/")[1].split(".")])
return target_branches
def get_latest_release_branch(git, remote):
"""Determines the latest release branch."""
branches = git.get_remote_branches(remote)
release_branches = []
for branch in branches:
match = re.match(r"^release/(\d+)\.(\d+)$", branch)
if match:
release_branches.append(branch)
if not release_branches:
return None
release_branches.sort(key=lambda b: [int(x) for x in b.split("/")[1].split(".")])
return release_branches[-1]
class BackportPrepare:
"""Class to prepare backport tracking issue and verify cherry-picks."""
def __init__(self, args, git: Git, gh: GitHub):
self.args = args
self.git = git
self.gh = gh
def run(self) -> int:
"""Executes the backport-prepare subcommand."""
args = self.args
print("Fetching remote to verify release branches...")
self.git.fetch(args.remote, tags=True, force=True)
issue_num = args.issue
if issue_num:
# Triggered by comment, read from issue
print(f"Reading metadata from issue #{issue_num}...")
body = self.gh.get_issue_body(issue_num)
try:
metadata = parse_backport_metadata(body)
except ValueError as e:
e.add_note(f"Failed to parse backport metadata from issue #{issue_num}")
raise
pr_ref = metadata.pr
from_minor = metadata.from_minor
to_minor = metadata.to_minor
else:
# Triggered manually, use args
pr_ref = args.pr
from_minor = args.from_minor
to_minor = args.to_minor
if not pr_ref or not from_minor:
raise ValueError(
"PR and From version are required if not running from an issue."
)
if not to_minor:
latest_branch = get_latest_release_branch(self.git, args.remote)
if not latest_branch:
raise RuntimeError(
"Could not determine latest release branch and --to-minor was not provided."
)
to_minor = latest_branch.split("/")[1]
print(f"Auto-determined latest minor version: {to_minor}")
# Resolve PR to merge commit
try:
pr_num = self.gh.resolve_pr_number(pr_ref)
print(f"Resolving PR #{pr_num} info...")
pr_info = self.gh.get_pr_info(pr_num)
except Exception as e:
e.add_note(f"Failed to resolve PR info for {pr_ref}")
raise
if pr_info.get("state") != "MERGED":
raise ValueError(
f"PR #{pr_num} is not merged (state: {pr_info.get('state')})."
)
merge_commit = pr_info.get("mergeCommit")
if not merge_commit or "oid" not in merge_commit:
raise ValueError(f"PR #{pr_num} has no merge commit SHA.")
pr_sha = merge_commit["oid"]
print(f"Resolved PR #{pr_num} to merge commit {pr_sha[:8]}")
target_branches = get_target_branches(
self.git, args.remote, from_minor, to_minor
)
if not target_branches:
raise ValueError(
f"No release branches found in range {from_minor} to {to_minor}"
)
print(f"Identified target branches: {target_branches}")
# Verify workspace is clean
if self.git.status():
raise RuntimeError("Workspace is dirty. Aborting.")
current_branch = self.git.get_current_branch()
verify_results = {} # branch -> (success, reason)
version_map = {} # branch -> next_version
try:
for branch in target_branches:
minor_ver = branch.split("/")[1]
print(f"Verifying application on {branch}...")
self.git.checkout(branch, track_remote=args.remote)
# Determine next version
next_version = determine_next_version(branch)
version_map[branch] = next_version
# Verify apply (cherry-pick + news)
try:
self.git.cherry_pick(pr_sha)
# Try news update
try:
release_date = datetime.date.today().strftime("%Y-%m-%d")
changelog_news.update_changelog(next_version, release_date)
verify_results[branch] = (True, "Success")
print(f"Verification successful for {branch}")
except Exception as e:
verify_results[branch] = (
False,
f"Changelog update failed: {e}",
)
print(
f"Verification failed for {branch} (changelog update): {e}"
)
except Exception as e:
verify_results[branch] = (False, f"Cherry-pick failed: {e}")
print(f"Verification failed for {branch} (cherry-pick): {e}")
finally:
# Always abort/reset
try:
self.git.cherry_pick_abort()
except Exception:
pass
self.git.reset_hard(reset_to=f"{args.remote}/{branch}")
finally:
# Restore original branch
if current_branch:
self.git.checkout(current_branch)
# Generate issue content
body_lines = [
f"* PR: #{pr_num}",
f"* From version: {from_minor}",
f"* To version: {to_minor}",
"",
"## Tasks",
"",
]
# Add Verify tasks
for branch in target_branches:
minor_ver = branch.split("/")[1]
success, reason = verify_results[branch]
if success:
body_lines.append(f"- [x] Verify apply {minor_ver} | status=success")
else:
status = "failed-conflict"
if "Changelog" in reason:
status = "failed-changelog"
body_lines.append(f"- [ ] Verify apply {minor_ver} | status={status}")
# Add Release tasks
for branch in target_branches:
next_version = version_map[branch]
body_lines.append(f"- [ ] Track Release {next_version}")
new_body = "\n".join(body_lines)
if issue_num:
if args.dry_run:
print(
f"[DRY RUN] Would update issue #{issue_num} with body:\n{new_body}"
)
else:
self.gh.update_issue_body(issue_num, new_body)
print(f"Successfully updated issue #{issue_num}")
else:
title = f"Backport: #{pr_num}"
if args.dry_run:
print(
f"[DRY RUN] Would create issue with title '{title}' and body:\n{new_body}"
)
else:
new_issue_num = self.gh.create_issue(
title, new_body, labels=[BACKPORT_LABEL]
)
print(f"Created backport tracking issue #{new_issue_num}")
return 0
@classmethod
def add_parser(cls, subparsers):
"""Adds parser for backport-prepare subcommand."""
parser = subparsers.add_parser(
"backport-prepare",
help="Prepare backport tracking issue and verify cherry-picks.",
)
parser.add_argument(
"--issue",
type=int,
help="The backport tracking issue number (if running from an existing issue).",
)
parser.add_argument(
"--pr",
type=str,
help="PR reference to backport (required if not running from issue).",
)
parser.add_argument(
"--from-minor",
type=str,
help="Oldest minor version to target (inclusive) (e.g. 1.7) (required if not running from issue).",
)
parser.add_argument(
"--to-minor",
type=str,
help="Newest minor version to target (inclusive) (optional, defaults to latest).",
)
parser.add_argument(
"--remote",
type=str,
default="origin",
help="The git remote (default: origin).",
)
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()