| """Subcommand to prepare the release (updates changelog, placeholders).""" |
| |
| import argparse |
| 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." |
| ) |
| else: |
| print(f"Branch {branch_name} already exists. Checking it out...") |
| self.git.checkout(branch_name) |
| else: |
| if args.dry_run: |
| print(f"[DRY RUN] Would create and checkout branch {branch_name}") |
| else: |
| self.git.checkout(branch_name, create_branch=True) |
| |
| # --- 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() |