Public Access
862 lines
36 KiB
Python
862 lines
36 KiB
Python
"""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("devx.ci.publish.subprocess.run")
|
|
@patch("devx.ci.publish.get_latest_tag", return_value="v0.1.0")
|
|
@patch("devx.gitea_cli.configure_tea_login")
|
|
@patch.dict("os.environ", {"CI_GITEA_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,
|
|
mock_run: MagicMock,
|
|
mock_tag: MagicMock,
|
|
mock_login: 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("devx.ci.publish.subprocess.run")
|
|
@patch("devx.ci.publish.get_latest_tag", return_value="v0.1.0")
|
|
@patch("devx.gitea_cli.configure_tea_login")
|
|
@patch.dict("os.environ", {"CI_GITEA_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,
|
|
mock_run: MagicMock,
|
|
mock_tag: MagicMock,
|
|
mock_login: 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("devx.ci.publish.subprocess.run")
|
|
@patch("devx.ci.publish.get_latest_tag", return_value="v0.1.0")
|
|
@patch("devx.gitea_cli.configure_tea_login")
|
|
@patch.dict("os.environ", {"CI_GITEA_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,
|
|
mock_run: MagicMock,
|
|
mock_tag: MagicMock,
|
|
mock_login: 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("devx.ci.publish.subprocess.run")
|
|
@patch("devx.ci.publish.get_latest_tag", return_value="v0.1.0")
|
|
@patch("devx.gitea_cli.configure_tea_login")
|
|
@patch.dict(
|
|
"os.environ",
|
|
{"CI_GITEA_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,
|
|
mock_run: MagicMock,
|
|
mock_tag: MagicMock,
|
|
mock_login: 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("devx.ci.publish.subprocess.run")
|
|
@patch("devx.ci.publish.get_latest_tag", return_value="v0.1.0")
|
|
@patch("devx.gitea_cli.configure_tea_login")
|
|
@patch.dict("os.environ", {"CI_GITEA_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,
|
|
mock_run: MagicMock,
|
|
mock_tag: MagicMock,
|
|
mock_login: 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("devx.ci.publish.subprocess.run")
|
|
@patch("devx.ci.publish.get_latest_tag", return_value="v0.1.0")
|
|
@patch("devx.gitea_cli.configure_tea_login")
|
|
@patch.dict("os.environ", {"CI_GITEA_TOKEN": ""}, clear=True)
|
|
def test_missing_repo_token_exits(self, mock_run: MagicMock, mock_tag: MagicMock, mock_login: MagicMock) -> None:
|
|
runner = CliRunner()
|
|
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
|
|
assert result.exit_code == 1
|
|
assert "CI_GITEA_TOKEN" in result.output
|
|
|
|
@patch("devx.ci.publish.subprocess.run")
|
|
@patch("devx.ci.publish.get_latest_tag", return_value="v0.1.0")
|
|
@patch("devx.gitea_cli.configure_tea_login")
|
|
@patch.dict("os.environ", {"CI_GITEA_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,
|
|
mock_run: MagicMock,
|
|
mock_tag: MagicMock,
|
|
mock_login: 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("devx.ci.publish.subprocess.run")
|
|
@patch("devx.ci.publish.get_latest_tag", return_value="v0.1.0")
|
|
@patch("devx.gitea_cli.configure_tea_login")
|
|
@patch.dict("os.environ", {"CI_GITEA_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,
|
|
mock_run: MagicMock,
|
|
mock_tag: MagicMock,
|
|
mock_login: 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("devx.ci.publish.subprocess.run")
|
|
@patch("devx.ci.publish.get_latest_tag", return_value="v0.1.0")
|
|
@patch("devx.gitea_cli.configure_tea_login")
|
|
@patch.dict("os.environ", {"CI_GITEA_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")
|
|
@patch("time.sleep")
|
|
def test_release_failure_raises_click(
|
|
self,
|
|
mock_sleep: MagicMock,
|
|
mock_build: MagicMock,
|
|
mock_publish: MagicMock,
|
|
mock_tea_cls: MagicMock,
|
|
mock_notes: MagicMock,
|
|
mock_run: MagicMock,
|
|
mock_tag: MagicMock,
|
|
mock_login: MagicMock,
|
|
) -> None:
|
|
"""Release creation failure after retries raises ClickException."""
|
|
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
|
|
# Retried 3 times (stop_after_attempt(3))
|
|
assert mock_tea.create_release.call_count == 3
|
|
|
|
@patch("devx.ci.publish.subprocess.run")
|
|
@patch("devx.ci.publish.get_latest_tag", return_value="v0.1.0")
|
|
@patch("devx.gitea_cli.configure_tea_login")
|
|
@patch.dict("os.environ", {"CI_GITEA_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,
|
|
mock_run: MagicMock,
|
|
mock_tag: MagicMock,
|
|
mock_login: 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("devx.ci.publish.subprocess.run")
|
|
@patch("devx.ci.publish.get_latest_tag", return_value="v0.1.0")
|
|
@patch("devx.gitea_cli.configure_tea_login")
|
|
@patch.dict("os.environ", {"CI_GITEA_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,
|
|
mock_run: MagicMock,
|
|
mock_tag: MagicMock,
|
|
mock_login: 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("devx.ci.publish.subprocess.run")
|
|
@patch("devx.ci.publish.get_latest_tag", return_value="v0.1.0")
|
|
@patch("devx.gitea_cli.configure_tea_login")
|
|
@patch.dict("os.environ", {"CI_GITEA_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,
|
|
mock_run: MagicMock,
|
|
mock_tag: MagicMock,
|
|
mock_login: 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("devx.ci.publish.subprocess.run")
|
|
@patch("devx.ci.publish.get_latest_tag", return_value="v0.1.0")
|
|
@patch("devx.gitea_cli.configure_tea_login")
|
|
@patch.dict("os.environ", {"CI_GITEA_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")
|
|
@patch("time.sleep")
|
|
def test_create_release_already_exists_is_idempotent(
|
|
self,
|
|
mock_sleep: MagicMock,
|
|
mock_build: MagicMock,
|
|
mock_publish: MagicMock,
|
|
mock_gitea_pub: MagicMock,
|
|
mock_tea_cls: MagicMock,
|
|
mock_notes: MagicMock,
|
|
mock_run: MagicMock,
|
|
mock_tag: MagicMock,
|
|
mock_login: MagicMock,
|
|
) -> None:
|
|
"""If create_release fails with 'already exists', treat as success (no retry)."""
|
|
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
|
|
# "already exists" is caught immediately — no retry
|
|
assert mock_tea.create_release.call_count == 1
|
|
|
|
@patch("devx.ci.publish.subprocess.run")
|
|
@patch("devx.ci.publish.get_latest_tag", return_value="v0.1.0")
|
|
@patch("devx.gitea_cli.configure_tea_login")
|
|
@patch.dict("os.environ", {"CI_GITEA_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")
|
|
@patch("time.sleep")
|
|
def test_create_release_other_error_raises(
|
|
self,
|
|
mock_sleep: MagicMock,
|
|
mock_build: MagicMock,
|
|
mock_publish: MagicMock,
|
|
mock_gitea_pub: MagicMock,
|
|
mock_tea_cls: MagicMock,
|
|
mock_notes: MagicMock,
|
|
mock_run: MagicMock,
|
|
mock_tag: MagicMock,
|
|
mock_login: MagicMock,
|
|
) -> None:
|
|
"""If create_release fails with a non-'already exists' error, raise after retries."""
|
|
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
|
|
# Retried 3 times before giving up
|
|
assert mock_tea.create_release.call_count == 3
|
|
|
|
|
|
class TestReleaseRetry:
|
|
"""Tests for retry logic on transient release creation failures."""
|
|
|
|
@patch("devx.ci.publish.subprocess.run")
|
|
@patch("devx.ci.publish.get_latest_tag", return_value="v0.1.0")
|
|
@patch("devx.gitea_cli.configure_tea_login")
|
|
@patch.dict("os.environ", {"CI_GITEA_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")
|
|
@patch("time.sleep")
|
|
def test_transient_failure_retried_and_succeeds(
|
|
self,
|
|
mock_sleep: MagicMock,
|
|
mock_build: MagicMock,
|
|
mock_tea_cls: MagicMock,
|
|
mock_notes: MagicMock,
|
|
mock_run: MagicMock,
|
|
mock_tag: MagicMock,
|
|
mock_login: MagicMock,
|
|
) -> None:
|
|
"""Transient failure on first attempt succeeds on retry."""
|
|
mock_tea = MagicMock()
|
|
mock_tea.list_releases.return_value = []
|
|
mock_tea.create_release.side_effect = [
|
|
TeaCLIError("connection timeout"),
|
|
None, # second attempt succeeds
|
|
]
|
|
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 "Gitea release v1.0.0 created" in result.output
|
|
assert mock_tea.create_release.call_count == 2
|
|
mock_sleep.assert_called() # slept between attempts
|
|
|
|
@patch("devx.ci.publish.subprocess.run")
|
|
@patch("devx.ci.publish.get_latest_tag", return_value="v0.1.0")
|
|
@patch("devx.gitea_cli.configure_tea_login")
|
|
@patch.dict("os.environ", {"CI_GITEA_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")
|
|
@patch("time.sleep")
|
|
def test_all_retries_exhausted_raises(
|
|
self,
|
|
mock_sleep: MagicMock,
|
|
mock_build: MagicMock,
|
|
mock_tea_cls: MagicMock,
|
|
mock_notes: MagicMock,
|
|
mock_run: MagicMock,
|
|
mock_tag: MagicMock,
|
|
mock_login: MagicMock,
|
|
) -> None:
|
|
"""All 3 retry attempts fail — raises ClickException."""
|
|
mock_tea = MagicMock()
|
|
mock_tea.list_releases.return_value = []
|
|
mock_tea.create_release.side_effect = TeaCLIError("503 service unavailable")
|
|
mock_tea_cls.return_value = mock_tea
|
|
runner = CliRunner()
|
|
result = runner.invoke(main, ["v1.0.0", "owner/repo", "--skip-build"])
|
|
assert result.exit_code == 1
|
|
assert "Release creation failed" in result.output
|
|
assert mock_tea.create_release.call_count == 3
|
|
assert mock_sleep.call_count == 2 # slept between 3 attempts (2 sleeps)
|
|
|
|
|
|
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.subprocess.run")
|
|
@patch("devx.gitea_cli.configure_tea_login")
|
|
@patch("devx.ci.publish.get_latest_tag", return_value=None)
|
|
def test_from_tag_no_tag_skips(self, _mock: MagicMock, mock_run: MagicMock, mock_login: 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.subprocess.run")
|
|
@patch("devx.gitea_cli.configure_tea_login")
|
|
@patch("devx.ci.publish.get_latest_tag", return_value=None)
|
|
def test_from_tag_no_repo_uses_env(self, _mock: MagicMock, mock_run: MagicMock, mock_login: 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.subprocess.run")
|
|
@patch("devx.gitea_cli.configure_tea_login")
|
|
@patch("devx.ci.publish.get_latest_tag", return_value=None)
|
|
def test_from_tag_no_repo_no_env_raises(self, _mock: MagicMock, mock_run: MagicMock, mock_login: 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.subprocess.run")
|
|
@patch("devx.ci.publish.generate_release_notes", return_value="notes")
|
|
@patch("devx.ci.publish.publish_to_pypi")
|
|
@patch("devx.ci.publish.publish_to_gitea_registry")
|
|
@patch("devx.gitea_cli.configure_tea_login")
|
|
@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,
|
|
mock_run: MagicMock,
|
|
mock_notes: MagicMock,
|
|
mock_pypi: MagicMock,
|
|
mock_gitea_reg: MagicMock,
|
|
mock_login: 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.subprocess.run")
|
|
@patch("devx.ci.publish.generate_release_notes", return_value="notes")
|
|
@patch("devx.ci.publish.publish_to_pypi")
|
|
@patch("devx.ci.publish.publish_to_gitea_registry")
|
|
@patch("devx.gitea_cli.configure_tea_login")
|
|
@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,
|
|
mock_run: MagicMock,
|
|
mock_notes: MagicMock,
|
|
mock_pypi: MagicMock,
|
|
mock_gitea_reg: MagicMock,
|
|
mock_login: MagicMock,
|
|
) -> None:
|
|
with patch.dict("os.environ", {"CI_GITEA_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.subprocess.run")
|
|
@patch("devx.ci.publish.generate_release_notes", return_value="notes")
|
|
@patch("devx.ci.publish.publish_to_pypi")
|
|
@patch("devx.ci.publish.publish_to_gitea_registry")
|
|
@patch("devx.gitea_cli.configure_tea_login")
|
|
@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,
|
|
mock_run: MagicMock,
|
|
mock_notes: MagicMock,
|
|
mock_pypi: MagicMock,
|
|
mock_gitea_reg: MagicMock,
|
|
mock_login: MagicMock,
|
|
) -> None:
|
|
with patch.dict("os.environ", {"CI_GITEA_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
|
|
|
|
@patch("devx.ci.publish.subprocess.run")
|
|
@patch("devx.ci.publish.get_latest_tag", return_value="v0.1.0")
|
|
@patch("devx.ci.publish.generate_release_notes", return_value="notes")
|
|
@patch("devx.ci.publish.publish_to_pypi")
|
|
@patch("devx.ci.publish.publish_to_gitea_registry")
|
|
@patch("devx.gitea_cli.configure_tea_login")
|
|
def test_no_tag_no_from_tag_raises(
|
|
self,
|
|
mock_run: MagicMock,
|
|
mock_tag: MagicMock,
|
|
mock_notes: MagicMock,
|
|
mock_pypi: MagicMock,
|
|
mock_gitea_reg: MagicMock,
|
|
mock_login: MagicMock,
|
|
) -> None:
|
|
runner = CliRunner()
|
|
result = runner.invoke(main, ["", "owner/repo", "--skip-build"])
|
|
assert result.exit_code != 0
|
|
assert "Tag is required" in result.output
|
|
|
|
|
|
class TestPublishAutoLogin:
|
|
"""Tests for --auto-login flag in publish."""
|
|
|
|
@patch("devx.ci.publish.subprocess.run")
|
|
@patch("devx.ci.publish.get_latest_tag", return_value="v0.1.0")
|
|
@patch("devx.ci.publish.generate_release_notes", return_value="notes")
|
|
@patch("devx.ci.publish.publish_to_pypi")
|
|
@patch("devx.ci.publish.publish_to_gitea_registry")
|
|
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
|
|
@patch("devx.ci.publish.configure_tea_login")
|
|
@patch("devx.ci.publish.TeaCLI")
|
|
def test_auto_login_calls_configure(
|
|
self,
|
|
mock_tea_cls: MagicMock,
|
|
mock_login: MagicMock,
|
|
mock_run: MagicMock,
|
|
mock_tag: MagicMock,
|
|
mock_notes: MagicMock,
|
|
mock_pypi: MagicMock,
|
|
mock_gitea_reg: MagicMock,
|
|
) -> None:
|
|
"""--auto-login calls configure_tea_login before creating release."""
|
|
mock_tea = MagicMock()
|
|
mock_tea.list_releases.return_value = []
|
|
mock_tea.create_release.return_value = {"tag_name": "v1.0.0"}
|
|
mock_tea_cls.return_value = mock_tea
|
|
with patch("devx.ci.publish.generate_release_notes", return_value="notes"):
|
|
runner = CliRunner()
|
|
result = runner.invoke(main, ["v1.0.0", "owner/repo", "--skip-build", "--auto-login"])
|
|
assert result.exit_code == 0
|
|
mock_login.assert_called_once()
|
|
|
|
@patch("devx.ci.publish.subprocess.run")
|
|
@patch("devx.ci.publish.get_latest_tag", return_value="v0.1.0")
|
|
@patch("devx.ci.publish.generate_release_notes", return_value="notes")
|
|
@patch("devx.ci.publish.publish_to_pypi")
|
|
@patch("devx.ci.publish.publish_to_gitea_registry")
|
|
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
|
|
@patch("devx.ci.publish.configure_tea_login")
|
|
@patch("devx.ci.publish.TeaCLI")
|
|
def test_no_auto_login_skips_configure(
|
|
self,
|
|
mock_tea_cls: MagicMock,
|
|
mock_login: MagicMock,
|
|
mock_run: MagicMock,
|
|
mock_tag: MagicMock,
|
|
mock_notes: MagicMock,
|
|
mock_pypi: MagicMock,
|
|
mock_gitea_reg: MagicMock,
|
|
) -> None:
|
|
"""Without --auto-login, configure_tea_login is not called."""
|
|
mock_tea = MagicMock()
|
|
mock_tea.list_releases.return_value = []
|
|
mock_tea.create_release.return_value = {"tag_name": "v1.0.0"}
|
|
mock_tea_cls.return_value = mock_tea
|
|
with patch("devx.ci.publish.generate_release_notes", return_value="notes"):
|
|
runner = CliRunner()
|
|
result = runner.invoke(main, ["v1.0.0", "owner/repo", "--skip-build"])
|
|
assert result.exit_code == 0
|
|
mock_login.assert_not_called()
|