"""Unit tests for scripts/release.py.""" from unittest.mock import MagicMock, patch import click import pytest from click.testing import CliRunner from scripts.release import ( create_and_push_tag, create_release_commit, get_bumped_version, get_changelog, get_latest_tag, has_unreleased_changes, main, run_cmd, update_init_version, ) class TestRunCmd: @patch("scripts.release.subprocess.run") def test_success(self, mock_run: MagicMock) -> None: mock_run.return_value = MagicMock(returncode=0, stderr="", stdout="") result = run_cmd(["echo", "hi"]) assert result.returncode == 0 mock_run.assert_called_once() @patch("scripts.release.subprocess.run") def test_failure_raises(self, mock_run: MagicMock) -> None: mock_run.return_value = MagicMock(returncode=1, stderr="err", stdout="") with pytest.raises(click.ClickException): run_cmd(["false"]) @patch("scripts.release.subprocess.run") def test_check_false_no_raise(self, mock_run: MagicMock) -> None: mock_run.return_value = MagicMock(returncode=1, stderr="err", stdout="") result = run_cmd(["false"], check=False) assert result.returncode == 1 class TestGetLatestTag: @patch("scripts.release.run_cmd") def test_returns_tag(self, mock_run_cmd: MagicMock) -> None: mock_run_cmd.return_value = MagicMock(returncode=0, stdout="v0.1.0\n") assert get_latest_tag() == "v0.1.0" @patch("scripts.release.run_cmd") def test_no_tags_returns_empty(self, mock_run_cmd: MagicMock) -> None: mock_run_cmd.return_value = MagicMock(returncode=1, stdout="") assert get_latest_tag() == "" class TestGetBumpedVersion: @patch("scripts.release.run_cmd") def test_returns_version(self, mock_run_cmd: MagicMock) -> None: mock_run_cmd.return_value = MagicMock(returncode=0, stdout="0.2.0\n") assert get_bumped_version() == "0.2.0" @patch("scripts.release.run_cmd") def test_strips_v_prefix(self, mock_run_cmd: MagicMock) -> None: mock_run_cmd.return_value = MagicMock(returncode=0, stdout="v0.2.0\n") assert get_bumped_version() == "0.2.0" @patch("scripts.release.run_cmd") def test_empty_raises(self, mock_run_cmd: MagicMock) -> None: mock_run_cmd.return_value = MagicMock(returncode=0, stdout="") with pytest.raises(click.ClickException): get_bumped_version() class TestGetChangelog: @patch("scripts.release.run_cmd") def test_returns_changelog(self, mock_run_cmd: MagicMock) -> None: mock_run_cmd.return_value = MagicMock(returncode=0, stdout="## 0.2.0\n- fix\n") assert get_changelog("0.2.0") == "## 0.2.0\n- fix" @patch("scripts.release.run_cmd") def test_strips_whitespace(self, mock_run_cmd: MagicMock) -> None: mock_run_cmd.return_value = MagicMock(returncode=0, stdout=" text \n") assert get_changelog("0.2.0") == "text" class TestHasUnreleasedChanges: @patch("scripts.release.get_latest_tag") @patch("scripts.release.run_cmd") def test_no_changes(self, mock_run_cmd: MagicMock, mock_latest: MagicMock) -> None: mock_run_cmd.return_value = MagicMock(returncode=0, stdout="v0.2.0\n") mock_latest.return_value = "v0.2.0" assert has_unreleased_changes() is False @patch("scripts.release.get_latest_tag") @patch("scripts.release.run_cmd") def test_has_changes(self, mock_run_cmd: MagicMock, mock_latest: MagicMock) -> None: mock_run_cmd.return_value = MagicMock(returncode=0, stdout="v0.3.0\n") mock_latest.return_value = "v0.2.0" assert has_unreleased_changes() is True @patch("scripts.release.get_latest_tag") @patch("scripts.release.run_cmd") def test_no_tags_returns_true(self, mock_run_cmd: MagicMock, mock_latest: MagicMock) -> None: mock_run_cmd.return_value = MagicMock(returncode=0, stdout="v0.2.0\n") mock_latest.return_value = "" assert has_unreleased_changes() is True @patch("scripts.release.run_cmd") def test_cliff_fails_returns_false(self, mock_run_cmd: MagicMock) -> None: mock_run_cmd.return_value = MagicMock(returncode=1, stdout="") assert has_unreleased_changes() is False class TestUpdateInitVersion: def test_updates_version(self, tmp_path, monkeypatch) -> None: init_file = tmp_path / "__init__.py" init_file.write_text('__version__ = "0.1.0"\n') monkeypatch.setattr("scripts.release.INIT_FILE", str(init_file)) update_init_version("0.2.0") assert '__version__ = "0.2.0"' in init_file.read_text() def test_same_version_ok(self, tmp_path, monkeypatch) -> None: """Updating to the same version should not raise.""" init_file = tmp_path / "__init__.py" init_file.write_text('__version__ = "0.1.0"\n') monkeypatch.setattr("scripts.release.INIT_FILE", str(init_file)) update_init_version("0.1.0") assert '__version__ = "0.1.0"' in init_file.read_text() def test_no_version_raises(self, tmp_path, monkeypatch) -> None: init_file = tmp_path / "__init__.py" init_file.write_text('"""module"""\n') monkeypatch.setattr("scripts.release.INIT_FILE", str(init_file)) with pytest.raises(click.ClickException): update_init_version("0.2.0") class TestCreateReleaseCommit: @patch("scripts.release.run_cmd") def test_commits(self, mock_run_cmd: MagicMock) -> None: create_release_commit("0.2.0") calls = [c.args[0] for c in mock_run_cmd.call_args_list] assert ["git", "add", "src/gitea_runner_manager/__init__.py"] in calls assert ["git", "commit", "-m", "chore(release): prepare for v0.2.0"] in calls class TestCreateAndPushTag: @patch("scripts.release.run_cmd") def test_creates_tag(self, mock_run_cmd: MagicMock) -> None: create_and_push_tag("0.2.0", "changelog", dry_run=True) first_call = mock_run_cmd.call_args_list[0].args[0] assert first_call[:3] == ["git", "tag", "-a"] assert first_call[3] == "v0.2.0" assert "Release v0.2.0" in first_call[5] @patch("scripts.release.run_cmd") def test_dry_run_no_push(self, mock_run_cmd: MagicMock) -> None: create_and_push_tag("0.2.0", "changelog", dry_run=True) for call in mock_run_cmd.call_args_list: assert call.args[0][0:2] != ["git", "push"] @patch("scripts.release.run_cmd") def test_pushes_tag(self, mock_run_cmd: MagicMock) -> None: create_and_push_tag("0.2.0", "changelog", dry_run=False) last_call = mock_run_cmd.call_args_list[-1].args[0] assert last_call == ["git", "push", "origin", "v0.2.0"] class TestMain: @patch.dict("os.environ", {}) @patch("scripts.release.run_cmd") def test_not_on_master_exits(self, mock_run_cmd: MagicMock) -> None: mock_run_cmd.return_value = MagicMock(returncode=0, stdout="feature-branch\n", stderr="") runner = CliRunner() result = runner.invoke(main, []) assert result.exit_code != 0 assert "master" in result.output @patch.dict("os.environ", {}) @patch("scripts.release.has_unreleased_changes", return_value=False) @patch("scripts.release.run_cmd") def test_no_unreleased_changes(self, mock_run_cmd: MagicMock, mock_has: MagicMock) -> None: mock_run_cmd.side_effect = [ MagicMock(returncode=0, stdout="master\n", stderr=""), # branch check MagicMock(returncode=0, stdout="GRM-34 feat: something\n", stderr=""), # last commit msg ] runner = CliRunner() result = runner.invoke(main, []) assert result.exit_code == 0 assert "No unreleased changes" in result.output @patch.dict("os.environ", {}) @patch("scripts.release.run_cmd") def test_skips_release_commit(self, mock_run_cmd: MagicMock) -> None: """Skip when last commit is a chore(release): commit to avoid loops.""" mock_run_cmd.side_effect = [ MagicMock(returncode=0, stdout="master\n", stderr=""), # branch check MagicMock(returncode=0, stdout="chore(release): prepare for v0.2.0\n", stderr=""), # last commit msg ] runner = CliRunner() result = runner.invoke(main, []) assert result.exit_code == 0 assert "release commit" in result.output @patch.dict("os.environ", {}) @patch("scripts.release.create_and_push_tag") @patch("scripts.release.create_release_commit") @patch("scripts.release.update_init_version") @patch("scripts.release.get_changelog", return_value="") @patch("scripts.release.get_latest_tag", return_value="v0.1.0") @patch("scripts.release.get_bumped_version", return_value="0.2.0") @patch("scripts.release.has_unreleased_changes", return_value=True) @patch("scripts.release.run_cmd") def test_dry_run_empty_changelog( self, mock_run_cmd: MagicMock, mock_has: MagicMock, mock_bumped: MagicMock, mock_latest: MagicMock, mock_changelog: MagicMock, mock_update_init: MagicMock, mock_commit: MagicMock, mock_tag: MagicMock, ) -> None: mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="") runner = CliRunner() result = runner.invoke(main, ["--dry-run"]) assert result.exit_code == 0 assert "empty changelog" in result.output @patch.dict("os.environ", {}) @patch("scripts.release.create_and_push_tag") @patch("scripts.release.create_release_commit") @patch("scripts.release.update_init_version") @patch("scripts.release.get_changelog", return_value="changelog") @patch("scripts.release.get_latest_tag", return_value="v0.1.0") @patch("scripts.release.get_bumped_version", return_value="0.2.0") @patch("scripts.release.has_unreleased_changes", return_value=True) @patch("scripts.release.run_cmd") def test_dry_run( self, mock_run_cmd: MagicMock, mock_has: MagicMock, mock_bumped: MagicMock, mock_latest: MagicMock, mock_changelog: MagicMock, mock_update_init: MagicMock, mock_commit: MagicMock, mock_tag: MagicMock, ) -> None: mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="") runner = CliRunner() result = runner.invoke(main, ["--dry-run"]) assert result.exit_code == 0 assert "[dry-run]" in result.output mock_update_init.assert_not_called() mock_commit.assert_not_called() mock_tag.assert_not_called() @patch.dict("os.environ", {}) @patch("scripts.release.create_and_push_tag") @patch("scripts.release.create_release_commit") @patch("scripts.release.update_init_version") @patch("scripts.release.get_changelog", return_value="changelog") @patch("scripts.release.get_latest_tag", return_value="v0.1.0") @patch("scripts.release.get_bumped_version", return_value="0.2.0") @patch("scripts.release.has_unreleased_changes", return_value=True) @patch("scripts.release.run_cmd") def test_full_flow( self, mock_run_cmd: MagicMock, mock_has: MagicMock, mock_bumped: MagicMock, mock_latest: MagicMock, mock_changelog: MagicMock, mock_update_init: MagicMock, mock_commit: MagicMock, mock_tag: MagicMock, ) -> None: mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="") runner = CliRunner() result = runner.invoke(main, []) assert result.exit_code == 0 assert "Bumping version" in result.output mock_update_init.assert_called_once_with("0.2.0") mock_commit.assert_called_once_with("0.2.0") mock_tag.assert_called_once_with("0.2.0", "changelog", False)