diff --git a/tests/tools/private/release/release_test.py b/tests/tools/private/release/release_test.py index d06b9097a9..1307f5e78b 100644 --- a/tests/tools/private/release/release_test.py +++ b/tests/tools/private/release/release_test.py @@ -1,3 +1,4 @@ +import datetime import os import pathlib import shutil @@ -5,7 +6,7 @@ import unittest from unittest.mock import MagicMock, call, patch -from tools.private.release import changelog_news, release as releaser, utils +from tools.private.release import changelog_news, git, release as releaser, utils from tools.private.release.gh import MultipleTrackingIssuesError, NoTrackingIssueError @@ -20,12 +21,14 @@ def _mock_git_and_gh(test_case): patch("tools.private.release.prepare.git", new=mock_git).start() patch("tools.private.release.create_release_branch.git", new=mock_git).start() patch("tools.private.release.create_rc.git", new=mock_git).start() + patch("tools.private.release.process_backports.git", new=mock_git).start() patch("tools.private.release.utils.git", new=mock_git).start() patch("tools.private.release.release.gh", new=mock_gh).start() patch("tools.private.release.prepare.gh", new=mock_gh).start() patch("tools.private.release.create_release_branch.gh", new=mock_gh).start() patch("tools.private.release.create_rc.gh", new=mock_gh).start() + patch("tools.private.release.process_backports.gh", new=mock_gh).start() mock_gh.MultipleTrackingIssuesError = MultipleTrackingIssuesError mock_gh.NoTrackingIssueError = NoTrackingIssueError @@ -984,7 +987,19 @@ def test_create_rc_success_first_rc(self): comment_call_args[1], ) self.assertIn( - "- Trigger Release Workflow: [Release Workflow](https://github.com/bazel-contrib/rules_python/actions/workflows/release.yml)", + "- [Github Release 2.0.0-rc0](https://github.com/bazel-contrib/rules_python/releases/tag/2.0.0-rc0)", + comment_call_args[1], + ) + self.assertIn( + "- BCR Entry: [rules_python@2.0.0](https://registry.bazel.build/modules/rules_python/2.0.0)", + comment_call_args[1], + ) + self.assertIn( + "- [BCR PRs](https://github.com/bazelbuild/bazel-central-registry/pulls?q=is%3Apr+rules_python+2.0.0)", + comment_call_args[1], + ) + self.assertIn( + "- [Release workflow status](https://github.com/bazel-contrib/rules_python/actions/workflows/release.yml)", comment_call_args[1], ) self.assertNotIn("🚀", comment_call_args[1]) @@ -1029,11 +1044,68 @@ def test_create_rc_success_next_rc(self): comment_call_args[1], ) self.assertIn( - "- Trigger Release Workflow: [Release Workflow](https://github.com/bazel-contrib/rules_python/actions/workflows/release.yml)", + "- [Github Release 2.0.0-rc1](https://github.com/bazel-contrib/rules_python/releases/tag/2.0.0-rc1)", + comment_call_args[1], + ) + self.assertIn( + "- BCR Entry: [rules_python@2.0.0](https://registry.bazel.build/modules/rules_python/2.0.0)", + comment_call_args[1], + ) + self.assertIn( + "- [BCR PRs](https://github.com/bazelbuild/bazel-central-registry/pulls?q=is%3Apr+rules_python+2.0.0)", + comment_call_args[1], + ) + self.assertIn( + "- [Release workflow status](https://github.com/bazel-contrib/rules_python/actions/workflows/release.yml)", comment_call_args[1], ) self.assertNotIn("🚀", comment_call_args[1]) + def test_create_rc_gating_on_backports(self): + # Arrange + args = MagicMock(issue=123, remote="my-remote") + self.mock_gh.get_issue_title.return_value = "Release 2.0.0" + self.mock_gh.get_issue_body.return_value = """ +## Checklist +- [x] Prepare Release | status=done pr=#122 commit=abcdef12 +- [x] Create Release branch | status=done branch=release/2.0 commit=abcdef12 +- [ ] Tag RC0 | status=pending + +## Backports +- [ ] #124 | status=pending +""" + # Act + result = releaser.cmd_create_rc(args) + + # Assert + self.assertEqual(result, 1) + self.mock_git.tag.assert_not_called() + self.mock_git.push.assert_not_called() + + def test_create_rc_with_finished_backports(self): + # Arrange + args = MagicMock(issue=123, remote="my-remote") + self.mock_gh.get_issue_title.return_value = "Release 2.0.0" + self.mock_gh.get_issue_body.return_value = """ +## Checklist +- [x] Prepare Release | status=done pr=#122 commit=abcdef12 +- [x] Create Release branch | status=done branch=release/2.0 commit=abcdef12 +- [ ] Tag RC0 | status=pending + +## Backports +- [x] #124 | status=done rc=rc0 commit=abcdef12 +""" + self.mock_git.get_remote_tags.return_value = [] + self.mock_git.get_commit_sha.return_value = "1234567890" + + # Act + result = releaser.cmd_create_rc(args) + + # Assert + self.assertEqual(result, 0) + self.mock_git.tag.assert_called_once_with("2.0.0-rc0", "my-remote/release/2.0") + self.mock_git.push.assert_called_once_with("my-remote", "2.0.0-rc0") + class CmdPromoteRcTest(unittest.TestCase): def setUp(self): @@ -1384,5 +1456,265 @@ def test_create_release_branch_already_exists_non_ff(self): self.mock_gh.update_issue_body.assert_not_called() +class CmdProcessBackportsTest(unittest.TestCase): + def setUp(self): + _mock_git_and_gh(self) + self.mock_changelog_news = patch( + "tools.private.release.process_backports.changelog_news" + ).start() + self.addCleanup(patch.stopall) + + def test_process_backports_no_pending(self): + args = MagicMock(issue=123, remote="origin", dry_run=False) + self.mock_gh.get_issue_body.return_value = "No backports here" + + result = releaser.cmd_process_backports(args) + + self.assertEqual(result, 0) + self.mock_gh.get_issue_body.assert_called_once_with(123) + self.mock_git.fetch.assert_not_called() + + @patch("tools.private.release.process_backports.datetime") + def test_process_backports_success(self, mock_datetime): + mock_datetime.date.today.return_value = datetime.date(2026, 7, 1) + args = MagicMock(issue=123, remote="origin", dry_run=False) + self.mock_gh.get_issue_title.return_value = "Release 2.0.0" + self.mock_gh.get_issue_body.return_value = """ +## Checklist +- [ ] Prepare Release +- [ ] Create Release branch + +## Backports +- [ ] #124 | status=pending +""" + self.mock_git.get_remote_tags.return_value = [] + + def mock_resolve(items): + for item in items: + if item.pr_ref == "#124": + item.commit = "abcdef12" + item.status = "done" + return items + + self.mock_gh.get_merge_commits_for_prs.side_effect = mock_resolve + + self.mock_git.sort_commits_chronologically.return_value = ["abcdef12"] + self.mock_git.get_commit_sha.return_value = "12345678" + self.mock_git.get_commit_message.return_value = 'Cherry-pick "fix bug"' + + result = releaser.cmd_process_backports(args) + + self.assertEqual(result, 0) + self.mock_git.fetch.assert_has_calls( + [call("origin", tags=True, force=True), call("origin")] + ) + self.mock_git.checkout.assert_called_once_with( + "release/2.0", track_remote="origin" + ) + self.mock_git.cherry_pick.assert_called_once_with("abcdef12") + self.mock_changelog_news.update_changelog.assert_called_once_with( + "2.0.0", "2026-07-01" + ) + self.mock_git.add.assert_called_once_with("CHANGELOG.md", "news/") + self.mock_git.commit.assert_called_once_with( + 'Cherry-pick "fix bug"\n\nWork towards #123', amend=True + ) + self.mock_git.push.assert_called_once_with("origin", "release/2.0") + + self.mock_gh.update_issue_body.assert_called_once() + call_args = self.mock_gh.update_issue_body.call_args[0] + self.assertEqual(call_args[0], 123) + self.assertIn("- [x] #124 | status=done rc=rc0 commit=12345678", call_args[1]) + + @patch("tools.private.release.process_backports.datetime") + def test_process_backports_dry_run(self, mock_datetime): + mock_datetime.date.today.return_value = datetime.date(2026, 7, 1) + args = MagicMock(issue=123, remote="origin", dry_run=True) + self.mock_gh.get_issue_title.return_value = "Release 2.0.0" + self.mock_gh.get_issue_body.return_value = """ +## Checklist +- [ ] Prepare Release +- [ ] Create Release branch + +## Backports +- [ ] #124 | status=pending +""" + self.mock_git.get_remote_tags.return_value = [] + + def mock_resolve(items): + for item in items: + if item.pr_ref == "#124": + item.commit = "abcdef12" + item.status = "done" + return items + + self.mock_gh.get_merge_commits_for_prs.side_effect = mock_resolve + + self.mock_git.sort_commits_chronologically.return_value = ["abcdef12"] + self.mock_git.get_commit_sha.return_value = "12345678" + self.mock_git.get_commit_message.return_value = 'Cherry-pick "fix bug"' + + result = releaser.cmd_process_backports(args) + + self.assertEqual(result, 0) + self.mock_git.fetch.assert_has_calls( + [call("origin", tags=True, force=True), call("origin")] + ) + self.mock_git.checkout.assert_called_once_with( + "release/2.0", track_remote="origin" + ) + self.mock_git.cherry_pick.assert_called_once_with("abcdef12") + self.mock_changelog_news.update_changelog.assert_called_once_with( + "2.0.0", "2026-07-01" + ) + self.mock_git.commit.assert_called_once_with( + 'Cherry-pick "fix bug"\n\nWork towards #123', amend=True + ) + self.mock_git.reset_hard.assert_called_once_with("12345678") + self.mock_git.push.assert_not_called() + self.mock_gh.update_issue_body.assert_not_called() + + def test_process_backports_ignored_and_failed_states(self): + args = MagicMock(issue=123, remote="origin", dry_run=False) + self.mock_gh.get_issue_title.return_value = "Release 2.0.0" + self.mock_gh.get_issue_body.return_value = """ +## Checklist +- [ ] Prepare Release +- [ ] Create Release branch + +## Backports +- [ ] #124 | status=pending +- [ ] #125 | status=pending +- [ ] #126 | status=pending +""" + self.mock_git.get_remote_tags.return_value = [] + + def mock_resolve(items): + for item in items: + if item.pr_ref == "#124": + item.status = "open-pr" + elif item.pr_ref == "#125": + item.status = "draft-pr" + elif item.pr_ref == "#126": + item.status = "error-closed-pr" + return items + + self.mock_gh.get_merge_commits_for_prs.side_effect = mock_resolve + + result = releaser.cmd_process_backports(args) + + self.assertEqual(result, 1) + self.mock_gh.update_issue_body.assert_called_once() + call_args = self.mock_gh.update_issue_body.call_args[0] + self.assertEqual(call_args[0], 123) + self.assertIn("- [ ] #126 | status=error-closed-pr", call_args[1]) + self.assertNotIn("status=open-pr", call_args[1]) + self.assertNotIn("status=draft-pr", call_args[1]) + self.mock_git.checkout.assert_not_called() + self.mock_git.cherry_pick.assert_not_called() + + def test_process_backports_ignored_error_status(self): + args = MagicMock(issue=123, remote="origin", dry_run=False) + self.mock_gh.get_issue_title.return_value = "Release 2.0.0" + self.mock_gh.get_issue_body.return_value = """ +## Checklist +- [ ] Prepare Release +- [ ] Create Release branch + +## Backports +- [ ] #124 | status=error-merge-conflict +- [ ] #125 | status=error-some-other-error +""" + self.mock_git.get_remote_tags.return_value = [] + self.mock_gh.get_merge_commits_for_prs.return_value = [] + + result = releaser.cmd_process_backports(args) + + self.assertEqual(result, 0) + self.mock_gh.get_merge_commits_for_prs.assert_not_called() + self.mock_git.checkout.assert_not_called() + + @patch("tools.private.release.process_backports.datetime") + def test_process_backports_cherry_pick_failed(self, mock_datetime): + mock_datetime.date.today.return_value = datetime.date(2026, 7, 1) + args = MagicMock(issue=123, remote="origin", dry_run=False) + self.mock_gh.get_issue_title.return_value = "Release 2.0.0" + self.mock_gh.get_issue_body.return_value = """ +## Checklist +- [ ] Prepare Release +- [ ] Create Release branch + +## Backports +- [ ] #124 | status=pending +""" + self.mock_git.get_remote_tags.return_value = [] + + def mock_resolve(items): + for item in items: + if item.pr_ref == "#124": + item.commit = "abcdef12" + item.status = "done" + return items + + self.mock_gh.get_merge_commits_for_prs.side_effect = mock_resolve + + self.mock_git.sort_commits_chronologically.return_value = ["abcdef12"] + self.mock_git.cherry_pick.side_effect = Exception("Cherry-pick conflict") + + result = releaser.cmd_process_backports(args) + + self.assertEqual(result, 1) + self.mock_git.checkout.assert_called_once_with( + "release/2.0", track_remote="origin" + ) + self.mock_git.cherry_pick.assert_called_once_with("abcdef12") + self.mock_git.cherry_pick_abort.assert_called_once() + + self.mock_gh.update_issue_body.assert_called_once() + call_args = self.mock_gh.update_issue_body.call_args[0] + self.assertEqual(call_args[0], 123) + self.assertIn("- [ ] #124 | status=error-merge-conflict", call_args[1]) + + self.mock_git.commit.assert_not_called() + self.mock_git.push.assert_not_called() + + +class GitCheckoutTest(unittest.TestCase): + @patch("tools.private.release.git.run_cmd") + def test_checkout_simple(self, mock_run_cmd): + git.checkout("my-branch") + mock_run_cmd.assert_called_once_with( + "git", "checkout", "my-branch", capture_output=False + ) + + @patch("tools.private.release.git.branch_exists") + @patch("tools.private.release.git.run_cmd") + def test_checkout_track_remote_new_branch(self, mock_run_cmd, mock_branch_exists): + mock_branch_exists.return_value = False + + git.checkout("my-branch", track_remote="origin") + + mock_branch_exists.assert_called_once_with("my-branch") + mock_run_cmd.assert_called_once_with( + "git", "checkout", "--track", "origin/my-branch", capture_output=False + ) + + @patch("tools.private.release.git.reset_hard") + @patch("tools.private.release.git.branch_exists") + @patch("tools.private.release.git.run_cmd") + def test_checkout_track_remote_existing_branch( + self, mock_run_cmd, mock_branch_exists, mock_reset_hard + ): + mock_branch_exists.return_value = True + + git.checkout("my-branch", track_remote="origin") + + mock_branch_exists.assert_called_once_with("my-branch") + mock_run_cmd.assert_called_once_with( + "git", "checkout", "my-branch", capture_output=False + ) + mock_reset_hard.assert_called_once_with("origin/my-branch") + + if __name__ == "__main__": unittest.main() diff --git a/tools/private/release/BUILD.bazel b/tools/private/release/BUILD.bazel index ad2e1b4cdc..1ae52cf37a 100644 --- a/tools/private/release/BUILD.bazel +++ b/tools/private/release/BUILD.bazel @@ -15,6 +15,7 @@ py_binary( "gh.py", "git.py", "prepare.py", + "process_backports.py", "release.py", "release_issue.py", "shell.py", diff --git a/tools/private/release/create_rc.py b/tools/private/release/create_rc.py index 5273ca8195..a4593323aa 100644 --- a/tools/private/release/create_rc.py +++ b/tools/private/release/create_rc.py @@ -30,7 +30,7 @@ def cmd_create_rc(args): # Gating: RC tagging is blocked if any backport is unchecked OR does not have status=done backports = parse_backports(body) conflicting_or_pending = [ - b for b in backports if not b["checked"] or b["status"] != "done" + b for b in backports if not b.checked or b.status != "done" ] if conflicting_or_pending: print( @@ -94,15 +94,17 @@ def cmd_create_rc(args): gh.update_issue_body(args.issue, updated_body) tag_url = f"{REPO_URL}/releases/tag/{next_rc}" + bcr_entry_url = f"https://registry.bazel.build/modules/rules_python/{version}" bcr_search_url = f"https://github.com/bazelbuild/bazel-central-registry/pulls?q=is%3Apr+rules_python+{version}" release_workflow_url = f"{REPO_URL}/actions/workflows/release.yml" comment_body = f"""**New Release Candidate Tagged!** 🐍🌿 Release Candidate **{next_rc}** has been successfully generated and tagged on branch `{branch_name}`. -- View Tag: [{next_rc}]({tag_url}) -- Track BCR Progress: [Search BCR Pull Requests]({bcr_search_url}) -- Trigger Release Workflow: [Release Workflow]({release_workflow_url})""" +- [Github Release {next_rc}]({tag_url}) +- BCR Entry: [rules_python@{version}]({bcr_entry_url}) +- [BCR PRs]({bcr_search_url}) +- [Release workflow status]({release_workflow_url})""" gh.post_issue_comment(args.issue, comment_body) print("RC creation completed successfully!") return 0 diff --git a/tools/private/release/gh.py b/tools/private/release/gh.py index da86415650..cec20ca2d6 100644 --- a/tools/private/release/gh.py +++ b/tools/private/release/gh.py @@ -4,6 +4,7 @@ import os import tempfile +from tools.private.release.release_issue import BackportTask from tools.private.release.shell import run_cmd _REPO = "bazel-contrib/rules_python" @@ -179,13 +180,13 @@ def get_open_pr(branch_name): def get_pr_info(pr_num): - """Gets information about a PR, including state, merge commit, and body.""" + """Gets information about a PR, including state, merge commit, body, and draft status.""" output = run_cmd( "gh", "pr", "view", str(pr_num), - "--json=state,mergeCommit,body", + "--json=state,mergeCommit,body,isDraft", ) return json.loads(output) if output else {} @@ -202,30 +203,44 @@ def post_issue_comment(issue_num, comment_body): ) -def resolve_backport_commits(pending_items): +def get_merge_commits_for_prs(pending_items: list[BackportTask]) -> list[BackportTask]: """Resolves PR references in pending backports to their merge commit SHAs. - Marks unmerged PRs or resolution failures with status='unmerged-pr'. + Updates item.status based on PR state if it cannot be resolved. """ resolved_items = [] for item in pending_items: - pr_num = item["pr_ref"].lstrip("#") + pr_num = item.pr_ref.lstrip("#") print(f"Resolving PR #{pr_num} to merge commit...") try: pr_info = get_pr_info(pr_num) - if not pr_info or pr_info.get("state") != "MERGED": - state = pr_info.get("state", "UNKNOWN") - print(f"PR #{pr_num} is not merged (state: {state}). Gating.") - item["status"] = "unmerged-pr" + if not pr_info: + print(f"PR #{pr_num} not found. Gating.") + item.status = "error-not-found" else: - merge_commit = pr_info.get("mergeCommit") - if merge_commit and "oid" in merge_commit: - item["commit"] = merge_commit["oid"] + state = pr_info.get("state") + is_draft = pr_info.get("isDraft", False) + if state == "OPEN" or is_draft: + print( + f"PR #{pr_num} is open or draft (state: {state}, draft: {is_draft}). Ignoring." + ) + item.status = "open-pr" if not is_draft else "draft-pr" + elif state == "CLOSED": + print(f"PR #{pr_num} is closed but not merged. Gating.") + item.status = "error-closed-pr" + elif state == "MERGED": + merge_commit = pr_info.get("mergeCommit") + if merge_commit and "oid" in merge_commit: + item.commit = merge_commit["oid"] + item.status = "resolved" + else: + print(f"PR #{pr_num} has no merge commit SHA. Gating.") + item.status = "error-no-merge-commit" else: - print(f"PR #{pr_num} has no merge commit SHA. Gating.") - item["status"] = "unmerged-pr" + print(f"PR #{pr_num} has unknown state: {state}. Gating.") + item.status = "error-unknown" except Exception as e: print(f"Error resolving PR #{pr_num}: {e}. Gating.") - item["status"] = "unmerged-pr" + item.status = "error-resolution-failed" resolved_items.append(item) return resolved_items diff --git a/tools/private/release/git.py b/tools/private/release/git.py index f7021b6f5c..795710fd8c 100644 --- a/tools/private/release/git.py +++ b/tools/private/release/git.py @@ -11,12 +11,34 @@ def get_tags(): return output.splitlines() if output else [] -def checkout(ref, create_branch=False): - """Checks out a git reference (tag, branch, or commit).""" +def checkout( + ref: str, create_branch: bool = False, track_remote: str | None = None +) -> None: + """Checks out a git reference (tag, branch, or commit). + + Args: + ref: The git reference (tag, branch, or commit) to checkout. + create_branch: If True, creates the branch before checking it out. + track_remote: If specified, checks out the branch tracking this remote's + corresponding branch. + """ + cmd = ["git", "checkout"] if create_branch: - run_cmd("git", "checkout", "-b", ref, capture_output=False) + cmd.append("-b") + + should_reset_hard = False + if track_remote: + if branch_exists(ref): + cmd.append(ref) + should_reset_hard = True + else: + cmd.extend(["--track", f"{track_remote}/{ref}"]) else: - run_cmd("git", "checkout", ref, capture_output=False) + cmd.append(ref) + run_cmd(*cmd, capture_output=False) + + if should_reset_hard: + reset_hard(f"{track_remote}/{ref}") def add(*files): @@ -75,8 +97,12 @@ def tag(tag_name, commit_ref): run_cmd("git", "tag", tag_name, commit_ref, capture_output=False) -def cherry_pick(sha): - """Cherry-picks a commit using -x to append the original commit info.""" +def cherry_pick(sha: str) -> None: + """Cherry-picks a commit. + + Args: + sha: The commit SHA to cherry-pick. + """ run_cmd("git", "cherry-pick", "-x", sha, capture_output=False) @@ -85,6 +111,15 @@ def cherry_pick_abort(): run_cmd("git", "cherry-pick", "--abort", capture_output=False) +def reset_hard(ref: str = "HEAD") -> None: + """Resets the index and working tree to a specific reference. + + Args: + ref: The git reference to reset to. Defaults to 'HEAD'. + """ + run_cmd("git", "reset", "--hard", ref, capture_output=False) + + def status(): """Returns the output of git status --porcelain.""" return run_cmd("git", "status", "--porcelain") @@ -99,6 +134,15 @@ def get_commit_sha(ref="HEAD", short=False): return run_cmd(*cmd) +def get_commit_message(ref: str = "HEAD") -> str: + """Returns the commit message of a given reference. + + Args: + ref: The git reference to get the message from. Defaults to 'HEAD'. + """ + return run_cmd("git", "log", "-1", "--format=%B", ref) + + def branch_exists(branch_name): """Returns True if a local branch exists.""" try: diff --git a/tools/private/release/process_backports.py b/tools/private/release/process_backports.py new file mode 100644 index 0000000000..373e1848ec --- /dev/null +++ b/tools/private/release/process_backports.py @@ -0,0 +1,242 @@ +"""Subcommand to process pending backports.""" + +import datetime +from typing import Any + +from tools.private.release import changelog_news, gh, git +from tools.private.release.release_issue import ( + RELEASE_TITLE_RE, + parse_backports, + update_task_in_body, +) +from tools.private.release.utils import get_latest_rc_tag + + +def _process_pr_commit_infos( + pr_commit_infos, body, issue, dry_run +) -> tuple[list[str], dict[str, Any], list[str], list[str], str]: + shas = [] + sha_to_item = {} + failed_prs = [] + ignored_prs = [] + for item in pr_commit_infos: + if item.commit: + sha = item.commit + sha_to_item[sha] = item + shas.append(sha) + elif item.status in ("open-pr", "draft-pr"): + print(f"PR {item.pr_ref} is open or draft. Ignoring.") + ignored_prs.append(item.pr_ref) + else: + failed_prs.append(item.pr_ref) + status_to_set = item.status or "error-unmerged-pr" + if dry_run: + print( + f"[DRY RUN] Would update tracking issue checklist for unresolved PR {item.pr_ref} to status={status_to_set}" + ) + else: + print( + f"Updating tracking issue checklist for unresolved PR {item.pr_ref}..." + ) + try: + body = update_task_in_body( + body, + item.pr_ref, + checked=False, + metadata={"status": status_to_set}, + ) + gh.update_issue_body(issue, body) + except Exception as e: + print( + f"ERROR: Failed to update tracking issue for unresolved PR {item.pr_ref}: {e}" + ) + return shas, sha_to_item, failed_prs, ignored_prs, body + + +def _cherry_pick_and_update_prs( + sorted_shas, + sha_to_item, + body, + issue, + remote, + dry_run, + version, + branch_name, + next_rc_suffix, +) -> tuple[list[str], str]: + failed_prs = [] + for sha in sorted_shas: + item = sha_to_item[sha] + print(f"Cherry-picking {item.pr_ref} / {sha}...") + try: + git.cherry_pick(sha) + + # Perform news processing (merging news/ files into the changelog) + print(f"Merging news fragments into changelog for PR {item.pr_ref}...") + release_date = datetime.date.today().strftime("%Y-%m-%d") + changelog_news.update_changelog(version, release_date) + + # Stage changelog changes and news/ deletions + git.add("CHANGELOG.md", "news/") + + # Amend cherry-pick commit to include news merging and deletions, + # and reference the release tracking issue. + print(f"Amending cherry-pick commit for PR {item.pr_ref}...") + current_msg = git.get_commit_message("HEAD") + new_msg = f"{current_msg.strip()}\n\nWork towards #{issue}" + git.commit(new_msg, amend=True) + + if not dry_run: + # Push amended commit + git.push(remote, branch_name) + + new_sha = git.get_commit_sha("HEAD", short=True) + metadata = {"status": "done", "rc": next_rc_suffix, "commit": new_sha} + print(f"Updating tracking issue checklist for PR {item.pr_ref}...") + try: + body = update_task_in_body( + body, item.pr_ref, checked=True, metadata=metadata + ) + gh.update_issue_body(issue, body) + except Exception as e: + print( + f"ERROR: Failed to update tracking issue for PR {item.pr_ref}: {e}" + ) + print(f"Success: backported {item.pr_ref} / {sha} to {branch_name}") + else: + print( + f"[DRY RUN] Success: {item.pr_ref} / {sha} can be backported without error." + ) + print( + f"[DRY RUN] Would update tracking issue checklist for PR {item.pr_ref} to status=done" + ) + except Exception as e: + print(f"ERROR: Conflict or error on {sha}: {e}. Aborting.") + try: + git.cherry_pick_abort() + except Exception: + pass + failed_prs.append(item.pr_ref) + + if dry_run: + print( + f"[DRY RUN] Would update tracking issue checklist for failed PR {item.pr_ref} to status=error-merge-conflict" + ) + else: + print( + f"Updating tracking issue checklist for failed PR {item.pr_ref}..." + ) + try: + body = update_task_in_body( + body, + item.pr_ref, + checked=False, + metadata={"status": "error-merge-conflict"}, + ) + gh.update_issue_body(issue, body) + print( + f"Updated back port of {item.pr_ref} to status=error-merge-conflict (unchecked)" + ) + except Exception as e: + print( + f"ERROR: Failed to update tracking issue for failed PR {item.pr_ref}: {e}" + ) + return failed_prs, body + + +def cmd_process_backports(args): + """Executes the process-backports subcommand.""" + body = gh.get_issue_body(args.issue) + items = parse_backports(body) + + pending_items = [ + item + for item in items + if not item.checked and not item.status.startswith("error-") + ] + + if not pending_items: + print("No pending backports found.") + return 0 + + print(f"Found {len(pending_items)} pending backports to process.") + + # Determine branch name from issue title + issue_title = gh.get_issue_title(args.issue) + version_match = RELEASE_TITLE_RE.search(issue_title) + if not version_match: + print(f"Error: Could not parse version from issue title: {issue_title}") + return 1 + + version = version_match.group(1) + branch_version = ".".join(version.split(".")[:2]) + branch_name = f"release/{branch_version}" + + # Determine next RC tag to write to backport metadata + git.fetch(args.remote, tags=True, force=True) + latest_rc = get_latest_rc_tag(version, remote=args.remote) + if not latest_rc: + next_rc_suffix = "rc0" + else: + rc_num = int(latest_rc.split("-rc")[-1]) + next_rc_suffix = f"rc{rc_num + 1}" + + # Resolve PRs to merge commits using gh helper. + pr_commit_infos = gh.get_merge_commits_for_prs(pending_items) + + shas, sha_to_item, failed_prs, ignored_prs, body = _process_pr_commit_infos( + pr_commit_infos, body, args.issue, args.dry_run + ) + + if not shas: + print("No valid merge commits to process.") + if failed_prs: + print("Failed PRs:") + for pr in failed_prs: + print(f"- {pr}") + return 1 + return 0 + + # Verify workspace is clean before proceeding + if git.status(): + print( + "ERROR: Git workspace is dirty. Please commit or stash changes before running backports." + ) + return 1 + + # Sort chronologically using git helper + sorted_shas = git.sort_commits_chronologically(shas) + + git.fetch(args.remote) + git.checkout(branch_name, track_remote=args.remote) + start_sha = git.get_commit_sha("HEAD") + + try: + new_failed_prs, body = _cherry_pick_and_update_prs( + sorted_shas, + sha_to_item, + body, + args.issue, + args.remote, + args.dry_run, + version, + branch_name, + next_rc_suffix, + ) + failed_prs.extend(new_failed_prs) + finally: + if args.dry_run: + print(f"[DRY RUN] Resetting branch {branch_name} to {start_sha}") + git.reset_hard(start_sha) + + if failed_prs: + print("ERROR: One or more cherry-picks/resolutions failed:") + for pr in failed_prs: + print(f"- {pr}") + return 1 + + if args.dry_run: + print("Dry run completed successfully. No errors found.") + else: + print("All backports successfully processed!") + return 0 diff --git a/tools/private/release/release.py b/tools/private/release/release.py index e1ebf8ec2e..a6c5b3f88b 100644 --- a/tools/private/release/release.py +++ b/tools/private/release/release.py @@ -1,19 +1,17 @@ """A tool to perform release steps.""" import argparse -import datetime import os import pathlib import re import sys -from tools.private.release import changelog_news, gh, git +from tools.private.release import gh, git from tools.private.release.create_rc import cmd_create_rc from tools.private.release.create_release_branch import cmd_create_release_branch from tools.private.release.prepare import cmd_prepare +from tools.private.release.process_backports import cmd_process_backports from tools.private.release.release_issue import ( - RELEASE_TITLE_RE, - parse_backports, update_task_in_body, ) from tools.private.release.utils import ( @@ -31,11 +29,6 @@ def _semver_type(value): return value -# ============================================================================== -# Checklist Parser and Formatter (Using new | key=value syntax) -# ============================================================================== - - # ============================================================================== # Subcommand Execution Functions # ============================================================================== @@ -112,128 +105,6 @@ def cmd_complete_prepare(args): return 0 -def cmd_process_backports(args): - """Executes the process-backports subcommand.""" - body = gh.get_issue_body(args.issue) - items = parse_backports(body) - - pending_items = [ - item - for item in items - if not item["checked"] and item["status"] != "merge-conflict" - ] - - if not pending_items: - print("No pending backports found.") - return 0 - - print(f"Found {len(pending_items)} pending backports to process.") - - # Determine branch name from issue title - issue_title = gh.get_issue_title(args.issue) - version_match = RELEASE_TITLE_RE.search(issue_title) - if not version_match: - print(f"Error: Could not parse version from issue title: {issue_title}") - return 1 - - version = version_match.group(1) - branch_version = ".".join(version.split(".")[:2]) - branch_name = f"release/{branch_version}" - - # Determine next RC tag to write to backport metadata - git.fetch("origin", tags=True, force=True) - latest_rc = get_latest_rc_tag(version, remote="origin") - if not latest_rc: - next_rc_suffix = "rc0" - else: - rc_num = int(latest_rc.split("-rc")[-1]) - next_rc_suffix = f"rc{rc_num + 1}" - - # Resolve PRs to merge commits using gh helper - resolved_items = gh.resolve_backport_commits(pending_items) - - shas = [] - sha_to_item = {} - any_failed = False - for item in resolved_items: - if item.get("commit"): - sha = item["commit"] - sha_to_item[sha] = item - shas.append(sha) - else: - any_failed = True - body = update_task_in_body( - body, - item["pr_ref"], - checked=False, - metadata={"status": item.get("status", "failed")}, - ) - gh.update_issue_body(args.issue, body) - - if not shas: - print("No valid merge commits to process.") - if any_failed: - return 1 - return 0 - - # Sort chronologically using git helper - sorted_shas = git.sort_commits_chronologically(shas) - - git.fetch("origin") - git.checkout(branch_name) - - for sha in sorted_shas: - item = sha_to_item[sha] - print(f"Cherry-picking {sha} (PR {item['pr_ref']})...") - try: - git.cherry_pick(sha) - - # Perform news processing (merging news/ files into the changelog) - print(f"Merging news fragments into changelog for PR {item['pr_ref']}...") - release_date = datetime.date.today().strftime("%Y-%m-%d") - changelog_news.update_changelog(version, release_date) - - # Stage changelog changes and news/ deletions - git.add("CHANGELOG.md", "news/") - - # Amend cherry-pick commit to include news merging and deletions - print(f"Amending cherry-pick commit for PR {item['pr_ref']}...") - git.commit("", amend=True, no_edit=True) - - # Push amended commit - git.push("origin", branch_name) - - new_sha = git.get_commit_sha("HEAD", short=True) - metadata = {"status": "done", "rc": next_rc_suffix, "commit": new_sha} - body = update_task_in_body( - body, item["pr_ref"], checked=True, metadata=metadata - ) - gh.update_issue_body(args.issue, body) - print(f"Applied: SUCCESS {new_sha}") - except Exception as e: - print(f"Conflict or error on {sha}: {e}. Aborting.") - try: - git.cherry_pick_abort() - except Exception: - pass - any_failed = True - - body = update_task_in_body( - body, - item["pr_ref"], - checked=False, - metadata={"status": "merge-conflict"}, - ) - gh.update_issue_body(args.issue, body) - print("Updated backport item to status=merge-conflict (unchecked)") - - if any_failed: - print("One or more cherry-picks/resolutions failed.") - return 1 - print("All backports successfully processed!") - return 0 - - def cmd_promote_rc(args): """Executes the promote-rc subcommand (Phase 3).""" # Fetch from upstream to ensure we have the latest tags @@ -411,6 +282,18 @@ def create_parser(): required=True, help="The tracking issue number (required).", ) + process_backports_parser.add_argument( + "--remote", + type=str, + required=True, + help="The git remote to push changes to (required).", + ) + process_backports_parser.add_argument( + "--dry-run", + action=argparse.BooleanOptionalAction, + default=True, + help="Perform a dry run (default: True). Use --no-dry-run to actually execute.", + ) # Subcommand: create-rc create_rc_parser = subparsers.add_parser( diff --git a/tools/private/release/release_issue.py b/tools/private/release/release_issue.py index eb7414e39a..adc0d88086 100644 --- a/tools/private/release/release_issue.py +++ b/tools/private/release/release_issue.py @@ -1,7 +1,43 @@ -"""Helper functions for managing release tracking issues and checklists.""" - import re + +class BackportTask: + """Represents a backport task from the tracking issue checklist.""" + + def __init__( + self, + pr_ref: str, + checked: bool, + status: str, + rc: str | None = None, + commit: str | None = None, + metadata: dict[str, str] | None = None, + ): + """Initializes a BackportTask. + + Args: + pr_ref: The PR reference (e.g. '#123'). + checked: Whether the checklist item is checked. + status: The status of the backport (e.g. 'pending', 'done', + 'error-merge-conflict'). + rc: The release candidate version this PR was backported to. + commit: The cherry-pick commit SHA. + metadata: Raw metadata parsed from the checklist line. + """ + self.pr_ref = pr_ref + self.checked = checked + self.status = status + self.rc = rc + self.commit = commit + self.metadata = metadata or {} + + def __repr__(self): + return ( + f"BackportTask(pr_ref={self.pr_ref!r}, checked={self.checked!r}, " + f"status={self.status!r}, rc={self.rc!r}, commit={self.commit!r})" + ) + + RELEASE_TITLE_RE = re.compile(r"Release (\d+\.\d+\.\d+)", re.IGNORECASE) @@ -147,13 +183,13 @@ def parse_backports(body): parsed = parse_metadata_line(line) if parsed: items.append( - { - "pr_ref": parsed["name"], - "checked": parsed["checked"], - "status": parsed["metadata"].get("status", "PENDING"), - "rc": parsed["metadata"].get("rc"), - "commit": parsed["metadata"].get("commit"), - "metadata": parsed["metadata"], - } + BackportTask( + pr_ref=parsed["name"], + checked=parsed["checked"], + status=parsed["metadata"].get("status", "pending"), + rc=parsed["metadata"].get("rc"), + commit=parsed["metadata"].get("commit"), + metadata=parsed["metadata"], + ) ) return items