1+ import datetime
12import os
23import pathlib
34import shutil
45import tempfile
56import unittest
67from unittest .mock import MagicMock , call , patch
78
8- from tools .private .release import changelog_news , release as releaser , utils
9+ from tools .private .release import changelog_news , git , release as releaser , utils
910from tools .private .release .gh import MultipleTrackingIssuesError , NoTrackingIssueError
1011
1112
@@ -20,12 +21,14 @@ def _mock_git_and_gh(test_case):
2021 patch ("tools.private.release.prepare.git" , new = mock_git ).start ()
2122 patch ("tools.private.release.create_release_branch.git" , new = mock_git ).start ()
2223 patch ("tools.private.release.create_rc.git" , new = mock_git ).start ()
24+ patch ("tools.private.release.process_backports.git" , new = mock_git ).start ()
2325 patch ("tools.private.release.utils.git" , new = mock_git ).start ()
2426
2527 patch ("tools.private.release.release.gh" , new = mock_gh ).start ()
2628 patch ("tools.private.release.prepare.gh" , new = mock_gh ).start ()
2729 patch ("tools.private.release.create_release_branch.gh" , new = mock_gh ).start ()
2830 patch ("tools.private.release.create_rc.gh" , new = mock_gh ).start ()
31+ patch ("tools.private.release.process_backports.gh" , new = mock_gh ).start ()
2932 mock_gh .MultipleTrackingIssuesError = MultipleTrackingIssuesError
3033 mock_gh .NoTrackingIssueError = NoTrackingIssueError
3134
@@ -984,7 +987,19 @@ def test_create_rc_success_first_rc(self):
984987 comment_call_args [1 ],
985988 )
986989 self .assertIn (
987- "- Trigger Release Workflow: [Release Workflow](https://github.com/bazel-contrib/rules_python/actions/workflows/release.yml)" ,
990+ "- [Github Release 2.0.0-rc0](https://github.com/bazel-contrib/rules_python/releases/tag/2.0.0-rc0)" ,
991+ comment_call_args [1 ],
992+ )
993+ self .assertIn (
994+ "- BCR Entry: [rules_python@2.0.0](https://registry.bazel.build/modules/rules_python/2.0.0)" ,
995+ comment_call_args [1 ],
996+ )
997+ self .assertIn (
998+ "- [BCR PRs](https://github.com/bazelbuild/bazel-central-registry/pulls?q=is%3Apr+rules_python+2.0.0)" ,
999+ comment_call_args [1 ],
1000+ )
1001+ self .assertIn (
1002+ "- [Release workflow status](https://github.com/bazel-contrib/rules_python/actions/workflows/release.yml)" ,
9881003 comment_call_args [1 ],
9891004 )
9901005 self .assertNotIn ("🚀" , comment_call_args [1 ])
@@ -1029,11 +1044,68 @@ def test_create_rc_success_next_rc(self):
10291044 comment_call_args [1 ],
10301045 )
10311046 self .assertIn (
1032- "- Trigger Release Workflow: [Release Workflow](https://github.com/bazel-contrib/rules_python/actions/workflows/release.yml)" ,
1047+ "- [Github Release 2.0.0-rc1](https://github.com/bazel-contrib/rules_python/releases/tag/2.0.0-rc1)" ,
1048+ comment_call_args [1 ],
1049+ )
1050+ self .assertIn (
1051+ "- BCR Entry: [rules_python@2.0.0](https://registry.bazel.build/modules/rules_python/2.0.0)" ,
1052+ comment_call_args [1 ],
1053+ )
1054+ self .assertIn (
1055+ "- [BCR PRs](https://github.com/bazelbuild/bazel-central-registry/pulls?q=is%3Apr+rules_python+2.0.0)" ,
1056+ comment_call_args [1 ],
1057+ )
1058+ self .assertIn (
1059+ "- [Release workflow status](https://github.com/bazel-contrib/rules_python/actions/workflows/release.yml)" ,
10331060 comment_call_args [1 ],
10341061 )
10351062 self .assertNotIn ("🚀" , comment_call_args [1 ])
10361063
1064+ def test_create_rc_gating_on_backports (self ):
1065+ # Arrange
1066+ args = MagicMock (issue = 123 , remote = "my-remote" )
1067+ self .mock_gh .get_issue_title .return_value = "Release 2.0.0"
1068+ self .mock_gh .get_issue_body .return_value = """
1069+ ## Checklist
1070+ - [x] Prepare Release | status=done pr=#122 commit=abcdef12
1071+ - [x] Create Release branch | status=done branch=release/2.0 commit=abcdef12
1072+ - [ ] Tag RC0 | status=pending
1073+
1074+ ## Backports
1075+ - [ ] #124 | status=pending
1076+ """
1077+ # Act
1078+ result = releaser .cmd_create_rc (args )
1079+
1080+ # Assert
1081+ self .assertEqual (result , 1 )
1082+ self .mock_git .tag .assert_not_called ()
1083+ self .mock_git .push .assert_not_called ()
1084+
1085+ def test_create_rc_with_finished_backports (self ):
1086+ # Arrange
1087+ args = MagicMock (issue = 123 , remote = "my-remote" )
1088+ self .mock_gh .get_issue_title .return_value = "Release 2.0.0"
1089+ self .mock_gh .get_issue_body .return_value = """
1090+ ## Checklist
1091+ - [x] Prepare Release | status=done pr=#122 commit=abcdef12
1092+ - [x] Create Release branch | status=done branch=release/2.0 commit=abcdef12
1093+ - [ ] Tag RC0 | status=pending
1094+
1095+ ## Backports
1096+ - [x] #124 | status=done rc=rc0 commit=abcdef12
1097+ """
1098+ self .mock_git .get_remote_tags .return_value = []
1099+ self .mock_git .get_commit_sha .return_value = "1234567890"
1100+
1101+ # Act
1102+ result = releaser .cmd_create_rc (args )
1103+
1104+ # Assert
1105+ self .assertEqual (result , 0 )
1106+ self .mock_git .tag .assert_called_once_with ("2.0.0-rc0" , "my-remote/release/2.0" )
1107+ self .mock_git .push .assert_called_once_with ("my-remote" , "2.0.0-rc0" )
1108+
10371109
10381110class CmdPromoteRcTest (unittest .TestCase ):
10391111 def setUp (self ):
@@ -1384,5 +1456,265 @@ def test_create_release_branch_already_exists_non_ff(self):
13841456 self .mock_gh .update_issue_body .assert_not_called ()
13851457
13861458
1459+ class CmdProcessBackportsTest (unittest .TestCase ):
1460+ def setUp (self ):
1461+ _mock_git_and_gh (self )
1462+ self .mock_changelog_news = patch (
1463+ "tools.private.release.process_backports.changelog_news"
1464+ ).start ()
1465+ self .addCleanup (patch .stopall )
1466+
1467+ def test_process_backports_no_pending (self ):
1468+ args = MagicMock (issue = 123 , remote = "origin" , dry_run = False )
1469+ self .mock_gh .get_issue_body .return_value = "No backports here"
1470+
1471+ result = releaser .cmd_process_backports (args )
1472+
1473+ self .assertEqual (result , 0 )
1474+ self .mock_gh .get_issue_body .assert_called_once_with (123 )
1475+ self .mock_git .fetch .assert_not_called ()
1476+
1477+ @patch ("tools.private.release.process_backports.datetime" )
1478+ def test_process_backports_success (self , mock_datetime ):
1479+ mock_datetime .date .today .return_value = datetime .date (2026 , 7 , 1 )
1480+ args = MagicMock (issue = 123 , remote = "origin" , dry_run = False )
1481+ self .mock_gh .get_issue_title .return_value = "Release 2.0.0"
1482+ self .mock_gh .get_issue_body .return_value = """
1483+ ## Checklist
1484+ - [ ] Prepare Release
1485+ - [ ] Create Release branch
1486+
1487+ ## Backports
1488+ - [ ] #124 | status=pending
1489+ """
1490+ self .mock_git .get_remote_tags .return_value = []
1491+
1492+ def mock_resolve (items ):
1493+ for item in items :
1494+ if item .pr_ref == "#124" :
1495+ item .commit = "abcdef12"
1496+ item .status = "done"
1497+ return items
1498+
1499+ self .mock_gh .get_merge_commits_for_prs .side_effect = mock_resolve
1500+
1501+ self .mock_git .sort_commits_chronologically .return_value = ["abcdef12" ]
1502+ self .mock_git .get_commit_sha .return_value = "12345678"
1503+ self .mock_git .get_commit_message .return_value = 'Cherry-pick "fix bug"'
1504+
1505+ result = releaser .cmd_process_backports (args )
1506+
1507+ self .assertEqual (result , 0 )
1508+ self .mock_git .fetch .assert_has_calls (
1509+ [call ("origin" , tags = True , force = True ), call ("origin" )]
1510+ )
1511+ self .mock_git .checkout .assert_called_once_with (
1512+ "release/2.0" , track_remote = "origin"
1513+ )
1514+ self .mock_git .cherry_pick .assert_called_once_with ("abcdef12" )
1515+ self .mock_changelog_news .update_changelog .assert_called_once_with (
1516+ "2.0.0" , "2026-07-01"
1517+ )
1518+ self .mock_git .add .assert_called_once_with ("CHANGELOG.md" , "news/" )
1519+ self .mock_git .commit .assert_called_once_with (
1520+ 'Cherry-pick "fix bug"\n \n Work towards #123' , amend = True
1521+ )
1522+ self .mock_git .push .assert_called_once_with ("origin" , "release/2.0" )
1523+
1524+ self .mock_gh .update_issue_body .assert_called_once ()
1525+ call_args = self .mock_gh .update_issue_body .call_args [0 ]
1526+ self .assertEqual (call_args [0 ], 123 )
1527+ self .assertIn ("- [x] #124 | status=done rc=rc0 commit=12345678" , call_args [1 ])
1528+
1529+ @patch ("tools.private.release.process_backports.datetime" )
1530+ def test_process_backports_dry_run (self , mock_datetime ):
1531+ mock_datetime .date .today .return_value = datetime .date (2026 , 7 , 1 )
1532+ args = MagicMock (issue = 123 , remote = "origin" , dry_run = True )
1533+ self .mock_gh .get_issue_title .return_value = "Release 2.0.0"
1534+ self .mock_gh .get_issue_body .return_value = """
1535+ ## Checklist
1536+ - [ ] Prepare Release
1537+ - [ ] Create Release branch
1538+
1539+ ## Backports
1540+ - [ ] #124 | status=pending
1541+ """
1542+ self .mock_git .get_remote_tags .return_value = []
1543+
1544+ def mock_resolve (items ):
1545+ for item in items :
1546+ if item .pr_ref == "#124" :
1547+ item .commit = "abcdef12"
1548+ item .status = "done"
1549+ return items
1550+
1551+ self .mock_gh .get_merge_commits_for_prs .side_effect = mock_resolve
1552+
1553+ self .mock_git .sort_commits_chronologically .return_value = ["abcdef12" ]
1554+ self .mock_git .get_commit_sha .return_value = "12345678"
1555+ self .mock_git .get_commit_message .return_value = 'Cherry-pick "fix bug"'
1556+
1557+ result = releaser .cmd_process_backports (args )
1558+
1559+ self .assertEqual (result , 0 )
1560+ self .mock_git .fetch .assert_has_calls (
1561+ [call ("origin" , tags = True , force = True ), call ("origin" )]
1562+ )
1563+ self .mock_git .checkout .assert_called_once_with (
1564+ "release/2.0" , track_remote = "origin"
1565+ )
1566+ self .mock_git .cherry_pick .assert_called_once_with ("abcdef12" )
1567+ self .mock_changelog_news .update_changelog .assert_called_once_with (
1568+ "2.0.0" , "2026-07-01"
1569+ )
1570+ self .mock_git .commit .assert_called_once_with (
1571+ 'Cherry-pick "fix bug"\n \n Work towards #123' , amend = True
1572+ )
1573+ self .mock_git .reset_hard .assert_called_once_with ("12345678" )
1574+ self .mock_git .push .assert_not_called ()
1575+ self .mock_gh .update_issue_body .assert_not_called ()
1576+
1577+ def test_process_backports_ignored_and_failed_states (self ):
1578+ args = MagicMock (issue = 123 , remote = "origin" , dry_run = False )
1579+ self .mock_gh .get_issue_title .return_value = "Release 2.0.0"
1580+ self .mock_gh .get_issue_body .return_value = """
1581+ ## Checklist
1582+ - [ ] Prepare Release
1583+ - [ ] Create Release branch
1584+
1585+ ## Backports
1586+ - [ ] #124 | status=pending
1587+ - [ ] #125 | status=pending
1588+ - [ ] #126 | status=pending
1589+ """
1590+ self .mock_git .get_remote_tags .return_value = []
1591+
1592+ def mock_resolve (items ):
1593+ for item in items :
1594+ if item .pr_ref == "#124" :
1595+ item .status = "open-pr"
1596+ elif item .pr_ref == "#125" :
1597+ item .status = "draft-pr"
1598+ elif item .pr_ref == "#126" :
1599+ item .status = "error-closed-pr"
1600+ return items
1601+
1602+ self .mock_gh .get_merge_commits_for_prs .side_effect = mock_resolve
1603+
1604+ result = releaser .cmd_process_backports (args )
1605+
1606+ self .assertEqual (result , 1 )
1607+ self .mock_gh .update_issue_body .assert_called_once ()
1608+ call_args = self .mock_gh .update_issue_body .call_args [0 ]
1609+ self .assertEqual (call_args [0 ], 123 )
1610+ self .assertIn ("- [ ] #126 | status=error-closed-pr" , call_args [1 ])
1611+ self .assertNotIn ("status=open-pr" , call_args [1 ])
1612+ self .assertNotIn ("status=draft-pr" , call_args [1 ])
1613+ self .mock_git .checkout .assert_not_called ()
1614+ self .mock_git .cherry_pick .assert_not_called ()
1615+
1616+ def test_process_backports_ignored_error_status (self ):
1617+ args = MagicMock (issue = 123 , remote = "origin" , dry_run = False )
1618+ self .mock_gh .get_issue_title .return_value = "Release 2.0.0"
1619+ self .mock_gh .get_issue_body .return_value = """
1620+ ## Checklist
1621+ - [ ] Prepare Release
1622+ - [ ] Create Release branch
1623+
1624+ ## Backports
1625+ - [ ] #124 | status=error-merge-conflict
1626+ - [ ] #125 | status=error-some-other-error
1627+ """
1628+ self .mock_git .get_remote_tags .return_value = []
1629+ self .mock_gh .get_merge_commits_for_prs .return_value = []
1630+
1631+ result = releaser .cmd_process_backports (args )
1632+
1633+ self .assertEqual (result , 0 )
1634+ self .mock_gh .get_merge_commits_for_prs .assert_not_called ()
1635+ self .mock_git .checkout .assert_not_called ()
1636+
1637+ @patch ("tools.private.release.process_backports.datetime" )
1638+ def test_process_backports_cherry_pick_failed (self , mock_datetime ):
1639+ mock_datetime .date .today .return_value = datetime .date (2026 , 7 , 1 )
1640+ args = MagicMock (issue = 123 , remote = "origin" , dry_run = False )
1641+ self .mock_gh .get_issue_title .return_value = "Release 2.0.0"
1642+ self .mock_gh .get_issue_body .return_value = """
1643+ ## Checklist
1644+ - [ ] Prepare Release
1645+ - [ ] Create Release branch
1646+
1647+ ## Backports
1648+ - [ ] #124 | status=pending
1649+ """
1650+ self .mock_git .get_remote_tags .return_value = []
1651+
1652+ def mock_resolve (items ):
1653+ for item in items :
1654+ if item .pr_ref == "#124" :
1655+ item .commit = "abcdef12"
1656+ item .status = "done"
1657+ return items
1658+
1659+ self .mock_gh .get_merge_commits_for_prs .side_effect = mock_resolve
1660+
1661+ self .mock_git .sort_commits_chronologically .return_value = ["abcdef12" ]
1662+ self .mock_git .cherry_pick .side_effect = Exception ("Cherry-pick conflict" )
1663+
1664+ result = releaser .cmd_process_backports (args )
1665+
1666+ self .assertEqual (result , 1 )
1667+ self .mock_git .checkout .assert_called_once_with (
1668+ "release/2.0" , track_remote = "origin"
1669+ )
1670+ self .mock_git .cherry_pick .assert_called_once_with ("abcdef12" )
1671+ self .mock_git .cherry_pick_abort .assert_called_once ()
1672+
1673+ self .mock_gh .update_issue_body .assert_called_once ()
1674+ call_args = self .mock_gh .update_issue_body .call_args [0 ]
1675+ self .assertEqual (call_args [0 ], 123 )
1676+ self .assertIn ("- [ ] #124 | status=error-merge-conflict" , call_args [1 ])
1677+
1678+ self .mock_git .commit .assert_not_called ()
1679+ self .mock_git .push .assert_not_called ()
1680+
1681+
1682+ class GitCheckoutTest (unittest .TestCase ):
1683+ @patch ("tools.private.release.git.run_cmd" )
1684+ def test_checkout_simple (self , mock_run_cmd ):
1685+ git .checkout ("my-branch" )
1686+ mock_run_cmd .assert_called_once_with (
1687+ "git" , "checkout" , "my-branch" , capture_output = False
1688+ )
1689+
1690+ @patch ("tools.private.release.git.branch_exists" )
1691+ @patch ("tools.private.release.git.run_cmd" )
1692+ def test_checkout_track_remote_new_branch (self , mock_run_cmd , mock_branch_exists ):
1693+ mock_branch_exists .return_value = False
1694+
1695+ git .checkout ("my-branch" , track_remote = "origin" )
1696+
1697+ mock_branch_exists .assert_called_once_with ("my-branch" )
1698+ mock_run_cmd .assert_called_once_with (
1699+ "git" , "checkout" , "--track" , "origin/my-branch" , capture_output = False
1700+ )
1701+
1702+ @patch ("tools.private.release.git.reset_hard" )
1703+ @patch ("tools.private.release.git.branch_exists" )
1704+ @patch ("tools.private.release.git.run_cmd" )
1705+ def test_checkout_track_remote_existing_branch (
1706+ self , mock_run_cmd , mock_branch_exists , mock_reset_hard
1707+ ):
1708+ mock_branch_exists .return_value = True
1709+
1710+ git .checkout ("my-branch" , track_remote = "origin" )
1711+
1712+ mock_branch_exists .assert_called_once_with ("my-branch" )
1713+ mock_run_cmd .assert_called_once_with (
1714+ "git" , "checkout" , "my-branch" , capture_output = False
1715+ )
1716+ mock_reset_hard .assert_called_once_with ("origin/my-branch" )
1717+
1718+
13871719if __name__ == "__main__" :
13881720 unittest .main ()
0 commit comments