| """Subcommand to prepare the release (updates changelog, placeholders).""" | |
| import argparse | |
| import contextlib | |
| import datetime | |
| from dev.release import changelog_news | |
| from dev.release.gh import ( | |
| RELEASE_PREPARED_LABEL, | |
| GitHub, | |
| MultipleTrackingIssuesError, | |
| NoTrackingIssueError, | |
| ) | |
| from dev.release.git import Git | |
| from dev.release.release_issue import ( | |
| load_release_tracking_template, | |
| parse_checklist_state, | |
| update_task_in_body, | |
| ) | |
| from dev.release.utils import ( | |
| determine_next_version, | |
| replace_version_next, | |
| semver_type, | |
| ) | |
| class Prepare: | |
| """Class to prepare the release.""" | |
| def __init__(self, args, git: Git, gh: GitHub): | |
| self.args = args | |
| self.git = git | |
| self.gh = gh | |
| def run(self) -> int: | |
| """Executes the prepare subcommand.""" | |
| args = self.args | |
| print("Fetching upstream to verify fresh release history...") | |
| self.git.fetch(tags=True, force=True) | |
| # Run pre-check: verify there are no local edits | |
| status = self.git.status() | |
| if status: | |
| print( | |
| "Error: Local edits detected. Workspace must be completely clean" | |
| " before running release preparation." | |
| ) | |
| for line in status.splitlines(): | |
| print(f" {line}") | |
| return 1 | |
| print("Pre-check passed: Workspace is clean.") | |
| version = args.version | |
| if version is None: | |
| version = determine_next_version() | |
| print(f"Running preparation pipeline for {version}...") | |
| # 1. Find or create tracking issue (EARLY) | |
| # We do this before any write operations (branch creation, commit, push) | |
| issue_num = args.issue | |
| if not issue_num: | |
| try: | |
| issue_num = self.gh.get_release_tracking_issue(version) | |
| print(f"Tracking issue: #{issue_num}") | |
| except MultipleTrackingIssuesError as e: | |
| print(f"Error: {e}") | |
| return 1 | |
| except NoTrackingIssueError: | |
| # Not found, we need the template | |
| template_content = load_release_tracking_template(version=version) | |
| if args.dry_run: | |
| print( | |
| f"[DRY RUN] No active tracking issue found for" | |
| f" {version}. Would create a new one." | |
| ) | |
| print(f"[DRY RUN] Title: Release {version}\n{template_content}") | |
| issue_num = None # Keep it None for dry-run prints later | |
| else: | |
| print( | |
| f"No active tracking issue found for {version}." | |
| " Creating a new one..." | |
| ) | |
| issue_num = self.gh.create_release_tracking_issue( | |
| version, template_content | |
| ) | |
| print(f"Tracking issue: #{issue_num}") | |
| else: | |
| print(f"Tracking issue: #{issue_num}") | |
| branch_name = f"prepare-{version}" | |
| # 2. Interleaved git and write operations | |
| # --- Branch selection/creation --- | |
| if self.git.branch_exists(branch_name): | |
| if args.dry_run: | |
| print( | |
| f"[DRY RUN] Branch {branch_name} already exists. Would" | |
| " checkout existing branch." | |
| ) | |
| checkout_cm = contextlib.nullcontext() | |
| else: | |
| print(f"Branch {branch_name} already exists. Checking it out...") | |
| checkout_cm = self.git.checkout(branch_name) | |
| else: | |
| if args.dry_run: | |
| print(f"[DRY RUN] Would create and checkout branch {branch_name}") | |
| checkout_cm = contextlib.nullcontext() | |
| else: | |
| checkout_cm = self.git.checkout(branch_name, create_branch=True) | |
| with checkout_cm: | |
| # --- Update files --- | |
| if args.dry_run: | |
| print( | |
| f"[DRY RUN] Would update CHANGELOG.md and version placeholders" | |
| f" for {version}" | |
| ) | |
| else: | |
| print("Updating changelog and placeholders...") | |
| release_date = datetime.date.today().strftime("%Y-%m-%d") | |
| changelog_news.update_changelog(version, release_date) | |
| replace_version_next(version) | |
| # --- Commit and Push --- | |
| if args.dry_run: | |
| print(f"[DRY RUN] Would push branch {branch_name} to origin") | |
| else: | |
| modified_files = self.git.status() | |
| if modified_files: | |
| # Stage all modified and deleted tracked files | |
| self.git.add_modified_and_deleted() | |
| self.git.commit(f"Prepare release {version}") | |
| else: | |
| print("No files modified by the release tool. Nothing to commit.") | |
| print(f"Pushing branch {branch_name} to origin...") | |
| # Force push to overwrite the remote branch if it already | |
| # exists (e.g. from a previous run) | |
| self.git.push("origin", branch_name, set_upstream=True, force=True) | |
| # --- Create PR --- | |
| # Determine if we need to create a PR or reuse an existing one | |
| open_pr = self.gh.get_open_pr(branch_name) | |
| associated_pr = None | |
| if not open_pr and issue_num: | |
| body = self.gh.get_issue_body(issue_num) | |
| state = parse_checklist_state(body) | |
| associated_pr = state["prepare_release"].pr | |
| if open_pr: | |
| pr_num = open_pr["number"] | |
| pr_url = open_pr["url"] | |
| print(f"Open Pull Request already exists: {pr_url} (PR #{pr_num})") | |
| elif associated_pr: | |
| pr_num = associated_pr.lstrip("#") | |
| pr_url = f"https://github.com/bazel-contrib/rules_python/pull/{pr_num}" | |
| print( | |
| f"PR #{pr_num} is already associated in tracking issue" | |
| f" #{issue_num}. Using it." | |
| ) | |
| else: | |
| if args.dry_run: | |
| target_issue = f"#{issue_num}" if issue_num else "<NEW_ISSUE>" | |
| print( | |
| f"[DRY RUN] Would create Pull Request for branch" | |
| f" {branch_name} targeting issue {target_issue}" | |
| ) | |
| pr_num = "<NEW_PR>" | |
| else: | |
| pr_url = self.gh.create_pr( | |
| title=f"Prepare release v{version}", | |
| body=f"Work towards #{issue_num}", | |
| base="main", | |
| labels=[RELEASE_PREPARED_LABEL], | |
| ) | |
| pr_num = pr_url.split("/")[-1] | |
| print(f"Created Pull Request: {pr_url} (PR #{pr_num})") | |
| # --- Update checklist --- | |
| if args.dry_run: | |
| target_issue = f"#{issue_num}" if issue_num else "<NEW_ISSUE>" | |
| print( | |
| f"[DRY RUN] Would update tracking issue {target_issue} checklist" | |
| " 'Prepare Release' task status to PENDING" | |
| ) | |
| else: | |
| print( | |
| f"Updating tracking issue #{issue_num} checklist 'Prepare" | |
| " Release' task status to PENDING..." | |
| ) | |
| body = self.gh.get_issue_body(issue_num) | |
| metadata = {"status": "pending", "pr": f"#{pr_num}"} | |
| updated_body = update_task_in_body( | |
| body, "Prepare Release", checked=False, metadata=metadata | |
| ) | |
| self.gh.update_issue_body(issue_num, updated_body) | |
| print("Preparation pipeline completed successfully!") | |
| return 0 | |
| @classmethod | |
| def add_parser(cls, subparsers): | |
| """Adds parser for prepare subcommand.""" | |
| parser = subparsers.add_parser( | |
| "prepare", | |
| help="Prepare the release (updates changelog, placeholders).", | |
| ) | |
| parser.add_argument( | |
| "version", | |
| nargs="?", | |
| type=semver_type, | |
| help="The new release version (e.g., 0.28.0). If not provided, " | |
| "it will be determined automatically.", | |
| ) | |
| parser.add_argument( | |
| "--issue", | |
| type=int, | |
| help="The tracking issue number (optional, triggers automated branch/PR pipeline).", | |
| ) | |
| 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() |