"""Unit tests for devx.ci.publish.""" from pathlib import Path from unittest.mock import MagicMock, patch import click import pytest from click.testing import CliRunner from devx.ci.publish import ( _default_gitea_registry_url, build_package, generate_release_notes, get_latest_tag, is_release_commit, main, publish_to_gitea_registry, publish_to_pypi, ) from devx.gitea_cli import TeaCLIError class TestGenerateReleaseNotes: @patch("devx.ci.publish.subprocess.run") @patch("devx.ci.publish.shutil.which", return_value="/usr/local/bin/git-cliff") def test_generates_from_git_cliff(self, mock_which: MagicMock, mock_run: MagicMock) -> None: mock_run.return_value = MagicMock(returncode=0, stdout="## v1.0.0\n- feat: x") result = generate_release_notes("v1.0.0") assert "## v1.0.0" in result assert "feat: x" in result @patch("devx.ci.publish.subprocess.run") @patch("devx.ci.publish.shutil.which", return_value="/usr/local/bin/git-cliff") def test_strips_whitespace(self, mock_which: MagicMock, mock_run: MagicMock) -> None: mock_run.return_value = MagicMock(returncode=0, stdout=" changelog \n") result = generate_release_notes("v1.0.0") assert result == "changelog" @patch("devx.ci.publish.subprocess.run") @patch("devx.ci.publish.shutil.which", return_value="/usr/local/bin/git-cliff") def test_falls_back_on_failure(self, mock_which: MagicMock, mock_run: MagicMock) -> None: mock_run.return_value = MagicMock(returncode=1, stdout="") result = generate_release_notes("v1.0.0") assert "Release v1.0.0" in result assert "CHANGELOG.md" in result @patch("devx.ci.publish.subprocess.run") @patch("devx.ci.publish.shutil.which", return_value="/usr/local/bin/git-cliff") def test_falls_back_on_empty_output(self, mock_which: MagicMock, mock_run: MagicMock) -> None: mock_run.return_value = MagicMock(returncode=0, stdout=" ") result = generate_release_notes("v1.0.0") assert "Release v1.0.0" in result @patch("devx.ci.publish.subprocess.run", side_effect=FileNotFoundError) @patch("devx.ci.publish.shutil.which", return_value="/usr/local/bin/git-cliff") def test_falls_back_on_file_not_found(self, mock_which: MagicMock, mock_run: MagicMock) -> None: result = generate_release_notes("v1.0.0") assert "Release v1.0.0" in result assert "CHANGELOG.md" in result @patch("devx.ci.publish.shutil.which", return_value=None) def test_falls_back_when_not_installed(self, mock_which: MagicMock) -> None: result = generate_release_notes("v1.0.0") assert "Release v1.0.0" in result assert "CHANGELOG.md" in result class TestBuildPackage: @patch("devx.ci.publish.subprocess.run") @patch("devx.ci.publish.Path.exists", return_value=False) def test_success(self, mock_exists: MagicMock, mock_run: MagicMock) -> None: mock_run.return_value = MagicMock(returncode=0, stderr="") build_package() args, _ = mock_run.call_args assert args[0][1] == "-m" assert args[0][2] == "build" @patch("devx.ci.publish.subprocess.run") @patch("devx.ci.publish.Path.exists", return_value=False) def test_failure_raises(self, mock_exists: MagicMock, mock_run: MagicMock) -> None: mock_run.return_value = MagicMock(returncode=1, stderr="build error") with pytest.raises(click.ClickException) as exc: build_package() assert "build" in str(exc.value) @patch("devx.ci.publish.subprocess.run") @patch("devx.ci.publish.shutil.rmtree") @patch("devx.ci.publish.Path.exists", return_value=True) def test_cleans_dist_before_build( self, mock_exists: MagicMock, mock_rmtree: MagicMock, mock_run: MagicMock ) -> None: mock_run.return_value = MagicMock(returncode=0, stderr="") build_package() mock_rmtree.assert_called_once_with(Path("dist")) class TestPublishToPypi: @patch("devx.ci.publish.subprocess.run") def test_success(self, mock_run: MagicMock) -> None: mock_run.return_value = MagicMock(returncode=0, stderr="") publish_to_pypi("pypi-tok") args, _ = mock_run.call_args assert "twine" in args[0] assert "pypi-tok" in args[0] @patch("devx.ci.publish.subprocess.run") def test_failure_raises(self, mock_run: MagicMock) -> None: mock_run.return_value = MagicMock(returncode=1, stderr="upload failed") with pytest.raises(click.ClickException) as exc: publish_to_pypi("pypi-tok") assert "PyPI" in str(exc.value) class TestPublishToGiteaRegistry: @patch("devx.ci.publish.subprocess.run") def test_success(self, mock_run: MagicMock) -> None: mock_run.return_value = MagicMock(returncode=0, stderr="") publish_to_gitea_registry("https://git.example.com/api/packages/owner/pypi", "gitea-tok") args, _ = mock_run.call_args assert "twine" in args[0] assert "--repository-url" in args[0] assert "https://git.example.com/api/packages/owner/pypi" in args[0] assert "gitea-tok" in args[0] @patch("devx.ci.publish.subprocess.run") def test_failure_raises(self, mock_run: MagicMock) -> None: mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="registry upload failed") with pytest.raises(click.ClickException) as exc: publish_to_gitea_registry("https://git.example.com/api/packages/owner/pypi", "gitea-tok") assert "Gitea PyPI registry" in str(exc.value) @patch("devx.ci.publish.subprocess.run") def test_409_conflict_is_non_fatal(self, mock_run: MagicMock) -> None: """409 Conflict (already published) should not raise — just continue.""" mock_run.return_value = MagicMock(returncode=1, stdout="ERROR 409 Conflict from url", stderr="") # Should not raise publish_to_gitea_registry("https://git.example.com/api/packages/owner/pypi", "gitea-tok") class TestDefaultGiteaRegistryUrl: @patch.dict("os.environ", {"DEVX_REPO_OWNER": "myorg"}, clear=True) @patch("devx.ci.publish.GITEA_API_URL", "https://git.example.com/api/v1") def test_derives_from_api_url(self) -> None: url = _default_gitea_registry_url() assert url == "https://git.example.com/api/packages/myorg/pypi" @patch.dict("os.environ", {"DEVX_REPO_OWNER": "myorg"}, clear=True) @patch("devx.ci.publish.GITEA_API_URL", "https://git.example.com/api") def test_derives_from_api_url_no_v1(self) -> None: url = _default_gitea_registry_url() assert url == "https://git.example.com/api/packages/myorg/pypi" @patch.dict("os.environ", {}, clear=True) @patch("devx.ci.publish.GITEA_API_URL", "https://git.example.com/api/v1") def test_default_owner(self) -> None: url = _default_gitea_registry_url() assert "oblachno-oss" in url @patch.dict("os.environ", {"DEVX_REPO_OWNER": "myorg"}, clear=True) @patch("devx.ci.publish.GITEA_API_URL", "https://git.example.com/") def test_no_api_suffix(self) -> None: """URL without /api/v1 or /api suffix is used as-is.""" url = _default_gitea_registry_url() assert url == "https://git.example.com/api/packages/myorg/pypi" class TestMain: @patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"}) @patch("devx.ci.publish.generate_release_notes", return_value="Release notes") @patch("devx.ci.publish.TeaCLI") @patch("devx.ci.publish.publish_to_pypi") @patch("devx.ci.publish.build_package") def test_full_flow_with_pypi( self, mock_build: MagicMock, mock_publish: MagicMock, mock_tea_cls: MagicMock, mock_notes: MagicMock, ) -> None: mock_tea = MagicMock() mock_tea.list_releases.return_value = [] mock_tea_cls.return_value = mock_tea runner = CliRunner() result = runner.invoke(main, ["v1.0.0", "owner/repo"]) assert result.exit_code == 0 assert "Gitea release v1.0.0 created" in result.output mock_build.assert_called_once() mock_publish.assert_called_once_with("pypi-tok") mock_tea.create_release.assert_called_once_with( "owner/repo", tag="v1.0.0", title="v1.0.0", body="Release notes" ) @patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok"}, clear=True) @patch("devx.ci.publish.generate_release_notes", return_value="Release notes") @patch("devx.ci.publish.TeaCLI") @patch("devx.ci.publish.publish_to_gitea_registry") @patch("devx.ci.publish.build_package") def test_without_pypi_uses_gitea_registry( self, mock_build: MagicMock, mock_gitea_publish: MagicMock, mock_tea_cls: MagicMock, mock_notes: MagicMock, ) -> None: """When no PYPI_TOKEN, publishes to Gitea PyPI registry.""" mock_tea = MagicMock() mock_tea.list_releases.return_value = [] mock_tea_cls.return_value = mock_tea runner = CliRunner() result = runner.invoke(main, ["v1.0.0", "owner/repo"]) assert result.exit_code == 0 mock_build.assert_called_once() mock_gitea_publish.assert_called_once() mock_tea.create_release.assert_called_once() @patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok"}, clear=True) @patch("devx.ci.publish.generate_release_notes", return_value="Release notes") @patch("devx.ci.publish.TeaCLI") @patch("devx.ci.publish.publish_to_gitea_registry") @patch("devx.ci.publish.build_package") def test_with_registry_url_flag( self, mock_build: MagicMock, mock_gitea_publish: MagicMock, mock_tea_cls: MagicMock, mock_notes: MagicMock, ) -> None: """--registry-url flag publishes to the specified Gitea registry.""" mock_tea = MagicMock() mock_tea.list_releases.return_value = [] mock_tea_cls.return_value = mock_tea runner = CliRunner() result = runner.invoke( main, ["v1.0.0", "owner/repo", "--registry-url", "https://custom.registry.com/pypi"], ) assert result.exit_code == 0 mock_gitea_publish.assert_called_once_with("https://custom.registry.com/pypi", "gitea-tok") @patch.dict( "os.environ", {"REPO_TOKEN": "gitea-tok", "DEVX_PYPI_REGISTRY_URL": "https://env.registry.com/pypi"}, clear=True, ) @patch("devx.ci.publish.generate_release_notes", return_value="Release notes") @patch("devx.ci.publish.TeaCLI") @patch("devx.ci.publish.publish_to_gitea_registry") @patch("devx.ci.publish.build_package") def test_registry_url_from_env( self, mock_build: MagicMock, mock_gitea_publish: MagicMock, mock_tea_cls: MagicMock, mock_notes: MagicMock, ) -> None: """DEVX_PYPI_REGISTRY_URL env var sets the registry URL.""" mock_tea = MagicMock() mock_tea.list_releases.return_value = [] mock_tea_cls.return_value = mock_tea runner = CliRunner() result = runner.invoke(main, ["v1.0.0", "owner/repo"]) assert result.exit_code == 0 mock_gitea_publish.assert_called_once_with("https://env.registry.com/pypi", "gitea-tok") @patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok"}, clear=True) @patch("devx.ci.publish.generate_release_notes", return_value="Release notes") @patch("devx.ci.publish.TeaCLI") @patch("devx.ci.publish.build_package") @patch("devx.ci.publish._default_gitea_registry_url", return_value="") def test_no_pypi_no_registry_skips_publish( self, mock_default_url: MagicMock, mock_build: MagicMock, mock_tea_cls: MagicMock, mock_notes: MagicMock, ) -> None: """When no PYPI_TOKEN and no registry URL, skips publish and creates release only.""" mock_tea = MagicMock() mock_tea.list_releases.return_value = [] mock_tea_cls.return_value = mock_tea runner = CliRunner() result = runner.invoke(main, ["v1.0.0", "owner/repo", "--registry-url", ""]) assert result.exit_code == 0 assert "PYPI_TOKEN not set" in result.output mock_tea.create_release.assert_called_once() @patch.dict("os.environ", {"REPO_TOKEN": ""}, clear=True) def test_missing_repo_token_exits(self) -> None: runner = CliRunner() result = runner.invoke(main, ["v1.0.0", "owner/repo"]) assert result.exit_code == 1 assert "REPO_TOKEN" in result.output @patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"}) @patch("devx.ci.publish.generate_release_notes", return_value="Release notes") @patch("devx.ci.publish.TeaCLI") @patch("devx.ci.publish.publish_to_pypi") @patch("devx.ci.publish.build_package") def test_build_failure_raises_click( self, mock_build: MagicMock, mock_publish: MagicMock, mock_tea_cls: MagicMock, mock_notes: MagicMock ) -> None: mock_build.side_effect = click.ClickException("build failed") runner = CliRunner() result = runner.invoke(main, ["v1.0.0", "owner/repo"]) assert result.exit_code == 1 assert "build" in result.output @patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"}) @patch("devx.ci.publish.generate_release_notes", return_value="Release notes") @patch("devx.ci.publish.TeaCLI") @patch("devx.ci.publish.publish_to_pypi") @patch("devx.ci.publish.build_package") def test_publish_failure_continues_to_gitea_release( self, mock_build: MagicMock, mock_publish: MagicMock, mock_tea_cls: MagicMock, mock_notes: MagicMock ) -> None: """PyPI publish failure is non-fatal — Gitea release is still created.""" mock_tea = MagicMock() mock_tea.list_releases.return_value = [] mock_tea_cls.return_value = mock_tea mock_publish.side_effect = click.ClickException("publish failed") runner = CliRunner() result = runner.invoke(main, ["v1.0.0", "owner/repo"]) assert result.exit_code == 0 assert "non-fatal" in result.output mock_tea.create_release.assert_called_once_with( "owner/repo", tag="v1.0.0", title="v1.0.0", body="Release notes" ) @patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"}) @patch("devx.ci.publish.generate_release_notes", return_value="Release notes") @patch("devx.ci.publish.TeaCLI") @patch("devx.ci.publish.publish_to_pypi") @patch("devx.ci.publish.build_package") def test_release_failure_raises_click( self, mock_build: MagicMock, mock_publish: MagicMock, mock_tea_cls: MagicMock, mock_notes: MagicMock ) -> None: mock_tea = MagicMock() mock_tea.list_releases.return_value = [] mock_tea.create_release.side_effect = TeaCLIError("server error") mock_tea_cls.return_value = mock_tea runner = CliRunner() result = runner.invoke(main, ["v1.0.0", "owner/repo"]) assert result.exit_code == 1 assert "Release creation failed" in result.output @patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok"}) @patch("devx.ci.publish.generate_release_notes", return_value="Release notes") @patch("devx.ci.publish.TeaCLI") @patch("devx.ci.publish.build_package") def test_skip_build_skips_build_and_publish( self, mock_build: MagicMock, mock_tea_cls: MagicMock, mock_notes: MagicMock ) -> None: """--skip-build skips build_package and PyPI publish, only creates Gitea release.""" mock_tea = MagicMock() mock_tea.list_releases.return_value = [] mock_tea_cls.return_value = mock_tea runner = CliRunner() result = runner.invoke(main, ["v1.0.0", "owner/repo", "--skip-build"]) assert result.exit_code == 0 assert "skip" in result.output.lower() mock_build.assert_not_called() mock_tea.create_release.assert_called_once() @patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"}) @patch("devx.ci.publish.generate_release_notes", return_value="Release notes") @patch("devx.ci.publish.TeaCLI") @patch("devx.ci.publish.publish_to_pypi") @patch("devx.ci.publish.build_package") def test_skips_release_creation_when_already_exists( self, mock_build: MagicMock, mock_publish: MagicMock, mock_tea_cls: MagicMock, mock_notes: MagicMock ) -> None: """If the Gitea release already exists, skip creation (idempotent).""" mock_tea = MagicMock() mock_tea.list_releases.return_value = [{"tag_name": "v1.0.0"}] mock_tea_cls.return_value = mock_tea runner = CliRunner() result = runner.invoke(main, ["v1.0.0", "owner/repo"]) assert result.exit_code == 0 assert "already exists" in result.output mock_tea.create_release.assert_not_called() @patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"}) @patch("devx.ci.publish.generate_release_notes", return_value="Release notes") @patch("devx.ci.publish.TeaCLI") @patch("devx.ci.publish.publish_to_pypi") @patch("devx.ci.publish.build_package") def test_proceeds_to_create_when_list_releases_fails( self, mock_build: MagicMock, mock_publish: MagicMock, mock_tea_cls: MagicMock, mock_notes: MagicMock ) -> None: """If list_releases raises TeaCLIError, proceed to create the release.""" mock_tea = MagicMock() mock_tea.list_releases.side_effect = TeaCLIError("api error") mock_tea_cls.return_value = mock_tea runner = CliRunner() result = runner.invoke(main, ["v1.0.0", "owner/repo"]) assert result.exit_code == 0 @patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok"}) @patch("devx.ci.publish.generate_release_notes", return_value="Release notes") @patch("devx.ci.publish.TeaCLI") @patch("devx.ci.publish.publish_to_gitea_registry") @patch("devx.ci.publish.publish_to_pypi") @patch("devx.ci.publish.build_package") def test_create_release_already_exists_is_idempotent( self, mock_build: MagicMock, mock_publish: MagicMock, mock_gitea_pub: MagicMock, mock_tea_cls: MagicMock, mock_notes: MagicMock, ) -> None: """If create_release fails with 'already exists', treat as success.""" mock_tea = MagicMock() mock_tea.list_releases.side_effect = TeaCLIError("api error") mock_tea.create_release.side_effect = TeaCLIError("there is already a release for this tag") mock_tea_cls.return_value = mock_tea runner = CliRunner() result = runner.invoke(main, ["v1.0.0", "owner/repo"]) assert result.exit_code == 0 assert "already exists" in result.output @patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok"}) @patch("devx.ci.publish.generate_release_notes", return_value="Release notes") @patch("devx.ci.publish.TeaCLI") @patch("devx.ci.publish.publish_to_gitea_registry") @patch("devx.ci.publish.publish_to_pypi") @patch("devx.ci.publish.build_package") def test_create_release_other_error_raises( self, mock_build: MagicMock, mock_publish: MagicMock, mock_gitea_pub: MagicMock, mock_tea_cls: MagicMock, mock_notes: MagicMock, ) -> None: """If create_release fails with a non-'already exists' error, raise.""" mock_tea = MagicMock() mock_tea.list_releases.side_effect = TeaCLIError("api error") mock_tea.create_release.side_effect = TeaCLIError("network error") mock_tea_cls.return_value = mock_tea runner = CliRunner() result = runner.invoke(main, ["v1.0.0", "owner/repo"]) assert result.exit_code != 0 assert "Release creation failed" in result.output class TestFromTag: def test_get_latest_tag_success(self) -> None: import subprocess with patch("devx.ci.publish.subprocess.run") as mock_run: mock_run.return_value = subprocess.CompletedProcess(args=[], returncode=0, stdout="v1.2.3\n") result = get_latest_tag() assert result == "v1.2.3" def test_get_latest_tag_no_tags(self) -> None: import subprocess with patch("devx.ci.publish.subprocess.run") as mock_run: mock_run.side_effect = subprocess.CalledProcessError(1, []) result = get_latest_tag() assert result is None def test_is_release_commit_match(self) -> None: import subprocess with patch("devx.ci.publish.subprocess.run") as mock_run: mock_run.return_value = subprocess.CompletedProcess( args=[], returncode=0, stdout="release: v1.2.3 [skip ci]\n" ) result = is_release_commit("v1.2.3") assert result is True def test_is_release_commit_no_match(self) -> None: import subprocess with patch("devx.ci.publish.subprocess.run") as mock_run: mock_run.return_value = subprocess.CompletedProcess(args=[], returncode=0, stdout="feat: add feature\n") result = is_release_commit("v1.2.3") assert result is False def test_is_release_commit_git_error(self) -> None: import subprocess with patch("devx.ci.publish.subprocess.run") as mock_run: mock_run.side_effect = subprocess.CalledProcessError(1, []) result = is_release_commit("v1.2.3") assert result is False @patch("devx.ci.publish.get_latest_tag", return_value=None) def test_from_tag_no_tag_skips(self, _mock: MagicMock) -> None: runner = CliRunner() result = runner.invoke(main, ["--from-tag", "--skip-build", "", "owner/repo"]) assert result.exit_code == 0 assert "No tag found" in result.output @patch("devx.ci.publish.get_latest_tag", return_value=None) def test_from_tag_no_repo_uses_env(self, _mock: MagicMock) -> None: runner = CliRunner() with patch.dict("os.environ", {"GITHUB_REPOSITORY": "owner/repo"}): result = runner.invoke(main, ["--from-tag", "--skip-build"]) assert result.exit_code == 0 assert "No tag found" in result.output @patch("devx.ci.publish.get_latest_tag", return_value=None) def test_from_tag_no_repo_no_env_raises(self, _mock: MagicMock) -> None: runner = CliRunner() with patch.dict("os.environ", {}, clear=True): result = runner.invoke(main, ["--from-tag", "--skip-build"]) assert result.exit_code != 0 assert "REPO argument is required" in result.output @patch("devx.ci.publish.is_release_commit", return_value=False) @patch("devx.ci.publish.get_latest_tag", return_value="v1.0.0") def test_from_tag_not_release_commit_skips(self, _mock_tag: MagicMock, _mock_rel: MagicMock) -> None: runner = CliRunner() result = runner.invoke(main, ["--from-tag", "--skip-build", "", "owner/repo"]) assert result.exit_code == 0 assert "not a release commit" in result.output @patch("devx.ci.publish.is_release_commit", return_value=True) @patch("devx.ci.publish.get_latest_tag", return_value="v1.0.0") def test_from_tag_publishes(self, _mock_tag: MagicMock, _mock_rel: MagicMock) -> None: with patch.dict("os.environ", {"REPO_TOKEN": "fake"}): with patch("devx.ci.publish.TeaCLI") as mock_tea_cls: mock_tea = MagicMock() mock_tea.list_releases.return_value = [] mock_tea_cls.return_value = mock_tea with patch("devx.ci.publish.generate_release_notes", return_value="notes"): runner = CliRunner() result = runner.invoke(main, ["--from-tag", "--skip-build", "", "owner/repo"]) assert result.exit_code == 0 assert "Publishing release v1.0.0" in result.output @patch("devx.ci.publish.is_release_commit", return_value=True) @patch("devx.ci.publish.get_latest_tag", return_value="v1.0.0") def test_from_tag_publishes_no_repo_arg(self, _mock_tag: MagicMock, _mock_rel: MagicMock) -> None: with patch.dict("os.environ", {"REPO_TOKEN": "fake", "GITHUB_REPOSITORY": "owner/repo"}): with patch("devx.ci.publish.TeaCLI") as mock_tea_cls: mock_tea = MagicMock() mock_tea.list_releases.return_value = [] mock_tea_cls.return_value = mock_tea with patch("devx.ci.publish.generate_release_notes", return_value="notes"): runner = CliRunner() result = runner.invoke(main, ["--from-tag", "--skip-build"]) assert result.exit_code == 0 assert "Publishing release v1.0.0" in result.output def test_no_tag_no_from_tag_raises(self) -> None: runner = CliRunner() result = runner.invoke(main, ["", "owner/repo", "--skip-build"]) assert result.exit_code != 0 assert "Tag is required" in result.output