Public Access
feat: extract reusable dev/CI tools from GRM into devx package
Post-merge / detect-type (push) Failing after 9s
Post-merge / validate-commit-msg (push) Has been skipped
Post-merge / release (push) Has been skipped
Post-merge / sync-wiki (push) Has been skipped
Post-merge / vikunja (push) Has been skipped
Post-merge / configure-repo (push) Has been skipped
Post-merge / badges (push) Failing after 25s
Post-merge / detect-type (push) Failing after 9s
Post-merge / validate-commit-msg (push) Has been skipped
Post-merge / release (push) Has been skipped
Post-merge / sync-wiki (push) Has been skipped
Post-merge / vikunja (push) Has been skipped
Post-merge / configure-repo (push) Has been skipped
Post-merge / badges (push) Failing after 25s
Port core modules (config, exceptions, i18n, api_clients, gitea_cli), 14 CI scripts, 6 dev tools, 5 molecule tools, CLI entry point, workflows, Makefile, tests (784 tests, 100% coverage), and documentation from GRM. The devx package is published to the Gitea PyPI registry and consumed by GRM, infra, and other oblachno-oss projects as a pip dependency. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
commit
60fd11419c
@@ -0,0 +1,302 @@
|
||||
"""Unit tests for devx.ci.publish."""
|
||||
|
||||
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,
|
||||
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")
|
||||
def test_success(self, 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")
|
||||
def test_failure_raises(self, 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)
|
||||
|
||||
|
||||
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, 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)
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
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_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_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_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_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_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_raises_click(
|
||||
self, mock_build: MagicMock, mock_publish: MagicMock, mock_tea_cls: MagicMock, mock_notes: MagicMock
|
||||
) -> None:
|
||||
mock_publish.side_effect = click.ClickException("publish failed")
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
|
||||
assert result.exit_code == 1
|
||||
assert "publish" 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_release_failure_raises_click(
|
||||
self, mock_build: MagicMock, mock_publish: MagicMock, mock_tea_cls: MagicMock, mock_notes: MagicMock
|
||||
) -> None:
|
||||
mock_tea = MagicMock()
|
||||
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
|
||||
Reference in New Issue
Block a user