blob: 229bf904b4ef78bd2800c100c4d8ac7196017db4 [file]
"""Subcommand to create sync PR to main for backports in a release."""
import argparse
import hashlib
import logging
import os
import traceback
from dev.release.gh import (
SYNC_CHANGELOG_LABEL,
GitHub,
GitHubInterface,
get_github_event_issue_number,
)
from dev.release.git import Git
from dev.release.process_news import ProcessNews
from dev.release.release_issue import (
RELEASE_TITLE_RE,
parse_checklist_state,
update_task_in_body,
)
from dev.release.utils import (
format_exception,
parse_pr_list,
)
logger = logging.getLogger(__name__)
SYNC_CHANGELOG_SUCCESS_COMMENT_TEMPLATE = "Sync changelog PR created: {pr_url}"
SYNC_CHANGELOG_FAILURE_COMMENT_TEMPLATE = (
"Warning: Failed to create sync PR to main for backports. {action_url_text}"
)
def _get_workflow_action_url_text(repo: str = "") -> str:
"""Returns a link to the GitHub Actions run if available."""
run_id = os.environ.get("GITHUB_RUN_ID")
server_url = os.environ.get("GITHUB_SERVER_URL", "https://github.com")
repository = (
os.environ.get("GITHUB_REPOSITORY") or repo or "bazel-contrib/rules_python"
)
if run_id:
action_url = f"{server_url}/{repository}/actions/runs/{run_id}"
return f"See [workflow run]({action_url}) for logs."
return "See workflow logs."
class SyncChangelog:
"""Class to sync changelog to main for backports in a release."""
def __init__(self, args, git: Git, gh: GitHubInterface):
self.args = args
self.git = git
self.gh = gh
def run(self) -> int:
"""Executes the sync-changelog subcommand."""
try:
return self._run_internal()
except Exception as e:
logger.error("Unexpected error in sync-changelog: %s", format_exception(e))
traceback.print_exc()
return 1
def _post_failure_comment(self, issue_num: int) -> None:
"""Posts a failure warning message to the tracking issue."""
try:
action_url_text = _get_workflow_action_url_text(self.gh.repo)
comment_body = SYNC_CHANGELOG_FAILURE_COMMENT_TEMPLATE.format(
action_url_text=action_url_text,
)
self.gh.post_issue_comment(issue_num, comment_body)
except Exception as e:
logger.warning(
"Failed to post warning comment to issue #%d: %s",
issue_num,
format_exception(e),
)
def _run_internal(self) -> int:
"""Internal implementation of sync-changelog."""
args = self.args
issue_num = args.issue or get_github_event_issue_number()
if not issue_num:
logger.info(
"No issue specified. Auto-discovering open release tracking issue..."
)
open_issues = self.gh.get_open_tracking_issues()
if len(open_issues) > 1:
logger.error(
"Multiple open release tracking issues found: %s",
[f"#{i['number']}" for i in open_issues],
)
return 1
elif len(open_issues) == 1:
issue_num = open_issues[0]["number"]
logger.info("Discovered release tracking issue #%d", issue_num)
else:
logger.error("No open release tracking issues found.")
return 1
try:
return self._sync_for_issue(issue_num)
except Exception as e:
err_msg = format_exception(e)
logger.error(
"Failed to sync changelog for issue #%d: %s", issue_num, err_msg
)
self._post_failure_comment(issue_num)
raise
def _sync_for_issue(self, issue_num: int) -> int:
args = self.args
body = self.gh.get_issue_body(issue_num)
issue_title = self.gh.get_issue_title(issue_num)
version_match = RELEASE_TITLE_RE.search(issue_title)
if not version_match:
err = f"Could not parse version from issue title: {issue_title}"
logger.error(err)
self._post_failure_comment(issue_num)
return 1
version = version_match.group(1)
if args.prs:
pending_prs = []
for pr_ref in args.prs:
try:
pr_num = self.gh.resolve_pr_number(pr_ref)
pending_prs.append(pr_num)
except Exception as e:
err = f"Failed to resolve PR reference '{pr_ref}': {format_exception(e)}"
logger.error(err)
self._post_failure_comment(issue_num)
return 1
else:
state = parse_checklist_state(body)
sync_tasks = state.get("sync_changelogs", {})
pending_prs = [
pr_num
for pr_num, task in sync_tasks.items()
if not task.checked
and task.status != "done"
and not (task.status or "").startswith("error-")
]
if not pending_prs:
logger.info("No pending sync changelog tasks found.")
return 0
logger.info(
"Found %d pending sync changelog tasks to process: %s",
len(pending_prs),
pending_prs,
)
if self.git.status():
err = "Git workspace is dirty. Please commit or stash changes before running sync-changelog."
logger.error(err)
self._post_failure_comment(issue_num)
return 1
sorted_prs = sorted(pending_prs)
prs_str = ",".join(str(n) for n in sorted_prs)
prs_hash = hashlib.sha256(prs_str.encode()).hexdigest()[:7]
main_branch = "main"
sync_branch = f"sync-changelog-{version}-{prs_hash}"
self.git.fetch(args.remote, refspec=main_branch)
self.git.checkout(main_branch, track_remote=args.remote)
try:
if self.git.branch_exists(sync_branch):
self.git.checkout(sync_branch)
self.git.reset_hard(reset_to=main_branch)
else:
self.git.checkout(sync_branch, create_branch=True)
# Run ProcessNews to process news files and version markers
process_news_args = argparse.Namespace(
version=version,
targets=[str(pr) for pr in sorted_prs],
release_date=args.release_date,
)
process_news_runner = ProcessNews(process_news_args, gh=self.gh)
ret = process_news_runner.run()
if ret != 0:
err = f"ProcessNews failed for targets: {sorted_prs}"
logger.error(err)
self._post_failure_comment(issue_num)
return 1
if not self.git.status():
logger.info("No changes to sync after running process-news.")
return 0
self.git.add_modified_and_deleted()
self.git.commit(f"chore(release): sync changelog for v{version} backports")
self.git.push(args.remote, sync_branch, set_upstream=True, force=True)
pr_title = f"chore(release): sync changelog for v{version} backports"
pr_body_lines = [
"Updates CHANGELOG.md and removes news files for backports:",
]
for pr_num in sorted_prs:
pr_body_lines.append(f"- #{pr_num}")
pr_body_lines.append("")
pr_body_lines.append(f"Work towards #{issue_num}")
pr_body_lines.append(f"Release-Tracking-Issue: #{issue_num}")
pr_body = "\n".join(pr_body_lines)
logger.info("Creating PR to %s...", main_branch)
pr_url = self.gh.create_pr(
title=pr_title,
body=pr_body,
base=main_branch,
labels=[SYNC_CHANGELOG_LABEL],
)
logger.info("Created PR: %s", pr_url)
pr_num = int(pr_url.split("/")[-1])
try:
logger.info("Enabling auto-merge for PR #%s...", pr_num)
self.gh.enable_auto_merge(pr_num)
except Exception as e:
logger.warning(
"Failed to enable auto-merge on PR #%s: %s",
pr_num,
format_exception(e),
)
try:
logger.info(
"Updating tracking issue #%s checklist with"
" Sync Changelog tasks...",
issue_num,
)
issue_body = self.gh.get_issue_body(issue_num)
for pr in sorted_prs:
task_name = f"Sync Changelog #{pr}"
metadata = {"status": "pending", "pr": f"#{pr_num}"}
issue_body = update_task_in_body(
issue_body,
task_name,
checked=False,
metadata=metadata,
)
self.gh.update_issue_body(issue_num, issue_body)
except Exception as e:
logger.warning(
"Failed to update tracking issue checklist: %s",
format_exception(e),
)
try:
success_body = SYNC_CHANGELOG_SUCCESS_COMMENT_TEMPLATE.format(
pr_url=pr_url,
)
self.gh.post_issue_comment(issue_num, success_body)
except Exception as e:
logger.warning(
"Failed to post success comment to issue #%s: %s",
issue_num,
format_exception(e),
)
finally:
self.git.checkout(main_branch)
return 0
@classmethod
def add_parser(cls, subparsers):
"""Adds parser for sync-changelog subcommand."""
parser = subparsers.add_parser(
"sync-changelog",
help="Create a sync PR to main for backports in a release.",
)
parser.add_argument(
"--issue",
type=int,
help="The tracking issue number (optional; extracted from GITHUB_EVENT_PATH or auto-discovered if omitted).",
)
parser.add_argument(
"--remote",
type=str,
required=True,
help="The git remote to push changes to (required).",
)
parser.add_argument(
"--prs",
type=parse_pr_list,
help=(
"PR references (numbers, #numbers, or URLs, comma/space"
" separated) to sync (optional)."
),
)
parser.add_argument(
"--release-date",
type=str,
default=None,
help=(
"Release date (YYYY-MM-DD) to use if creating a new version"
" section in CHANGELOG.md."
),
)
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()