From f8ce53a84e3da699cdda2d1c39be9de2b664de75 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Wed, 2 Sep 2026 16:24:13 +0530 Subject: [PATCH 1/9] [feature] Automated follow-up alpha version bump PRs Added a releaser step that creates a follow-up pull request after a feature release. The pull request sets the next compatible version to alpha and creates a new unreleased changelog section. --- docs/developer/releaser-tool.rst | 4 + openwisp_utils/releaser/release.py | 160 +++++++++-- openwisp_utils/releaser/tests/conftest.py | 4 + openwisp_utils/releaser/tests/test_release.py | 259 +++++++++++++++++- openwisp_utils/releaser/tests/test_utils.py | 21 ++ .../releaser/tests/test_version_bumping.py | 27 ++ openwisp_utils/releaser/utils.py | 53 ++++ openwisp_utils/releaser/version.py | 39 ++- 8 files changed, 535 insertions(+), 32 deletions(-) diff --git a/docs/developer/releaser-tool.rst b/docs/developer/releaser-tool.rst index 2f525c32..a4777719 100644 --- a/docs/developer/releaser-tool.rst +++ b/docs/developer/releaser-tool.rst @@ -100,3 +100,7 @@ process: 7. Finally, it creates a draft release on GitHub with the changelog notes. 8. If releasing a bugfix, it offers to port the changelog to the ``main`` or ``master`` branch. +9. If releasing a new feature from a Python or npm project, it offers to + bump the version to the next alpha release: it opens a pull request + which sets the version to the next minor release with the ``alpha`` + marker and adds a new ``[unreleased]`` section to the change log. diff --git a/openwisp_utils/releaser/release.py b/openwisp_utils/releaser/release.py index da860466..af19759f 100644 --- a/openwisp_utils/releaser/release.py +++ b/openwisp_utils/releaser/release.py @@ -17,18 +17,22 @@ from openwisp_utils.releaser.config import load_config from openwisp_utils.releaser.github import GitHub from openwisp_utils.releaser.utils import ( + AbortSignal, SkipSignal, adjust_markdown_headings, branch_exists, demote_markdown_headings, format_file_with_docstrfmt, get_current_branch, + get_remote_branch_commit, rst_to_markdown, + run_git, ) from openwisp_utils.releaser.version import ( bump_version, determine_new_version, get_current_version, + supports_prerelease, ) MAIN_BRANCHES = ["master", "main"] @@ -90,6 +94,20 @@ def check_prerequisites(): return config, gh +def resolve_main_branch(question): + """Returns the local main branch, asking the user when both exist.""" + master_exists = branch_exists("master") + main_exists = branch_exists("main") + if master_exists and main_exists: + return questionary.select(question, choices=MAIN_BRANCHES).ask() + if master_exists: + return "master" + if main_exists: + return "main" + print("Neither 'master' nor 'main' branches were found locally.") + return None + + def port_changelog_to_main(gh, config, version, changelog_body, original_branch): """Checks out the main branch, updates the changelog, and creates a new PR.""" print("\n" + "=" * 50) @@ -110,26 +128,11 @@ def port_changelog_to_main(gh, config, version, changelog_body, original_branch) full_block_to_port = f"{version_header}\n{underline}\n\n{changelog_body}" try: - master_exists = branch_exists("master") - main_exists = branch_exists("main") - if master_exists and main_exists: - main_branch = questionary.select( - "Which branch should the changelog be ported to?", - choices=MAIN_BRANCHES, - ).ask() - elif master_exists: - main_branch = "master" - elif main_exists: - main_branch = "main" - else: - print( - "Neither 'master' nor 'main' branches were found locally. " - "Skipping changelog porting." - ) - return - + main_branch = resolve_main_branch( + "Which branch should the changelog be ported to?" + ) if not main_branch: - print("Porting cancelled.") + print("Skipping changelog porting.") return port_branch = f"chore/port-changelog-{version}" @@ -168,7 +171,9 @@ def port_changelog_to_main(gh, config, version, changelog_body, original_branch) print(f"Pushing branch '{port_branch}' to origin...") subprocess.run( - ["git", "push", "origin", port_branch], check=True, capture_output=True + ["git", "push", "-u", "origin", port_branch], + check=True, + capture_output=True, ) print("Creating pull request...") @@ -195,6 +200,103 @@ def port_changelog_to_main(gh, config, version, changelog_body, original_branch) ) +def bump_to_next_alpha(gh, config, released_version, original_branch): + """Bumps the version to the next alpha release and opens a PR for it.""" + print("\n" + "=" * 50) + print("๐Ÿค– Starting Version Bump to the Next Alpha Release") + print("=" * 50) + package_type = config.get("package_type") + if not supports_prerelease(package_type): + print( + f"Skipping alpha version bump: '{package_type}' projects cannot store " + "an alpha marker." + ) + return + next_version = determine_new_version(released_version, "final", is_bugfix=False) + if not next_version: + print("No version provided. Version bump cancelled.") + return + + base_branch = resolve_main_branch("Which branch should the version be bumped on?") + if not base_branch: + print("Skipping the version bump.") + return + bump_branch = f"chore/bump-version-{next_version}" + pr_title = f"[chores] Bumped version to {next_version} alpha" + force_with_lease = None + + try: + print(f"Checking out '{base_branch}' and pulling latest changes...") + run_git(["checkout", base_branch], f"checkout '{base_branch}'") + run_git(["pull", "origin", base_branch], f"pull '{base_branch}'") + while True: + remote_commit = get_remote_branch_commit(bump_branch) + if not branch_exists(bump_branch) and not remote_commit: + break + decision = questionary.select( + f"Branch '{bump_branch}' already exists. How would you like to proceed?", + choices=[ + f"Reset it to '{base_branch}'", + "Use a different branch name", + "Abort the version bump", + ], + ).ask() + if decision == f"Reset it to '{base_branch}'": + force_with_lease = remote_commit + break + elif decision == "Use a different branch name": + bump_branch = questionary.text("Enter the branch name:").ask() + if not bump_branch: + raise AbortSignal("No branch name provided.") + else: # Abort or None + raise AbortSignal("User aborted the version bump.") + print(f"Creating new branch '{bump_branch}'...") + run_git(["checkout", "-B", bump_branch], f"create branch '{bump_branch}'") + bump_version(config, next_version, version_type="alpha") + print(f"โœ… Version bumped to {next_version} and set to 'alpha'.") + changelog_path = config["changelog_path"] + prefix = "Version " if config.get("changelog_uses_version_prefix", True) else "" + version_header = f"{prefix}{next_version} [unreleased]" + if config["changelog_format"] == "md": + unreleased_block = f"## {version_header}\n\nWork in progress." + else: # rst + underline = "-" * len(version_header) + unreleased_block = f"{version_header}\n{underline}\n\nWork in progress." + + update_changelog_file(changelog_path, unreleased_block) + if config["changelog_format"] == "rst": + format_file_with_docstrfmt(changelog_path) + print(f"โœ… {changelog_path} has been updated.") + print("Committing changes...") + run_git(["add", "-u"], "stage the version bump") + run_git(["commit", "-m", pr_title], "commit the version bump") + + print(f"โคด๏ธ Pushing branch '{bump_branch}' to origin...") + push_args = ["push", "-u", "origin", bump_branch] + if force_with_lease: + push_args.insert( + 1, + f"--force-with-lease=refs/heads/{bump_branch}:{force_with_lease}", + ) + run_git(push_args, f"push branch '{bump_branch}'") + print("Creating pull request...") + pr_url = gh.create_pr(bump_branch, base_branch, pr_title) + print(f"\nโœ… Successfully created Pull Request for the version bump: {pr_url}") + except (SkipSignal, AbortSignal) as e: + print( + f"\nโš ๏ธ {e}" + "\nPlease complete the version bump manually." + f"\n Branch: {bump_branch}" + f"\n Base: {base_branch}" + f"\n Title: {pr_title}" + ) + finally: + print(f"\nSwitching back to original branch '{original_branch}'...") + subprocess.run( + ["git", "checkout", original_branch], check=True, capture_output=True + ) + + def main(): config, gh = check_prerequisites() original_branch = get_current_branch() @@ -316,7 +418,9 @@ def main(): print(f"โคด๏ธ Pushing new branch '{release_branch}' to GitHub...") subprocess.run( - ["git", "push", "origin", release_branch], check=True, capture_output=True + ["git", "push", "-u", "origin", release_branch], + check=True, + capture_output=True, ) try: @@ -393,3 +497,17 @@ def main(): ) else: print("Skipping changelog port. Please remember to do it manually.") + elif ( + supports_prerelease(config.get("package_type")) + and questionary.confirm( + "Do you want to bump the version to the next alpha release now?" + ).ask() + ): + bump_to_next_alpha(gh, config, new_version, original_branch) + elif supports_prerelease(config.get("package_type")): + print("Skipping the version bump. Please remember to do it manually.") + else: + print( + f"Skipping alpha version bump: '{config.get('package_type')}' projects " + "cannot store an alpha marker." + ) diff --git a/openwisp_utils/releaser/tests/conftest.py b/openwisp_utils/releaser/tests/conftest.py index 1e50066a..cd7e806c 100644 --- a/openwisp_utils/releaser/tests/conftest.py +++ b/openwisp_utils/releaser/tests/conftest.py @@ -208,6 +208,9 @@ def subprocess_side_effect(command, *args, **kwargs): "bump_version": mocker.patch( "openwisp_utils.releaser.release.bump_version", return_value=True ), + "bump_to_next_alpha": mocker.patch( + "openwisp_utils.releaser.release.bump_to_next_alpha" + ), "update_changelog": mocker.patch( "openwisp_utils.releaser.release.update_changelog_file" ), @@ -243,6 +246,7 @@ def subprocess_side_effect(command, *args, **kwargs): mock_config = { "repo": "test/repo", + "package_type": "python", "changelog_path": "CHANGES.rst", "changelog_format": "rst", "changelog_uses_version_prefix": True, diff --git a/openwisp_utils/releaser/tests/test_release.py b/openwisp_utils/releaser/tests/test_release.py index f2431543..07189fe0 100644 --- a/openwisp_utils/releaser/tests/test_release.py +++ b/openwisp_utils/releaser/tests/test_release.py @@ -1,7 +1,7 @@ -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock, call, patch import pytest -from openwisp_utils.releaser.release import check_prerequisites +from openwisp_utils.releaser.release import bump_to_next_alpha, check_prerequisites from openwisp_utils.releaser.release import main as run_release from openwisp_utils.releaser.release import port_changelog_to_main from openwisp_utils.releaser.utils import SkipSignal @@ -373,3 +373,258 @@ def test_port_changelog_skip_pr_creation(mock_subprocess, mock_branch_exists, mo mock_all["questionary_confirm"].assert_any_call( "Press Enter when you have created the PR manually." ) + + +@pytest.fixture +def bump_mocks(mocker): + """Mocks the external dependencies of ``bump_to_next_alpha``.""" + mocks = { + "run_git": mocker.patch("openwisp_utils.releaser.release.run_git"), + "subprocess": mocker.patch("openwisp_utils.releaser.release.subprocess.run"), + "branch_exists": mocker.patch( + "openwisp_utils.releaser.release.branch_exists", + side_effect=lambda name: name == "master", + ), + "get_remote_branch_commit": mocker.patch( + "openwisp_utils.releaser.release.get_remote_branch_commit", + return_value=None, + ), + "determine_new_version": mocker.patch( + "openwisp_utils.releaser.release.determine_new_version", + return_value="1.3.0", + ), + "bump_version": mocker.patch( + "openwisp_utils.releaser.release.bump_version", return_value=True + ), + "update_changelog": mocker.patch( + "openwisp_utils.releaser.release.update_changelog_file" + ), + "format_file": mocker.patch( + "openwisp_utils.releaser.release.format_file_with_docstrfmt" + ), + "questionary": mocker.patch("openwisp_utils.releaser.release.questionary"), + "print": mocker.patch("builtins.print"), + } + return mocks + + +def _git_commands(mock_run_git): + return [call.args[0] for call in mock_run_git.call_args_list] + + +def test_bump_to_next_alpha_flow(bump_mocks): + mock_gh = MagicMock() + mock_gh.create_pr.return_value = "http://pr.url/3" + config = { + "package_type": "python", + "changelog_path": "CHANGES.rst", + "changelog_format": "rst", + "changelog_uses_version_prefix": True, + } + bump_to_next_alpha(mock_gh, config, "1.2.0", "master") + bump_mocks["bump_version"].assert_called_once_with( + config, "1.3.0", version_type="alpha" + ) + bump_mocks["update_changelog"].assert_called_once_with( + "CHANGES.rst", + "Version 1.3.0 [unreleased]\n--------------------------\n\nWork in progress.", + ) + bump_mocks["format_file"].assert_called_once_with("CHANGES.rst") + assert _git_commands(bump_mocks["run_git"]) == [ + ["checkout", "master"], + ["pull", "origin", "master"], + ["checkout", "-B", "chore/bump-version-1.3.0"], + ["add", "-u"], + ["commit", "-m", "[chores] Bumped version to 1.3.0 alpha"], + ["push", "-u", "origin", "chore/bump-version-1.3.0"], + ] + mock_gh.create_pr.assert_called_once_with( + "chore/bump-version-1.3.0", + "master", + "[chores] Bumped version to 1.3.0 alpha", + ) + mock_gh.is_pr_merged.assert_not_called() + bump_mocks["subprocess"].assert_called_once_with( + ["git", "checkout", "master"], check=True, capture_output=True + ) + + +def test_bump_to_next_alpha_changelog_block_variants(bump_mocks): + mock_gh = MagicMock() + variants = [ + ( + {"changelog_format": "md", "changelog_uses_version_prefix": True}, + "## Version 1.3.0 [unreleased]\n\nWork in progress.", + ), + ( + {"changelog_format": "md", "changelog_uses_version_prefix": False}, + "## 1.3.0 [unreleased]\n\nWork in progress.", + ), + ( + {"changelog_format": "rst", "changelog_uses_version_prefix": False}, + "1.3.0 [unreleased]\n------------------\n\nWork in progress.", + ), + ] + for changelog_config, expected_block in variants: + bump_mocks["update_changelog"].reset_mock() + bump_mocks["format_file"].reset_mock() + config = { + "package_type": "python", + "changelog_path": "CHANGES." + changelog_config["changelog_format"], + **changelog_config, + } + bump_to_next_alpha(mock_gh, config, "1.2.0", "master") + bump_mocks["update_changelog"].assert_called_once_with( + config["changelog_path"], expected_block + ) + if changelog_config["changelog_format"] == "md": + bump_mocks["format_file"].assert_not_called() + + +def test_bump_to_next_alpha_existing_branch_reset(bump_mocks): + mock_gh = MagicMock() + bump_mocks["branch_exists"].side_effect = lambda name: name in [ + "master", + "chore/bump-version-1.3.0", + ] + bump_mocks["questionary"].select.return_value.ask.return_value = ( + "Reset it to 'master'" + ) + remote_commit = "a" * 40 + bump_mocks["get_remote_branch_commit"].return_value = remote_commit + config = { + "package_type": "python", + "changelog_path": "CHANGES.rst", + "changelog_format": "rst", + "changelog_uses_version_prefix": True, + } + bump_to_next_alpha(mock_gh, config, "1.2.0", "master") + assert [ + "push", + "--force-with-lease=refs/heads/chore/bump-version-1.3.0:" + remote_commit, + "-u", + "origin", + "chore/bump-version-1.3.0", + ] in _git_commands(bump_mocks["run_git"]) + mock_gh.create_pr.assert_called_once() + + +def test_bump_to_next_alpha_existing_remote_branch_reset(bump_mocks): + mock_gh = MagicMock() + remote_commit = "a" * 40 + bump_mocks["get_remote_branch_commit"].return_value = remote_commit + bump_mocks["questionary"].select.return_value.ask.return_value = ( + "Reset it to 'master'" + ) + config = { + "package_type": "python", + "changelog_path": "CHANGES.rst", + "changelog_format": "rst", + "changelog_uses_version_prefix": True, + } + bump_to_next_alpha(mock_gh, config, "1.2.0", "master") + assert [ + "push", + "--force-with-lease=refs/heads/chore/bump-version-1.3.0:" + remote_commit, + "-u", + "origin", + "chore/bump-version-1.3.0", + ] in _git_commands(bump_mocks["run_git"]) + + +def test_bump_to_next_alpha_existing_branch_abort(bump_mocks): + mock_gh = MagicMock() + bump_mocks["branch_exists"].side_effect = lambda name: name in [ + "master", + "chore/bump-version-1.3.0", + ] + bump_mocks["questionary"].select.return_value.ask.return_value = ( + "Abort the version bump" + ) + config = { + "package_type": "python", + "changelog_path": "CHANGES.rst", + "changelog_format": "rst", + "changelog_uses_version_prefix": True, + } + bump_to_next_alpha(mock_gh, config, "1.2.0", "master") + bump_mocks["update_changelog"].assert_not_called() + mock_gh.create_pr.assert_not_called() + bump_mocks["subprocess"].assert_called_once_with( + ["git", "checkout", "master"], check=True, capture_output=True + ) + + +def test_bump_to_next_alpha_package_without_prerelease_support(bump_mocks): + mock_gh = MagicMock() + config = { + "package_type": "generic", + "changelog_path": "CHANGES.rst", + "changelog_format": "rst", + "changelog_uses_version_prefix": True, + } + bump_to_next_alpha(mock_gh, config, "1.2.0", "master") + bump_mocks["bump_version"].assert_not_called() + bump_mocks["run_git"].assert_not_called() + bump_mocks["update_changelog"].assert_not_called() + mock_gh.create_pr.assert_not_called() + + +def test_bump_to_next_alpha_skip_pr_creation(bump_mocks): + mock_gh = MagicMock() + mock_gh.create_pr.side_effect = SkipSignal("User chose to skip this operation.") + config = { + "package_type": "python", + "changelog_path": "CHANGES.rst", + "changelog_format": "rst", + "changelog_uses_version_prefix": True, + } + bump_to_next_alpha(mock_gh, config, "1.2.0", "master") + printed_output = "\n".join( + str(call.args[0]) for call in bump_mocks["print"].call_args_list if call.args + ) + assert "Please complete the version bump manually." in printed_output + assert "chore/bump-version-1.3.0" in printed_output + bump_mocks["subprocess"].assert_called_once_with( + ["git", "checkout", "master"], check=True, capture_output=True + ) + + +def test_bump_to_next_alpha_cancelled(bump_mocks): + mock_gh = MagicMock() + bump_mocks["determine_new_version"].return_value = None + config = { + "package_type": "python", + "changelog_path": "CHANGES.rst", + "changelog_format": "rst", + "changelog_uses_version_prefix": True, + } + bump_to_next_alpha(mock_gh, config, "1.2.0", "master") + bump_mocks["run_git"].assert_not_called() + mock_gh.create_pr.assert_not_called() + + +def test_main_feature_flow_offers_alpha_bump(mock_all): + run_release() + mock_all["bump_to_next_alpha"].assert_called_once() + assert mock_all["bump_to_next_alpha"].call_args[0][2] == "1.3.0" + + +def test_main_bugfix_flow_does_not_offer_alpha_bump(mock_all, mocker): + mock_all["_git_command_map"][("git", "rev-parse", "--abbrev-ref", "HEAD")] = ( + MagicMock(stdout="1.2.x") + ) + mocker.patch("openwisp_utils.releaser.release.branch_exists", return_value=True) + run_release() + mock_all["bump_to_next_alpha"].assert_not_called() + + +def test_main_feature_flow_skips_alpha_bump_for_unsupported_package(mock_all): + mock_config, _ = mock_all["check_prerequisites"].return_value + mock_config["package_type"] = "generic" + run_release() + mock_all["bump_to_next_alpha"].assert_not_called() + assert ( + call("Do you want to bump the version to the next alpha release now?") + not in mock_all["questionary_confirm"].call_args_list + ) diff --git a/openwisp_utils/releaser/tests/test_utils.py b/openwisp_utils/releaser/tests/test_utils.py index 468aaf52..ef99edf8 100644 --- a/openwisp_utils/releaser/tests/test_utils.py +++ b/openwisp_utils/releaser/tests/test_utils.py @@ -12,6 +12,7 @@ SkipSignal, branch_exists, format_file_with_docstrfmt, + get_remote_branch_commit, retryable_request, ) @@ -43,6 +44,26 @@ """ +@patch("openwisp_utils.releaser.utils.subprocess.run") +def test_get_remote_branch_commit(mock_subprocess): + mock_subprocess.return_value = MagicMock( + returncode=0, stdout="a" * 40 + "\trefs/heads/bump\n" + ) + assert get_remote_branch_commit("bump") == "a" * 40 + mock_subprocess.assert_called_once_with( + ["git", "ls-remote", "--exit-code", "--heads", "origin", "bump"], + capture_output=True, + text=True, + encoding="utf-8", + ) + + +@patch("openwisp_utils.releaser.utils.subprocess.run") +def test_get_remote_branch_commit_missing_branch(mock_subprocess): + mock_subprocess.return_value = MagicMock(returncode=2) + assert get_remote_branch_commit("bump") is None + + def test_rst_to_markdown_conversion(): """Test basic reStructuredText to Markdown conversion.""" # Test that the function calls it correctly. diff --git a/openwisp_utils/releaser/tests/test_version_bumping.py b/openwisp_utils/releaser/tests/test_version_bumping.py index 323a52cc..03c7b081 100644 --- a/openwisp_utils/releaser/tests/test_version_bumping.py +++ b/openwisp_utils/releaser/tests/test_version_bumping.py @@ -6,6 +6,7 @@ bump_version, determine_new_version, get_current_version, + supports_prerelease, ) SAMPLE_INIT_FILE = """ @@ -66,6 +67,10 @@ def test_bump_version_success(mock_config): expected_content = 'VERSION = (1, 2, 0, "final")' assert expected_content in written_content + with patch("os.path.exists", return_value=True), patch("builtins.open", m_open): + bump_version(mock_config, "1.3.0", version_type="alpha") + assert 'VERSION = (1, 3, 0, "alpha")' in m_open().write.call_args[0][0] + def test_bump_version_version_py(): """Tests bumping version when VERSION is in version.py.""" @@ -231,6 +236,28 @@ def test_bump_version_npm(): written_content = m_open().write.call_args[0][0] assert '"version": "1.2.4"' in written_content + with patch("os.path.exists", return_value=True), patch("builtins.open", m_open): + bump_version(config, "1.3.0", version_type="alpha") + assert '"version": "1.3.0-alpha"' in m_open().write.call_args[0][0] + + +def test_bump_version_prerelease_support(): + """Only version files that can store a marker accept a pre-release type.""" + assert supports_prerelease("python") is True + assert supports_prerelease("npm") is True + plain_version_packages = { + "generic": "VERSION", + "ansible": "templates/openwisp2/version.py", + "docker": "images/common/openwisp/VERSION", + } + for package_type, version_path in plain_version_packages.items(): + assert supports_prerelease(package_type) is False + config = {"package_type": package_type, "version_path": version_path} + m_open = mock_open(read_data="1.2.3\n") + with patch("os.path.exists", return_value=True), patch("builtins.open", m_open): + with pytest.raises(RuntimeError, match="cannot express"): + bump_version(config, "1.3.0", version_type="alpha") + # Docker (docker-openwisp) Package Version Tests def test_get_current_version_docker(): diff --git a/openwisp_utils/releaser/utils.py b/openwisp_utils/releaser/utils.py index 032e29a1..2b2fe4d0 100644 --- a/openwisp_utils/releaser/utils.py +++ b/openwisp_utils/releaser/utils.py @@ -14,6 +14,42 @@ class SkipSignal(Exception): pass +class AbortSignal(Exception): + """Signal that the user has chosen to abort an operation.""" + + pass + + +def run_git(args, description): + """Runs a git command, prompting Retry/Skip/Abort on failure.""" + while True: + try: + return subprocess.run( + ["git", *args], + check=True, + capture_output=True, + text=True, + encoding="utf-8", + ) + except subprocess.CalledProcessError as e: + print(f"\nโŒ ERROR: Failed to {description}.", file=sys.stderr) + print("Git output:", file=sys.stderr) + indented_stderr = "\n".join( + [f" {line}" for line in (e.stderr or "").strip().split("\n")] + ) + print(indented_stderr, file=sys.stderr) + decision = questionary.select( + "An error occurred. What would you like to do?", + choices=["Retry", "Skip", "Abort"], + ).ask() + if decision == "Retry": + continue + elif decision == "Skip": + raise SkipSignal(f"User chose to skip: {description}.") + else: # Abort or None + raise AbortSignal(f"User aborted while trying to {description}.") + + def retryable_request(**kwargs): """Executes a requests call and provides a retry/skip/abort prompt on failure.""" while True: @@ -95,6 +131,23 @@ def branch_exists(branch_name): return result.returncode == 0 +def get_remote_branch_commit(branch_name): + """Return the origin branch commit, or None when the branch does not exist.""" + result = subprocess.run( + ["git", "ls-remote", "--exit-code", "--heads", "origin", branch_name], + capture_output=True, + text=True, + encoding="utf-8", + ) + if result.returncode == 0: + return result.stdout.split()[0] + if result.returncode == 2: + return None + raise subprocess.CalledProcessError( + result.returncode, result.args, output=result.stdout, stderr=result.stderr + ) + + def rst_to_markdown(text): """Convert reStructuredText to Markdown using pypandoc.""" escaped_text = re.sub(r"(? Date: Wed, 2 Sep 2026 20:56:16 +0530 Subject: [PATCH 2/9] [chores:fix] Fixes by @coderabbitai --- openwisp_utils/releaser/release.py | 18 +++++++--- openwisp_utils/releaser/tests/test_release.py | 22 +++++++++++++ openwisp_utils/releaser/tests/test_utils.py | 33 +++++++++++++++++++ .../releaser/tests/test_version_bumping.py | 15 +++++++++ openwisp_utils/releaser/utils.py | 18 +++++----- openwisp_utils/releaser/version.py | 5 +-- 6 files changed, 96 insertions(+), 15 deletions(-) diff --git a/openwisp_utils/releaser/release.py b/openwisp_utils/releaser/release.py index af19759f..786ed83b 100644 --- a/openwisp_utils/releaser/release.py +++ b/openwisp_utils/releaser/release.py @@ -224,6 +224,8 @@ def bump_to_next_alpha(gh, config, released_version, original_branch): bump_branch = f"chore/bump-version-{next_version}" pr_title = f"[chores] Bumped version to {next_version} alpha" force_with_lease = None + bump_branch_created = False + changes_committed = False try: print(f"Checking out '{base_branch}' and pulling latest changes...") @@ -252,6 +254,7 @@ def bump_to_next_alpha(gh, config, released_version, original_branch): raise AbortSignal("User aborted the version bump.") print(f"Creating new branch '{bump_branch}'...") run_git(["checkout", "-B", bump_branch], f"create branch '{bump_branch}'") + bump_branch_created = True bump_version(config, next_version, version_type="alpha") print(f"โœ… Version bumped to {next_version} and set to 'alpha'.") changelog_path = config["changelog_path"] @@ -270,6 +273,7 @@ def bump_to_next_alpha(gh, config, released_version, original_branch): print("Committing changes...") run_git(["add", "-u"], "stage the version bump") run_git(["commit", "-m", pr_title], "commit the version bump") + changes_committed = True print(f"โคด๏ธ Pushing branch '{bump_branch}' to origin...") push_args = ["push", "-u", "origin", bump_branch] @@ -291,10 +295,16 @@ def bump_to_next_alpha(gh, config, released_version, original_branch): f"\n Title: {pr_title}" ) finally: - print(f"\nSwitching back to original branch '{original_branch}'...") - subprocess.run( - ["git", "checkout", original_branch], check=True, capture_output=True - ) + if not bump_branch_created or changes_committed: + print(f"\nSwitching back to original branch '{original_branch}'...") + subprocess.run( + ["git", "checkout", original_branch], check=True, capture_output=True + ) + else: + print( + f"\nKeeping branch '{bump_branch}' checked out because it has " + "uncommitted version-bump changes." + ) def main(): diff --git a/openwisp_utils/releaser/tests/test_release.py b/openwisp_utils/releaser/tests/test_release.py index 07189fe0..f14b66b5 100644 --- a/openwisp_utils/releaser/tests/test_release.py +++ b/openwisp_utils/releaser/tests/test_release.py @@ -590,6 +590,28 @@ def test_bump_to_next_alpha_skip_pr_creation(bump_mocks): ) +def test_bump_to_next_alpha_preserves_uncommitted_changes(bump_mocks): + mock_gh = MagicMock() + + def fail_commit(args, description): + if args[0] == "commit": + raise SkipSignal("User chose to skip: commit the version bump.") + + bump_mocks["run_git"].side_effect = fail_commit + config = { + "package_type": "python", + "changelog_path": "CHANGES.rst", + "changelog_format": "rst", + "changelog_uses_version_prefix": True, + } + bump_to_next_alpha(mock_gh, config, "1.2.0", "master") + bump_mocks["subprocess"].assert_not_called() + printed_output = "\n".join( + str(call.args[0]) for call in bump_mocks["print"].call_args_list if call.args + ) + assert "Keeping branch 'chore/bump-version-1.3.0' checked out" in printed_output + + def test_bump_to_next_alpha_cancelled(bump_mocks): mock_gh = MagicMock() bump_mocks["determine_new_version"].return_value = None diff --git a/openwisp_utils/releaser/tests/test_utils.py b/openwisp_utils/releaser/tests/test_utils.py index ef99edf8..3c1dd0ce 100644 --- a/openwisp_utils/releaser/tests/test_utils.py +++ b/openwisp_utils/releaser/tests/test_utils.py @@ -9,6 +9,7 @@ rst_to_markdown, ) from openwisp_utils.releaser.utils import ( + AbortSignal, SkipSignal, branch_exists, format_file_with_docstrfmt, @@ -64,6 +65,38 @@ def test_get_remote_branch_commit_missing_branch(mock_subprocess): assert get_remote_branch_commit("bump") is None +@patch("openwisp_utils.releaser.utils.questionary.select") +@patch("openwisp_utils.releaser.utils.subprocess.run") +def test_get_remote_branch_commit_retries_after_failure( + mock_subprocess, mock_questionary +): + error = subprocess.CalledProcessError(128, "git", stderr="Authentication failed") + mock_subprocess.side_effect = [ + error, + MagicMock(returncode=0, stdout="a" * 40 + "\trefs/heads/bump\n"), + ] + mock_questionary.return_value.ask.return_value = "Retry" + assert get_remote_branch_commit("bump") == "a" * 40 + assert mock_subprocess.call_count == 2 + + +@pytest.mark.parametrize( + ("decision", "exception"), + [("Skip", SkipSignal), ("Abort", AbortSignal)], +) +@patch("openwisp_utils.releaser.utils.questionary.select") +@patch("openwisp_utils.releaser.utils.subprocess.run") +def test_get_remote_branch_commit_handles_failure_decisions( + mock_subprocess, mock_questionary, decision, exception +): + mock_subprocess.side_effect = subprocess.CalledProcessError( + 128, "git", stderr="Authentication failed" + ) + mock_questionary.return_value.ask.return_value = decision + with pytest.raises(exception): + get_remote_branch_commit("bump") + + def test_rst_to_markdown_conversion(): """Test basic reStructuredText to Markdown conversion.""" # Test that the function calls it correctly. diff --git a/openwisp_utils/releaser/tests/test_version_bumping.py b/openwisp_utils/releaser/tests/test_version_bumping.py index 03c7b081..950d4157 100644 --- a/openwisp_utils/releaser/tests/test_version_bumping.py +++ b/openwisp_utils/releaser/tests/test_version_bumping.py @@ -136,6 +136,21 @@ def test_bump_version_invalid_format(): bump_version(mock_config, "1.2") +@pytest.mark.parametrize( + ("package_type", "content"), + [ + ("python", SAMPLE_INIT_FILE), + ("npm", '{"version": "1.2.3"}'), + ], +) +def test_bump_version_rejects_prerelease_version(package_type, content): + config = {"package_type": package_type, "version_path": "version-file"} + with patch("builtins.open", mock_open(read_data=content)) as mocked_open: + with pytest.raises(SystemExit): + bump_version(config, "1.3.0-alpha", version_type="alpha") + mocked_open.assert_not_called() + + @patch("openwisp_utils.releaser.version.questionary") def test_determine_new_version_not_final(mock_questionary): """Tests the version suggestion when the current version is not 'final'.""" diff --git a/openwisp_utils/releaser/utils.py b/openwisp_utils/releaser/utils.py index 2b2fe4d0..c1f11c4f 100644 --- a/openwisp_utils/releaser/utils.py +++ b/openwisp_utils/releaser/utils.py @@ -20,7 +20,7 @@ class AbortSignal(Exception): pass -def run_git(args, description): +def run_git(args, description, allowed_returncodes=()): """Runs a git command, prompting Retry/Skip/Abort on failure.""" while True: try: @@ -32,6 +32,10 @@ def run_git(args, description): encoding="utf-8", ) except subprocess.CalledProcessError as e: + if e.returncode in allowed_returncodes: + return subprocess.CompletedProcess( + e.cmd, e.returncode, e.output, e.stderr + ) print(f"\nโŒ ERROR: Failed to {description}.", file=sys.stderr) print("Git output:", file=sys.stderr) indented_stderr = "\n".join( @@ -133,19 +137,15 @@ def branch_exists(branch_name): def get_remote_branch_commit(branch_name): """Return the origin branch commit, or None when the branch does not exist.""" - result = subprocess.run( - ["git", "ls-remote", "--exit-code", "--heads", "origin", branch_name], - capture_output=True, - text=True, - encoding="utf-8", + result = run_git( + ["ls-remote", "--exit-code", "--heads", "origin", branch_name], + f"look up remote branch '{branch_name}'", + allowed_returncodes=(2,), ) if result.returncode == 0: return result.stdout.split()[0] if result.returncode == 2: return None - raise subprocess.CalledProcessError( - result.returncode, result.args, output=result.stdout, stderr=result.stderr - ) def rst_to_markdown(text): diff --git a/openwisp_utils/releaser/version.py b/openwisp_utils/releaser/version.py index fd9ab3b6..0dd55561 100644 --- a/openwisp_utils/releaser/version.py +++ b/openwisp_utils/releaser/version.py @@ -155,8 +155,9 @@ def bump_version(config, new_version, version_type="final"): # version bumping was not performed return False try: - new_version_parts = new_version.split(".") - if len(new_version_parts) != 3: + if not isinstance(new_version, str) or not re.fullmatch( + r"\d+\.\d+\.\d+", new_version + ): raise ValueError("Version must be in the format X.Y.Z") except ValueError as e: print(f"Error: Invalid version format. {e}", file=sys.stderr) From 072340fce7af4022b79f850b30d6b5df900f5451 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Thu, 3 Sep 2026 00:39:26 +0530 Subject: [PATCH 3/9] [chores:fix] Use prefix "bump" for commit message and PR title --- openwisp_utils/releaser/release.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openwisp_utils/releaser/release.py b/openwisp_utils/releaser/release.py index 786ed83b..8cf6979c 100644 --- a/openwisp_utils/releaser/release.py +++ b/openwisp_utils/releaser/release.py @@ -222,7 +222,7 @@ def bump_to_next_alpha(gh, config, released_version, original_branch): print("Skipping the version bump.") return bump_branch = f"chore/bump-version-{next_version}" - pr_title = f"[chores] Bumped version to {next_version} alpha" + pr_title = f"[bump] Bumped version to {next_version} alpha" force_with_lease = None bump_branch_created = False changes_committed = False From 0c92e4108421858906f8b64ab238dda2bc2fc8e5 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Thu, 3 Sep 2026 16:11:20 +0530 Subject: [PATCH 4/9] [fix] Fixed dependency links in GitHub release notes Protected ReST dependency links with version specifiers during Pandoc conversion, ensuring GitHub releases render them as valid Markdown links. Added a regression test for version constraint links. --- openwisp_utils/releaser/tests/test_utils.py | 11 ++++++++++ openwisp_utils/releaser/utils.py | 24 ++++++++++++++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/openwisp_utils/releaser/tests/test_utils.py b/openwisp_utils/releaser/tests/test_utils.py index 3c1dd0ce..ed5af496 100644 --- a/openwisp_utils/releaser/tests/test_utils.py +++ b/openwisp_utils/releaser/tests/test_utils.py @@ -106,6 +106,17 @@ def test_rst_to_markdown_conversion(): mock_convert.assert_called_once() +def test_rst_to_markdown_converts_dependency_version_links(): + rst = """- Bumped ``django-organizations`` to `>=2.7.0,<2.8.0 + `_. +""" + expected = ( + "- Bumped `django-organizations` to [>=2.7.0,<2.8.0]" + "(https://github.com/bennylope/django-organizations/blob/master/HISTORY.rst)." + ) + assert rst_to_markdown(rst) == expected + + def test_adjust_markdown_headings(): """Test that markdown headings are correctly adjusted for the CHANGES.md file.""" raw_md = """ diff --git a/openwisp_utils/releaser/utils.py b/openwisp_utils/releaser/utils.py index c1f11c4f..15a64e7d 100644 --- a/openwisp_utils/releaser/utils.py +++ b/openwisp_utils/releaser/utils.py @@ -150,10 +150,32 @@ def get_remote_branch_commit(branch_name): def rst_to_markdown(text): """Convert reStructuredText to Markdown using pypandoc.""" + links = [] + + def protect_dependency_link(match): + """Replace a matched ReST dependency link with a marker. + + Stores its GitHub Flavoured Markdown equivalent for restoration + after Pandoc conversion. + """ + link = f"[{''.join(match['version'].split())}]({match['url']})" + marker = f"OPENWISPRELEASERLINK{len(links)}" + links.append((marker, link)) + return marker + + text = re.sub( + r"`(?P[><=~!].*?)\s+<(?Phttps?://[^>\s]+)>`_{1,2}", + protect_dependency_link, + text, + flags=re.DOTALL, + ) escaped_text = re.sub(r"(? Date: Thu, 3 Sep 2026 18:51:25 +0530 Subject: [PATCH 5/9] [fix] Limited releaser commits to release files --- docs/developer/releaser-tool.rst | 6 ++++-- openwisp_utils/releaser/release.py | 7 +++++-- openwisp_utils/releaser/tests/test_release.py | 5 +++++ 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/docs/developer/releaser-tool.rst b/docs/developer/releaser-tool.rst index a4777719..558eeb4b 100644 --- a/docs/developer/releaser-tool.rst +++ b/docs/developer/releaser-tool.rst @@ -92,8 +92,10 @@ process: 1. Updates the version number in your project's ``__init__.py``. 2. Writes the new release notes to your ``CHANGES.rst`` or ``CHANGES.md`` - file. -3. Creates a ``release/`` branch and commits the changes. + file. +3. Creates a ``release/`` branch and commits only the updated + changelog and detected version file, leaving unrelated worktree changes + unstaged. 4. Pushes the new branch to GitHub. 5. Creates a pull request and waits for you to merge it. 6. Once merged, it creates and pushes a signed git tag. diff --git a/openwisp_utils/releaser/release.py b/openwisp_utils/releaser/release.py index 8cf6979c..f4e5b703 100644 --- a/openwisp_utils/releaser/release.py +++ b/openwisp_utils/releaser/release.py @@ -417,8 +417,11 @@ def main(): ["git", "checkout", "-b", release_branch], check=True, capture_output=True ) - print("Adding tracked changes to git...") - subprocess.run(["git", "add", "-u"], check=True, capture_output=True) + paths_to_add = [changelog_path] + if version_path := config.get("version_path"): + paths_to_add.append(version_path) + print("Adding release changes to git...") + subprocess.run(["git", "add", *paths_to_add], check=True, capture_output=True) commit_message = f"[release] Version {new_version}" subprocess.run( diff --git a/openwisp_utils/releaser/tests/test_release.py b/openwisp_utils/releaser/tests/test_release.py index f14b66b5..5c4f77c2 100644 --- a/openwisp_utils/releaser/tests/test_release.py +++ b/openwisp_utils/releaser/tests/test_release.py @@ -12,6 +12,7 @@ def test_feature_release_flow_markdown(mock_all, mocker): mock_config, mock_gh = mock_all["check_prerequisites"].return_value mock_config["changelog_path"] = "CHANGES.md" mock_config["changelog_format"] = "md" + mock_config["version_path"] = "package/__init__.py" mock_all["get_release_block_from_file"].return_value = None @@ -25,6 +26,10 @@ def test_feature_release_flow_markdown(mock_all, mocker): mock_all["update_changelog"].assert_called_once() mock_all["format_file"].assert_not_called() + assert ["git", "add", "CHANGES.md", "package/__init__.py"] in [ + call.args[0] for call in mock_all["subprocess"].call_args_list + ] + release_call_args = mock_gh.create_release.call_args.args assert "## Markdown Changelog" in release_call_args[2] From fb77d5290022c0ae1eb15f644f1fe6be93a8f2d5 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Thu, 3 Sep 2026 20:13:46 +0530 Subject: [PATCH 6/9] [fix] Generation of markdown for GitHub release --- openwisp_utils/releaser/tests/test_utils.py | 15 +++++++++------ openwisp_utils/releaser/utils.py | 2 +- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/openwisp_utils/releaser/tests/test_utils.py b/openwisp_utils/releaser/tests/test_utils.py index ed5af496..e4f9c537 100644 --- a/openwisp_utils/releaser/tests/test_utils.py +++ b/openwisp_utils/releaser/tests/test_utils.py @@ -107,13 +107,16 @@ def test_rst_to_markdown_conversion(): def test_rst_to_markdown_converts_dependency_version_links(): - rst = """- Bumped ``django-organizations`` to `>=2.7.0,<2.8.0 - `_. + rst = """- Bumped ``openwisp-users`` from ``~=1.2.0`` to `~=1.3.0 + `_. +- Bumped ``openwisp-utils[rest]`` from ``~=1.2.0`` to `~=1.3.0 + `__. +- Bumped ``django-reversion`` from ``~=6.0.0`` to `~=6.3.0 + `_. """ - expected = ( - "- Bumped `django-organizations` to [>=2.7.0,<2.8.0]" - "(https://github.com/bennylope/django-organizations/blob/master/HISTORY.rst)." - ) + expected = """- Bumped `openwisp-users` from `~=1.2.0` to [~=1.3.0](https://github.com/openwisp/openwisp-users/blob/1.3.0/CHANGES.rst). +- Bumped `openwisp-utils[rest]` from `~=1.2.0` to [~=1.3.0](https://github.com/openwisp/openwisp-utils/blob/1.3.0/CHANGES.rst). +- Bumped `django-reversion` from `~=6.0.0` to [~=6.3.0](https://github.com/etianen/django-reversion/blob/v6.3.0/CHANGELOG.rst).""" assert rst_to_markdown(rst) == expected diff --git a/openwisp_utils/releaser/utils.py b/openwisp_utils/releaser/utils.py index 15a64e7d..ade6835d 100644 --- a/openwisp_utils/releaser/utils.py +++ b/openwisp_utils/releaser/utils.py @@ -164,7 +164,7 @@ def protect_dependency_link(match): return marker text = re.sub( - r"`(?P[><=~!].*?)\s+<(?Phttps?://[^>\s]+)>`_{1,2}", + r"`(?P[><=~!][^`\s]*)\s+<(?Phttps?://[^>\s]+)>`_{1,2}", protect_dependency_link, text, flags=re.DOTALL, From 8252ea6dfc331650b02fc31ebf395af57ea3126f Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Fri, 4 Sep 2026 14:33:22 +0530 Subject: [PATCH 7/9] [tests] Fixed releaser test expectations --- openwisp_utils/releaser/tests/test_release.py | 4 ++-- openwisp_utils/releaser/tests/test_utils.py | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/openwisp_utils/releaser/tests/test_release.py b/openwisp_utils/releaser/tests/test_release.py index 5c4f77c2..ea28dc8f 100644 --- a/openwisp_utils/releaser/tests/test_release.py +++ b/openwisp_utils/releaser/tests/test_release.py @@ -440,13 +440,13 @@ def test_bump_to_next_alpha_flow(bump_mocks): ["pull", "origin", "master"], ["checkout", "-B", "chore/bump-version-1.3.0"], ["add", "-u"], - ["commit", "-m", "[chores] Bumped version to 1.3.0 alpha"], + ["commit", "-m", "[bump] Bumped version to 1.3.0 alpha"], ["push", "-u", "origin", "chore/bump-version-1.3.0"], ] mock_gh.create_pr.assert_called_once_with( "chore/bump-version-1.3.0", "master", - "[chores] Bumped version to 1.3.0 alpha", + "[bump] Bumped version to 1.3.0 alpha", ) mock_gh.is_pr_merged.assert_not_called() bump_mocks["subprocess"].assert_called_once_with( diff --git a/openwisp_utils/releaser/tests/test_utils.py b/openwisp_utils/releaser/tests/test_utils.py index e4f9c537..dab51881 100644 --- a/openwisp_utils/releaser/tests/test_utils.py +++ b/openwisp_utils/releaser/tests/test_utils.py @@ -53,6 +53,7 @@ def test_get_remote_branch_commit(mock_subprocess): assert get_remote_branch_commit("bump") == "a" * 40 mock_subprocess.assert_called_once_with( ["git", "ls-remote", "--exit-code", "--heads", "origin", "bump"], + check=True, capture_output=True, text=True, encoding="utf-8", From b111bb8bdb5099a479dca70c9e2957d7d5a1b410 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Fri, 4 Sep 2026 15:37:43 +0530 Subject: [PATCH 8/9] [feature] Allowed resuming an on-going release --- docs/developer/releaser-tool.rst | 14 + openwisp_utils/releaser/__main__.py | 11 +- openwisp_utils/releaser/github.py | 63 ++- openwisp_utils/releaser/release.py | 263 ++++++++--- openwisp_utils/releaser/tests/conftest.py | 2 + openwisp_utils/releaser/tests/test_github.py | 38 ++ openwisp_utils/releaser/tests/test_release.py | 417 ++++++------------ openwisp_utils/releaser/utils.py | 4 +- 8 files changed, 457 insertions(+), 355 deletions(-) diff --git a/docs/developer/releaser-tool.rst b/docs/developer/releaser-tool.rst index 558eeb4b..09b13790 100644 --- a/docs/developer/releaser-tool.rst +++ b/docs/developer/releaser-tool.rst @@ -59,6 +59,20 @@ the following command: python -m openwisp_utils.releaser +If the process stops after it creates the release pull request, resume it +without repeating the version bump or changelog preparation: + +.. code-block:: shell + + python -m openwisp_utils.releaser resume https://github.com/owner/repository/pull/123 + +The resume command watches that release pull request until it is merged +and then creates any missing downstream artifacts. It reuses an existing +version tag on the merged base branch, an existing GitHub release for that +tag, and matching follow-up pull requests instead of creating duplicates. +A pull request that was closed without merging must be reopened or +replaced before the release can be resumed. + The Interactive Workflow ------------------------ diff --git a/openwisp_utils/releaser/__main__.py b/openwisp_utils/releaser/__main__.py index 196c8b8c..3f3b695c 100644 --- a/openwisp_utils/releaser/__main__.py +++ b/openwisp_utils/releaser/__main__.py @@ -1,3 +1,4 @@ +import argparse import subprocess import sys @@ -5,8 +6,16 @@ from openwisp_utils.releaser.release import main if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("command", nargs="?", choices=["resume"]) + parser.add_argument("pr_url", nargs="?") + args = parser.parse_args() + if args.command == "resume" and not args.pr_url: + parser.error("resume requires the release pull request URL") + if args.command is None and args.pr_url: + parser.error("a pull request URL can only be used with the resume command") try: - main() + main(resume_pr_url=args.pr_url) except KeyboardInterrupt: print("\n\nโŒ Release process terminated by user.") sys.exit(1) diff --git a/openwisp_utils/releaser/github.py b/openwisp_utils/releaser/github.py index 2aae2bfe..23f64136 100644 --- a/openwisp_utils/releaser/github.py +++ b/openwisp_utils/releaser/github.py @@ -1,4 +1,5 @@ import uuid +from urllib.parse import urlparse import requests from openwisp_utils.utils import retryable_request as utils_retryable_request @@ -38,11 +39,60 @@ def create_pr(self, head, base, title): def is_pr_merged(self, pr_url): """Checks if a pull request has been merged.""" - pr_number = pr_url.split("/")[-1] + pr_number = pr_url.rstrip("/").split("/")[-1] url = f"{self.base_url}/pulls/{pr_number}" response = retryable_request(method="get", url=url, headers=self.headers) return response.json().get("merged", False) + def get_pr(self, pr_url): + """Returns metadata for a pull request in the configured repository.""" + parsed_url = urlparse(pr_url) + path = parsed_url.path.strip("/").split("/") + if ( + parsed_url.scheme not in {"http", "https"} + or parsed_url.netloc != "github.com" + or len(path) != 4 + or "/".join(path[:2]) != self.repo + or path[2] != "pull" + or not path[3].isdigit() + ): + raise ValueError( + "The pull request URL must belong to the configured GitHub repository." + ) + pr_number = path[3] + url = f"{self.base_url}/pulls/{pr_number}" + response = retryable_request(method="get", url=url, headers=self.headers) + return response.json() + + def get_release(self, tag_name): + """Returns the release for a tag, or None when it has not been created.""" + response = retryable_request( + method="get", + url=f"{self.base_url}/releases/tags/{tag_name}", + headers=self.headers, + allowed_status_codes=(404,), + ) + if response.status_code == 404: + return None + return response.json() + + def find_pr(self, head, base, title): + """Returns an existing matching pull request, regardless of its state.""" + response = retryable_request( + method="get", + url=f"{self.base_url}/pulls", + headers=self.headers, + params={"state": "all", "base": base, "per_page": 100}, + ) + for pull_request in response.json(): + if ( + pull_request.get("title") == title + and pull_request.get("head", {}).get("ref") == head + and pull_request.get("base", {}).get("ref") == base + ): + return pull_request + return None + def create_release(self, tag_name, title, body): """Creates a draft release on GitHub.""" url = f"{self.base_url}/releases" @@ -54,8 +104,17 @@ def create_release(self, tag_name, title, body): "prerelease": False, } response = retryable_request( - method="post", url=url, headers=self.headers, json=payload + method="post", + url=url, + headers=self.headers, + json=payload, + allowed_status_codes=(422,), ) + if response.status_code == 422: + existing_release = self.get_release(tag_name) + if existing_release: + return existing_release["html_url"] + response.raise_for_status() return response.json()["html_url"] def check_pr_creation_permission(self) -> tuple[bool, str]: diff --git a/openwisp_utils/releaser/release.py b/openwisp_utils/releaser/release.py index f4e5b703..9460fe79 100644 --- a/openwisp_utils/releaser/release.py +++ b/openwisp_utils/releaser/release.py @@ -38,6 +38,142 @@ MAIN_BRANCHES = ["master", "main"] +def wait_for_pr_merge(gh, pr_url): + """Waits for a release pull request and returns its merged metadata.""" + print("โณ Waiting for PR to be merged... (checking every 20s)") + while True: + pull_request = gh.get_pr(pr_url) + if pull_request.get("merged"): + print("โœ… PR merged!") + return pull_request + if pull_request.get("state") == "closed": + raise RuntimeError( + "The release pull request was closed without being merged. " + "Reopen or replace it before resuming the release." + ) + time.sleep(20) + + +def get_release_version(pull_request): + """Returns the version encoded in a release pull request branch.""" + branch = pull_request.get("head", {}).get("ref", "") + if not branch.startswith("release/") or not branch.removeprefix("release/"): + raise RuntimeError( + "The pull request must originate from a release/ branch." + ) + return branch.removeprefix("release/") + + +def tag_exists_on_branch(tag_name, branch): + """Checks whether a tag resolves to a commit contained by a branch.""" + tag = subprocess.run( + ["git", "rev-parse", "--verify", "--quiet", f"{tag_name}^{{commit}}"], + capture_output=True, + text=True, + ) + if tag.returncode: + return False + return ( + subprocess.run( + ["git", "merge-base", "--is-ancestor", f"{tag_name}^{{commit}}", branch], + capture_output=True, + text=True, + ).returncode + == 0 + ) + + +def complete_release_artifacts( + gh, + config, + version, + base_branch, + latest_changelog_block, + tag_date_str, + changelog_date_str, +): + """Creates only the release artifacts that do not already exist.""" + run_git(["checkout", base_branch], f"checkout '{base_branch}'") + run_git(["pull", "origin", base_branch], f"pull '{base_branch}'") + run_git(["fetch", "origin", "--tags"], "fetch remote tags") + + tag_name = version + if tag_exists_on_branch(tag_name, base_branch): + print(f"๐Ÿท๏ธ Git tag '{tag_name}' already exists on '{base_branch}'.") + else: + local_tag = subprocess.run( + ["git", "rev-parse", "--verify", "--quiet", f"{tag_name}^{{commit}}"], + capture_output=True, + text=True, + ) + if local_tag.returncode == 0: + raise RuntimeError( + f"Git tag '{tag_name}' exists but is not contained by '{base_branch}'." + ) + tag_message = f"Version {version} [{tag_date_str}]" + run_git(["tag", "-s", tag_name, "-m", tag_message], f"create tag '{tag_name}'") + run_git(["push", "origin", tag_name], f"push tag '{tag_name}'") + print(f"๐Ÿท๏ธ Git tag '{tag_name}' created and pushed.") + + release_title = f"{version} [{changelog_date_str}]" + if config["changelog_format"] == "md": + release_body_md = "\n".join(latest_changelog_block.splitlines()[1:]).strip() + release_body_md = demote_markdown_headings(release_body_md) + else: + release_body_rst = "\n".join(latest_changelog_block.splitlines()[2:]).strip() + release_body_md = rst_to_markdown(release_body_rst) + + existing_release = gh.get_release(tag_name) + if existing_release: + print(f"๐Ÿ“ฆ GitHub release already exists: {existing_release['html_url']}") + else: + try: + release_url = gh.create_release(tag_name, release_title, release_body_md) + print(f"๐Ÿ“ฆ Draft release created on GitHub: {release_url}") + except SkipSignal: + print( + "\nOperation skipped. Please create the GitHub release manually." + f"\n Tag: {tag_name}" + f"\n Title: {release_title}" + "\n Body: (You can find the content in the latest commit)." + ) + questionary.confirm( + "Press Enter when you have created the release manually." + ).ask() + + +def resume(pr_url): + """Completes a release after its already-created pull request is merged.""" + config, gh = check_prerequisites() + pull_request = wait_for_pr_merge(gh, pr_url) + version = get_release_version(pull_request) + base_branch = pull_request["base"]["ref"] + + run_git(["checkout", base_branch], f"checkout '{base_branch}'") + run_git(["pull", "origin", base_branch], f"pull '{base_branch}'") + latest_changelog_block = get_release_block_from_file(config, version) + if not latest_changelog_block: + raise RuntimeError( + f"Could not find the changelog entry for version {version} on '{base_branch}'." + ) + + changelog_date = datetime.now().strftime("%Y-%m-%d") + header = latest_changelog_block.splitlines()[0] + date_match = re.search(r"\[(\d{4}-\d{2}-\d{2})\]", header) + if date_match: + changelog_date = date_match.group(1) + complete_release_artifacts( + gh, + config, + version, + base_branch, + latest_changelog_block, + datetime.now().strftime("%d-%m-%Y"), + changelog_date, + ) + _complete_follow_up(gh, config, version, latest_changelog_block, base_branch) + + def check_prerequisites(): """Checks for all required prerequisite.""" print("๐Ÿ”Ž Checking prerequisites...") @@ -131,6 +267,7 @@ def port_changelog_to_main(gh, config, version, changelog_body, original_branch) main_branch = resolve_main_branch( "Which branch should the changelog be ported to?" ) + if not main_branch: print("Skipping changelog porting.") return @@ -138,6 +275,12 @@ def port_changelog_to_main(gh, config, version, changelog_body, original_branch) port_branch = f"chore/port-changelog-{version}" commit_message = f"[docs] Port changelog for {version}" pr_title = f"[docs] Port changelog for release {version}" + existing_pr = gh.find_pr(port_branch, main_branch, pr_title) + if isinstance(existing_pr, dict): + print( + f"Changelog port pull request already exists: {existing_pr['html_url']}" + ) + return print(f"Checking out '{main_branch}' and pulling latest changes...") subprocess.run( @@ -227,6 +370,11 @@ def bump_to_next_alpha(gh, config, released_version, original_branch): bump_branch_created = False changes_committed = False + existing_pr = gh.find_pr(bump_branch, base_branch, pr_title) + if isinstance(existing_pr, dict): + print(f"Version bump pull request already exists: {existing_pr['html_url']}") + return + try: print(f"Checking out '{base_branch}' and pulling latest changes...") run_git(["checkout", base_branch], f"checkout '{base_branch}'") @@ -307,7 +455,43 @@ def bump_to_next_alpha(gh, config, released_version, original_branch): ) -def main(): +def _complete_follow_up(gh, config, version, latest_changelog_block, base_branch): + """Offers the existing post-release follow-up appropriate for the base branch.""" + is_bugfix = base_branch not in MAIN_BRANCHES + if is_bugfix: + print("\n๐Ÿ› Bugfix release complete.") + if questionary.confirm( + "Do you want to create a PR to port the changelog to the main branch now?" + ).ask(): + lines_to_skip = 2 if config["changelog_format"] != "md" else 1 + changelog_body_for_porting = "\n".join( + latest_changelog_block.splitlines()[lines_to_skip:] + ).strip() + port_changelog_to_main( + gh, config, version, changelog_body_for_porting, base_branch + ) + else: + print("Skipping changelog port. Please remember to do it manually.") + elif ( + supports_prerelease(config.get("package_type")) + and questionary.confirm( + "Do you want to bump the version to the next alpha release now?" + ).ask() + ): + bump_to_next_alpha(gh, config, version, base_branch) + elif supports_prerelease(config.get("package_type")): + print("Skipping the version bump. Please remember to do it manually.") + else: + print( + f"Skipping alpha version bump: '{config.get('package_type')}' projects " + "cannot store an alpha marker." + ) + + +def main(resume_pr_url=None): + if resume_pr_url: + resume(resume_pr_url) + return config, gh = check_prerequisites() original_branch = get_current_branch() is_bugfix = original_branch not in MAIN_BRANCHES @@ -454,73 +638,18 @@ def main(): ) questionary.confirm("Press Enter when you have merged the PR manually.").ask() - subprocess.run( - ["git", "checkout", original_branch], check=True, capture_output=True + complete_release_artifacts( + gh, + config, + new_version, + original_branch, + latest_changelog_block, + tag_date_str, + changelog_date_str, ) - subprocess.run( - ["git", "pull", "origin", original_branch], check=True, capture_output=True - ) - - tag_name = new_version - tag_message = f"Version {new_version} [{tag_date_str}]" - subprocess.run(["git", "tag", "-s", tag_name, "-m", tag_message], check=True) - subprocess.run(["git", "push", "origin", tag_name], check=True) - print(f"๐Ÿท๏ธ Git tag '{tag_name}' created and pushed.") - - release_title = f"{new_version} [{changelog_date_str}]" - - if config["changelog_format"] == "md": - release_body_md = "\n".join(latest_changelog_block.splitlines()[1:]).strip() - release_body_md = demote_markdown_headings(release_body_md) - else: - release_body_rst = "\n".join(latest_changelog_block.splitlines()[2:]).strip() - release_body_md = rst_to_markdown(release_body_rst) - - try: - release_url = gh.create_release(tag_name, release_title, release_body_md) - print(f"๐Ÿ“ฆ Draft release created on GitHub: {release_url}") - except SkipSignal: - print( - "\nOperation skipped. Please create the GitHub release manually." - f"\n Tag: {tag_name}" - f"\n Title: {release_title}" - "\n Body: (You can find the content in the latest commit)." - ) - questionary.confirm( - "Press Enter when you have created the release manually." - ).ask() print("\n๐ŸŽ‰ Release process completed successfully!") - if is_bugfix: - print("\n๐Ÿ› Bugfix release complete.") - if questionary.confirm( - "Do you want to create a PR to port the changelog to the main branch now?" - ).ask(): - lines_to_skip = 2 if config["changelog_format"] != "md" else 1 - changelog_body_for_porting = "\n".join( - latest_changelog_block.splitlines()[lines_to_skip:] - ).strip() - port_changelog_to_main( - gh, - config, - new_version, - changelog_body_for_porting, - original_branch, - ) - else: - print("Skipping changelog port. Please remember to do it manually.") - elif ( - supports_prerelease(config.get("package_type")) - and questionary.confirm( - "Do you want to bump the version to the next alpha release now?" - ).ask() - ): - bump_to_next_alpha(gh, config, new_version, original_branch) - elif supports_prerelease(config.get("package_type")): - print("Skipping the version bump. Please remember to do it manually.") - else: - print( - f"Skipping alpha version bump: '{config.get('package_type')}' projects " - "cannot store an alpha marker." - ) + _complete_follow_up( + gh, config, new_version, latest_changelog_block, original_branch + ) diff --git a/openwisp_utils/releaser/tests/conftest.py b/openwisp_utils/releaser/tests/conftest.py index cd7e806c..39354eb3 100644 --- a/openwisp_utils/releaser/tests/conftest.py +++ b/openwisp_utils/releaser/tests/conftest.py @@ -197,6 +197,7 @@ def subprocess_side_effect(command, *args, **kwargs): "openwisp_utils.releaser.release.subprocess.run", side_effect=subprocess_side_effect, ), + "run_git": mocker.patch("openwisp_utils.releaser.release.run_git"), "GitHub": mocker.patch("openwisp_utils.releaser.release.GitHub"), "time": mocker.patch("openwisp_utils.releaser.release.time.sleep"), "print": mocker.patch("builtins.print"), @@ -243,6 +244,7 @@ def subprocess_side_effect(command, *args, **kwargs): mock_gh_instance = mocks["GitHub"].return_value mock_gh_instance.create_pr.side_effect = ["http://pr.url/1", "http://pr.url/2"] mock_gh_instance.is_pr_merged.return_value = True + mock_gh_instance.get_release.return_value = None mock_config = { "repo": "test/repo", diff --git a/openwisp_utils/releaser/tests/test_github.py b/openwisp_utils/releaser/tests/test_github.py index 5e7bfd58..50b3b797 100644 --- a/openwisp_utils/releaser/tests/test_github.py +++ b/openwisp_utils/releaser/tests/test_github.py @@ -56,6 +56,27 @@ def test_is_pr_merged(mock_retryable_request, github_client): assert call_args["url"].endswith("/pulls/123") +@patch("openwisp_utils.releaser.github.retryable_request") +def test_get_pr_validates_repository(mock_retryable_request, github_client): + mock_retryable_request.return_value = mock_response(200, {"merged": True}) + + pull_request = github_client.get_pr("https://github.com/owner/repo/pull/123") + + assert pull_request["merged"] is True + with pytest.raises(ValueError, match="configured GitHub repository"): + github_client.get_pr("https://github.com/other/repo/pull/123") + + +@patch("openwisp_utils.releaser.github.retryable_request") +def test_get_release_returns_none_for_missing_tag( + mock_retryable_request, github_client +): + mock_retryable_request.return_value = mock_response(404) + + assert github_client.get_release("1.0.0") is None + assert mock_retryable_request.call_args.kwargs["allowed_status_codes"] == (404,) + + @patch("openwisp_utils.releaser.github.retryable_request") def test_create_release(mock_retryable_request, github_client): mock_retryable_request.return_value = mock_response( @@ -68,6 +89,23 @@ def test_create_release(mock_retryable_request, github_client): assert call_args["json"]["tag_name"] == "v1.0.0" +@patch("openwisp_utils.releaser.github.retryable_request") +def test_create_release_reuses_release_after_duplicate_response( + mock_retryable_request, github_client, mocker +): + mock_retryable_request.return_value = mock_response(422) + mocker.patch.object( + github_client, + "get_release", + return_value={"html_url": "http://example.com/releases/1"}, + ) + + release_url = github_client.create_release("1.0.0", "Version 1.0.0", "Notes") + + assert release_url == "http://example.com/releases/1" + assert mock_retryable_request.call_args.kwargs["allowed_status_codes"] == (422,) + + @patch("openwisp_utils.releaser.github.utils_retryable_request") def test_check_pr_creation_permission_success(mock_utils_request, github_client): """Tests the successful permission check flow where the probe fails as expected.""" diff --git a/openwisp_utils/releaser/tests/test_release.py b/openwisp_utils/releaser/tests/test_release.py index ea28dc8f..ffb7642b 100644 --- a/openwisp_utils/releaser/tests/test_release.py +++ b/openwisp_utils/releaser/tests/test_release.py @@ -1,9 +1,13 @@ from unittest.mock import MagicMock, call, patch import pytest -from openwisp_utils.releaser.release import bump_to_next_alpha, check_prerequisites +from openwisp_utils.releaser.release import ( + bump_to_next_alpha, + check_prerequisites, + complete_release_artifacts, +) from openwisp_utils.releaser.release import main as run_release -from openwisp_utils.releaser.release import port_changelog_to_main +from openwisp_utils.releaser.release import port_changelog_to_main, resume from openwisp_utils.releaser.utils import SkipSignal @@ -12,7 +16,6 @@ def test_feature_release_flow_markdown(mock_all, mocker): mock_config, mock_gh = mock_all["check_prerequisites"].return_value mock_config["changelog_path"] = "CHANGES.md" mock_config["changelog_format"] = "md" - mock_config["version_path"] = "package/__init__.py" mock_all["get_release_block_from_file"].return_value = None @@ -26,10 +29,6 @@ def test_feature_release_flow_markdown(mock_all, mocker): mock_all["update_changelog"].assert_called_once() mock_all["format_file"].assert_not_called() - assert ["git", "add", "CHANGES.md", "package/__init__.py"] in [ - call.args[0] for call in mock_all["subprocess"].call_args_list - ] - release_call_args = mock_gh.create_release.call_args.args assert "## Markdown Changelog" in release_call_args[2] @@ -45,6 +44,58 @@ def test_release_flow_manual_bump(mock_all): ) +def test_alpha_bump_uses_bump_prefix(mock_all, mocker): + mock_config = { + "package_type": "python", + "changelog_path": "CHANGES.rst", + "changelog_format": "rst", + } + mocker.patch( + "openwisp_utils.releaser.release.determine_new_version", + return_value="1.3.0", + ) + mocker.patch( + "openwisp_utils.releaser.release.resolve_main_branch", return_value="main" + ) + mocker.patch("openwisp_utils.releaser.release.branch_exists", return_value=False) + mocker.patch( + "openwisp_utils.releaser.release.get_remote_branch_commit", return_value=None + ) + + bump_to_next_alpha(mock_all["GitHub"].return_value, mock_config, "1.2.0", "main") + + mock_all["run_git"].assert_any_call( + ["commit", "-m", "[bump] Bumped version to 1.3.0 alpha"], + "commit the version bump", + ) + + +def test_alpha_bump_prompts_for_manual_version_update(mock_all, mocker): + mock_config = { + "package_type": "python", + "changelog_path": "CHANGES.rst", + "changelog_format": "rst", + } + mocker.patch( + "openwisp_utils.releaser.release.determine_new_version", + return_value="1.3.0", + ) + mocker.patch( + "openwisp_utils.releaser.release.resolve_main_branch", return_value="main" + ) + mocker.patch("openwisp_utils.releaser.release.branch_exists", return_value=False) + mocker.patch( + "openwisp_utils.releaser.release.get_remote_branch_commit", return_value=None + ) + mock_all["bump_version"].return_value = False + + bump_to_next_alpha(mock_all["GitHub"].return_value, mock_config, "1.2.0", "main") + + mock_all["questionary_confirm"].assert_any_call( + "Press Enter when you have bumped the version number..." + ) + + def test_prerequisite_check_failure(mocker): """Tests that the script exits if the prerequisite check fails.""" mocker.patch("openwisp_utils.releaser.release.shutil.which", return_value=None) @@ -155,6 +206,81 @@ def test_main_flow_pr_merge_wait(mock_all): assert mock_gh_instance.is_pr_merged.call_count == 2 +def test_resume_waits_for_and_completes_merged_release_pr(mock_all): + mock_gh = mock_all["GitHub"].return_value + mock_gh.get_pr.side_effect = [ + {"merged": False, "state": "open"}, + { + "merged": True, + "state": "closed", + "head": {"ref": "release/1.3.0"}, + "base": {"ref": "master"}, + }, + ] + mock_all["get_release_block_from_file"].return_value = ( + "Version 1.3.0 [2025-08-11]\n--------------------------\n\n- A change" + ) + + resume("https://github.com/test/repo/pull/123") + + mock_all["time"].assert_called_once_with(20) + mock_gh.create_release.assert_called_once() + mock_all["bump_to_next_alpha"].assert_called_once() + + +def test_resume_rejects_closed_unmerged_release_pr(mock_all): + mock_gh = mock_all["GitHub"].return_value + mock_gh.get_pr.return_value = {"merged": False, "state": "closed"} + + with pytest.raises(RuntimeError, match="closed without being merged"): + resume("https://github.com/test/repo/pull/123") + + +def test_resume_reuses_existing_tag_and_github_release(mock_all, mocker): + mock_gh = mock_all["GitHub"].return_value + mock_gh.get_pr.return_value = { + "merged": True, + "state": "closed", + "head": {"ref": "release/1.3.0"}, + "base": {"ref": "master"}, + } + mock_gh.get_release.return_value = {"html_url": "https://example.com/releases/1"} + mock_all["get_release_block_from_file"].return_value = ( + "Version 1.3.0 [2025-08-11]\n--------------------------\n\n- A change" + ) + mocker.patch( + "openwisp_utils.releaser.release.tag_exists_on_branch", return_value=True + ) + + resume("https://github.com/test/repo/pull/123") + + mock_gh.create_release.assert_not_called() + assert ["tag", "-s", "1.3.0", "-m", "Version 1.3.0 [02-09-2026]"] not in [ + call.args[0] for call in mock_all["run_git"].call_args_list + ] + + +def test_complete_release_artifacts_rejects_conflicting_tag(mock_all, mocker): + mocker.patch( + "openwisp_utils.releaser.release.tag_exists_on_branch", return_value=False + ) + mocker.patch( + "openwisp_utils.releaser.release.subprocess.run", + return_value=MagicMock(returncode=0), + ) + + with pytest.raises(RuntimeError, match="exists but is not contained"): + complete_release_artifacts( + mock_all["GitHub"].return_value, + {"changelog_format": "rst"}, + "1.3.0", + "master", + "Version 1.3.0 [2025-08-11]\n--------------------------\n\n- A change", + "11-08-2025", + "2025-08-11", + ) + + @patch("openwisp_utils.releaser.release.update_changelog_file") @patch("openwisp_utils.releaser.release.format_file_with_docstrfmt") @patch("openwisp_utils.releaser.release.subprocess.run") @@ -378,280 +504,3 @@ def test_port_changelog_skip_pr_creation(mock_subprocess, mock_branch_exists, mo mock_all["questionary_confirm"].assert_any_call( "Press Enter when you have created the PR manually." ) - - -@pytest.fixture -def bump_mocks(mocker): - """Mocks the external dependencies of ``bump_to_next_alpha``.""" - mocks = { - "run_git": mocker.patch("openwisp_utils.releaser.release.run_git"), - "subprocess": mocker.patch("openwisp_utils.releaser.release.subprocess.run"), - "branch_exists": mocker.patch( - "openwisp_utils.releaser.release.branch_exists", - side_effect=lambda name: name == "master", - ), - "get_remote_branch_commit": mocker.patch( - "openwisp_utils.releaser.release.get_remote_branch_commit", - return_value=None, - ), - "determine_new_version": mocker.patch( - "openwisp_utils.releaser.release.determine_new_version", - return_value="1.3.0", - ), - "bump_version": mocker.patch( - "openwisp_utils.releaser.release.bump_version", return_value=True - ), - "update_changelog": mocker.patch( - "openwisp_utils.releaser.release.update_changelog_file" - ), - "format_file": mocker.patch( - "openwisp_utils.releaser.release.format_file_with_docstrfmt" - ), - "questionary": mocker.patch("openwisp_utils.releaser.release.questionary"), - "print": mocker.patch("builtins.print"), - } - return mocks - - -def _git_commands(mock_run_git): - return [call.args[0] for call in mock_run_git.call_args_list] - - -def test_bump_to_next_alpha_flow(bump_mocks): - mock_gh = MagicMock() - mock_gh.create_pr.return_value = "http://pr.url/3" - config = { - "package_type": "python", - "changelog_path": "CHANGES.rst", - "changelog_format": "rst", - "changelog_uses_version_prefix": True, - } - bump_to_next_alpha(mock_gh, config, "1.2.0", "master") - bump_mocks["bump_version"].assert_called_once_with( - config, "1.3.0", version_type="alpha" - ) - bump_mocks["update_changelog"].assert_called_once_with( - "CHANGES.rst", - "Version 1.3.0 [unreleased]\n--------------------------\n\nWork in progress.", - ) - bump_mocks["format_file"].assert_called_once_with("CHANGES.rst") - assert _git_commands(bump_mocks["run_git"]) == [ - ["checkout", "master"], - ["pull", "origin", "master"], - ["checkout", "-B", "chore/bump-version-1.3.0"], - ["add", "-u"], - ["commit", "-m", "[bump] Bumped version to 1.3.0 alpha"], - ["push", "-u", "origin", "chore/bump-version-1.3.0"], - ] - mock_gh.create_pr.assert_called_once_with( - "chore/bump-version-1.3.0", - "master", - "[bump] Bumped version to 1.3.0 alpha", - ) - mock_gh.is_pr_merged.assert_not_called() - bump_mocks["subprocess"].assert_called_once_with( - ["git", "checkout", "master"], check=True, capture_output=True - ) - - -def test_bump_to_next_alpha_changelog_block_variants(bump_mocks): - mock_gh = MagicMock() - variants = [ - ( - {"changelog_format": "md", "changelog_uses_version_prefix": True}, - "## Version 1.3.0 [unreleased]\n\nWork in progress.", - ), - ( - {"changelog_format": "md", "changelog_uses_version_prefix": False}, - "## 1.3.0 [unreleased]\n\nWork in progress.", - ), - ( - {"changelog_format": "rst", "changelog_uses_version_prefix": False}, - "1.3.0 [unreleased]\n------------------\n\nWork in progress.", - ), - ] - for changelog_config, expected_block in variants: - bump_mocks["update_changelog"].reset_mock() - bump_mocks["format_file"].reset_mock() - config = { - "package_type": "python", - "changelog_path": "CHANGES." + changelog_config["changelog_format"], - **changelog_config, - } - bump_to_next_alpha(mock_gh, config, "1.2.0", "master") - bump_mocks["update_changelog"].assert_called_once_with( - config["changelog_path"], expected_block - ) - if changelog_config["changelog_format"] == "md": - bump_mocks["format_file"].assert_not_called() - - -def test_bump_to_next_alpha_existing_branch_reset(bump_mocks): - mock_gh = MagicMock() - bump_mocks["branch_exists"].side_effect = lambda name: name in [ - "master", - "chore/bump-version-1.3.0", - ] - bump_mocks["questionary"].select.return_value.ask.return_value = ( - "Reset it to 'master'" - ) - remote_commit = "a" * 40 - bump_mocks["get_remote_branch_commit"].return_value = remote_commit - config = { - "package_type": "python", - "changelog_path": "CHANGES.rst", - "changelog_format": "rst", - "changelog_uses_version_prefix": True, - } - bump_to_next_alpha(mock_gh, config, "1.2.0", "master") - assert [ - "push", - "--force-with-lease=refs/heads/chore/bump-version-1.3.0:" + remote_commit, - "-u", - "origin", - "chore/bump-version-1.3.0", - ] in _git_commands(bump_mocks["run_git"]) - mock_gh.create_pr.assert_called_once() - - -def test_bump_to_next_alpha_existing_remote_branch_reset(bump_mocks): - mock_gh = MagicMock() - remote_commit = "a" * 40 - bump_mocks["get_remote_branch_commit"].return_value = remote_commit - bump_mocks["questionary"].select.return_value.ask.return_value = ( - "Reset it to 'master'" - ) - config = { - "package_type": "python", - "changelog_path": "CHANGES.rst", - "changelog_format": "rst", - "changelog_uses_version_prefix": True, - } - bump_to_next_alpha(mock_gh, config, "1.2.0", "master") - assert [ - "push", - "--force-with-lease=refs/heads/chore/bump-version-1.3.0:" + remote_commit, - "-u", - "origin", - "chore/bump-version-1.3.0", - ] in _git_commands(bump_mocks["run_git"]) - - -def test_bump_to_next_alpha_existing_branch_abort(bump_mocks): - mock_gh = MagicMock() - bump_mocks["branch_exists"].side_effect = lambda name: name in [ - "master", - "chore/bump-version-1.3.0", - ] - bump_mocks["questionary"].select.return_value.ask.return_value = ( - "Abort the version bump" - ) - config = { - "package_type": "python", - "changelog_path": "CHANGES.rst", - "changelog_format": "rst", - "changelog_uses_version_prefix": True, - } - bump_to_next_alpha(mock_gh, config, "1.2.0", "master") - bump_mocks["update_changelog"].assert_not_called() - mock_gh.create_pr.assert_not_called() - bump_mocks["subprocess"].assert_called_once_with( - ["git", "checkout", "master"], check=True, capture_output=True - ) - - -def test_bump_to_next_alpha_package_without_prerelease_support(bump_mocks): - mock_gh = MagicMock() - config = { - "package_type": "generic", - "changelog_path": "CHANGES.rst", - "changelog_format": "rst", - "changelog_uses_version_prefix": True, - } - bump_to_next_alpha(mock_gh, config, "1.2.0", "master") - bump_mocks["bump_version"].assert_not_called() - bump_mocks["run_git"].assert_not_called() - bump_mocks["update_changelog"].assert_not_called() - mock_gh.create_pr.assert_not_called() - - -def test_bump_to_next_alpha_skip_pr_creation(bump_mocks): - mock_gh = MagicMock() - mock_gh.create_pr.side_effect = SkipSignal("User chose to skip this operation.") - config = { - "package_type": "python", - "changelog_path": "CHANGES.rst", - "changelog_format": "rst", - "changelog_uses_version_prefix": True, - } - bump_to_next_alpha(mock_gh, config, "1.2.0", "master") - printed_output = "\n".join( - str(call.args[0]) for call in bump_mocks["print"].call_args_list if call.args - ) - assert "Please complete the version bump manually." in printed_output - assert "chore/bump-version-1.3.0" in printed_output - bump_mocks["subprocess"].assert_called_once_with( - ["git", "checkout", "master"], check=True, capture_output=True - ) - - -def test_bump_to_next_alpha_preserves_uncommitted_changes(bump_mocks): - mock_gh = MagicMock() - - def fail_commit(args, description): - if args[0] == "commit": - raise SkipSignal("User chose to skip: commit the version bump.") - - bump_mocks["run_git"].side_effect = fail_commit - config = { - "package_type": "python", - "changelog_path": "CHANGES.rst", - "changelog_format": "rst", - "changelog_uses_version_prefix": True, - } - bump_to_next_alpha(mock_gh, config, "1.2.0", "master") - bump_mocks["subprocess"].assert_not_called() - printed_output = "\n".join( - str(call.args[0]) for call in bump_mocks["print"].call_args_list if call.args - ) - assert "Keeping branch 'chore/bump-version-1.3.0' checked out" in printed_output - - -def test_bump_to_next_alpha_cancelled(bump_mocks): - mock_gh = MagicMock() - bump_mocks["determine_new_version"].return_value = None - config = { - "package_type": "python", - "changelog_path": "CHANGES.rst", - "changelog_format": "rst", - "changelog_uses_version_prefix": True, - } - bump_to_next_alpha(mock_gh, config, "1.2.0", "master") - bump_mocks["run_git"].assert_not_called() - mock_gh.create_pr.assert_not_called() - - -def test_main_feature_flow_offers_alpha_bump(mock_all): - run_release() - mock_all["bump_to_next_alpha"].assert_called_once() - assert mock_all["bump_to_next_alpha"].call_args[0][2] == "1.3.0" - - -def test_main_bugfix_flow_does_not_offer_alpha_bump(mock_all, mocker): - mock_all["_git_command_map"][("git", "rev-parse", "--abbrev-ref", "HEAD")] = ( - MagicMock(stdout="1.2.x") - ) - mocker.patch("openwisp_utils.releaser.release.branch_exists", return_value=True) - run_release() - mock_all["bump_to_next_alpha"].assert_not_called() - - -def test_main_feature_flow_skips_alpha_bump_for_unsupported_package(mock_all): - mock_config, _ = mock_all["check_prerequisites"].return_value - mock_config["package_type"] = "generic" - run_release() - mock_all["bump_to_next_alpha"].assert_not_called() - assert ( - call("Do you want to bump the version to the next alpha release now?") - not in mock_all["questionary_confirm"].call_args_list - ) diff --git a/openwisp_utils/releaser/utils.py b/openwisp_utils/releaser/utils.py index ade6835d..58d1891a 100644 --- a/openwisp_utils/releaser/utils.py +++ b/openwisp_utils/releaser/utils.py @@ -54,11 +54,13 @@ def run_git(args, description, allowed_returncodes=()): raise AbortSignal(f"User aborted while trying to {description}.") -def retryable_request(**kwargs): +def retryable_request(allowed_status_codes=(), **kwargs): """Executes a requests call and provides a retry/skip/abort prompt on failure.""" while True: try: response = requests.request(**kwargs) + if response.status_code in allowed_status_codes: + return response response.raise_for_status() return response except requests.RequestException as e: From 6da67ec84e98cb37cc46035aac7b45d2057c37cc Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Fri, 4 Sep 2026 15:48:35 +0530 Subject: [PATCH 9/9] [fix] Prompted for manual alpha version updates --- openwisp_utils/releaser/release.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/openwisp_utils/releaser/release.py b/openwisp_utils/releaser/release.py index 9460fe79..d387a76c 100644 --- a/openwisp_utils/releaser/release.py +++ b/openwisp_utils/releaser/release.py @@ -403,8 +403,17 @@ def bump_to_next_alpha(gh, config, released_version, original_branch): print(f"Creating new branch '{bump_branch}'...") run_git(["checkout", "-B", bump_branch], f"create branch '{bump_branch}'") bump_branch_created = True - bump_version(config, next_version, version_type="alpha") - print(f"โœ… Version bumped to {next_version} and set to 'alpha'.") + was_bumped = bump_version(config, next_version, version_type="alpha") + if was_bumped: + print(f"โœ… Version bumped to {next_version} and set to 'alpha'.") + else: + print( + "\nโš ๏ธ The version number could not be bumped automatically." + "\n Please bump it manually before the changelog is committed." + ) + questionary.confirm( + "Press Enter when you have bumped the version number..." + ).ask() changelog_path = config["changelog_path"] prefix = "Version " if config.get("changelog_uses_version_prefix", True) else "" version_header = f"{prefix}{next_version} [unreleased]"