diff --git a/tools/create_release.py b/tools/create_release.py index fbacf28..a111938 100755 --- a/tools/create_release.py +++ b/tools/create_release.py @@ -5,6 +5,7 @@ import os import re import subprocess # nosec +import traceback from dataclasses import dataclass import create_tarballs @@ -123,6 +124,7 @@ def __init__(self, config: Config, git_prov: git.Git, github_prov: github.GitHub self.config = config self.git = git_prov self.github = github_prov + self.version = "" def require(self, condition: bool, message: str | None = None) -> None: if not condition: @@ -130,22 +132,50 @@ def require(self, condition: bool, message: str | None = None) -> None: def assign_to_user( self, - s: stage.Stage, + s: stage.Stage | None, version: str, task: str | None = None, action: str = "", instruction: str | None = None, ) -> stage.UserAbort: """Assign the issue to the acting user for them to take some action.""" - self.github.issue_unassign(self.config.issue, ["toktok-releaser"]) - self.github.issue_assign(self.config.issue, [self.github.actor()]) + if self.config.issue: + self.github.issue_unassign(self.config.issue, ["toktok-releaser"]) + self.github.issue_assign(self.config.issue, [self.github.actor()]) self.update_dashboard(version, current_task=task, instruction=instruction) - s.ok(f"Assigned to {self.github.actor()}") + if s: + s.ok(f"Assigned to {self.github.actor()}") return stage.UserAbort(f"Returning to the user to {action}") + def report_failure(self, version: str, exception: Exception) -> None: + """Report a failure to the release tracking issue.""" + if not self.config.issue: + return + + instruction = f"❌ **Failure:** {exception}" + self.assign_to_user( + None, + version, + action="fix the failure", + instruction=instruction, + ) + + def run(self) -> None: + """Run the release process.""" + try: + self.run_stages() + except stage.UserAbort as e: + print(e.message) + except Exception as e: + traceback.print_exc() + self.report_failure(self.version, e) + raise e + def compute_done_milestones(self, version: str) -> set[str]: """Heuristics to determine which milestones are completed.""" - done = set() + done: set[str] = set() + if not version: + return done # 1. Preparation if self.github.find_pr_for_branch( @@ -200,15 +230,20 @@ def render_progress_list( ] lines = [] + instruction_rendered = False for name, desc in milestones: status = "[x]" if name in done else "[ ]" if current_task == name: lines.append(f"- {status} **Current Step: {desc}**") if instruction: lines.append(f" > ℹ️ **Action Required:** {instruction}") + instruction_rendered = True else: lines.append(f"- {status} {desc}") + if instruction and not instruction_rendered: + lines.append(f"\nℹ️ **Action Required:** {instruction}") + return "\n".join(lines) def update_dashboard( @@ -272,6 +307,7 @@ def stage_version(self) -> str: if self.config.version == "latest": version = self.github.latest_release() s.ok(f"Using latest release {version}") + self.version = version return version self.require( @@ -280,6 +316,7 @@ def stage_version(self) -> str: f"(expected: {git.VERSION_REGEX.pattern})", ) s.ok(f"Accepting override version {self.config.version}") + self.version = self.config.version return self.config.version version = self.github.next_milestone().title if not self.config.production: @@ -288,6 +325,7 @@ def stage_version(self) -> str: version = f"{version}-rc.{rc + 1}" self.require(re.match(git.VERSION_REGEX, version) is not None) s.ok(version) + self.version = version return version def stage_rename_issue(self, version: str) -> None: @@ -931,13 +969,15 @@ def stage_close_issue(self) -> None: self.github.close_issue(self.config.issue) s.ok(f"Issue {self.config.issue} closed") - def run_stages(self) -> None: - self.require(self.git.current_branch() == self.config.branch) - self.require(self.git.is_clean()) + def run_stages(self, version: str | None = None) -> None: + if version is None: + self.require(self.git.current_branch() == self.config.branch) + self.require(self.git.is_clean()) + + self.stage_init() - self.stage_init() + version = self.stage_version() - version = self.stage_version() self.stage_rename_issue(version) self.stage_assign_milestone(version) self.stage_production_ready(version) @@ -995,19 +1035,15 @@ def main(config: Config) -> None: git_prov = git.DEFAULT_GIT github_prov = github.DEFAULT_GITHUB - try: - # Stash any local changes for the user to later resume working on. - with git.Stash(prov=git_prov): - # We need to be on the main branch to create a release, but we - # want to return to the original branch afterwards. - with git.Checkout(config.branch, prov=git_prov): - # Undo any partial changes if the script is aborted. - with git.ResetOnExit(prov=git_prov): - releaser = Releaser(config, git_prov, github_prov) - releaser.run_stages() - except stage.UserAbort as e: - print(e.message) - return + # Stash any local changes for the user to later resume working on. + with git.Stash(prov=git_prov): + # We need to be on the main branch to create a release, but we + # want to return to the original branch afterwards. + with git.Checkout(config.branch, prov=git_prov): + # Undo any partial changes if the script is aborted. + with git.ResetOnExit(prov=git_prov): + releaser = Releaser(config, git_prov, github_prov) + releaser.run() if __name__ == "__main__": diff --git a/tools/create_release_test.py b/tools/create_release_test.py index fd86977..b2577c6 100644 --- a/tools/create_release_test.py +++ b/tools/create_release_test.py @@ -50,5 +50,44 @@ def test_render_all_done(self) -> None: self.assertIn("[x] Finalize release", rendered) +class TestReleaserLogic(unittest.TestCase): + def setUp(self) -> None: + self.config = Config( + branch="master", + main_branch="master", + dryrun=False, + force=True, + github_actions=True, + issue=1, + production=True, + rebase=True, + resume=False, + verify=False, + version="v1.0.0", + upstream="origin", + ) + self.github = MagicMock() + self.git = MagicMock() + self.releaser = Releaser(self.config, self.git, self.github) + + def test_report_failure(self) -> None: + self.github.actor.return_value = "human" + self.github.get_issue.return_value = MagicMock( + body="### Release progress\n[ ] ..." + ) + + self.releaser.report_failure("v1.0.0", Exception("Something went wrong")) + + # Check that issue was reassigned + self.github.issue_unassign.assert_called_with(1, ["toktok-releaser"]) + self.github.issue_assign.assert_called_with(1, ["human"]) + + # Check that dashboard was updated + self.github.change_issue.assert_called() + args, kwargs = self.github.change_issue.call_args + self.assertEqual(args[0], 1) + self.assertIn("❌ **Failure:** Something went wrong", args[1]["body"]) + + if __name__ == "__main__": unittest.main() diff --git a/tools/release_e2e_test.py b/tools/release_e2e_test.py index dfe685e..f653148 100644 --- a/tools/release_e2e_test.py +++ b/tools/release_e2e_test.py @@ -287,6 +287,7 @@ def __init__(self) -> None: self._log: list[str] = ["initial commit"] self._up_to_date = False self._rebase_success = True + self._is_clean = True def current_branch(self) -> str: return self._current_branch @@ -298,7 +299,7 @@ def log(self, branch: str, count: int = 100) -> list[str]: return self._log def is_clean(self) -> bool: - return True + return self._is_clean def branch_sha(self, branch: str) -> str: return "sha123" @@ -741,6 +742,32 @@ def test_release_ci_failure(self) -> None: self.assertIn("checks failed", str(cm.exception)) + def test_run_reports_failure(self) -> None: + config = self.make_config() + gh = FakeGitHub() + gh.add_issue( + 1, + "Release tracking issue: v1.0.0", + "### Release progress\n[ ] ...\nProduction release", + ) + gh.add_milestone(1, "v1.0.0") + + gt = FakeGit() + # Mock git.is_clean to fail to trigger an exception in run() + gt._is_clean = False + + releaser = Releaser(config, gt, gh) + + with self.release_mocks(gh, gt): + with self.assertRaises(Exception): + releaser.run() + + # Verify reassignment to human + self.assertEqual(gh._issues[1]["assignees"], [{"login": "human"}]) + + # Verify failure message in dashboard + self.assertIn("❌ **Failure:** Requirement not met", gh._issues[1]["body"]) + def test_release_ci_timeout(self) -> None: config = self.make_config() gh = FakeGitHub()