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