diff --git a/.github/workflows/on_prepare_release_pr_merged.yml b/.github/workflows/release_complete_prepare.yaml similarity index 96% rename from .github/workflows/on_prepare_release_pr_merged.yml rename to .github/workflows/release_complete_prepare.yaml index 8ad4d1055c..6d8bc8fd03 100644 --- a/.github/workflows/on_prepare_release_pr_merged.yml +++ b/.github/workflows/release_complete_prepare.yaml @@ -1,4 +1,4 @@ -name: On PR Merged (Release Prepared) +name: "Release: Complete Prepare" on: pull_request: diff --git a/.github/workflows/generate_rc.yml b/.github/workflows/release_create_rc.yaml similarity index 97% rename from .github/workflows/generate_rc.yml rename to .github/workflows/release_create_rc.yaml index 19a80bb1de..2e22ed1413 100644 --- a/.github/workflows/generate_rc.yml +++ b/.github/workflows/release_create_rc.yaml @@ -1,4 +1,4 @@ -name: Generate RC Tag +name: "Release: Create RC" on: workflow_dispatch: diff --git a/.github/workflows/cut_release_branch.yml b/.github/workflows/release_create_release_branch.yaml similarity index 96% rename from .github/workflows/cut_release_branch.yml rename to .github/workflows/release_create_release_branch.yaml index f054f4cbc8..ea030a7085 100644 --- a/.github/workflows/cut_release_branch.yml +++ b/.github/workflows/release_create_release_branch.yaml @@ -1,4 +1,4 @@ -name: Cut Release Branch +name: "Release: Create Release Branch" on: issues: diff --git a/.github/workflows/prepare_release.yml b/.github/workflows/release_prepare.yaml similarity index 97% rename from .github/workflows/prepare_release.yml rename to .github/workflows/release_prepare.yaml index b080f121eb..5c9a0b8f49 100644 --- a/.github/workflows/prepare_release.yml +++ b/.github/workflows/release_prepare.yaml @@ -1,4 +1,4 @@ -name: Prepare Release +name: "Release: Prepare" on: workflow_dispatch: diff --git a/.github/workflows/process_backports.yml b/.github/workflows/release_process_backports.yaml similarity index 96% rename from .github/workflows/process_backports.yml rename to .github/workflows/release_process_backports.yaml index 4d8055d3c5..ac6ea99d80 100644 --- a/.github/workflows/process_backports.yml +++ b/.github/workflows/release_process_backports.yaml @@ -1,4 +1,4 @@ -name: Process Backports +name: "Release: Process Backports" on: workflow_dispatch: diff --git a/.github/workflows/promote_rc.yml b/.github/workflows/release_promote_rc.yaml similarity index 96% rename from .github/workflows/promote_rc.yml rename to .github/workflows/release_promote_rc.yaml index d5e697f6d7..b668a15338 100644 --- a/.github/workflows/promote_rc.yml +++ b/.github/workflows/release_promote_rc.yaml @@ -1,4 +1,4 @@ -name: Promote RC to Final Release +name: "Release: Promote RC" on: workflow_dispatch: diff --git a/tests/tools/private/release/release_test.py b/tests/tools/private/release/release_test.py index 1307f5e78b..96bb65280f 100644 --- a/tests/tools/private/release/release_test.py +++ b/tests/tools/private/release/release_test.py @@ -6,8 +6,17 @@ import unittest from unittest.mock import MagicMock, call, patch -from tools.private.release import changelog_news, git, release as releaser, utils -from tools.private.release.gh import MultipleTrackingIssuesError, NoTrackingIssueError +from tools.private.release import changelog_news, release as releaser, utils +from tools.private.release.create_rc import CreateRc +from tools.private.release.create_release_branch import CreateReleaseBranch +from tools.private.release.gh import ( + MultipleTrackingIssuesError, + NoTrackingIssueError, +) +from tools.private.release.git import Git +from tools.private.release.prepare import Prepare +from tools.private.release.process_backports import ProcessBackports +from tools.private.release.promote_rc import PromoteRc def _mock_git_and_gh(test_case): @@ -16,19 +25,9 @@ def _mock_git_and_gh(test_case): test_case.mock_git = mock_git test_case.mock_gh = mock_gh - # Patch bindings in modules that import them at module level - patch("tools.private.release.release.git", new=mock_git).start() - 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 Git inside utils.py since it instantiates it locally + patch("tools.private.release.utils.Git", return_value=mock_git).start() + mock_gh.MultipleTrackingIssuesError = MultipleTrackingIssuesError mock_gh.NoTrackingIssueError = NoTrackingIssueError @@ -528,12 +527,12 @@ def test_invalid_version(self): class GetLatestVersionTest(unittest.TestCase): - @patch("tools.private.release.git.get_tags") + @patch("tools.private.release.git.Git.get_tags") def test_get_latest_version_success(self, mock_get_tags): mock_get_tags.return_value = ["0.1.0", "1.0.0", "0.2.0"] self.assertEqual(utils.get_latest_version(), "1.0.0") - @patch("tools.private.release.git.get_tags") + @patch("tools.private.release.git.Git.get_tags") def test_get_latest_version_rc_is_latest(self, mock_get_tags): mock_get_tags.return_value = ["0.1.0", "1.0.0", "1.1.0rc0"] with self.assertRaisesRegex( @@ -541,7 +540,7 @@ def test_get_latest_version_rc_is_latest(self, mock_get_tags): ): utils.get_latest_version() - @patch("tools.private.release.git.get_tags") + @patch("tools.private.release.git.Git.get_tags") def test_get_latest_version_no_tags(self, mock_get_tags): mock_get_tags.return_value = [] with self.assertRaisesRegex( @@ -549,7 +548,7 @@ def test_get_latest_version_no_tags(self, mock_get_tags): ): utils.get_latest_version() - @patch("tools.private.release.git.get_tags") + @patch("tools.private.release.git.Git.get_tags") def test_get_latest_version_no_matching_tags(self, mock_get_tags): mock_get_tags.return_value = ["v1.0", "latest"] with self.assertRaisesRegex( @@ -557,7 +556,7 @@ def test_get_latest_version_no_matching_tags(self, mock_get_tags): ): utils.get_latest_version() - @patch("tools.private.release.git.get_tags") + @patch("tools.private.release.git.Git.get_tags") def test_get_latest_version_only_rc_tags(self, mock_get_tags): mock_get_tags.return_value = ["1.0.0rc0", "1.1.0rc0"] with self.assertRaisesRegex( @@ -567,17 +566,17 @@ def test_get_latest_version_only_rc_tags(self, mock_get_tags): class GetLatestRcTagTest(unittest.TestCase): - @patch("tools.private.release.git.get_tags") + @patch("tools.private.release.git.Git.get_tags") def test_get_latest_rc_tag_no_tags(self, mock_get_tags): mock_get_tags.return_value = [] self.assertIsNone(utils.get_latest_rc_tag("2.0.0")) - @patch("tools.private.release.git.get_tags") + @patch("tools.private.release.git.Git.get_tags") def test_get_latest_rc_tag_no_matching_tags(self, mock_get_tags): mock_get_tags.return_value = ["1.0.0", "2.0.0", "v2.0.0-rc0", "2.1.0-rc0"] self.assertIsNone(utils.get_latest_rc_tag("2.0.0")) - @patch("tools.private.release.git.get_tags") + @patch("tools.private.release.git.Git.get_tags") def test_get_latest_rc_tag_success(self, mock_get_tags): mock_get_tags.return_value = [ "2.0.0-rc0", @@ -587,12 +586,12 @@ def test_get_latest_rc_tag_success(self, mock_get_tags): ] self.assertEqual(utils.get_latest_rc_tag("2.0.0"), "2.0.0-rc2") - @patch("tools.private.release.git.get_tags") + @patch("tools.private.release.git.Git.get_tags") def test_get_latest_rc_tag_ignores_v_prefix(self, mock_get_tags): mock_get_tags.return_value = ["v2.0.0-rc0", "2.0.0-rc1"] self.assertEqual(utils.get_latest_rc_tag("2.0.0"), "2.0.0-rc1") - @patch("tools.private.release.git.get_remote_tags") + @patch("tools.private.release.git.Git.get_remote_tags") def test_get_latest_rc_tag_remote_success(self, mock_get_remote_tags): mock_get_remote_tags.return_value = [ "2.0.0-rc0", @@ -611,7 +610,7 @@ def setUp(self): "tools.private.release.utils.get_latest_version" ).start() self.mock_get_current_branch = patch( - "tools.private.release.git.get_current_branch" + "tools.private.release.git.Git.get_current_branch" ).start() self.mock_get_current_branch.return_value = "main" self.addCleanup(patch.stopall) @@ -657,8 +656,8 @@ def test_both_markers(self): self.assertEqual(next_version, "1.3.0") - @patch("tools.private.release.git.get_current_branch") - @patch("tools.private.release.git.get_tags") + @patch("tools.private.release.git.Git.get_current_branch") + @patch("tools.private.release.git.Git.get_tags") def test_determine_next_version_on_release_branch_with_existing_tags( self, mock_get_tags, mock_get_branch ): @@ -669,8 +668,8 @@ def test_determine_next_version_on_release_branch_with_existing_tags( self.assertEqual(next_version, "0.37.2") - @patch("tools.private.release.git.get_current_branch") - @patch("tools.private.release.git.get_tags") + @patch("tools.private.release.git.Git.get_current_branch") + @patch("tools.private.release.git.Git.get_tags") def test_determine_next_version_on_release_branch_no_tags( self, mock_get_tags, mock_get_branch ): @@ -681,8 +680,8 @@ def test_determine_next_version_on_release_branch_no_tags( self.assertEqual(next_version, "0.38.0") - @patch("tools.private.release.git.get_current_branch") - @patch("tools.private.release.git.get_tags") + @patch("tools.private.release.git.Git.get_current_branch") + @patch("tools.private.release.git.Git.get_tags") def test_determine_next_version_on_release_branch_with_active_rc( self, mock_get_tags, mock_get_branch ): @@ -695,8 +694,8 @@ def test_determine_next_version_on_release_branch_with_active_rc( # Should target 0.37.0, not 0.37.1 self.assertEqual(next_version, "0.37.0") - @patch("tools.private.release.git.get_current_branch") - @patch("tools.private.release.git.get_tags") + @patch("tools.private.release.git.Git.get_current_branch") + @patch("tools.private.release.git.Git.get_tags") def test_determine_next_version_on_release_branch_with_stable_and_active_patch_rc( self, mock_get_tags, mock_get_branch ): @@ -709,7 +708,7 @@ def test_determine_next_version_on_release_branch_with_stable_and_active_patch_r # Should target 0.37.1, not 0.37.2 self.assertEqual(next_version, "0.37.1") - @patch("tools.private.release.git.get_current_branch") + @patch("tools.private.release.git.Git.get_current_branch") def test_determine_next_version_on_main_branch_fallback(self, mock_get_branch): mock_get_branch.return_value = "main" # Should fallback to default behavior (which uses mock_get_latest_version from setUp) @@ -739,7 +738,7 @@ def test_prepare_success_existing_issue(self, mock_replace, mock_changelog): self.mock_gh.get_issue_body.return_value = "- [ ] Prepare Release" # Act - result = releaser.cmd_prepare(args) + result = Prepare(args, self.mock_git, self.mock_gh).run() # Assert self.assertEqual(result, 0) @@ -768,7 +767,7 @@ def test_prepare_success_create_issue(self, mock_replace, mock_changelog): self.mock_gh.get_issue_body.return_value = "- [ ] Prepare Release" # Act - result = releaser.cmd_prepare(args) + result = Prepare(args, self.mock_git, self.mock_gh).run() # Assert self.assertEqual(result, 0) @@ -791,7 +790,7 @@ def test_prepare_ambiguous_issue(self, mock_replace, mock_changelog): ) # Act - result = releaser.cmd_prepare(args) + result = Prepare(args, self.mock_git, self.mock_gh).run() # Assert self.assertEqual(result, 1) @@ -810,7 +809,7 @@ def test_prepare_dry_run(self, mock_replace, mock_changelog): self.mock_gh.get_release_tracking_issue.return_value = 123 # Act - result = releaser.cmd_prepare(args) + result = Prepare(args, self.mock_git, self.mock_gh).run() # Assert self.assertEqual(result, 0) @@ -841,7 +840,7 @@ def test_prepare_use_associated_pr_from_tracking_issue( ) # Act - result = releaser.cmd_prepare(args) + result = Prepare(args, self.mock_git, self.mock_gh).run() # Assert self.assertEqual(result, 0) @@ -871,7 +870,7 @@ def test_prepare_create_pr_when_none_associated(self, mock_replace, mock_changel self.mock_gh.create_pr.return_value = "https://github.com/foo/bar/pull/789" # Act - result = releaser.cmd_prepare(args) + result = Prepare(args, self.mock_git, self.mock_gh).run() # Assert self.assertEqual(result, 0) @@ -902,7 +901,7 @@ def test_prepare_reuse_existing_pr(self, mock_replace, mock_changelog): self.mock_gh.get_issue_body.return_value = "- [ ] Prepare Release" # Act - result = releaser.cmd_prepare(args) + result = Prepare(args, self.mock_git, self.mock_gh).run() # Assert self.assertEqual(result, 0) @@ -933,7 +932,7 @@ def test_prepare_dry_run_no_issue(self, mock_replace, mock_changelog): ) # Act - result = releaser.cmd_prepare(args) + result = Prepare(args, self.mock_git, self.mock_gh).run() # Assert self.assertEqual(result, 0) @@ -961,7 +960,7 @@ def test_create_rc_success_first_rc(self): self.mock_git.get_commit_sha.return_value = "1234567890" # Act - result = releaser.cmd_create_rc(args) + result = CreateRc(args, self.mock_git, self.mock_gh).run() # Assert self.assertEqual(result, 0) @@ -1019,7 +1018,7 @@ def test_create_rc_success_next_rc(self): self.mock_git.get_commit_sha.return_value = "1234567890" # Act - result = releaser.cmd_create_rc(args) + result = CreateRc(args, self.mock_git, self.mock_gh).run() # Assert self.assertEqual(result, 0) @@ -1075,7 +1074,7 @@ def test_create_rc_gating_on_backports(self): - [ ] #124 | status=pending """ # Act - result = releaser.cmd_create_rc(args) + result = CreateRc(args, self.mock_git, self.mock_gh).run() # Assert self.assertEqual(result, 1) @@ -1099,7 +1098,7 @@ def test_create_rc_with_finished_backports(self): self.mock_git.get_commit_sha.return_value = "1234567890" # Act - result = releaser.cmd_create_rc(args) + result = CreateRc(args, self.mock_git, self.mock_gh).run() # Assert self.assertEqual(result, 0) @@ -1121,7 +1120,7 @@ def test_promote_rc_success(self): self.mock_gh.get_issue_body.return_value = initial_body # Act - result = releaser.cmd_promote_rc(args) + result = PromoteRc(args, self.mock_git, self.mock_gh).run() # Assert self.assertEqual(result, 0) @@ -1159,7 +1158,7 @@ def test_promote_rc_resolve_issue_success(self): self.mock_gh.get_issue_body.return_value = initial_body # Act - result = releaser.cmd_promote_rc(args) + result = PromoteRc(args, self.mock_git, self.mock_gh).run() # Assert self.assertEqual(result, 0) @@ -1194,7 +1193,7 @@ def test_promote_rc_defaults_to_determine_next_version(self): self.mock_gh.get_issue_body.return_value = initial_body # Act - result = releaser.cmd_promote_rc(args) + result = PromoteRc(args, self.mock_git, self.mock_gh).run() # Assert self.assertEqual(result, 0) @@ -1230,7 +1229,7 @@ def test_promote_rc_dry_run_success(self): self.mock_gh.get_issue_body.return_value = initial_body # Act - result = releaser.cmd_promote_rc(args) + result = PromoteRc(args, self.mock_git, self.mock_gh).run() # Assert self.assertEqual(result, 0) @@ -1251,7 +1250,7 @@ def test_promote_rc_tag_already_exists(self): self.mock_git.tag_exists.return_value = True # Act - result = releaser.cmd_promote_rc(args) + result = PromoteRc(args, self.mock_git, self.mock_gh).run() # Assert self.assertEqual(result, 1) @@ -1271,7 +1270,7 @@ def test_promote_rc_issue_not_found(self): ) # Act - result = releaser.cmd_promote_rc(args) + result = PromoteRc(args, self.mock_git, self.mock_gh).run() # Assert self.assertEqual(result, 1) @@ -1291,7 +1290,7 @@ def test_promote_rc_issue_malformed(self): self.mock_gh.get_issue_body.return_value = initial_body # Act - result = releaser.cmd_promote_rc(args) + result = PromoteRc(args, self.mock_git, self.mock_gh).run() # Assert self.assertEqual(result, 1) @@ -1307,7 +1306,7 @@ def test_promote_rc_no_rc_found(self): self.mock_git.get_remote_tags.return_value = [] # Act - result = releaser.cmd_promote_rc(args) + result = PromoteRc(args, self.mock_git, self.mock_gh).run() # Assert self.assertEqual(result, 1) @@ -1333,7 +1332,7 @@ def test_create_release_branch_success(self): self.mock_git.remote_branch_exists.return_value = False # Act - result = releaser.cmd_create_release_branch(args) + result = CreateReleaseBranch(args, self.mock_git, self.mock_gh).run() # Assert self.assertEqual(result, 0) @@ -1362,7 +1361,7 @@ def test_create_release_branch_prepare_not_done(self): - [ ] Create Release branch | status=pending """ # Act - result = releaser.cmd_create_release_branch(args) + result = CreateReleaseBranch(args, self.mock_git, self.mock_gh).run() # Assert self.assertEqual(result, 1) @@ -1380,7 +1379,7 @@ def test_create_release_branch_already_checked(self): - [x] Create Release branch | status=done branch=release/2.0 commit=abcdef12 """ # Act - result = releaser.cmd_create_release_branch(args) + result = CreateReleaseBranch(args, self.mock_git, self.mock_gh).run() # Assert self.assertEqual(result, 0) @@ -1401,7 +1400,7 @@ def test_create_release_branch_already_exists_same_commit(self): self.mock_git.get_commit_sha.return_value = "abcdef12" # Act - result = releaser.cmd_create_release_branch(args) + result = CreateReleaseBranch(args, self.mock_git, self.mock_gh).run() # Assert self.assertEqual(result, 0) @@ -1423,7 +1422,7 @@ def test_create_release_branch_already_exists_fast_forward(self): self.mock_git.is_ancestor.return_value = True # Act - result = releaser.cmd_create_release_branch(args) + result = CreateReleaseBranch(args, self.mock_git, self.mock_gh).run() # Assert self.assertEqual(result, 0) @@ -1447,7 +1446,7 @@ def test_create_release_branch_already_exists_non_ff(self): self.mock_git.is_ancestor.return_value = False # Act - result = releaser.cmd_create_release_branch(args) + result = CreateReleaseBranch(args, self.mock_git, self.mock_gh).run() # Assert self.assertEqual(result, 1) @@ -1468,7 +1467,7 @@ 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) + result = ProcessBackports(args, self.mock_git, self.mock_gh).run() self.assertEqual(result, 0) self.mock_gh.get_issue_body.assert_called_once_with(123) @@ -1502,7 +1501,7 @@ def mock_resolve(items): 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) + result = ProcessBackports(args, self.mock_git, self.mock_gh).run() self.assertEqual(result, 0) self.mock_git.fetch.assert_has_calls( @@ -1554,7 +1553,7 @@ def mock_resolve(items): 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) + result = ProcessBackports(args, self.mock_git, self.mock_gh).run() self.assertEqual(result, 0) self.mock_git.fetch.assert_has_calls( @@ -1601,7 +1600,7 @@ def mock_resolve(items): self.mock_gh.get_merge_commits_for_prs.side_effect = mock_resolve - result = releaser.cmd_process_backports(args) + result = ProcessBackports(args, self.mock_git, self.mock_gh).run() self.assertEqual(result, 1) self.mock_gh.update_issue_body.assert_called_once() @@ -1628,7 +1627,7 @@ def test_process_backports_ignored_error_status(self): self.mock_git.get_remote_tags.return_value = [] self.mock_gh.get_merge_commits_for_prs.return_value = [] - result = releaser.cmd_process_backports(args) + result = ProcessBackports(args, self.mock_git, self.mock_gh).run() self.assertEqual(result, 0) self.mock_gh.get_merge_commits_for_prs.assert_not_called() @@ -1661,7 +1660,7 @@ def mock_resolve(items): 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) + result = ProcessBackports(args, self.mock_git, self.mock_gh).run() self.assertEqual(result, 1) self.mock_git.checkout.assert_called_once_with( @@ -1680,38 +1679,41 @@ def mock_resolve(items): 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 + def setUp(self): + self.git = Git(".") + self.patcher = patch.object(self.git, "_run_git") + self.mock_run_git = self.patcher.start() + self.addCleanup(self.patcher.stop) + + def test_checkout_simple(self): + self.git.checkout("my-branch") + self.mock_run_git.assert_called_once_with( + "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): + @patch("tools.private.release.git.Git.branch_exists") + def test_checkout_track_remote_new_branch(self, mock_branch_exists): mock_branch_exists.return_value = False - git.checkout("my-branch", track_remote="origin") + self.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 + self.mock_run_git.assert_called_once_with( + "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") + @patch("tools.private.release.git.Git.reset_hard") + @patch("tools.private.release.git.Git.branch_exists") def test_checkout_track_remote_existing_branch( - self, mock_run_cmd, mock_branch_exists, mock_reset_hard + self, mock_branch_exists, mock_reset_hard ): mock_branch_exists.return_value = True - git.checkout("my-branch", track_remote="origin") + self.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 + self.mock_run_git.assert_called_once_with( + "checkout", "my-branch", capture_output=False ) mock_reset_hard.assert_called_once_with("origin/my-branch") diff --git a/tools/private/release/BUILD.bazel b/tools/private/release/BUILD.bazel index 1ae52cf37a..7ea3d2e535 100644 --- a/tools/private/release/BUILD.bazel +++ b/tools/private/release/BUILD.bazel @@ -10,12 +10,17 @@ py_library( py_binary( name = "release", srcs = [ + "__init__.py", + "complete_prepare.py", "create_rc.py", "create_release_branch.py", + "create_release_issue.py", + "determine_next_version.py", "gh.py", "git.py", "prepare.py", "process_backports.py", + "promote_rc.py", "release.py", "release_issue.py", "shell.py", diff --git a/tools/private/release/__init__.py b/tools/private/release/__init__.py new file mode 100644 index 0000000000..e68f0391f8 --- /dev/null +++ b/tools/private/release/__init__.py @@ -0,0 +1 @@ +"""Release tools package.""" diff --git a/tools/private/release/complete_prepare.py b/tools/private/release/complete_prepare.py new file mode 100644 index 0000000000..7657738aad --- /dev/null +++ b/tools/private/release/complete_prepare.py @@ -0,0 +1,81 @@ +"""Subcommand to mark preparation task as complete.""" + +import re + +from tools.private.release.gh import GitHub +from tools.private.release.release_issue import update_task_in_body + + +class CompletePrepare: + """Class to mark preparation task as complete.""" + + def __init__(self, args, gh: GitHub): + self.args = args + self.gh = gh + + def run(self) -> int: + """Executes the complete-prepare subcommand (Phase 2 PR merged).""" + args = self.args + print(f"Completing preparation for PR #{args.pr}...") + + pr_info = self.gh.get_pr_info(args.pr) + if not pr_info or pr_info.get("state") != "MERGED": + state = pr_info.get("state", "UNKNOWN") + print(f"Error: PR #{args.pr} is not merged yet (state: {state}).") + return 1 + + # Resolve issue number from PR body + pr_body = pr_info.get("body", "") + match = re.search(r"Work towards #(\d+)", pr_body) + if not match: + match = re.search(r"#(\d+)", pr_body) + if not match: + print( + f"Error: Could not determine tracking issue number from PR" + f" #{args.pr} body: {pr_body}" + ) + return 1 + + issue_num = int(match.group(1)) + print(f"Resolved tracking issue #{issue_num} from PR #{args.pr} body.") + + commit_sha = pr_info["mergeCommit"]["oid"] + short_commit = commit_sha[:8] + print( + f"PR #{args.pr} merged at commit {commit_sha}. Updating tracking issue..." + ) + + # Update checklist: mark Prepare Release as done (checked) and set SUCCESS + body = self.gh.get_issue_body(issue_num) + metadata = { + "status": "done", + "pr": f"#{args.pr}", + "commit": short_commit, + } + updated_body = update_task_in_body( + body, "Prepare Release", checked=True, metadata=metadata + ) + self.gh.update_issue_body(issue_num, updated_body) + print("Prepare Release task marked complete successfully!") + return 0 + + @classmethod + def add_parser(cls, subparsers): + """Adds parser for complete-prepare subcommand.""" + parser = subparsers.add_parser( + "complete-prepare", + help="Mark the Prepare Release task as complete in the tracking issue.", + ) + parser.add_argument( + "--pr", + type=int, + required=True, + help="The merged preparation PR number.", + ) + parser.set_defaults(command=cls.run_from_args) + + @classmethod + def run_from_args(cls, args): + """Instantiates and runs the command from parsed args.""" + gh = GitHub() + return cls(args, gh).run() diff --git a/tools/private/release/create_rc.py b/tools/private/release/create_rc.py index a4593323aa..ea4d8d7450 100644 --- a/tools/private/release/create_rc.py +++ b/tools/private/release/create_rc.py @@ -1,6 +1,7 @@ """Subcommand to tag and push the next release candidate.""" -from tools.private.release import gh, git +from tools.private.release.gh import GitHub +from tools.private.release.git import Git from tools.private.release.release_issue import ( RELEASE_TITLE_RE, parse_backports, @@ -13,91 +14,104 @@ ) -def cmd_create_rc(args): - """Executes the create-rc subcommand.""" - body = gh.get_issue_body(args.issue) - state = parse_checklist_state(body) - - if ( - state["prepare_release"]["status"] != "done" - or state["create_branch"]["status"] != "done" - ): - print( - "Error: Preconditions not met (release must be prepared and branch created)." - ) - return 1 - - # 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" - ] - if conflicting_or_pending: - print( - f"Gating RC tagging: {len(conflicting_or_pending)} backports are still" - " unfinished, failed, or in conflict." - ) - return 1 - - # Resolve version and branch - 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 - git.fetch(args.remote) - git.fetch(args.remote, tags=True, force=True) - latest_rc = get_latest_rc_tag(version, remote=args.remote) - - if not latest_rc: - next_rc_num = 0 - next_rc = f"{version}-rc0" - else: - rc_num = int(latest_rc.split("-rc")[-1]) - next_rc_num = rc_num + 1 - next_rc = f"{version}-rc{next_rc_num}" - - # Precheck: next RC number must exist and be unchecked in the checklist - rc_tags = state.get("rc_tags", {}) - if next_rc_num not in rc_tags: - print( - f"Error: Checklist is missing required task 'Tag RC{next_rc_num}'" - f" to cut {version}-rc{next_rc_num}." +class CreateRc: + """Class to tag and push the next release candidate.""" + + def __init__(self, args, git: Git, gh: GitHub): + self.args = args + self.git = git + self.gh = gh + + def run(self) -> int: + """Executes the create-rc subcommand.""" + args = self.args + body = self.gh.get_issue_body(args.issue) + state = parse_checklist_state(body) + + if ( + state["prepare_release"].status != "done" + or state["create_branch"].status != "done" + ): + print( + "Error: Preconditions not met (release must be prepared and" + " branch created)." + ) + return 1 + + # 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" + ] + if conflicting_or_pending: + print( + f"Gating RC tagging: {len(conflicting_or_pending)} backports" + " are still unfinished, failed, or in conflict." + ) + return 1 + + # Resolve version and branch + issue_title = self.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 + self.git.fetch(args.remote) + self.git.fetch(args.remote, tags=True, force=True) + latest_rc = get_latest_rc_tag(version, remote=args.remote) + + if not latest_rc: + next_rc_num = 0 + next_rc = f"{version}-rc0" + else: + rc_num = int(latest_rc.split("-rc")[-1]) + next_rc_num = rc_num + 1 + next_rc = f"{version}-rc{next_rc_num}" + + # Precheck: next RC number must exist and be unchecked in the checklist + rc_tags = state.get("rc_tags", {}) + if next_rc_num not in rc_tags: + print( + f"Error: Checklist is missing required task 'Tag RC{next_rc_num}'" + f" to cut {version}-rc{next_rc_num}." + ) + return 1 + + target_rc_task = rc_tags[next_rc_num] + if target_rc_task.checked or target_rc_task.status == "done": + print( + f"Error: Task 'Tag RC{next_rc_num}' is already marked done in" + " the checklist." + ) + return 1 + + target_ref = f"{args.remote}/{branch_name}" + commit_sha = self.git.get_commit_sha(target_ref) + + print(f"Tagging and pushing next RC: {next_rc}...") + self.git.tag(next_rc, target_ref) + self.git.push(args.remote, next_rc) + + # Check off the appropriate "Tag RC{N}" task in the checklist + print(f"Checking off Tag RC{next_rc_num} task...") + metadata = {"status": "done", "tag": next_rc, "commit": commit_sha[:8]} + task_name = f"Tag RC{next_rc_num}" + updated_body = update_task_in_body( + body, task_name, checked=True, metadata=metadata ) - return 1 - - target_rc_task = rc_tags[next_rc_num] - if target_rc_task["checked"] or target_rc_task["status"] == "done": - print( - f"Error: Task 'Tag RC{next_rc_num}' is already marked done in the checklist." - ) - return 1 - - target_ref = f"{args.remote}/{branch_name}" - commit_sha = git.get_commit_sha(target_ref) + self.gh.update_issue_body(args.issue, updated_body) - print(f"Tagging and pushing next RC: {next_rc}...") - git.tag(next_rc, target_ref) - git.push(args.remote, next_rc) - - # Check off the appropriate "Tag RC{N}" task in the checklist - print(f"Checking off Tag RC{next_rc_num} task...") - metadata = {"status": "done", "tag": next_rc, "commit": commit_sha[:8]} - task_name = f"Tag RC{next_rc_num}" - updated_body = update_task_in_body(body, task_name, checked=True, metadata=metadata) - 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!** 🐍🌿 + 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}`. @@ -105,6 +119,34 @@ def cmd_create_rc(args): - 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 + self.gh.post_issue_comment(args.issue, comment_body) + print("RC creation completed successfully!") + return 0 + + @classmethod + def add_parser(cls, subparsers): + """Adds parser for create-rc subcommand.""" + parser = subparsers.add_parser( + "create-rc", + help="Tags the next RC on the release branch if no backports remain.", + ) + parser.add_argument( + "--issue", + type=int, + required=True, + help="The tracking issue number (required).", + ) + parser.add_argument( + "--remote", + type=str, + required=True, + help="The git remote to push the RC tag to (required).", + ) + 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() diff --git a/tools/private/release/create_release_branch.py b/tools/private/release/create_release_branch.py index 7b12465f4c..7a9d7eec37 100644 --- a/tools/private/release/create_release_branch.py +++ b/tools/private/release/create_release_branch.py @@ -1,6 +1,7 @@ """Subcommand to create a release branch from a merged PR commit.""" -from tools.private.release import gh, git +from tools.private.release.gh import GitHub +from tools.private.release.git import Git from tools.private.release.release_issue import ( RELEASE_TITLE_RE, parse_checklist_state, @@ -9,76 +10,122 @@ from tools.private.release.utils import REPO_URL -def cmd_create_release_branch(args): - """Executes the create-release-branch subcommand.""" - print(f"Evaluating branch creation for tracking issue #{args.issue}...") - body = gh.get_issue_body(args.issue) - state = parse_checklist_state(body) +class CreateReleaseBranch: + """Class to create a release branch from a merged PR commit.""" - if ( - state["prepare_release"]["status"] != "done" - or not state["prepare_release"]["commit"] - ): - print( - "Error: Prepare Release task is not marked 'done' with a valid commit SHA." - ) - return 1 + def __init__(self, args, git: Git, gh: GitHub): + self.args = args + self.git = git + self.gh = gh - if state["create_branch"]["checked"]: - print("Release branch has already been created and checked. Skipping.") - return 0 + def run(self) -> int: + """Executes the create-release-branch subcommand.""" + args = self.args + print(f"Evaluating branch creation for tracking issue #{args.issue}...") + body = self.gh.get_issue_body(args.issue) + state = parse_checklist_state(body) - # Extract version 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 + if ( + state["prepare_release"].status != "done" + or not state["prepare_release"].commit + ): + print( + "Error: Prepare Release task is not marked 'done' with a valid" + " commit SHA." + ) + return 1 - version = version_match.group(1) - branch_version = ".".join(version.split(".")[:2]) - branch_name = f"release/{branch_version}" + if state["create_branch"].checked: + print("Release branch has already been created and checked. Skipping.") + return 0 - commit_sha = state["prepare_release"]["commit"] - print(f"Cutting branch {branch_name} from commit {commit_sha}...") + # Extract version from issue title + issue_title = self.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 - # Create and push branch without affecting local checkout - git.fetch(args.remote) + version = version_match.group(1) + branch_version = ".".join(version.split(".")[:2]) + branch_name = f"release/{branch_version}" - if git.remote_branch_exists(args.remote, branch_name): - remote_ref = f"{args.remote}/{branch_name}" - remote_sha = git.get_commit_sha(remote_ref) - if remote_sha == commit_sha: - print( - f"Branch {branch_name} already exists on {args.remote} and points to {commit_sha}. Skipping push." - ) - elif git.is_ancestor(remote_ref, commit_sha): - print( - f"Branch {branch_name} exists on {args.remote} but can be fast-forwarded to {commit_sha}. Pushing..." - ) - ref_spec = f"{commit_sha}:refs/heads/{branch_name}" - git.push(args.remote, ref_spec) + commit_sha = state["prepare_release"].commit + print(f"Cutting branch {branch_name} from commit {commit_sha}...") + + # Create and push branch without affecting local checkout + self.git.fetch(args.remote) + + if self.git.remote_branch_exists(args.remote, branch_name): + remote_ref = f"{args.remote}/{branch_name}" + remote_sha = self.git.get_commit_sha(remote_ref) + if remote_sha == commit_sha: + print( + f"Branch {branch_name} already exists on {args.remote} and" + f" points to {commit_sha}. Skipping push." + ) + elif self.git.is_ancestor(remote_ref, commit_sha): + print( + f"Branch {branch_name} exists on {args.remote} but can be" + f" fast-forwarded to {commit_sha}. Pushing..." + ) + ref_spec = f"{commit_sha}:refs/heads/{branch_name}" + self.git.push(args.remote, ref_spec) + else: + print( + f"Error: Branch {branch_name} already exists on" + f" {args.remote} at {remote_sha[:8]}, which is not an" + f" ancestor of {commit_sha[:8]}. Cannot fast-forward." + ) + return 1 else: + print(f"Branch {branch_name} does not exist on {args.remote}. Pushing...") + ref_spec = f"{commit_sha}:refs/heads/{branch_name}" + self.git.push(args.remote, ref_spec) print( - f"Error: Branch {branch_name} already exists on {args.remote} at {remote_sha[:8]}, " - f"which is not an ancestor of {commit_sha[:8]}. Cannot fast-forward." + f"Successfully pushed branch {branch_name} pointing to" + f" {commit_sha} to {args.remote}" ) - return 1 - else: - print(f"Branch {branch_name} does not exist on {args.remote}. Pushing...") - ref_spec = f"{commit_sha}:refs/heads/{branch_name}" - git.push(args.remote, ref_spec) - print( - f"Successfully pushed branch {branch_name} pointing to {commit_sha} to {args.remote}" + + # Update tracking issue checklist + print("Updating tracking issue checklist...") + branch_url = f"{REPO_URL}/tree/{branch_name}" + metadata = { + "status": "done", + "branch_url": branch_url, + "commit": commit_sha[:8], + } + updated_body = update_task_in_body( + body, "Create Release branch", checked=True, metadata=metadata + ) + self.gh.update_issue_body(args.issue, updated_body) + print("Create Release branch task marked complete successfully!") + return 0 + + @classmethod + def add_parser(cls, subparsers): + """Adds parser for create-release-branch subcommand.""" + parser = subparsers.add_parser( + "create-release-branch", + help="Create the release branch pointing to the merged PR commit.", + ) + parser.add_argument( + "--issue", + type=int, + required=True, + help="The tracking issue number (required).", + ) + parser.add_argument( + "--remote", + type=str, + required=True, + help="The git remote to create the branch on (required).", ) + parser.set_defaults(command=cls.run_from_args) - # Update tracking issue checklist - print("Updating tracking issue checklist...") - branch_url = f"{REPO_URL}/tree/{branch_name}" - metadata = {"status": "done", "branch_url": branch_url, "commit": commit_sha[:8]} - updated_body = update_task_in_body( - body, "Create Release branch", checked=True, metadata=metadata - ) - gh.update_issue_body(args.issue, updated_body) - print("Create Release branch task marked complete successfully!") - return 0 + @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() diff --git a/tools/private/release/create_release_issue.py b/tools/private/release/create_release_issue.py new file mode 100644 index 0000000000..2d00d7369a --- /dev/null +++ b/tools/private/release/create_release_issue.py @@ -0,0 +1,59 @@ +"""Subcommand to create a release tracking issue.""" + +import pathlib + +from tools.private.release.gh import GitHub +from tools.private.release.utils import determine_next_version, semver_type + + +class CreateReleaseIssue: + """Class to create a release tracking issue.""" + + def __init__(self, args, gh: GitHub): + self.args = args + self.gh = gh + + def run(self) -> int: + """Executes the create-release-issue subcommand.""" + version = self.args.version + if version is None: + version = determine_next_version() + + # Concurrency check + open_issues = self.gh.get_open_tracking_issues() + if open_issues: + print("Error: A release is already in progress. Active tracking issues:") + for issue in open_issues: + print(f"- {issue['title']}: {issue['url']}") + return 1 + + template_path = pathlib.Path( + ".github/ISSUE_TEMPLATE/release_tracking_template.md" + ) + if not template_path.exists(): + raise FileNotFoundError(f"Template file not found at {template_path}") + template_content = template_path.read_text(encoding="utf-8") + + issue_num = self.gh.create_tracking_issue(version, template_content) + print(f"Created tracking issue #{issue_num} for v{version}") + return 0 + + @classmethod + def add_parser(cls, subparsers): + """Adds parser for create-release-issue subcommand.""" + parser = subparsers.add_parser( + "create-release-issue", + help="Search for open releases and create a new tracking issue.", + ) + parser.add_argument( + "--version", + type=semver_type, + help="The release version (e.g., 0.38.0). If not provided, determined automatically.", + ) + parser.set_defaults(command=cls.run_from_args) + + @classmethod + def run_from_args(cls, args): + """Instantiates and runs the command from parsed args.""" + gh = GitHub() + return cls(args, gh).run() diff --git a/tools/private/release/determine_next_version.py b/tools/private/release/determine_next_version.py new file mode 100644 index 0000000000..541daddfe7 --- /dev/null +++ b/tools/private/release/determine_next_version.py @@ -0,0 +1,30 @@ +"""Subcommand to determine the next version.""" + +from tools.private.release.utils import determine_next_version + + +class DetermineNextVersion: + """Class to determine the next version.""" + + def __init__(self, args, git=None, gh=None): + self.args = args + + def run(self) -> int: + """Executes the determine-next-version subcommand.""" + version = determine_next_version() + print(version) + return 0 + + @classmethod + def add_parser(cls, subparsers): + """Adds parser for determine-next-version subcommand.""" + parser = subparsers.add_parser( + "determine-next-version", + help="Determine the next version and print it, without making any changes.", + ) + parser.set_defaults(command=cls.run_from_args) + + @classmethod + def run_from_args(cls, args): + """Instantiates and runs the command from parsed args.""" + return cls(args).run() diff --git a/tools/private/release/gh.py b/tools/private/release/gh.py index cec20ca2d6..8b938aecf6 100644 --- a/tools/private/release/gh.py +++ b/tools/private/release/gh.py @@ -7,9 +7,6 @@ from tools.private.release.release_issue import BackportTask from tools.private.release.shell import run_cmd -_REPO = "bazel-contrib/rules_python" -_LABEL = "type: release" - class MultipleTrackingIssuesError(ValueError): """Raised when multiple open tracking issues are found for a version.""" @@ -23,224 +20,352 @@ class NoTrackingIssueError(ValueError): pass -def list_issues(*, fields, label=None, state=None, search=None): - """Helper to list issues using gh CLI.""" - cmd = ["gh", "issue", "list", f"--repo={_REPO}"] - if label: - cmd.append(f"--label={label}") - if state: - cmd.append(f"--state={state}") - if search: - cmd.append(f"--search={search}") - cmd.append(f"--json={fields}") - - output = run_cmd(*cmd) - return json.loads(output) if output else [] - - -def get_open_tracking_issues(version=None): - """Returns a list of open tracking issues with the 'type: release' label.""" - search = f'"Release {version}" in:title' if version else None - return list_issues( - label=_LABEL, - state="open", - search=search, - fields="number,title,url", - ) - - -def get_release_tracking_issue(version): - """Resolves the tracking issue number for a given version. - - Searches for an open issue with label 'type: release' and 'Release ' in the title. - Raises ValueError if 0 or multiple issues are found. - """ - matching_issues = get_open_tracking_issues(version) - - exact_matches = [] - for issue in matching_issues: - if issue["title"] == f"Release {version}": - exact_matches.append(issue) - - if not exact_matches: - raise NoTrackingIssueError( - f"No open tracking issue found matching 'Release {version}' " - f"in repo {_REPO} with label '{_LABEL}'" +class GitHub: + """GitHub CLI helper class for the release tool.""" + + def __init__(self, repo: str = "bazel-contrib/rules_python"): + """Initializes the GitHub helper. + + Args: + repo: The GitHub repository to operate on. + """ + self.repo = repo + self.label = "type: release" + + def _run_gh( + self, *args: str, check: bool = True, capture_output: bool = True + ) -> str | None: + """Runs a 'gh' command. + + Args: + *args: Arguments for 'gh' (excluding 'gh'). + check: If True, raises CalledProcessError on failure. + capture_output: If True, captures and returns stdout. + + Returns: + The stdout of the command, stripped, or None. + """ + return run_cmd("gh", *args, check=check, capture_output=capture_output) + + def _gh_issue( + self, *args: str, check: bool = True, capture_output: bool = True + ) -> str | None: + """Runs a 'gh issue' command.""" + return self._run_gh( + "issue", + *args, + f"--repo={self.repo}", + check=check, + capture_output=capture_output, + ) + + def _gh_pr( + self, *args: str, check: bool = True, capture_output: bool = True + ) -> str | None: + """Runs a 'gh pr' command.""" + return self._run_gh( + "pr", + *args, + f"--repo={self.repo}", + check=check, + capture_output=capture_output, ) - if len(exact_matches) > 1: - urls = [issue["url"] for issue in exact_matches] - raise MultipleTrackingIssuesError( - f"Multiple open tracking issues found for version {version} " - f"in repo {_REPO} with label '{_LABEL}':\n" + "\n".join(urls) + + def list_issues( + self, + *, + fields: str, + label: str | None = None, + state: str | None = None, + search: str | None = None, + ) -> list[dict]: + """Helper to list issues using gh CLI. + + Args: + fields: Comma-separated list of fields to return. + label: Filter by label. + state: Filter by state (open, closed, all). + search: Search query. + + Returns: + A list of dictionaries representing the issues. + """ + cmd = ["list"] + if label: + cmd.append(f"--label={label}") + if state: + cmd.append(f"--state={state}") + if search: + cmd.append(f"--search={search}") + cmd.append(f"--json={fields}") + + output = self._gh_issue(*cmd) + return json.loads(output) if output else [] + + def get_open_tracking_issues(self, version: str | None = None) -> list[dict]: + """Returns a list of open tracking issues with the 'type: release' label. + + Args: + version: Optional version to filter by. + + Returns: + A list of open tracking issues. + """ + search = f'"Release {version}" in:title' if version else None + return self.list_issues( + label=self.label, + state="open", + search=search, + fields="number,title,url", ) - return exact_matches[0]["number"] + def get_release_tracking_issue(self, version: str) -> int: + """Resolves the tracking issue number for a given version. + + Searches for an open issue with label 'type: release' and 'Release + ' in the title. + + Args: + version: The version to find the tracking issue for. + + Returns: + The tracking issue number. + + Raises: + NoTrackingIssueError: If no open tracking issue is found. + MultipleTrackingIssuesError: If multiple open tracking issues are + found. + """ + matching_issues = self.get_open_tracking_issues(version) + + exact_matches = [] + for issue in matching_issues: + if issue["title"] == f"Release {version}": + exact_matches.append(issue) + + if not exact_matches: + raise NoTrackingIssueError( + f"No open tracking issue found matching 'Release {version}' " + f"in repo {self.repo} with label '{self.label}'" + ) + if len(exact_matches) > 1: + urls = [issue["url"] for issue in exact_matches] + raise MultipleTrackingIssuesError( + f"Multiple open tracking issues found for version {version} " + f"in repo {self.repo} with label '{self.label}':\n" + "\n".join(urls) + ) + + return exact_matches[0]["number"] + + def create_tracking_issue(self, version: str, template_content: str) -> int: + """Creates a new release tracking issue from template content. + + Strips YAML frontmatter if present. + + Args: + version: The version to create the tracking issue for. + template_content: The markdown template content for the issue body. + + Returns: + The created issue number. + """ + # Strip YAML frontmatter if present + issue_body = template_content + if template_content.startswith("---"): + parts = template_content.split("---", 2) + if len(parts) >= 3: + issue_body = parts[2].strip() + + # Write body to a secure temporary file to pass to the CLI + with tempfile.NamedTemporaryFile(mode="w", suffix=".md", delete=False) as f: + f.write(issue_body) + temp_path = f.name + try: + output = self._gh_issue( + "create", + f"--title=Release {version}", + f"--label={self.label}", + f"--body-file={temp_path}", + ) + if not output: + raise RuntimeError("Failed to get issue URL from gh issue create") + issue_url = output.strip() + issue_num = int(issue_url.split("/")[-1]) + return issue_num + finally: + if os.path.exists(temp_path): + os.unlink(temp_path) + + def get_issue_body(self, issue_num: int) -> str: + """Fetches the body of a specific issue. + + Args: + issue_num: The issue number. + + Returns: + The issue body markdown. + """ + output = self._gh_issue( + "view", + str(issue_num), + "--json=body", + "--jq=.body", + ) + return output if output else "" -def create_tracking_issue(version, template_content): - """Creates a new release tracking issue from template content (strips YAML frontmatter).""" - # Strip YAML frontmatter if present - issue_body = template_content - if template_content.startswith("---"): - parts = template_content.split("---", 2) - if len(parts) >= 3: - issue_body = parts[2].strip() + def get_issue_title(self, issue_num: int) -> str: + """Fetches the title of a specific issue. - # Write body to a secure temporary file to pass to the CLI - with tempfile.NamedTemporaryFile(mode="w", suffix=".md", delete=False) as f: - f.write(issue_body) - temp_path = f.name + Args: + issue_num: The issue number. - try: - output = run_cmd( - "gh", - "issue", + Returns: + The issue title. + """ + output = self._gh_issue( + "view", + str(issue_num), + "--json=title", + ) + return json.loads(output)["title"] if output else "" + + def update_issue_body(self, issue_num: int, body: str) -> None: + """Updates the body of a specific issue. + + Args: + issue_num: The issue number. + body: The new issue body markdown. + """ + with tempfile.NamedTemporaryFile(mode="w", suffix=".md", delete=False) as f: + f.write(body) + temp_path = f.name + try: + self._gh_issue( + "edit", + str(issue_num), + f"--body-file={temp_path}", + capture_output=False, + ) + finally: + if os.path.exists(temp_path): + os.unlink(temp_path) + + def create_pr(self, version: str, issue_num: int) -> str: + """Creates a pull request for release preparation. + + Args: + version: The version being prepared. + issue_num: The associated tracking issue number. + + Returns: + The URL of the created PR. + """ + output = self._gh_pr( "create", - f"--title=Release {version}", - f"--label={_LABEL}", - f"--body-file={temp_path}", + f"--title=Prepare release v{version}", + f"--body=Work towards #{issue_num}", + "--base=main", ) - issue_url = output.strip() - issue_num = int(issue_url.split("/")[-1]) - return issue_num - finally: - if os.path.exists(temp_path): - os.unlink(temp_path) - - -def get_issue_body(issue_num): - """Fetches the body of a specific issue.""" - return run_cmd( - "gh", - "issue", - "view", - str(issue_num), - "--json=body", - "--jq=.body", - ) - - -def get_issue_title(issue_num): - """Fetches the title of a specific issue.""" - output = run_cmd( - "gh", - "issue", - "view", - str(issue_num), - "--json=title", - ) - return json.loads(output)["title"] if output else "" - - -def update_issue_body(issue_num, body): - """Updates the body of a specific issue.""" - with tempfile.NamedTemporaryFile(mode="w", suffix=".md", delete=False) as f: - f.write(body) - temp_path = f.name - try: - run_cmd( - "gh", - "issue", - "edit", + return output if output else "" + + def get_open_pr(self, branch_name: str) -> dict | None: + """Returns PR info if an open PR exists for the given branch. + + Args: + branch_name: The head branch name of the PR. + + Returns: + A dictionary with 'number' and 'url' of the PR, or None. + """ + output = self._gh_pr( + "list", + f"--head={branch_name}", + "--state=open", + "--json=number,url", + ) + prs = json.loads(output) if output else [] + return prs[0] if prs else None + + def get_pr_info(self, pr_num: int) -> dict: + """Gets information about a PR. + + Includes state, merge commit, body, and draft status. + + Args: + pr_num: The PR number. + + Returns: + A dictionary containing the PR info. + """ + output = self._gh_pr( + "view", + str(pr_num), + "--json=state,mergeCommit,body,isDraft", + ) + return json.loads(output) if output else {} + + def post_issue_comment(self, issue_num: int, comment_body: str) -> None: + """Posts a comment to a specific issue. + + Args: + issue_num: The issue number. + comment_body: The comment body markdown. + """ + self._gh_issue( + "comment", str(issue_num), - f"--body-file={temp_path}", + f"--body={comment_body}", capture_output=False, ) - finally: - if os.path.exists(temp_path): - os.unlink(temp_path) - - -def create_pr(version, issue_num): - """Creates a pull request for release preparation.""" - return run_cmd( - "gh", - "pr", - "create", - f"--title=Prepare release v{version}", - f"--body=Work towards #{issue_num}", - "--base=main", - ) - - -def get_open_pr(branch_name): - """Returns PR info if an open PR exists for the given branch, else None.""" - cmd = [ - "gh", - "pr", - "list", - f"--repo={_REPO}", - f"--head={branch_name}", - "--state=open", - "--json=number,url", - ] - output = run_cmd(*cmd) - prs = json.loads(output) if output else [] - return prs[0] if prs else None - - -def get_pr_info(pr_num): - """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,isDraft", - ) - return json.loads(output) if output else {} - - -def post_issue_comment(issue_num, comment_body): - """Posts a comment to a specific issue.""" - run_cmd( - "gh", - "issue", - "comment", - str(issue_num), - f"--body={comment_body}", - capture_output=False, - ) - - -def get_merge_commits_for_prs(pending_items: list[BackportTask]) -> list[BackportTask]: - """Resolves PR references in pending backports to their merge commit SHAs. - - 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("#") - print(f"Resolving PR #{pr_num} to merge commit...") - try: - pr_info = get_pr_info(pr_num) - if not pr_info: - print(f"PR #{pr_num} not found. Gating.") - item.status = "error-not-found" - else: - 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" + + def get_merge_commits_for_prs( + self, pending_items: list[BackportTask] + ) -> list[BackportTask]: + """Resolves PR references in pending backports to their merge commit SHAs. + + Updates item.status based on PR state if it cannot be resolved. + + Args: + pending_items: A list of BackportTask items to resolve. + + Returns: + The list of resolved BackportTask items. + """ + resolved_items = [] + for item in pending_items: + pr_num = int(item.pr_ref.lstrip("#")) + print(f"Resolving PR #{pr_num} to merge commit...") + try: + pr_info = self.get_pr_info(pr_num) + if not pr_info: + print(f"PR #{pr_num} not found. Gating.") + item.status = "error-not-found" else: - 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 = "error-resolution-failed" - resolved_items.append(item) - return resolved_items + 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}," + f" 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 unknown state: {state}. Gating.") + item.status = "error-unknown" + except Exception as e: + print(f"Error resolving PR #{pr_num}: {e}. Gating.") + 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 795710fd8c..69edba3565 100644 --- a/tools/private/release/git.py +++ b/tools/private/release/git.py @@ -5,213 +5,344 @@ from tools.private.release.shell import run_cmd -def get_tags(): - """Returns a list of all git tags in the repository.""" - output = run_cmd("git", "tag") - return output.splitlines() if output else [] - - -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. +class Git: + """Git helper class for the release tool. + + Operates on a specific git repository path. """ - cmd = ["git", "checkout"] - if create_branch: - cmd.append("-b") - should_reset_hard = False - if track_remote: - if branch_exists(ref): - cmd.append(ref) - should_reset_hard = True + def __init__(self, repo: str): + """Initializes the Git helper. + + Args: + repo: The path to the git repository. + """ + self._repo = repo + + def _run_git( + self, *args: str, check: bool = True, capture_output: bool = True + ) -> str | None: + """Runs a git command in the repository directory. + + Args: + *args: Arguments passed to the git command. + check: If True, raises CalledProcessError on failure. + capture_output: If True, captures and returns stdout. + + Returns: + The stdout of the command, stripped, or None if capture_output is + False. + """ + return run_cmd( + "git", + *args, + check=check, + capture_output=capture_output, + cwd=self._repo, + ) + + def get_tags(self) -> list[str]: + """Returns a list of all git tags in the repository. + + Returns: + A list of tag names (strings). + """ + output = self._run_git("tag") + return output.splitlines() if output else [] + + def checkout( + self, + 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 = ["checkout"] + if create_branch: + cmd.append("-b") + + should_reset_hard = False + if track_remote: + if self.branch_exists(ref): + cmd.append(ref) + should_reset_hard = True + else: + cmd.extend(["--track", f"{track_remote}/{ref}"]) else: - cmd.extend(["--track", f"{track_remote}/{ref}"]) - else: + cmd.append(ref) + self._run_git(*cmd, capture_output=False) + + if should_reset_hard: + self.reset_hard(f"{track_remote}/{ref}") + + def add(self, *files: str) -> None: + """Stages files for commit. + + Args: + *files: Paths to files to stage. + """ + self._run_git("add", *files, capture_output=False) + + def add_modified_and_deleted(self) -> None: + """Stages all modified and deleted tracked files.""" + self._run_git("add", "--update", capture_output=False) + + def commit(self, message: str, amend: bool = False, no_edit: bool = False) -> None: + """Commits staged changes, optionally amending the previous commit. + + Args: + message: The commit message. + amend: If True, amends the previous commit. + no_edit: If True, uses the existing commit message without editing. + """ + cmd = ["commit"] + if amend: + cmd.append("--amend") + if no_edit: + cmd.append("--no-edit") + if message: + cmd.extend(["-m", message]) + self._run_git(*cmd, capture_output=False) + + def push( + self, + remote: str, + ref: str, + set_upstream: bool = False, + force: bool = False, + ) -> None: + """Pushes a reference to a remote repository. + + Args: + remote: The remote repository name (e.g., 'origin'). + ref: The reference to push (e.g., a branch name). + set_upstream: If True, sets the upstream tracking branch. + force: If True, force pushes the changes. + """ + cmd = ["push"] + if set_upstream: + cmd.append("--set-upstream") + if force: + cmd.append("--force") + cmd.extend([remote, ref]) + self._run_git(*cmd, capture_output=False) + + def fetch( + self, remote: str = "origin", tags: bool = False, force: bool = False + ) -> None: + """Fetches updates from a remote repository. + + Args: + remote: The remote repository name. Defaults to 'origin'. + tags: If True, fetches all tags. + force: If True, force fetches updates. + """ + cmd = ["fetch", remote] + if tags: + cmd.append("--tags") + if force: + cmd.append("--force") + self._run_git(*cmd, capture_output=False) + + def merge(self, commit_ref: str, ff_only: bool = True) -> None: + """Merges a commit into the current branch. + + Args: + commit_ref: The commit reference to merge. + ff_only: If True, only allows fast-forward merges. + """ + cmd = ["merge", commit_ref] + if ff_only: + cmd.append("--ff-only") + self._run_git(*cmd, capture_output=False) + + def tag(self, tag_name: str, commit_ref: str) -> None: + """Creates a local tag pointing to a specific commit. + + Args: + tag_name: The name of the tag to create. + commit_ref: The commit reference the tag should point to. + """ + self._run_git("tag", tag_name, commit_ref, capture_output=False) + + def cherry_pick(self, sha: str) -> None: + """Cherry-picks a commit. + + Args: + sha: The commit SHA to cherry-pick. + """ + self._run_git("cherry-pick", "-x", sha, capture_output=False) + + def cherry_pick_abort(self) -> None: + """Aborts an in-progress cherry-pick operation.""" + self._run_git("cherry-pick", "--abort", capture_output=False) + + def reset_hard(self, 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'. + """ + self._run_git("reset", "--hard", ref, capture_output=False) + + def status(self) -> str: + """Returns the output of git status --porcelain. + + Returns: + The porcelain status output. + """ + output = self._run_git("status", "--porcelain") + return output if output else "" + + def get_commit_sha(self, ref: str = "HEAD", short: bool = False) -> str: + """Returns the commit SHA of a given reference. + + Args: + ref: The git reference. Defaults to 'HEAD'. + short: If True, returns a short SHA. + + Returns: + The commit SHA. + """ + cmd = ["rev-parse"] + if short: + cmd.append("--short") cmd.append(ref) - run_cmd(*cmd, capture_output=False) - - if should_reset_hard: - reset_hard(f"{track_remote}/{ref}") - - -def add(*files): - """Stages files for commit.""" - run_cmd("git", "add", *files, capture_output=False) - - -def add_modified_and_deleted(): - """Stages all modified and deleted tracked files.""" - run_cmd("git", "add", "--update", capture_output=False) - - -def commit(message, amend=False, no_edit=False): - """Commits staged changes, optionally amending the previous commit.""" - cmd = ["git", "commit"] - if amend: - cmd.append("--amend") - if no_edit: - cmd.append("--no-edit") - if message: - cmd.extend(["-m", message]) - run_cmd(*cmd, capture_output=False) - - -def push(remote, ref, set_upstream=False, force=False): - """Pushes a reference to a remote repository.""" - cmd = ["git", "push"] - if set_upstream: - cmd.append("--set-upstream") - if force: - cmd.append("--force") - cmd.extend([remote, ref]) - run_cmd(*cmd, capture_output=False) - - -def fetch(remote="origin", tags=False, force=False): - """Fetches updates from a remote repository.""" - cmd = ["git", "fetch", remote] - if tags: - cmd.append("--tags") - if force: - cmd.append("--force") - run_cmd(*cmd, capture_output=False) - - -def merge(commit_ref, ff_only=True): - """Merges a commit into the current branch.""" - cmd = ["git", "merge", commit_ref] - if ff_only: - cmd.append("--ff-only") - run_cmd(*cmd, capture_output=False) - - -def tag(tag_name, commit_ref): - """Creates a local tag pointing to a specific commit.""" - run_cmd("git", "tag", tag_name, commit_ref, capture_output=False) - - -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) - - -def cherry_pick_abort(): - """Aborts an in-progress cherry-pick operation.""" - 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") - - -def get_commit_sha(ref="HEAD", short=False): - """Returns the commit SHA of a given reference.""" - cmd = ["git", "rev-parse"] - if short: - cmd.append("--short") - cmd.append(ref) - 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: - run_cmd("git", "show-ref", "--verify", f"refs/heads/{branch_name}") - return True - except subprocess.CalledProcessError: - return False - - -def tag_exists(tag_name): - """Returns True if a local tag exists.""" - try: - run_cmd("git", "show-ref", "--verify", f"refs/tags/{tag_name}") - return True - except subprocess.CalledProcessError: - return False - - -def sort_commits_chronologically(shas): - """Sorts a list of commit SHAs chronologically (oldest first).""" - output = run_cmd("git", "log", "--no-walk", "--reverse", "--format=%H", *shas) - return output.splitlines() if output else [] - - -def get_current_branch(): - """Returns the current git branch name.""" - return run_cmd("git", "rev-parse", "--abbrev-ref", "HEAD") - - -def remote_branch_exists(remote, branch_name): - """Returns True if a remote branch exists.""" - try: - run_cmd("git", "show-ref", "--verify", f"refs/remotes/{remote}/{branch_name}") - return True - except subprocess.CalledProcessError: - return False - - -def is_ancestor(ancestor, descendant): - """Returns True if ancestor is an ancestor of descendant (fast-forwardable).""" - try: - run_cmd("git", "merge-base", "--is-ancestor", ancestor, descendant) - return True - except subprocess.CalledProcessError: - return False - - -def get_remote_tags(remote: str) -> list[str]: - """Returns a list of tags present on the specified remote repository. - - Args: - remote: The name of the git remote to query (e.g., 'origin', 'upstream'). - - Returns: - A list of tag names (strings) found on the remote, excluding peeled tags. - """ - output = run_cmd("git", "ls-remote", "--tags", remote) - tags = [] - for line in output.splitlines(): - if not line: - continue - parts = line.split() - if len(parts) < 2: - continue - ref = parts[1] - if ref.startswith("refs/tags/"): - tag = ref[len("refs/tags/") :] - # Skip peeled tags (e.g. tag^{}) to avoid - # duplicate tag names in the output. - if not tag.endswith("^{}"): - tags.append(tag) - return tags + output = self._run_git(*cmd) + return output if output else "" + + def get_commit_message(self, ref: str = "HEAD") -> str: + """Returns the commit message of a given reference. + + Args: + ref: The git reference. Defaults to 'HEAD'. + + Returns: + The commit message. + """ + output = self._run_git("log", "-1", "--format=%B", ref) + return output if output else "" + + def branch_exists(self, branch_name: str) -> bool: + """Returns True if a local branch exists. + + Args: + branch_name: The name of the branch to check. + + Returns: + True if the branch exists, False otherwise. + """ + try: + self._run_git("show-ref", "--verify", f"refs/heads/{branch_name}") + return True + except subprocess.CalledProcessError: + return False + + def tag_exists(self, tag_name: str) -> bool: + """Returns True if a local tag exists. + + Args: + tag_name: The name of the tag to check. + + Returns: + True if the tag exists, False otherwise. + """ + try: + self._run_git("show-ref", "--verify", f"refs/tags/{tag_name}") + return True + except subprocess.CalledProcessError: + return False + + def sort_commits_chronologically(self, shas: list[str]) -> list[str]: + """Sorts a list of commit SHAs chronologically (oldest first). + + Args: + shas: A list of commit SHAs to sort. + + Returns: + The sorted list of commit SHAs. + """ + output = self._run_git("log", "--no-walk", "--reverse", "--format=%H", *shas) + return output.splitlines() if output else [] + + def get_current_branch(self) -> str: + """Returns the current git branch name. + + Returns: + The current branch name. + """ + output = self._run_git("rev-parse", "--abbrev-ref", "HEAD") + return output if output else "" + + def remote_branch_exists(self, remote: str, branch_name: str) -> bool: + """Returns True if a remote branch exists. + + Args: + remote: The name of the remote. + branch_name: The name of the branch. + + Returns: + True if the remote branch exists, False otherwise. + """ + try: + self._run_git( + "show-ref", + "--verify", + f"refs/remotes/{remote}/{branch_name}", + ) + return True + except subprocess.CalledProcessError: + return False + + def is_ancestor(self, ancestor: str, descendant: str) -> bool: + """Returns True if ancestor is an ancestor of descendant. + + Args: + ancestor: The commit reference that might be an ancestor. + descendant: The commit reference that might be a descendant. + + Returns: + True if ancestor is an ancestor of descendant, False otherwise. + """ + try: + self._run_git("merge-base", "--is-ancestor", ancestor, descendant) + return True + except subprocess.CalledProcessError: + return False + + def get_remote_tags(self, remote: str) -> list[str]: + """Returns a list of tags present on the specified remote repository. + + Args: + remote: The name of the git remote to query (e.g., 'origin', + 'upstream'). + + Returns: + A list of tag names (strings) found on the remote, excluding peeled + tags. + """ + output = self._run_git("ls-remote", "--tags", remote) + tags = [] + if not output: + return tags + for line in output.splitlines(): + if not line: + continue + parts = line.split() + if len(parts) < 2: + continue + ref = parts[1] + if ref.startswith("refs/tags/"): + tag = ref[len("refs/tags/") :] + # Skip peeled tags (e.g. tag^{}) to avoid + # duplicate tag names in the output. + if not tag.endswith("^{}"): + tags.append(tag) + return tags diff --git a/tools/private/release/prepare.py b/tools/private/release/prepare.py index 2d32ee4de0..610f2369e7 100644 --- a/tools/private/release/prepare.py +++ b/tools/private/release/prepare.py @@ -1,7 +1,16 @@ +"""Subcommand to prepare the release (updates changelog, placeholders).""" + +import argparse import datetime import pathlib -from tools.private.release import changelog_news, gh, git +from tools.private.release import changelog_news +from tools.private.release.gh import ( + GitHub, + MultipleTrackingIssuesError, + NoTrackingIssueError, +) +from tools.private.release.git import Git from tools.private.release.release_issue import ( parse_checklist_state, update_task_in_body, @@ -9,161 +18,215 @@ from tools.private.release.utils import ( determine_next_version, replace_version_next, + semver_type, ) -def cmd_prepare(args): - """Executes the prepare subcommand.""" - print("Fetching upstream to verify fresh release history...") - git.fetch(tags=True, force=True) +class Prepare: + """Class to prepare the release.""" - # Run pre-check: verify there are no local edits - status = 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.") + def __init__(self, args, git: Git, gh: GitHub): + self.args = args + self.git = git + self.gh = gh - version = args.version - if version is None: - version = determine_next_version() + 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) - print(f"Running preparation pipeline for {version}...") + # 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.") - # 1. Find or create tracking issue (EARLY) - # We do this before any write operations (branch creation, commit, push) - issue_num = args.issue + version = args.version + if version is None: + version = determine_next_version() - if not issue_num: - try: - issue_num = gh.get_release_tracking_issue(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_path = pathlib.Path( + ".github/ISSUE_TEMPLATE/release_tracking_template.md" + ) + if not template_path.exists(): + raise FileNotFoundError( + f"Template file not found at {template_path}" + ) + template_content = template_path.read_text(encoding="utf-8") + + 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_tracking_issue(version, template_content) + print(f"Tracking issue: #{issue_num}") + else: print(f"Tracking issue: #{issue_num}") - except gh.MultipleTrackingIssuesError as e: - print(f"Error: {e}") - return 1 - except gh.NoTrackingIssueError: - # Not found, we need the template - template_path = pathlib.Path( - ".github/ISSUE_TEMPLATE/release_tracking_template.md" - ) - if not template_path.exists(): - raise FileNotFoundError(f"Template file not found at {template_path}") - template_content = template_path.read_text(encoding="utf-8") + 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] No active tracking issue found for {version}. Would create a new one." + f"[DRY RUN] Branch {branch_name} already exists. Would" + " checkout existing branch." ) - 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 = gh.create_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 + 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) - # --- Branch selection/creation --- - if git.branch_exists(branch_name): + # --- Update files --- if args.dry_run: print( - f"[DRY RUN] Branch {branch_name} already exists. Would checkout existing branch." + f"[DRY RUN] Would update CHANGELOG.md and version placeholders" + f" for {version}" ) else: - print(f"Branch {branch_name} already exists. Checking it out...") - git.checkout(branch_name) - 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 create and checkout branch {branch_name}") + print(f"[DRY RUN] Would push branch {branch_name} to origin") else: - git.checkout(branch_name, create_branch=True) - - # --- Update files --- - if args.dry_run: - print( - f"[DRY RUN] Would update CHANGELOG.md and version placeholders 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 = git.status() - if modified_files: - # Stage all modified and deleted tracked files - git.add_modified_and_deleted() - git.commit(f"Prepare release {version}") + 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: - 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) - 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 = gh.get_open_pr(branch_name) - associated_pr = None - - if not open_pr and issue_num: - body = 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 #{issue_num}. Using it." - ) - else: + if args.dry_run: + target_issue = f"#{issue_num}" if issue_num else "" + print( + f"[DRY RUN] Would create Pull Request for branch" + f" {branch_name} targeting issue {target_issue}" + ) + pr_num = "" + else: + pr_url = self.gh.create_pr(version, issue_num) + 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 "" print( - f"[DRY RUN] Would create Pull Request for branch {branch_name} targeting issue {target_issue}" + f"[DRY RUN] Would update tracking issue {target_issue} checklist" + " 'Prepare Release' task status to PENDING" ) - pr_num = "" else: - pr_url = gh.create_pr(version, issue_num) - 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 "" - print( - f"[DRY RUN] Would update tracking issue {target_issue} checklist 'Prepare Release' task status to PENDING" + 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).", ) - else: - print( - f"Updating tracking issue #{issue_num} checklist 'Prepare Release' task status to PENDING..." + 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.", ) - body = 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 + parser.add_argument( + "--issue", + type=int, + help="The tracking issue number (optional, triggers automated branch/PR pipeline).", ) - gh.update_issue_body(issue_num, updated_body) - print("Preparation pipeline completed successfully!") - - return 0 + 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() diff --git a/tools/private/release/process_backports.py b/tools/private/release/process_backports.py index 373e1848ec..21af2d5154 100644 --- a/tools/private/release/process_backports.py +++ b/tools/private/release/process_backports.py @@ -1,9 +1,12 @@ """Subcommand to process pending backports.""" +import argparse import datetime from typing import Any -from tools.private.release import changelog_news, gh, git +from tools.private.release import changelog_news +from tools.private.release.gh import GitHub +from tools.private.release.git import Git from tools.private.release.release_issue import ( RELEASE_TITLE_RE, parse_backports, @@ -12,231 +15,290 @@ 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}" - ) +class ProcessBackports: + """Class to process pending backports.""" + + def __init__(self, args, git: Git, gh: GitHub): + self.args = args + self.git = git + self.gh = gh + + def _process_pr_commit_infos( + self, 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: - 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}, + 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" + f" unresolved PR {item.pr_ref} to status={status_to_set}" ) - gh.update_issue_body(issue, body) - except Exception as e: + else: print( - f"ERROR: Failed to update tracking issue for unresolved PR {item.pr_ref}: {e}" + f"Updating tracking issue checklist for unresolved PR" + f" {item.pr_ref}..." ) - 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 + try: + body = update_task_in_body( + body, + item.pr_ref, + checked=False, + metadata={"status": status_to_set}, + ) + self.gh.update_issue_body(issue, body) + except Exception as e: + print( + f"ERROR: Failed to update tracking issue for" + f" unresolved PR {item.pr_ref}: {e}" + ) + return shas, sha_to_item, failed_prs, ignored_prs, body + + def _cherry_pick_and_update_prs( + self, + 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: + self.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 + self.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 = self.git.get_commit_message("HEAD") + new_msg = f"{current_msg.strip()}\n\nWork towards #{issue}" + self.git.commit(new_msg, amend=True) + + if not dry_run: + # Push amended commit + self.git.push(remote, branch_name) + + new_sha = self.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 + ) + self.gh.update_issue_body(issue, body) + except Exception as e: + print( + f"ERROR: Failed to update tracking issue for PR" + f" {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" + f" backported without error." ) - gh.update_issue_body(issue, body) - except Exception as e: print( - f"ERROR: Failed to update tracking issue for PR {item.pr_ref}: {e}" + f"[DRY RUN] Would update tracking issue checklist for" + f" PR {item.pr_ref} to status=done" ) - 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}..." - ) + except Exception as e: + print(f"ERROR: Conflict or error on {sha}: {e}. Aborting.") try: - body = update_task_in_body( - body, - item.pr_ref, - checked=False, - metadata={"status": "error-merge-conflict"}, - ) - gh.update_issue_body(issue, body) + self.git.cherry_pick_abort() + except Exception: + pass + failed_prs.append(item.pr_ref) + + if dry_run: print( - f"Updated back port of {item.pr_ref} to status=error-merge-conflict (unchecked)" + f"[DRY RUN] Would update tracking issue checklist for" + f" failed PR {item.pr_ref} to status=error-merge-conflict" ) - except Exception as e: + else: print( - f"ERROR: Failed to update tracking issue for failed PR {item.pr_ref}: {e}" + f"Updating tracking issue checklist for failed PR" + f" {item.pr_ref}..." ) - return failed_prs, body + try: + body = update_task_in_body( + body, + item.pr_ref, + checked=False, + metadata={"status": "error-merge-conflict"}, + ) + self.gh.update_issue_body(issue, body) + print( + f"Updated back port of {item.pr_ref} to" + f" status=error-merge-conflict (unchecked)" + ) + except Exception as e: + print( + f"ERROR: Failed to update tracking issue for" + f" failed PR {item.pr_ref}: {e}" + ) + return failed_prs, body + def run(self) -> int: + """Executes the process-backports subcommand.""" + args = self.args + body = self.gh.get_issue_body(args.issue) + items = parse_backports(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-") + ] - 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 - 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 = self.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 + self.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 = self.gh.get_merge_commits_for_prs(pending_items) + + shas, sha_to_item, failed_prs, ignored_prs, body = ( + self._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 self.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 = self.git.sort_commits_chronologically(shas) + + self.git.fetch(args.remote) + self.git.checkout(branch_name, track_remote=args.remote) + start_sha = self.git.get_commit_sha("HEAD") + + try: + new_failed_prs, body = self._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}") + self.git.reset_hard(start_sha) - 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:") + 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 - # Verify workspace is clean before proceeding - if git.status(): - print( - "ERROR: Git workspace is dirty. Please commit or stash changes before running backports." + @classmethod + def add_parser(cls, subparsers): + """Adds parser for process-backports subcommand.""" + parser = subparsers.add_parser( + "process-backports", + help="Cherry-pick pending backports listed in the tracking issue.", ) - 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, + parser.add_argument( + "--issue", + type=int, + required=True, + help="The tracking issue number (required).", ) - 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 + parser.add_argument( + "--remote", + type=str, + required=True, + help="The git remote to push changes to (required).", + ) + 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() diff --git a/tools/private/release/promote_rc.py b/tools/private/release/promote_rc.py new file mode 100644 index 0000000000..877a65b485 --- /dev/null +++ b/tools/private/release/promote_rc.py @@ -0,0 +1,143 @@ +"""Subcommand to promote a release candidate to final release.""" + +import argparse +import urllib.parse + +from tools.private.release.gh import GitHub +from tools.private.release.git import Git +from tools.private.release.release_issue import update_task_in_body +from tools.private.release.utils import ( + REPO_URL, + determine_next_version, + get_latest_rc_tag, + semver_type, +) + + +class PromoteRc: + """Class to promote a release candidate to final release.""" + + def __init__(self, args, git: Git, gh: GitHub): + self.args = args + self.git = git + self.gh = gh + + def run(self) -> int: + """Executes the promote-rc subcommand (Phase 3).""" + args = self.args + # Fetch from upstream to ensure we have the latest tags + self.git.fetch("upstream", tags=True, force=True) + + version = args.version + if version is None: + version = determine_next_version() + + latest_rc = get_latest_rc_tag(version, remote="upstream") + if not latest_rc: + print(f"Error: No release candidate tags found matching {version}-rc*") + return 1 + + # Verify final tag doesn't already exist + if self.git.tag_exists(version): + print(f"Error: Final tag {version} already exists.") + return 1 + + # Verify issue can be found + issue_num = args.issue + if not issue_num: + try: + issue_num = self.gh.get_release_tracking_issue(version) + except ValueError as e: + print(f"Error: {e}") + return 1 + except Exception as e: + print(f"Error: Unexpected error finding tracking issue: {e}") + return 1 + + # Get commit SHA of the RC tag (which will be the same for the final tag) + commit_sha = self.git.get_commit_sha(latest_rc) + + # Verify issue is in the right format by trying to prepare the update + print(f"Verifying tracking issue #{issue_num} format...") + body = self.gh.get_issue_body(issue_num) + metadata = {"status": "done", "tag": version, "commit": commit_sha[:8]} + try: + updated_body = update_task_in_body( + body, "Tag Final", checked=True, metadata=metadata + ) + except ValueError as e: + print(f"Error: Tracking issue #{issue_num} is malformed: {e}") + return 1 + + # All pre-conditions met, perform modifications + if args.dry_run: + print( + f"[DRY RUN] Pre-conditions passed successfully for promoting" + f" {latest_rc} to {version}." + ) + print(f"[DRY RUN] Would tag commit {commit_sha[:8]} as {version}") + print(f"[DRY RUN] Would push tag {version} to upstream") + print(f"[DRY RUN] Would update tracking issue #{issue_num} checklist") + print(f"[DRY RUN] Would post comment to tracking issue #{issue_num}") + return 0 + + print( + f"Promoting {latest_rc} to final release {version} (commit" + f" {commit_sha[:8]}) using tracking issue #{issue_num}..." + ) + + # Tag the specific commit without checkout, and push to upstream + self.git.tag(version, commit_sha) + self.git.push("upstream", version) + + print(f"Updating tracking issue #{issue_num} checklist...") + self.gh.update_issue_body(issue_num, updated_body) + + print(f"Posting comment to tracking issue #{issue_num}...") + + release_url = f"{REPO_URL}/releases/tag/{version}" + bcr_query = ( + f'is:pr ("bazel-contrib/rules_python" in:title) ("@{version}" in:title)' + ) + bcr_search_url = f"https://github.com/bazelbuild/bazel-central-registry/pulls?q={urllib.parse.quote(bcr_query)}" + comment_body = ( + f"Version {version} has been tagged.\n\n" + f"- **Release Page**: {release_url}\n" + f"- **BCR PR Search**: [{bcr_query}]({bcr_search_url})" + ) + self.gh.post_issue_comment(issue_num, comment_body) + + return 0 + + @classmethod + def add_parser(cls, subparsers): + """Adds parser for promote-rc subcommand.""" + parser = subparsers.add_parser( + "promote-rc", + help="Promote the latest RC to final release.", + ) + parser.add_argument( + "version", + nargs="?", + type=semver_type, + help="The final version to release (e.g., 0.38.0).", + ) + parser.add_argument( + "--issue", + type=int, + help="The tracking issue number (optional).", + ) + 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() diff --git a/tools/private/release/release.py b/tools/private/release/release.py index a6c5b3f88b..a6f3543e71 100644 --- a/tools/private/release/release.py +++ b/tools/private/release/release.py @@ -2,192 +2,27 @@ import argparse import os -import pathlib -import re import sys -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 ( - update_task_in_body, -) -from tools.private.release.utils import ( - REPO_URL, - determine_next_version, - get_latest_rc_tag, -) - - -def _semver_type(value): - if not re.match(r"^\d+\.\d+\.\d+(rc\d+)?$", value): - raise argparse.ArgumentTypeError( - f"'{value}' is not a valid semantic version (X.Y.Z or X.Y.ZrcN)" - ) - return value - - -# ============================================================================== -# Subcommand Execution Functions -# ============================================================================== - - -def cmd_determine_next_version(args): - """Executes the determine-next-version subcommand.""" - version = determine_next_version() - print(version) - return 0 - - -def cmd_create_release_issue(args): - """Executes the create-release-issue subcommand.""" - version = args.version - if version is None: - version = determine_next_version() - - # Concurrency check - open_issues = gh.get_open_tracking_issues() - if open_issues: - print("Error: A release is already in progress. Active tracking issues:") - for issue in open_issues: - print(f"- {issue['title']}: {issue['url']}") - return 1 - - template_path = pathlib.Path(".github/ISSUE_TEMPLATE/release_tracking_template.md") - if not template_path.exists(): - raise FileNotFoundError(f"Template file not found at {template_path}") - template_content = template_path.read_text(encoding="utf-8") - - issue_num = gh.create_tracking_issue(version, template_content) - print(f"Created tracking issue #{issue_num} for v{version}") - return 0 - - -def cmd_complete_prepare(args): - """Executes the complete-prepare subcommand (Phase 2 PR merged).""" - print(f"Completing preparation for PR #{args.pr}...") - - pr_info = gh.get_pr_info(args.pr) - if not pr_info or pr_info.get("state") != "MERGED": - state = pr_info.get("state", "UNKNOWN") - print(f"Error: PR #{args.pr} is not merged yet (state: {state}).") - return 1 - - # Resolve issue number from PR body - pr_body = pr_info.get("body", "") - match = re.search(r"Work towards #(\d+)", pr_body) - if not match: - match = re.search(r"#(\d+)", pr_body) - if not match: - print( - f"Error: Could not determine tracking issue number from PR #{args.pr}" - f" body: {pr_body}" - ) - return 1 - - issue_num = int(match.group(1)) - print(f"Resolved tracking issue #{issue_num} from PR #{args.pr} body.") - - commit_sha = pr_info["mergeCommit"]["oid"] - short_commit = commit_sha[:8] - print(f"PR #{args.pr} merged at commit {commit_sha}. Updating tracking issue...") - - # Update checklist: mark Prepare Release as done (checked) and set SUCCESS - body = gh.get_issue_body(issue_num) - metadata = {"status": "done", "pr": f"#{args.pr}", "commit": short_commit} - updated_body = update_task_in_body( - body, "Prepare Release", checked=True, metadata=metadata - ) - gh.update_issue_body(issue_num, updated_body) - print("Prepare Release task marked complete successfully!") - return 0 - - -def cmd_promote_rc(args): - """Executes the promote-rc subcommand (Phase 3).""" - # Fetch from upstream to ensure we have the latest tags - git.fetch("upstream", tags=True, force=True) - - version = args.version - if version is None: - version = determine_next_version() - - latest_rc = get_latest_rc_tag(version, remote="upstream") - if not latest_rc: - print(f"Error: No release candidate tags found matching {version}-rc*") - return 1 - - # Verify final tag doesn't already exist - if git.tag_exists(version): - print(f"Error: Final tag {version} already exists.") - return 1 - - # Verify issue can be found - issue_num = args.issue - if not issue_num: - try: - issue_num = gh.get_release_tracking_issue(version) - except ValueError as e: - print(f"Error: {e}") - return 1 - except Exception as e: - print(f"Error: Unexpected error finding tracking issue: {e}") - return 1 - - # Get commit SHA of the RC tag (which will be the same for the final tag) - commit_sha = git.get_commit_sha(latest_rc) - - # Verify issue is in the right format by trying to prepare the update - print(f"Verifying tracking issue #{issue_num} format...") - body = gh.get_issue_body(issue_num) - metadata = {"status": "done", "tag": version, "commit": commit_sha[:8]} - try: - updated_body = update_task_in_body( - body, "Tag Final", checked=True, metadata=metadata - ) - except ValueError as e: - print(f"Error: Tracking issue #{issue_num} is malformed: {e}") - return 1 - - # All pre-conditions met, perform modifications - if args.dry_run: - print( - f"[DRY RUN] Pre-conditions passed successfully for promoting {latest_rc} to {version}." - ) - print(f"[DRY RUN] Would tag commit {commit_sha[:8]} as {version}") - print(f"[DRY RUN] Would push tag {version} to upstream") - print(f"[DRY RUN] Would update tracking issue #{issue_num} checklist") - print(f"[DRY RUN] Would post comment to tracking issue #{issue_num}") - return 0 - - print( - f"Promoting {latest_rc} to final release {version} (commit" - f" {commit_sha[:8]}) using tracking issue #{issue_num}..." - ) - - # Tag the specific commit without checkout, and push to upstream - git.tag(version, commit_sha) - git.push("upstream", version) - - print(f"Updating tracking issue #{issue_num} checklist...") - gh.update_issue_body(issue_num, updated_body) - - print(f"Posting comment to tracking issue #{issue_num}...") - import urllib.parse - - release_url = f"{REPO_URL}/releases/tag/{version}" - bcr_query = f'is:pr ("bazel-contrib/rules_python" in:title) ("@{version}" in:title)' - bcr_search_url = f"https://github.com/bazelbuild/bazel-central-registry/pulls?q={urllib.parse.quote(bcr_query)}" - comment_body = ( - f"Version {version} has been tagged.\n\n" - f"- **Release Page**: {release_url}\n" - f"- **BCR PR Search**: [{bcr_query}]({bcr_search_url})" - ) - gh.post_issue_comment(issue_num, comment_body) - - return 0 +from tools.private.release.complete_prepare import CompletePrepare +from tools.private.release.create_rc import CreateRc +from tools.private.release.create_release_branch import CreateReleaseBranch +from tools.private.release.create_release_issue import CreateReleaseIssue +from tools.private.release.determine_next_version import DetermineNextVersion +from tools.private.release.prepare import Prepare +from tools.private.release.process_backports import ProcessBackports +from tools.private.release.promote_rc import PromoteRc + +cmds = [ + DetermineNextVersion, + CreateReleaseIssue, + Prepare, + CompletePrepare, + CreateReleaseBranch, + ProcessBackports, + CreateRc, + PromoteRc, +] def create_parser(): @@ -200,141 +35,8 @@ def create_parser(): dest="command", required=True, help="Subcommands" ) - # Subcommand: determine-next-version - subparsers.add_parser( - "determine-next-version", - help="Determine the next version and print it, without making any changes.", - ) - - # Subcommand: create-release-issue - create_issue_parser = subparsers.add_parser( - "create-release-issue", - help="Search for open releases and create a new tracking issue.", - ) - create_issue_parser.add_argument( - "--version", - type=_semver_type, - help="The release version (e.g., 0.38.0). If not provided, determined automatically.", - ) - - # Subcommand: prepare - prepare_parser = subparsers.add_parser( - "prepare", - help="Prepare the release (updates changelog, placeholders).", - ) - prepare_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.", - ) - prepare_parser.add_argument( - "--issue", - type=int, - help="The tracking issue number (optional, triggers automated branch/PR pipeline).", - ) - prepare_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: complete-prepare - complete_prep_parser = subparsers.add_parser( - "complete-prepare", - help="Mark the Prepare Release task as complete in the tracking issue.", - ) - complete_prep_parser.add_argument( - "--pr", - type=int, - required=True, - help="The merged preparation PR number.", - ) - - # Subcommand: create-release-branch - create_branch_parser = subparsers.add_parser( - "create-release-branch", - help="Create the release branch pointing to the merged PR commit.", - ) - create_branch_parser.add_argument( - "--issue", - type=int, - required=True, - help="The tracking issue number (required).", - ) - create_branch_parser.add_argument( - "--remote", - type=str, - required=True, - help="The git remote to create the branch on (required).", - ) - - # Subcommand: process-backports - process_backports_parser = subparsers.add_parser( - "process-backports", - help="Cherry-pick pending backports listed in the tracking issue.", - ) - process_backports_parser.add_argument( - "--issue", - type=int, - 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( - "create-rc", - help="Tags the next RC on the release branch if no backports remain.", - ) - create_rc_parser.add_argument( - "--issue", - type=int, - required=True, - help="The tracking issue number (required).", - ) - create_rc_parser.add_argument( - "--remote", - type=str, - required=True, - help="The git remote to push the RC tag to (required).", - ) - - # Subcommand: promote-rc - promote_parser = subparsers.add_parser( - "promote-rc", - help="Promote the latest RC to final release.", - ) - promote_parser.add_argument( - "version", - nargs="?", - type=_semver_type, - help="The final version to release (e.g., 0.38.0).", - ) - promote_parser.add_argument( - "--issue", - type=int, - help="The tracking issue number (optional).", - ) - promote_parser.add_argument( - "--dry-run", - action=argparse.BooleanOptionalAction, - default=True, - help="Perform a dry run (default: True). Use --no-dry-run to actually execute.", - ) + for cmd in cmds: + cmd.add_parser(subparsers) return parser @@ -348,24 +50,10 @@ def main(): exit_code = 1 try: - if args.command == "determine-next-version": - exit_code = cmd_determine_next_version(args) - elif args.command == "create-release-issue": - exit_code = cmd_create_release_issue(args) - elif args.command == "prepare": - exit_code = cmd_prepare(args) - elif args.command == "complete-prepare": - exit_code = cmd_complete_prepare(args) - elif args.command == "create-release-branch": - exit_code = cmd_create_release_branch(args) - elif args.command == "process-backports": - exit_code = cmd_process_backports(args) - elif args.command == "create-rc": - exit_code = cmd_create_rc(args) - elif args.command == "promote-rc": - exit_code = cmd_promote_rc(args) + # args.command is the run_from_args classmethod of the selected command + exit_code = args.command(args) except Exception as e: - print(f"Fatal error executing {args.command}: {e}", file=sys.stderr) + print(f"Fatal error: {e}", file=sys.stderr) if hasattr(e, "__notes__"): for note in e.__notes__: print(note, file=sys.stderr) diff --git a/tools/private/release/release_issue.py b/tools/private/release/release_issue.py index adc0d88086..276261bef6 100644 --- a/tools/private/release/release_issue.py +++ b/tools/private/release/release_issue.py @@ -38,6 +38,49 @@ def __repr__(self): ) +class ReleaseTask: + """Represents a release task from the tracking issue checklist.""" + + def __init__( + self, + name: str, + checked: bool, + status: str | None = None, + pr: str | None = None, + commit: str | None = None, + branch: str | None = None, + tag: str | None = None, + metadata: dict[str, str] | None = None, + ): + """Initializes a ReleaseTask. + + Args: + name: The name of the task (e.g. 'Prepare Release'). + checked: Whether the checklist item is checked. + status: The status of the task (e.g. 'pending', 'done'). + pr: The associated PR reference (e.g. '#123'). + commit: The associated commit SHA. + branch: The associated branch name. + tag: The associated tag name. + metadata: Raw metadata parsed from the checklist line. + """ + self.name = name + self.checked = checked + self.status = status + self.pr = pr + self.commit = commit + self.branch = branch + self.tag = tag + self.metadata = metadata or {} + + def __repr__(self): + return ( + f"ReleaseTask(name={self.name!r}, checked={self.checked!r}, " + f"status={self.status!r}, pr={self.pr!r}, commit={self.commit!r}, " + f"branch={self.branch!r}, tag={self.tag!r})" + ) + + RELEASE_TITLE_RE = re.compile(r"Release (\d+\.\d+\.\d+)", re.IGNORECASE) @@ -101,22 +144,17 @@ def update_task_in_body(body, task_name, checked, metadata): def parse_checklist_state(body): - """Parses the main checklist tasks and their metadata.""" + """Parses the main checklist tasks and their metadata. + + Returns: + A dict containing ReleaseTask objects for 'prepare_release', + 'create_branch', 'tag_final', and a dict of RC tags. + """ state = { - "prepare_release": { - "checked": False, - "status": None, - "pr": None, - "commit": None, - }, - "create_branch": { - "checked": False, - "status": None, - "branch": None, - "commit": None, - }, - "tag_final": {"checked": False, "status": None, "tag": None, "commit": None}, - "rc_tags": {}, # Dynamically mapped: int -> metadata dict + "prepare_release": ReleaseTask("Prepare Release", False), + "create_branch": ReleaseTask("Create Release branch", False), + "tag_final": ReleaseTask("Tag Final", False), + "rc_tags": {}, # Dynamically mapped: int -> ReleaseTask } lines = body.splitlines() @@ -131,37 +169,45 @@ def parse_checklist_state(body): name_lower = name.lower() if "prepare release" in name_lower: - state["prepare_release"] = { - "checked": checked, - "status": meta.get("status"), - "pr": meta.get("pr"), - "commit": meta.get("commit"), - } + state["prepare_release"] = ReleaseTask( + name=name, + checked=checked, + status=meta.get("status"), + pr=meta.get("pr"), + commit=meta.get("commit"), + metadata=meta, + ) elif "create release branch" in name_lower: - state["create_branch"] = { - "checked": checked, - "status": meta.get("status"), - "branch": meta.get("branch"), - "commit": meta.get("commit"), - } + state["create_branch"] = ReleaseTask( + name=name, + checked=checked, + status=meta.get("status"), + branch=meta.get("branch"), + commit=meta.get("commit"), + metadata=meta, + ) elif "tag final" in name_lower: - state["tag_final"] = { - "checked": checked, - "status": meta.get("status"), - "tag": meta.get("tag"), - "commit": meta.get("commit"), - } + state["tag_final"] = ReleaseTask( + name=name, + checked=checked, + status=meta.get("status"), + tag=meta.get("tag"), + commit=meta.get("commit"), + metadata=meta, + ) else: # Match Tag RC rc_match = re.match(r"Tag RC(\d+)", name, re.IGNORECASE) if rc_match: rc_num = int(rc_match.group(1)) - state["rc_tags"][rc_num] = { - "checked": checked, - "status": meta.get("status"), - "tag": meta.get("tag"), - "commit": meta.get("commit"), - } + state["rc_tags"][rc_num] = ReleaseTask( + name=name, + checked=checked, + status=meta.get("status"), + tag=meta.get("tag"), + commit=meta.get("commit"), + metadata=meta, + ) return state diff --git a/tools/private/release/shell.py b/tools/private/release/shell.py index cfff53f4e4..0093a28574 100644 --- a/tools/private/release/shell.py +++ b/tools/private/release/shell.py @@ -4,14 +4,15 @@ import subprocess -def run_cmd(*args, check=True, capture_output=True): +def run_cmd(*args, check=True, capture_output=True, cwd=None): """Runs a command as a subprocess with separate arguments (prints command). If the command fails, it raises the CalledProcessError after attaching a detailed note explaining the failure to preserve the stack trace. """ cmd = [str(arg) for arg in args] - print(f"Running: {shlex.join(cmd)}") + cwd_suffix = f" (cwd: {cwd})" if cwd else "" + print(f"> {shlex.join(cmd)}{cwd_suffix}") try: result = subprocess.run( cmd, @@ -19,10 +20,11 @@ def run_cmd(*args, check=True, capture_output=True): stdout=subprocess.PIPE if capture_output else None, stderr=subprocess.PIPE if capture_output else None, universal_newlines=True, + cwd=cwd, ) return result.stdout.strip() if capture_output else None except subprocess.CalledProcessError as e: - note = f"Error running command: {shlex.join(cmd)}" + note = f"Error running command: {shlex.join(cmd)}{cwd_suffix}" if capture_output: note += f"\nStdout: {e.stdout}\nStderr: {e.stderr}" e.add_note(note) diff --git a/tools/private/release/utils.py b/tools/private/release/utils.py index 2d050022b1..e774c80134 100644 --- a/tools/private/release/utils.py +++ b/tools/private/release/utils.py @@ -1,15 +1,26 @@ """Utility functions for the release tool.""" +import argparse import fnmatch import os import re from packaging.version import parse as parse_version -from tools.private.release import git +from tools.private.release.git import Git REPO_URL = "https://github.com/bazel-contrib/rules_python" + +def semver_type(value): + """Argparse type validator for semantic versions.""" + if not re.match(r"^\d+\.\d+\.\d+(rc\d+)?$", value): + raise argparse.ArgumentTypeError( + f"'{value}' is not a valid semantic version (X.Y.Z or X.Y.ZrcN)" + ) + return value + + _EXCLUDE_PATTERNS = [ "./.git/*", "./.github/*", @@ -45,6 +56,7 @@ def _iter_version_placeholder_files(): def get_latest_version(): """Gets the latest version from git tags.""" + git = Git(".") tags = git.get_tags() versions = [ (tag, parse_version(tag)) @@ -69,6 +81,7 @@ def get_latest_version(): def get_latest_rc_tag(version, remote=None): """Queries git tags and returns the highest RC tag for the version.""" + git = Git(".") if remote: tags = git.get_remote_tags(remote) else: @@ -97,6 +110,7 @@ def should_increment_minor(): def determine_next_version(branch_name=None): """Determines the next version based on git tags and the current branch.""" + git = Git(".") if branch_name is None: branch_name = git.get_current_branch()