feat: extract reusable dev/CI tools from GRM into devx package

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:
emil
2026-06-22 17:01:20 +02:00
co-authored by Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
commit 60fd11419c
86 changed files with 15947 additions and 0 deletions
View File
View File
+651
View File
@@ -0,0 +1,651 @@
"""Unit tests for api_clients module."""
import http
from unittest.mock import MagicMock, patch
import pytest
import requests
from devx.api_clients import GiteaClient, VikunjaClient, _is_retryable, _parse_error
from devx.config import (
DEFAULT_PER_PAGE,
DEFAULT_TIMEOUT,
VIKUNJA_PROJECT_ID,
)
from devx.exceptions import APIError
# Local test config — TEST_BP_CONFIG moved to configure_repo.py defaults in devx
TEST_BP_CONFIG: dict[str, object] = {
"branch_name": "master",
"enable_push": True,
"enable_push_whitelist": True,
"push_whitelist_usernames": [],
"enable_status_check": True,
"status_check_contexts": ["CI / quality (pull_request)"],
"required_approvals": 0,
}
def _mock_response(json_data: object | None = None, raise_on_status: bool = False) -> MagicMock:
mock = MagicMock()
if json_data is not None:
mock.json.return_value = json_data
if raise_on_status:
mock.raise_for_status.side_effect = requests.HTTPError(str(http.HTTPStatus.INTERNAL_SERVER_ERROR))
return mock
def _mock_http_error(status_code: int, message: str = "") -> requests.HTTPError:
"""Create an HTTPError with a proper response attached (for _parse_error)."""
resp = MagicMock()
resp.status_code = status_code
resp.json.return_value = {"message": message or str(status_code)}
err = requests.HTTPError(f"{status_code} {message}", response=resp)
return err
class TestParseError:
def test_json_parse_fallback(self) -> None:
mock_response = MagicMock()
mock_response.status_code = http.HTTPStatus.BAD_GATEWAY
mock_response.json = MagicMock(side_effect=ValueError("not json"))
err = requests.HTTPError(str(http.HTTPStatus.BAD_GATEWAY), response=mock_response)
status, message = _parse_error(err)
assert status == http.HTTPStatus.BAD_GATEWAY
assert str(http.HTTPStatus.BAD_GATEWAY) in message
def test_no_response(self) -> None:
err = requests.HTTPError("connection failed")
err.response = None # type: ignore[assignment]
status, message = _parse_error(err)
assert status == 0
assert "connection failed" in message
class TestGiteaClient:
def test_init_sets_headers(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
assert client._base_url == "https://git.example.com"
assert client._session.headers["Authorization"] == "token tok"
assert client._session.headers["Content-Type"] == "application/json"
def test_url_constructs_path(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
assert client._url("/labels") == ("https://git.example.com/repos/owner/repo/labels")
def test_url_strips_trailing_slash(self) -> None:
client = GiteaClient("https://git.example.com/", "tok", "owner", "repo")
assert client._url("/labels") == ("https://git.example.com/repos/owner/repo/labels")
def test_list_labels(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response([{"name": "bug", "color": "ff0000"}]))
result = client.list_labels()
assert len(result) == 1
client._session.request.assert_called_once_with(
"GET",
"https://git.example.com/repos/owner/repo/labels",
timeout=DEFAULT_TIMEOUT,
)
def test_list_labels_raises_api_error(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response(raise_on_status=True))
with pytest.raises(APIError):
client.list_labels()
def test_http_error_json_parse_fallback(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
mock_response = MagicMock()
mock_response.status_code = http.HTTPStatus.BAD_GATEWAY
# Make json() itself raise so the except block in _parse_error is hit
mock_response.json = MagicMock(side_effect=ValueError("not json"))
mock_response.raise_for_status.side_effect = requests.HTTPError(str(http.HTTPStatus.BAD_GATEWAY))
client._session.request = MagicMock(return_value=mock_response)
with pytest.raises(APIError) as exc_info:
client.list_labels()
assert str(http.HTTPStatus.BAD_GATEWAY) in str(exc_info.value)
def test_create_label(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response({"name": "ready-to-merge", "color": "2ecc71"}))
result = client.create_label("ready-to-merge", "2ecc71", "Auto-merge label")
assert result["name"] == "ready-to-merge"
client._session.request.assert_called_once_with(
"POST",
"https://git.example.com/repos/owner/repo/labels",
timeout=DEFAULT_TIMEOUT,
json={"name": "ready-to-merge", "color": "2ecc71", "description": "Auto-merge label"},
)
def test_ensure_label_creates_when_not_exists(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client.list_labels = MagicMock(return_value=[])
client.create_label = MagicMock(return_value={"name": "ready-to-merge", "color": "2ecc71"})
result = client.ensure_label("ready-to-merge", "2ecc71", "desc")
assert result is not None
assert result["name"] == "ready-to-merge"
client.create_label.assert_called_once_with("ready-to-merge", "2ecc71", "desc")
def test_ensure_label_returns_none_when_exists(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client.list_labels = MagicMock(return_value=[{"name": "ready-to-merge", "color": "2ecc71"}])
client.create_label = MagicMock()
result = client.ensure_label("ready-to-merge", "2ecc71", "desc")
assert result is None
client.create_label.assert_not_called()
def test_list_branch_protections(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(
return_value=_mock_response(
[
{"id": 1, "branch_name": "master"},
{"id": 2, "branch_name": "develop"},
]
)
)
result = client.list_branch_protections()
assert len(result) == 2
assert result[0]["branch_name"] == "master"
def test_create_branch_protection(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response({"id": 3, "branch_name": "master"}))
result = client.create_branch_protection(TEST_BP_CONFIG)
assert result["id"] == 3
client._session.request.assert_called_once_with(
"POST",
"https://git.example.com/repos/owner/repo/branch_protections",
timeout=DEFAULT_TIMEOUT,
json=TEST_BP_CONFIG,
)
def test_update_branch_protection(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
resp = {"branch_name": "master", "required_approvals": 2}
client._session.request = MagicMock(return_value=_mock_response(resp))
update = {"required_approvals": 2}
result = client.update_branch_protection("master", update)
assert result["required_approvals"] == 2
client._session.request.assert_called_once_with(
"PATCH",
"https://git.example.com/repos/owner/repo/branch_protections/master",
timeout=DEFAULT_TIMEOUT,
json=update,
)
def test_ensure_branch_protection_creates_when_none_exist(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client.list_branch_protections = MagicMock(return_value=[])
client.create_branch_protection = MagicMock(return_value={"id": 1, "branch_name": "master"})
result = client.ensure_branch_protection("master", TEST_BP_CONFIG)
assert result["id"] == 1
client.create_branch_protection.assert_called_once_with(TEST_BP_CONFIG)
def test_ensure_branch_protection_updates_when_exists(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client.list_branch_protections = MagicMock(return_value=[{"branch_name": "master", "required_approvals": 0}])
client.update_branch_protection = MagicMock(return_value={"branch_name": "master", "required_approvals": 1})
result = client.ensure_branch_protection("master", TEST_BP_CONFIG)
assert result["required_approvals"] == 1
expected_update = {k: v for k, v in TEST_BP_CONFIG.items() if k != "branch_name"}
client.update_branch_protection.assert_called_once_with("master", expected_update)
def test_merge_pr(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response())
client.merge_pr(1, "fix: bug")
client._session.request.assert_called_once_with(
"POST",
"https://git.example.com/repos/owner/repo/pulls/1/merge",
timeout=DEFAULT_TIMEOUT,
json={"Do": "squash", "MergeTitleField": "fix: bug"},
)
def test_get_pr_labels(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response([{"name": "ready-to-merge"}]))
result = client.get_pr_labels(5)
assert result == [{"name": "ready-to-merge"}]
client._session.request.assert_called_once_with(
"GET",
"https://git.example.com/repos/owner/repo/issues/5/labels",
timeout=DEFAULT_TIMEOUT,
)
def test_get_commit_status(self) -> None:
"""Uses combined status endpoint (/status, not /statuses)."""
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(
return_value=_mock_response({"statuses": [{"context": "CI / quality", "status": "success"}]})
)
result = client.get_commit_status("abc123")
assert result == [{"context": "CI / quality", "status": "success"}]
client._session.request.assert_called_once_with(
"GET",
"https://git.example.com/repos/owner/repo/commits/abc123/status",
timeout=DEFAULT_TIMEOUT,
)
def test_get_pr(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response({"number": 7, "head": {"sha": "abc123"}}))
result = client.get_pr(7)
assert result["number"] == 7
assert result["head"]["sha"] == "abc123"
client._session.request.assert_called_once_with(
"GET",
"https://git.example.com/repos/owner/repo/pulls/7",
timeout=DEFAULT_TIMEOUT,
)
def test_get_pr_files(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(
return_value=_mock_response([{"filename": "src/main.py", "status": "modified"}])
)
result = client.get_pr_files(7)
assert len(result) == 1
assert result[0]["filename"] == "src/main.py"
client._session.request.assert_called_once_with(
"GET",
"https://git.example.com/repos/owner/repo/pulls/7/files",
timeout=DEFAULT_TIMEOUT,
)
def test_get_pr_commits(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(
return_value=_mock_response([{"sha": "abc123", "commit": {"message": "fix: bug"}}])
)
result = client.get_pr_commits(7)
assert len(result) == 1
assert result[0]["commit"]["message"] == "fix: bug"
client._session.request.assert_called_once_with(
"GET",
"https://git.example.com/repos/owner/repo/pulls/7/commits",
timeout=DEFAULT_TIMEOUT,
)
def test_get_pr_reviews(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response([{"id": 1, "state": "APPROVED"}]))
result = client.get_pr_reviews(7)
assert len(result) == 1
assert result[0]["state"] == "APPROVED"
client._session.request.assert_called_once_with(
"GET",
"https://git.example.com/repos/owner/repo/pulls/7/reviews",
timeout=DEFAULT_TIMEOUT,
)
def test_create_issue(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response({"id": 42, "title": "bug"}))
result = client.create_issue(title="bug", body="description", labels=[1])
assert result["id"] == 42
client._session.request.assert_called_once_with(
"POST",
"https://git.example.com/repos/owner/repo/issues",
json={"title": "bug", "body": "description", "labels": [1]},
timeout=DEFAULT_TIMEOUT,
)
def test_create_issue_no_labels(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response({"id": 43, "title": "bug"}))
result = client.create_issue(title="bug", body="description")
assert result["id"] == 43
client._session.request.assert_called_once_with(
"POST",
"https://git.example.com/repos/owner/repo/issues",
json={"title": "bug", "body": "description"},
timeout=DEFAULT_TIMEOUT,
)
def test_create_review_comment(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response({"id": 42}))
result = client.create_review(7, event="COMMENT", body="Looks good")
assert result["id"] == 42
client._session.request.assert_called_once_with(
"POST",
"https://git.example.com/repos/owner/repo/pulls/7/reviews",
timeout=DEFAULT_TIMEOUT,
json={"event": "COMMENT", "body": "Looks good"},
)
def test_create_review_approve_maps_to_approved(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response({"id": 44, "state": "APPROVED"}))
result = client.create_review(7, event="APPROVE", body="Good work")
assert result["id"] == 44
client._session.request.assert_called_once_with(
"POST",
"https://git.example.com/repos/owner/repo/pulls/7/reviews",
timeout=DEFAULT_TIMEOUT,
json={"event": "APPROVED", "body": "Good work"},
)
def test_create_review_with_inline_comments(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response({"id": 43}))
comments = [{"path": "src/main.py", "body": "Fix this", "new_position": 10}]
result = client.create_review(7, event="REQUEST_CHANGES", body="Please fix", comments=comments)
assert result["id"] == 43
client._session.request.assert_called_once_with(
"POST",
"https://git.example.com/repos/owner/repo/pulls/7/reviews",
timeout=DEFAULT_TIMEOUT,
json={"event": "REQUEST_CHANGES", "body": "Please fix", "comments": comments},
)
def test_update_repo_settings(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response({"default_delete_branch_after_merge": True}))
settings = {"default_delete_branch_after_merge": True}
result = client.update_repo_settings(settings)
assert result["default_delete_branch_after_merge"] is True
client._session.request.assert_called_once_with(
"PATCH",
"https://git.example.com/repos/owner/repo",
timeout=DEFAULT_TIMEOUT,
json=settings,
)
def test_create_release(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response({"id": 1}))
client.create_release("v1.0.0")
client._session.request.assert_called_once_with(
"POST",
"https://git.example.com/repos/owner/repo/releases",
timeout=DEFAULT_TIMEOUT,
json={"tag_name": "v1.0.0", "name": "v1.0.0", "body": "", "draft": False, "prerelease": False},
)
def test_get_release_by_tag_found(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response({"id": 1, "tag_name": "v1.0.0"}))
result = client.get_release_by_tag("v1.0.0")
assert result is not None
assert result["id"] == 1
def test_get_release_by_tag_not_found(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
mock_resp = MagicMock()
mock_resp.raise_for_status.side_effect = requests.HTTPError("404")
mock_resp.status_code = 404
client._session.request = MagicMock(return_value=mock_resp)
result = client.get_release_by_tag("v9.9.9")
assert result is None
def test_create_release_idempotent_existing(self) -> None:
"""If release already exists, should return it without creating a new one."""
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
existing_response = _mock_response({"id": 42, "tag_name": "v1.0.0"})
client._session.request = MagicMock(return_value=existing_response)
result = client.create_release_idempotent("v1.0.0")
assert result["id"] == 42
# Should only call GET (check), not POST (create)
assert client._session.request.call_count == 1
assert client._session.request.call_args[0][0] == "GET"
def test_create_release_idempotent_new(self) -> None:
"""If release doesn't exist, should create it."""
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
not_found_resp = MagicMock()
not_found_resp.raise_for_status.side_effect = requests.HTTPError("404")
not_found_resp.status_code = 404
create_resp = _mock_response({"id": 1, "tag_name": "v1.0.0"})
client._session.request = MagicMock(side_effect=[not_found_resp, create_resp])
result = client.create_release_idempotent("v1.0.0")
assert result["id"] == 1
assert client._session.request.call_count == 2
@patch("devx.api_clients.time.sleep")
def test_request_retries_on_429(self, mock_sleep: MagicMock) -> None:
"""Should retry on 429 rate limit with exponential backoff."""
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
rate_limited = MagicMock()
rate_limited.raise_for_status.side_effect = _mock_http_error(429, "rate limited")
success = _mock_response({"ok": True})
client._session.request = MagicMock(side_effect=[rate_limited, rate_limited, success])
result = client._request("GET", "/test")
assert result.json() == {"ok": True}
assert client._session.request.call_count == 3
assert mock_sleep.call_count == 2
@patch("devx.api_clients.time.sleep")
def test_request_retries_on_503(self, mock_sleep: MagicMock) -> None:
"""Should retry on 503 service unavailable."""
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
unavailable = MagicMock()
unavailable.raise_for_status.side_effect = _mock_http_error(503, "unavailable")
success = _mock_response({"ok": True})
client._session.request = MagicMock(side_effect=[unavailable, success])
result = client._request("GET", "/test")
assert result.json() == {"ok": True}
assert client._session.request.call_count == 2
@patch("devx.api_clients.time.sleep")
def test_request_no_retry_on_404(self, mock_sleep: MagicMock) -> None:
"""Should NOT retry on 404 — it's not a transient error."""
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
not_found = MagicMock()
not_found.raise_for_status.side_effect = _mock_http_error(404, "not found")
client._session.request = MagicMock(return_value=not_found)
with pytest.raises(APIError) as exc_info:
client._request("GET", "/test")
assert exc_info.value.status == 404
assert client._session.request.call_count == 1
mock_sleep.assert_not_called()
@patch("devx.api_clients.time.sleep")
def test_request_retries_on_connection_error(self, mock_sleep: MagicMock) -> None:
"""Should retry on connection errors."""
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
success = _mock_response({"ok": True})
client._session.request = MagicMock(side_effect=[requests.ConnectionError("refused"), success])
result = client._request("GET", "/test")
assert result.json() == {"ok": True}
assert client._session.request.call_count == 2
@patch("devx.api_clients.time.sleep")
def test_request_max_retries_exhausted(self, mock_sleep: MagicMock) -> None:
"""Should raise APIError after max retries on persistent 503."""
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
unavailable = MagicMock()
unavailable.raise_for_status.side_effect = _mock_http_error(503, "unavailable")
client._session.request = MagicMock(return_value=unavailable)
with pytest.raises(APIError) as exc_info:
client._request("GET", "/test")
assert exc_info.value.status == 503
assert client._session.request.call_count == 3 # MAX_RETRIES
@patch("devx.api_clients.time.sleep")
def test_request_connection_error_exhausted(self, mock_sleep: MagicMock) -> None:
"""Should raise APIError after max retries on persistent connection errors."""
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(side_effect=requests.ConnectionError("refused"))
with pytest.raises(APIError) as exc_info:
client._request("GET", "/test")
assert exc_info.value.status == 0
assert client._session.request.call_count == 3 # MAX_RETRIES
class TestVikunjaClient:
def test_init_sets_headers(self) -> None:
client = VikunjaClient("https://work.example.com", "tok")
assert client._base_url == "https://work.example.com"
assert client._session.headers["Authorization"] == "Bearer tok"
def test_list_tasks(self) -> None:
client = VikunjaClient("https://work.example.com", "tok")
client._session.request = MagicMock(
return_value=_mock_response([{"id": 1, "identifier": "DEVX-19", "project_id": VIKUNJA_PROJECT_ID}])
)
result = client.list_tasks(per_page=DEFAULT_PER_PAGE)
assert len(result) == 1
client._session.request.assert_called_once_with(
"GET",
"https://work.example.com/tasks",
timeout=DEFAULT_TIMEOUT,
params={"per_page": DEFAULT_PER_PAGE},
)
def test_list_project_tasks(self) -> None:
client = VikunjaClient("https://work.example.com", "tok")
client._session.request = MagicMock(return_value=_mock_response([{"id": 1, "identifier": "DEVX-19"}]))
result = client.list_project_tasks(VIKUNJA_PROJECT_ID, page=1, per_page=DEFAULT_PER_PAGE)
assert len(result) == 1
client._session.request.assert_called_once_with(
"GET",
f"https://work.example.com/projects/{VIKUNJA_PROJECT_ID}/tasks",
timeout=DEFAULT_TIMEOUT,
params={"page": 1, "per_page": DEFAULT_PER_PAGE},
)
def test_get_task(self) -> None:
client = VikunjaClient("https://work.example.com", "tok")
client._session.request = MagicMock(
return_value=_mock_response({"id": 292, "identifier": "DEVX-32", "title": "Some task"})
)
result = client.get_task(292)
assert result["identifier"] == "DEVX-32"
assert result["title"] == "Some task"
client._session.request.assert_called_once_with(
"GET",
"https://work.example.com/tasks/292",
timeout=DEFAULT_TIMEOUT,
)
def test_post_comment(self) -> None:
client = VikunjaClient("https://work.example.com", "tok")
client._session.request = MagicMock(return_value=_mock_response())
client.post_comment(42, "<p>hi</p>")
client._session.request.assert_called_once_with(
"PUT",
"https://work.example.com/tasks/42/comments",
timeout=DEFAULT_TIMEOUT,
json={"comment": "<p>hi</p>"},
)
def test_update_task(self) -> None:
client = VikunjaClient("https://work.example.com", "tok")
client._session.request = MagicMock(return_value=_mock_response())
client.update_task(42, done=True)
client._session.request.assert_called_once_with(
"POST",
"https://work.example.com/tasks/42",
timeout=DEFAULT_TIMEOUT,
json={"done": True},
)
def test_http_error_raises_api_error(self) -> None:
client = VikunjaClient("https://work.example.com", "tok")
mock_resp = MagicMock()
mock_resp.raise_for_status.side_effect = _mock_http_error(http.HTTPStatus.INTERNAL_SERVER_ERROR, "server error")
client._session.request = MagicMock(return_value=mock_resp)
with pytest.raises(APIError):
client.list_tasks()
def test_http_error_no_response(self) -> None:
client = VikunjaClient("https://work.example.com", "tok")
err = requests.HTTPError("connection failed")
err.response = None # type: ignore[assignment]
mock_resp = MagicMock()
mock_resp.raise_for_status.side_effect = err
client._session.request = MagicMock(return_value=mock_resp)
with pytest.raises(APIError) as exc_info:
client.list_tasks()
assert "connection failed" in str(exc_info.value)
@patch("devx.api_clients.time.sleep")
def test_vikunja_retries_on_503(self, mock_sleep: MagicMock) -> None:
"""VikunjaClient should also retry on 503."""
client = VikunjaClient("https://work.example.com", "tok")
unavailable = MagicMock()
unavailable.raise_for_status.side_effect = _mock_http_error(503, "unavailable")
success = _mock_response([{"id": 1}])
client._session.request = MagicMock(side_effect=[unavailable, success])
result = client.list_tasks()
assert len(result) == 1
assert client._session.request.call_count == 2
@patch("devx.api_clients.time.sleep")
def test_vikunja_retries_on_connection_error(self, mock_sleep: MagicMock) -> None:
"""VikunjaClient should retry on connection errors."""
client = VikunjaClient("https://work.example.com", "tok")
success = _mock_response([{"id": 1}])
client._session.request = MagicMock(side_effect=[requests.ConnectionError("refused"), success])
result = client.list_tasks()
assert len(result) == 1
assert client._session.request.call_count == 2
@patch("devx.api_clients.time.sleep")
def test_vikunja_max_retries_exhausted(self, mock_sleep: MagicMock) -> None:
"""VikunjaClient should raise APIError after max retries on persistent 503."""
client = VikunjaClient("https://work.example.com", "tok")
unavailable = MagicMock()
unavailable.raise_for_status.side_effect = _mock_http_error(503, "unavailable")
client._session.request = MagicMock(return_value=unavailable)
with pytest.raises(APIError) as exc_info:
client.list_tasks()
assert exc_info.value.status == 503
assert client._session.request.call_count == 3 # MAX_RETRIES
@patch("devx.api_clients.time.sleep")
def test_vikunja_connection_error_exhausted(self, mock_sleep: MagicMock) -> None:
"""VikunjaClient should raise APIError after max retries on persistent connection errors."""
client = VikunjaClient("https://work.example.com", "tok")
client._session.request = MagicMock(side_effect=requests.ConnectionError("refused"))
with pytest.raises(APIError) as exc_info:
client.list_tasks()
assert exc_info.value.status == 0
assert client._session.request.call_count == 3 # MAX_RETRIES
class TestIsRetryable:
def test_connection_error_is_retryable(self) -> None:
assert _is_retryable(requests.ConnectionError("refused")) is True
def test_timeout_is_retryable(self) -> None:
assert _is_retryable(requests.Timeout("timed out")) is True
def test_429_is_retryable(self) -> None:
err = _mock_http_error(429, "rate limited")
assert _is_retryable(err) is True
def test_404_is_not_retryable(self) -> None:
err = _mock_http_error(404, "not found")
assert _is_retryable(err) is False
def test_generic_exception_is_not_retryable(self) -> None:
assert _is_retryable(ValueError("oops")) is False
+345
View File
@@ -0,0 +1,345 @@
"""Unit tests for scripts/ci/auto_merge.py."""
from unittest.mock import MagicMock, patch
import click
import pytest
from click.testing import CliRunner
from devx.ci.auto_merge import (
extract_conventional_msg,
extract_task_id,
main,
read_taskid,
run_cmd,
validate_pr_title,
validate_pr_title_matches_vikunja,
)
from devx.exceptions import APIError
# -- read_taskid --
class TestReadTaskid:
def test_reads_from_file(self, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
monkeypatch.chdir(tmp_path)
(tmp_path / ".taskid").write_text("DEVX-60\n")
assert read_taskid("some-branch") == "DEVX-60"
def test_falls_back_to_branch_name(self, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
monkeypatch.chdir(tmp_path)
assert read_taskid("DEVX-19-fix-bug") == "DEVX-19"
def test_returns_empty_when_no_file_no_match(self, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
monkeypatch.chdir(tmp_path)
assert read_taskid("feature-branch") == ""
def test_empty_file_falls_back_to_branch(self, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
monkeypatch.chdir(tmp_path)
(tmp_path / ".taskid").write_text("\n")
assert read_taskid("DEVX-42-test") == "DEVX-42"
# -- extract_task_id (legacy fallback) --
class TestExtractTaskId:
def test_extracts_from_branch(self) -> None:
assert extract_task_id("DEVX-19-fix-bug") == "DEVX-19"
assert extract_task_id("DEVX-123") == "DEVX-123"
def test_returns_empty_when_no_match(self) -> None:
assert extract_task_id("feature-branch") == ""
# -- validate_pr_title --
class TestValidatePrTitle:
def test_valid_title(self) -> None:
validate_pr_title("DEVX-19: Add new feature", "DEVX-19")
def test_missing_colon(self) -> None:
with pytest.raises(click.ClickException, match="format"):
validate_pr_title("DEVX-19 Add new feature", "DEVX-19")
def test_task_id_mismatch(self) -> None:
with pytest.raises(click.ClickException, match="mismatch"):
validate_pr_title("DEVX-20: Add feature", "DEVX-19")
def test_no_task_id_in_title(self) -> None:
with pytest.raises(click.ClickException, match="format"):
validate_pr_title("Add new feature", "DEVX-19")
# -- validate_pr_title_matches_vikunja --
class TestValidatePrTitleMatchesVikunja:
@patch.dict("os.environ", {}, clear=True)
def test_skips_when_no_token(self) -> None:
# Should not raise — just warn
validate_pr_title_matches_vikunja("DEVX-19: test", "DEVX-19")
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True)
@patch("devx.ci.auto_merge.VikunjaClient")
def test_matches(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client.list_project_tasks.return_value = [
{"id": 1, "identifier": "DEVX-19", "title": "Add new feature"},
]
mock_client_cls.return_value = mock_client
validate_pr_title_matches_vikunja("DEVX-19: Add new feature", "DEVX-19")
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True)
@patch("devx.ci.auto_merge.VikunjaClient")
def test_mismatch_raises(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client.list_project_tasks.return_value = [
{"id": 1, "identifier": "DEVX-19", "title": "Different title"},
]
mock_client_cls.return_value = mock_client
with pytest.raises(click.ClickException, match="does not match"):
validate_pr_title_matches_vikunja("DEVX-19: Add new feature", "DEVX-19")
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True)
@patch("devx.ci.auto_merge.VikunjaClient")
def test_task_not_found_raises(self, mock_client_cls: MagicMock) -> None:
"""When Vikunja task is not found and token is set, raises ClickException."""
mock_client = MagicMock()
mock_client.list_project_tasks.return_value = []
mock_client_cls.return_value = mock_client
with pytest.raises(click.ClickException, match="Could not find"):
validate_pr_title_matches_vikunja("DEVX-99: test", "DEVX-99")
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True)
@patch("devx.ci.auto_merge.VikunjaClient")
def test_task_found_on_second_page(self, mock_client_cls: MagicMock) -> None:
"""Pagination: task found on page 2."""
mock_client = MagicMock()
page1 = [{"id": i, "identifier": f"DEVX-{i}", "title": f"Title {i}"} for i in range(50)]
page2 = [{"id": 100, "identifier": "DEVX-99", "title": "Found me"}]
mock_client.list_project_tasks.side_effect = [page1, page2]
mock_client_cls.return_value = mock_client
# Should not raise — title matches
validate_pr_title_matches_vikunja("DEVX-99: Found me", "DEVX-99")
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True)
@patch("devx.ci.auto_merge.VikunjaClient")
def test_task_not_found_partial_page_raises(self, mock_client_cls: MagicMock) -> None:
"""Pagination stops when page has fewer than DEFAULT_PER_PAGE results. Task not found raises."""
mock_client = MagicMock()
mock_client.list_project_tasks.return_value = [
{"id": 1, "identifier": "DEVX-1", "title": "Title 1"},
]
mock_client_cls.return_value = mock_client
with pytest.raises(click.ClickException, match="Could not find"):
validate_pr_title_matches_vikunja("DEVX-99: test", "DEVX-99")
# -- extract_conventional_msg --
class TestExtractConventionalMsg:
def test_finds_conventional(self) -> None:
commits = [
{"commit": {"message": "fix: resolve timeout"}},
{"commit": {"message": "merge branch"}},
]
assert extract_conventional_msg(commits) == "fix: resolve timeout"
def test_finds_latest_conventional(self) -> None:
commits = [
{"commit": {"message": "merge branch"}},
{"commit": {"message": "feat: add feature"}},
]
assert extract_conventional_msg(commits) == "feat: add feature"
def test_falls_back_to_newest(self) -> None:
commits = [
{"commit": {"message": "random message"}},
]
assert extract_conventional_msg(commits) == "random message"
def test_empty_commits(self) -> None:
assert extract_conventional_msg([]) == ""
def test_multiline_message(self) -> None:
commits = [
{"commit": {"message": "feat: add feature\n\nBody text."}},
]
assert extract_conventional_msg(commits) == "feat: add feature"
# -- run_cmd --
class TestRunCmd:
def test_success(self) -> None:
result = run_cmd(["echo", "hello"])
assert result.returncode == 0
def test_failure_raises(self) -> None:
with pytest.raises(click.ClickException, match="Command failed"):
run_cmd(["false"])
def test_failure_no_check(self) -> None:
result = run_cmd(["false"], check=False)
assert result.returncode != 0
# -- main (integration) --
class TestMain:
@patch.dict("os.environ", {"REPO_TOKEN": "tok", "VIKUNJA_TOKEN": ""}, clear=True)
@patch("devx.ci.auto_merge.GiteaClient")
def test_full_merge_flow(self, mock_client_cls: MagicMock, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
monkeypatch.chdir(tmp_path)
(tmp_path / ".taskid").write_text("DEVX-19\n")
mock_client = MagicMock()
mock_client.get_pr_commits.return_value = [
{"commit": {"message": "fix: resolve timeout"}},
]
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(
main,
["DEVX-19-fix-bug", "DEVX-19: Fix timeout", "owner/repo", "7"],
)
assert result.exit_code == 0, result.output
mock_client.merge_pr.assert_called_once_with("7", "DEVX-19: fix: resolve timeout")
@patch.dict("os.environ", {"REPO_TOKEN": ""}, clear=True)
def test_no_token_raises(self) -> None:
runner = CliRunner()
result = runner.invoke(main, ["DEVX-19-fix", "DEVX-19: test", "owner/repo", "7"])
assert result.exit_code != 0
assert "REPO_TOKEN" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
@patch("devx.ci.auto_merge.GiteaClient")
def test_no_task_id_raises(self, mock_client_cls: MagicMock, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
monkeypatch.chdir(tmp_path)
# No .taskid file, no DEVX-N in branch name
runner = CliRunner()
result = runner.invoke(main, ["feature-branch", "DEVX-19: test", "owner/repo", "7"])
assert result.exit_code != 0
assert "No task ID" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
@patch("devx.ci.auto_merge.GiteaClient")
def test_invalid_pr_title_raises(self, mock_client_cls: MagicMock, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
monkeypatch.chdir(tmp_path)
(tmp_path / ".taskid").write_text("DEVX-19\n")
runner = CliRunner()
result = runner.invoke(main, ["DEVX-19-fix", "Bad title", "owner/repo", "7"])
assert result.exit_code != 0
assert "format" in result.output.lower()
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
@patch("devx.ci.auto_merge.GiteaClient")
def test_merge_behind_master_rebases(self, mock_client_cls: MagicMock, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
monkeypatch.chdir(tmp_path)
(tmp_path / ".taskid").write_text("DEVX-19\n")
mock_client = MagicMock()
mock_client.get_pr_commits.return_value = [
{"commit": {"message": "fix: resolve timeout"}},
]
mock_client.merge_pr.side_effect = [
APIError(405, "HEAD branch is behind master"),
None, # Second call succeeds
]
mock_client_cls.return_value = mock_client
with patch("devx.ci.auto_merge.run_cmd") as mock_run:
runner = CliRunner()
result = runner.invoke(
main,
["DEVX-19-fix-bug", "DEVX-19: Fix timeout", "owner/repo", "7"],
)
assert result.exit_code == 0, result.output
assert mock_client.merge_pr.call_count == 2
# Should have fetched, rebased, and pushed
assert mock_run.call_count == 3
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
@patch("devx.ci.auto_merge.GiteaClient")
def test_merge_failure_raises(self, mock_client_cls: MagicMock, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
monkeypatch.chdir(tmp_path)
(tmp_path / ".taskid").write_text("DEVX-19\n")
mock_client = MagicMock()
mock_client.get_pr_commits.return_value = [
{"commit": {"message": "fix: resolve timeout"}},
]
mock_client.merge_pr.side_effect = APIError(409, "Conflict")
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(
main,
["DEVX-19-fix-bug", "DEVX-19: Fix timeout", "owner/repo", "7"],
)
assert result.exit_code != 0
assert "Merge failed" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
@patch("devx.ci.auto_merge.GiteaClient")
def test_no_conventional_msg_raises(self, mock_client_cls: MagicMock, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
"""When no conventional commit message is found in PR commits, raises."""
monkeypatch.chdir(tmp_path)
(tmp_path / ".taskid").write_text("DEVX-19\n")
mock_client = MagicMock()
mock_client.get_pr_commits.return_value = []
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(
main,
["DEVX-19-fix-bug", "DEVX-19: Fix timeout", "owner/repo", "7"],
)
assert result.exit_code != 0
assert "conventional commit" in result.output.lower()
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
@patch("devx.ci.auto_merge.GiteaClient")
def test_rebase_retry_failure_raises(self, mock_client_cls: MagicMock, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
"""When rebase retry also fails, raises with helpful message."""
monkeypatch.chdir(tmp_path)
(tmp_path / ".taskid").write_text("DEVX-19\n")
mock_client = MagicMock()
mock_client.get_pr_commits.return_value = [
{"commit": {"message": "fix: resolve timeout"}},
]
mock_client.merge_pr.side_effect = APIError(405, "HEAD branch is behind master")
mock_client_cls.return_value = mock_client
with patch("devx.ci.auto_merge.run_cmd") as mock_run:
mock_run.side_effect = click.ClickException("git rebase failed")
runner = CliRunner()
result = runner.invoke(
main,
["DEVX-19-fix-bug", "DEVX-19: Fix timeout", "owner/repo", "7"],
)
assert result.exit_code != 0
assert "rebase" in result.output.lower()
def test_main_module_block() -> None:
"""Test that the __main__ block can be executed."""
import devx.ci.auto_merge as am
with open(am.__file__) as f:
source = f.read()
source = source.replace('if __name__ == "__main__":\n main()\n', "")
namespace = dict(am.__dict__)
exec(compile(source, am.__file__, "exec"), namespace)
# Verify main is callable
assert callable(namespace["main"])
+140
View File
@@ -0,0 +1,140 @@
"""Unit tests for scripts/check_test_speed.py."""
from unittest.mock import MagicMock, patch
import click
import pytest
from click.testing import CliRunner
from devx.tools.check_test_speed import (
DEFAULT_MAX_SECONDS,
TEST_COMMAND,
check_speed,
cli,
parse_duration,
run_tests,
)
class TestRunTests:
@patch("devx.tools.check_test_speed.subprocess.run")
def test_run_tests_returns_stdout_stderr(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(stdout="out", stderr="err", returncode=0)
stdout, stderr = run_tests()
assert stdout == "out"
assert stderr == "err"
mock_run.assert_called_once_with(
TEST_COMMAND,
capture_output=True,
text=True,
check=False,
)
class TestParseDuration:
def test_parses_valid_line(self) -> None:
assert parse_duration("234 passed in 0.70s") == 0.70
def test_parses_with_warnings(self) -> None:
assert parse_duration("293 passed, 1 warning in 0.45s") == 0.45
def test_parses_multiline_output(self) -> None:
output = "some header\n234 passed in 1.23s\nfooter"
assert parse_duration(output) == 1.23
def test_raises_when_no_timing_line(self) -> None:
with pytest.raises(click.ClickException) as exc:
parse_duration("no timing here")
assert "Could not parse" in str(exc.value)
class TestCheckSpeed:
def test_under_budget_passes(self) -> None:
check_speed(1.0, 2.0) # should not raise
def test_exact_budget_passes(self) -> None:
check_speed(2.0, 2.0) # should not raise
def test_over_budget_raises(self) -> None:
with pytest.raises(click.ClickException) as exc:
check_speed(2.1, 2.0)
msg = str(exc.value)
assert "too slow" in msg.lower()
assert "2.10s" in msg
assert "max allowed: 2.0s" in msg
def test_main_module_block() -> None:
import devx.tools.check_test_speed as cts
with patch.object(cts, "cli") as mock_cli:
with patch.object(cts, "__name__", "__main__"):
cts.cli([])
mock_cli.assert_called_once_with([])
class TestMain:
@patch("devx.tools.check_test_speed.run_tests")
@patch("devx.tools.check_test_speed.parse_duration")
@patch("devx.tools.check_test_speed.check_speed")
def test_successful_run(
self,
mock_check: MagicMock,
mock_parse: MagicMock,
mock_run: MagicMock,
) -> None:
mock_run.return_value = ("stdout\n", "stderr\n")
mock_parse.return_value = 1.5
runner = CliRunner()
result = runner.invoke(cli, [])
assert result.exit_code == 0
assert "1.50s" in result.output
assert "under 2.0s limit" in result.output
mock_run.assert_called_once()
mock_parse.assert_called_once_with("stdout\n\nstderr\n")
mock_check.assert_called_once_with(1.5, DEFAULT_MAX_SECONDS)
@patch("devx.tools.check_test_speed.run_tests")
@patch("devx.tools.check_test_speed.parse_duration")
def test_slow_tests_exit(
self,
mock_parse: MagicMock,
mock_run: MagicMock,
) -> None:
mock_run.return_value = ("out\n", "err\n")
mock_parse.return_value = 3.0
runner = CliRunner()
result = runner.invoke(cli, [])
assert result.exit_code == 1
assert "too slow" in result.output.lower()
@patch("devx.tools.check_test_speed.run_tests")
def test_parse_failure_exits(
self,
mock_run: MagicMock,
) -> None:
mock_run.return_value = ("bad output\n", "")
runner = CliRunner()
result = runner.invoke(cli, [])
assert result.exit_code == 1
assert "Could not parse" in result.output
@patch("devx.tools.check_test_speed.run_tests")
@patch("devx.tools.check_test_speed.parse_duration")
@patch("devx.tools.check_test_speed.check_speed")
def test_custom_max_seconds(
self,
mock_check: MagicMock,
mock_parse: MagicMock,
mock_run: MagicMock,
) -> None:
mock_run.return_value = ("out\n", "err\n")
mock_parse.return_value = 0.5
runner = CliRunner()
result = runner.invoke(cli, ["--max-seconds", "1.5"])
assert result.exit_code == 0
mock_check.assert_called_once_with(0.5, 1.5)
+380
View File
@@ -0,0 +1,380 @@
from __future__ import annotations
import json
from pathlib import Path
import pytest
from click.testing import CliRunner
import devx.ci.check_translations as check_translations
class TestExtractKeys:
def test_extracts_underscore_calls(self, tmp_path: Path) -> None:
f = tmp_path / "test.py"
f.write_text('from devx.i18n import _\nprint(_("Hello world"))\n')
keys = check_translations.extract_keys(f)
assert "Hello world" in keys
def test_extracts_wrapper_calls(self, tmp_path: Path) -> None:
f = tmp_path / "test.py"
f.write_text('@_handle_errors("Update failed: {error}")\ndef foo(): pass\n')
keys = check_translations.extract_keys(f)
assert "Update failed: {error}" in keys
def test_ignores_non_string_args(self, tmp_path: Path) -> None:
f = tmp_path / "test.py"
f.write_text('x = "key"\n_(x)\n')
keys = check_translations.extract_keys(f)
assert keys == set()
def test_syntax_error_returns_empty(self, tmp_path: Path) -> None:
f = tmp_path / "test.py"
f.write_text("def broken(:\n")
keys = check_translations.extract_keys(f)
assert keys == set()
class TestCheckTranslationSet:
def test_all_good(self, tmp_path: Path) -> None:
src_dir = tmp_path / "src"
src_dir.mkdir()
(src_dir / "mod.py").write_text('_("Hello")\n')
trans_file = tmp_path / "translations.json"
trans_file.write_text(
json.dumps({"Hello": {"en": "Hello", "bg": "Здравей", "de": "Hallo", "ru": "Привет", "zh": "你好"}})
)
result = check_translations.check_translation_set("test", src_dir, trans_file)
assert not result.errors
assert not result.warnings
def test_missing_key(self, tmp_path: Path) -> None:
src_dir = tmp_path / "src"
src_dir.mkdir()
(src_dir / "mod.py").write_text('_("Missing")\n')
trans_file = tmp_path / "translations.json"
trans_file.write_text(json.dumps({"Other": {"en": "Other"}}))
result = check_translations.check_translation_set("test", src_dir, trans_file)
assert any("Missing key" in e for e in result.errors)
def test_dead_key(self, tmp_path: Path) -> None:
src_dir = tmp_path / "src"
src_dir.mkdir()
(src_dir / "mod.py").write_text('_("Used")\n')
trans_file = tmp_path / "translations.json"
trans_file.write_text(json.dumps({"Used": {"en": "Used"}, "Dead": {"en": "Dead"}}))
result = check_translations.check_translation_set("test", src_dir, trans_file)
assert any("Dead key" in w for w in result.warnings)
def test_missing_language(self, tmp_path: Path) -> None:
src_dir = tmp_path / "src"
src_dir.mkdir()
(src_dir / "mod.py").write_text('_("Hello")\n')
trans_file = tmp_path / "translations.json"
trans_file.write_text(json.dumps({"Hello": {"en": "Hello"}}))
result = check_translations.check_translation_set("test", src_dir, trans_file)
assert any("Missing languages" in w for w in result.warnings)
def test_missing_translations_file(self, tmp_path: Path) -> None:
src_dir = tmp_path / "src"
src_dir.mkdir()
(src_dir / "mod.py").write_text('_("Hello")\n')
trans_file = tmp_path / "nonexistent.json"
result = check_translations.check_translation_set("test", src_dir, trans_file)
assert any("not found" in e for e in result.errors)
class TestMain:
def test_passes_on_clean_repo(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""The actual repo should pass when there are no errors."""
ok_result = check_translations.TranslationCheckResult(
name="devx",
src_dir=Path("/tmp"),
trans_file=Path("/tmp/t.json"),
used_keys={"a"},
defined_keys={"a"},
)
monkeypatch.setattr(
check_translations,
"check_translation_set",
lambda name, src, trans: ok_result,
)
runner = CliRunner()
result = runner.invoke(check_translations.main, [])
assert result.exit_code == 0
def test_strict_fails_on_warnings(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""--strict should fail if there are missing language warnings."""
warn_result = check_translations.TranslationCheckResult(
name="devx",
src_dir=Path("/tmp"),
trans_file=Path("/tmp/t.json"),
used_keys={"a"},
defined_keys={"a"},
warnings=["Dead key: 'bar'"],
)
monkeypatch.setattr(
check_translations,
"check_translation_set",
lambda name, src, trans: warn_result,
)
runner = CliRunner()
result = runner.invoke(check_translations.main, ["--strict"])
assert result.exit_code == 1
def test_fails_on_errors(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""Should fail with exit code 1 when errors are found."""
error_result = check_translations.TranslationCheckResult(
name="devx",
src_dir=Path("/tmp"),
trans_file=Path("/tmp/t.json"),
errors=["Missing key: 'foo'"],
)
ok_result = check_translations.TranslationCheckResult(
name="devx2",
src_dir=Path("/tmp"),
trans_file=Path("/tmp/t.json"),
used_keys={"a"},
defined_keys={"a"},
)
monkeypatch.setattr(
check_translations,
"check_translation_set",
lambda name, src, trans: error_result if name == "devx" else ok_result,
)
runner = CliRunner()
result = runner.invoke(check_translations.main, [])
assert result.exit_code == 1
assert "FAIL" in result.output
def test_passes_no_warnings(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""Should pass with exit code 0 and 'PASS:' message when no warnings."""
ok_result = check_translations.TranslationCheckResult(
name="devx",
src_dir=Path("/tmp"),
trans_file=Path("/tmp/t.json"),
used_keys={"a"},
defined_keys={"a"},
)
monkeypatch.setattr(
check_translations,
"check_translation_set",
lambda name, src, trans: ok_result,
)
runner = CliRunner()
result = runner.invoke(check_translations.main, [])
assert result.exit_code == 0
assert "PASS: All translations" in result.output
def test_translations_flag(self, tmp_path: Path) -> None:
"""--translations flag should check a specific file."""
trans_file = tmp_path / "translations.json"
trans_file.write_text(
json.dumps({"Hello": {"en": "Hello", "bg": "Здравей", "de": "Hallo", "ru": "Привет", "zh": "你好"}})
)
(tmp_path / "mod.py").write_text('_("Hello")\n')
runner = CliRunner()
result = runner.invoke(check_translations.main, ["--translations", str(trans_file)])
assert result.exit_code == 0
class TestPrintResult:
def test_prints_all_good(self, capsys: pytest.CaptureFixture[str]) -> None:
result = check_translations.TranslationCheckResult(
name="test",
src_dir=Path("/tmp"),
trans_file=Path("/tmp/t.json"),
used_keys={"a"},
defined_keys={"a"},
)
check_translations.print_result(result)
captured = capsys.readouterr()
assert "All good!" in captured.out
def test_prints_errors(self, capsys: pytest.CaptureFixture[str]) -> None:
result = check_translations.TranslationCheckResult(
name="test",
src_dir=Path("/tmp"),
trans_file=Path("/tmp/t.json"),
errors=["Missing key: 'foo'"],
)
check_translations.print_result(result)
captured = capsys.readouterr()
assert "ERROR" in captured.err
def test_prints_warnings(self, capsys: pytest.CaptureFixture[str]) -> None:
result = check_translations.TranslationCheckResult(
name="test",
src_dir=Path("/tmp"),
trans_file=Path("/tmp/t.json"),
warnings=["Dead key: 'bar'"],
)
check_translations.print_result(result)
captured = capsys.readouterr()
assert "WARN" in captured.err
class TestDevxI18n:
"""Test the devx.i18n module."""
def test_english_default(self) -> None:
from devx.i18n import _
assert _("Running tests...") == "Running tests..."
def test_format_kwargs(self) -> None:
from devx.i18n import _
result = _("Comparing {base}..{head} ({count} files changed)", base="a", head="b", count=5)
assert "a..b" in result
assert "5 files" in result
def test_unknown_key_returns_key(self) -> None:
from devx.i18n import _
assert _("Nonexistent key 12345") == "Nonexistent key 12345"
def test_devx_lang_override(self, monkeypatch: pytest.MonkeyPatch) -> None:
import importlib
monkeypatch.setenv("DEVX_LANG", "de")
import devx.i18n
importlib.reload(devx.i18n)
# "ERROR: REPO_TOKEN is not set." has a German translation
result = devx.i18n._("ERROR: REPO_TOKEN is not set.")
assert "FEHLER" in result
# Restore
monkeypatch.delenv("DEVX_LANG", raising=False)
importlib.reload(devx.i18n)
def test_unsupported_lang_fallback(self, monkeypatch: pytest.MonkeyPatch) -> None:
import importlib
monkeypatch.setenv("DEVX_LANG", "xx")
import devx.i18n
importlib.reload(devx.i18n)
result = devx.i18n._("Running tests...")
assert result == "Running tests..." # Falls back to English
monkeypatch.delenv("DEVX_LANG", raising=False)
importlib.reload(devx.i18n)
class TestTranslationsFile:
"""Test that the devx translations file is valid."""
def test_translations_exist(self) -> None:
trans = Path("src/devx/translations.json")
assert trans.exists()
data = json.loads(trans.read_text())
assert len(data) > 0
def test_all_keys_have_english(self) -> None:
trans = Path("src/devx/translations.json")
data = json.loads(trans.read_text())
for key, langs in data.items():
assert "en" in langs, f"Key {key!r} missing English translation"
class TestCollectKeys:
def test_skips_i18n_py(self, tmp_path: Path) -> None:
"""i18n.py should be skipped when collecting keys."""
(tmp_path / "i18n.py").write_text('_("should_not_appear")\n')
(tmp_path / "mod.py").write_text('_("should_appear")\n')
keys = check_translations.collect_keys(tmp_path)
assert "should_appear" in keys
assert "should_not_appear" not in keys
def test_non_default_dir_no_dynamic_keys(self, tmp_path: Path) -> None:
"""Non-default source dirs should not include DYNAMIC_KEYS."""
(tmp_path / "mod.py").write_text('_("mykey")\n')
keys = check_translations.collect_keys(tmp_path)
assert "mykey" in keys
# Dynamic keys should NOT be present for non-default dirs
assert "completed" not in keys
assert "pending" not in keys
def test_default_dir_includes_dynamic_keys(self) -> None:
"""The default source dir should include DYNAMIC_KEYS."""
keys = check_translations.collect_keys(check_translations.DEFAULT_SRC_DIR)
assert "completed" in keys
assert "pending" in keys
assert "in_progress" in keys
class TestMainWarnings:
def test_passes_with_warnings(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""Should pass with exit code 0 and 'PASS with warnings' message."""
warn_result = check_translations.TranslationCheckResult(
name="devx",
src_dir=Path("/tmp"),
trans_file=Path("/tmp/t.json"),
used_keys={"a"},
defined_keys={"a"},
warnings=["Dead key: 'bar'"],
)
monkeypatch.setattr(
check_translations,
"check_translation_set",
lambda name, src, trans: warn_result,
)
runner = CliRunner()
result = runner.invoke(check_translations.main, [])
assert result.exit_code == 0
assert "PASS with warnings" in result.output
class TestI18nProjectTranslations:
"""Test devx.i18n._load_project_translations function."""
def test_loads_project_translations(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Project translations from DEVX_TRANSLATIONS_PATH are loaded."""
trans_file = tmp_path / "extra.json"
trans_file.write_text(json.dumps({"Custom key": {"en": "Custom value"}}))
monkeypatch.setenv("DEVX_TRANSLATIONS_PATH", str(trans_file))
import importlib
import devx.i18n
importlib.reload(devx.i18n)
assert "Custom key" in devx.i18n.TRANSLATIONS
# Restore
monkeypatch.delenv("DEVX_TRANSLATIONS_PATH", raising=False)
importlib.reload(devx.i18n)
def test_nonexistent_path_returns_empty(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""Nonexistent DEVX_TRANSLATIONS_PATH returns empty dict."""
monkeypatch.setenv("DEVX_TRANSLATIONS_PATH", "/nonexistent/path/file.json")
import importlib
import devx.i18n
importlib.reload(devx.i18n)
# Should still work with built-in translations
assert devx.i18n._("Running tests...") == "Running tests..."
monkeypatch.delenv("DEVX_TRANSLATIONS_PATH", raising=False)
importlib.reload(devx.i18n)
def test_invalid_json_returns_empty(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Invalid JSON in DEVX_TRANSLATIONS_PATH returns empty dict."""
trans_file = tmp_path / "bad.json"
trans_file.write_text("{invalid json content")
monkeypatch.setenv("DEVX_TRANSLATIONS_PATH", str(trans_file))
import importlib
import devx.i18n
importlib.reload(devx.i18n)
# Should still work with built-in translations
assert devx.i18n._("Running tests...") == "Running tests..."
monkeypatch.delenv("DEVX_TRANSLATIONS_PATH", raising=False)
importlib.reload(devx.i18n)
+369
View File
@@ -0,0 +1,369 @@
"""Unit tests for scripts/ci/classify_changes.py."""
from pathlib import Path
from unittest.mock import MagicMock, patch
import click
import pytest
from click.testing import CliRunner
import devx.ci.classify_changes as classify_changes_mod
from devx.ci.classify_changes import (
classify_changes,
get_changed_files,
get_latest_tag,
has_user_facing_changes,
is_user_facing,
is_workflow_only,
main,
run_git,
)
class TestIsUserFacing:
def test_src_is_user_facing(self) -> None:
assert is_user_facing("src/devx/cli.py") is True
def test_ansible_is_user_facing(self) -> None:
assert is_user_facing("ansible/roles/gitea-runner/tasks/main.yml") is True
def test_pyproject_is_user_facing(self) -> None:
assert is_user_facing("pyproject.toml") is True
def test_workflow_is_not_user_facing(self) -> None:
assert is_user_facing(".gitea/workflows/ci.yml") is False
def test_ci_scripts_are_not_user_facing(self) -> None:
assert is_user_facing("scripts/ci/release.py") is False
def test_dev_scripts_are_not_user_facing(self) -> None:
"""All scripts under scripts/ are infrastructure (CI/CD, dev tools).
User-facing code lives in src/devx/."""
assert is_user_facing("scripts/check_test_speed.py") is False
assert is_user_facing("scripts/configure_repo.py") is False
assert is_user_facing("scripts/install_checkmake.py") is False
def test_shell_scripts_are_not_user_facing(self) -> None:
assert is_user_facing("scripts/setup.sh") is False
assert is_user_facing("scripts/molecule_all.sh") is False
def test_scripts_init_is_not_user_facing(self) -> None:
assert is_user_facing("scripts/__init__.py") is False
def test_version_file_is_not_user_facing(self) -> None:
"""__init__.py only contains __version__ — a release artifact,
not user-facing code. Version bumps alone should not trigger releases."""
assert is_user_facing("src/devx/__init__.py") is False
def test_api_clients_is_not_user_facing(self) -> None:
"""api_clients.py is used only by CI/CD scripts, not by the GRM CLI."""
assert is_user_facing("src/devx/api_clients.py") is False
def test_docs_are_not_user_facing(self) -> None:
assert is_user_facing("docs/user/getting-started.md") is False
def test_tests_are_not_user_facing(self) -> None:
assert is_user_facing("tests/unit/test_cli.py") is False
def test_agents_md_is_not_user_facing(self) -> None:
assert is_user_facing("AGENTS.md") is False
def test_makefile_is_not_user_facing(self) -> None:
assert is_user_facing("Makefile") is False
def test_unknown_file_defaults_to_user_facing(self) -> None:
"""Safe default: unknown files are user-facing (require release)."""
assert is_user_facing("some/new/file.type") is True
assert is_user_facing("new_root_file.txt") is True
def test_is_workflow_only_inverse(self) -> None:
assert is_workflow_only(".gitea/workflows/ci.yml") is True
assert is_workflow_only("src/devx/cli.py") is False
assert is_workflow_only("pyproject.toml") is False
class TestClassifyChanges:
def test_all_user_facing(self) -> None:
files = ["src/devx/cli.py", "ansible/roles/gitea-runner/tasks/main.yml"]
result = classify_changes(files)
assert result["user_facing"] == files
assert result["workflow_only"] == []
def test_all_workflow_only(self) -> None:
files = [".gitea/workflows/ci.yml", "docs/index.md", "AGENTS.md"]
result = classify_changes(files)
assert result["user_facing"] == []
assert result["workflow_only"] == files
def test_mixed(self) -> None:
files = [
"src/devx/cli.py",
".gitea/workflows/ci.yml",
"pyproject.toml",
"docs/index.md",
]
result = classify_changes(files)
assert "src/devx/cli.py" in result["user_facing"]
assert "pyproject.toml" in result["user_facing"]
assert ".gitea/workflows/ci.yml" in result["workflow_only"]
assert "docs/index.md" in result["workflow_only"]
def test_empty(self) -> None:
result = classify_changes([])
assert result == {"user_facing": [], "workflow_only": []}
class TestGetChangedFiles:
@patch("devx.ci.classify_changes.run_git")
def test_returns_file_list(self, mock_run_git: MagicMock) -> None:
mock_run_git.return_value = "file1.py\nfile2.py\nfile3.md"
result = get_changed_files("v0.1.0", "HEAD")
assert result == ["file1.py", "file2.py", "file3.md"]
@patch("devx.ci.classify_changes.run_git")
def test_empty_when_no_changes(self, mock_run_git: MagicMock) -> None:
mock_run_git.return_value = ""
result = get_changed_files("v0.1.0", "HEAD")
assert result == []
class TestHasUserFacingChanges:
@patch("devx.ci.classify_changes.get_changed_files")
def test_true_when_user_facing(self, mock_get: MagicMock) -> None:
mock_get.return_value = ["src/devx/cli.py", "docs/index.md"]
assert has_user_facing_changes("v0.1.0", "HEAD") is True
@patch("devx.ci.classify_changes.get_changed_files")
def test_false_when_workflow_only(self, mock_get: MagicMock) -> None:
mock_get.return_value = [".gitea/workflows/ci.yml", "docs/index.md"]
assert has_user_facing_changes("v0.1.0", "HEAD") is False
@patch("devx.ci.classify_changes.get_changed_files")
def test_false_when_no_changes(self, mock_get: MagicMock) -> None:
mock_get.return_value = []
assert has_user_facing_changes("v0.1.0", "HEAD") is False
class TestGetLatestTag:
@patch("subprocess.run")
def test_returns_tag(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(returncode=0, stdout="v0.3.0\n", stderr="")
assert get_latest_tag() == "v0.3.0"
@patch("subprocess.run")
def test_returns_empty_when_no_tags(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="error")
assert get_latest_tag() == ""
class TestRunGit:
@patch("devx.ci.classify_changes.subprocess.run")
def test_success(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(returncode=0, stdout="file1.py\n", stderr="")
result = run_git(["git", "diff", "--name-only", "v0.1.0", "HEAD"])
assert result == "file1.py"
@patch("devx.ci.classify_changes.subprocess.run")
def test_failure_raises(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="git error")
with pytest.raises(click.ClickException):
run_git(["git", "bad-command"])
class TestMain:
@patch("devx.ci.classify_changes.get_latest_tag", return_value="")
def test_no_tags_outputs_true(self, mock_tag: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(main, ["--quiet"])
assert result.exit_code == 0
assert "true" in result.output
@patch("devx.ci.classify_changes.get_changed_files", return_value=[])
@patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
def test_no_changes_outputs_false(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(main, ["--quiet"])
assert result.exit_code == 0
assert "false" in result.output
@patch("devx.ci.classify_changes.get_changed_files")
@patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
def test_workflow_only_exits_2(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
mock_changes.return_value = [".gitea/workflows/ci.yml", "docs/index.md"]
runner = CliRunner()
result = runner.invoke(main, [])
assert result.exit_code == 2
assert "no release needed" in result.output
@patch("devx.ci.classify_changes.get_changed_files")
@patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
def test_user_facing_exits_0(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
mock_changes.return_value = ["src/devx/cli.py", "docs/index.md"]
runner = CliRunner()
result = runner.invoke(main, [])
assert result.exit_code == 0
assert "release needed" in result.output
@patch("devx.ci.classify_changes.get_latest_tag", return_value="")
def test_no_tags_non_quiet(self, mock_tag: MagicMock) -> None:
"""Non-quiet mode with no tags prints user-facing message."""
runner = CliRunner()
result = runner.invoke(main, [])
assert result.exit_code == 0
assert "No tags found" in result.output
@patch("devx.ci.classify_changes.get_changed_files", return_value=[])
@patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
def test_no_changes_non_quiet(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
"""Non-quiet mode with no changes prints message."""
runner = CliRunner()
result = runner.invoke(main, [])
assert result.exit_code == 0
assert "No changes" in result.output
@patch("devx.ci.classify_changes.get_changed_files")
@patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
def test_quiet_user_facing(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
"""Quiet mode with user-facing changes outputs true."""
mock_changes.return_value = ["src/devx/cli.py"]
runner = CliRunner()
result = runner.invoke(main, ["--quiet"])
assert result.exit_code == 0
assert "true" in result.output
@patch("devx.ci.classify_changes.get_changed_files")
@patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
def test_quiet_workflow_only(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
"""Quiet mode with workflow-only changes outputs false."""
mock_changes.return_value = [".gitea/workflows/ci.yml"]
runner = CliRunner()
result = runner.invoke(main, ["--quiet"])
assert result.exit_code == 0
assert "false" in result.output
@patch("devx.ci.classify_changes.get_changed_files")
@patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
def test_with_explicit_base(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
"""Explicit --base overrides latest tag."""
mock_changes.return_value = ["src/devx/cli.py"]
runner = CliRunner()
result = runner.invoke(main, ["--base", "v0.2.0", "--head", "HEAD"])
assert result.exit_code == 0
assert "release needed" in result.output
@patch("devx.ci.classify_changes.get_changed_files")
@patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
def test_check_ansible_true(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
"""--check ansible with Ansible changes outputs true."""
mock_changes.return_value = ["ansible/tasks/main.yml", ".gitea/workflows/ci.yml"]
runner = CliRunner()
result = runner.invoke(main, ["--check", "ansible", "--quiet"])
assert result.exit_code == 0
assert "true" in result.output
@patch("devx.ci.classify_changes.get_changed_files")
@patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
def test_check_ansible_false(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
"""--check ansible with no Ansible changes outputs false."""
mock_changes.return_value = ["src/devx/cli.py", ".gitea/workflows/ci.yml"]
runner = CliRunner()
result = runner.invoke(main, ["--check", "ansible", "--quiet"])
assert result.exit_code == 0
assert "false" in result.output
@patch("devx.ci.classify_changes.get_changed_files")
@patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
def test_check_user_facing_true(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
"""--check user-facing with user-facing changes outputs true."""
mock_changes.return_value = ["src/devx/cli.py", ".gitea/workflows/ci.yml"]
runner = CliRunner()
result = runner.invoke(main, ["--check", "user-facing", "--quiet"])
assert result.exit_code == 0
assert "true" in result.output
@patch("devx.ci.classify_changes.get_changed_files")
@patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
def test_check_user_facing_false(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
"""--check user-facing with only workflow changes outputs false."""
mock_changes.return_value = [".gitea/workflows/ci.yml", "tests/test_foo.py"]
runner = CliRunner()
result = runner.invoke(main, ["--check", "user-facing", "--quiet"])
assert result.exit_code == 0
assert "false" in result.output
@patch("devx.ci.classify_changes.get_changed_files")
@patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
def test_check_ansible_non_quiet(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
"""--check ansible in non-quiet mode prints file list."""
mock_changes.return_value = ["ansible/tasks/main.yml"]
runner = CliRunner()
result = runner.invoke(main, ["--check", "ansible"])
assert result.exit_code == 0
assert "Ansible changes detected" in result.output
@patch("devx.ci.classify_changes.get_changed_files")
@patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
def test_check_user_facing_non_quiet(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
"""--check user-facing in non-quiet mode prints file list."""
mock_changes.return_value = ["src/devx/cli.py"]
runner = CliRunner()
result = runner.invoke(main, ["--check", "user-facing"])
assert result.exit_code == 0
assert "User-facing changes detected" in result.output
class TestGithubOutput:
def test_writes_outputs(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
gh_file = tmp_path / "output.txt"
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
with patch.object(
classify_changes_mod, "get_changed_files", return_value=["src/cli.py", "ansible/tasks/main.yml"]
):
runner = CliRunner()
result = runner.invoke(main, ["--base", "v1.0", "--head", "HEAD", "--github-output"])
assert result.exit_code == 0
content = gh_file.read_text()
assert "ansible-changed=true" in content
assert "user-facing-changed=true" in content
def test_no_changes(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
gh_file = tmp_path / "output.txt"
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
with patch.object(classify_changes_mod, "get_changed_files", return_value=[]):
runner = CliRunner()
result = runner.invoke(main, ["--base", "v1.0", "--head", "HEAD", "--github-output"])
assert result.exit_code == 0
content = gh_file.read_text()
assert "ansible-changed=false" in content
assert "user-facing-changed=false" in content
def test_no_tags(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
gh_file = tmp_path / "output.txt"
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
with patch.object(classify_changes_mod, "get_latest_tag", return_value=""):
runner = CliRunner()
result = runner.invoke(main, ["--github-output"])
assert result.exit_code == 0
content = gh_file.read_text()
assert "ansible-changed=true" in content
assert "user-facing-changed=true" in content
def test_no_env_var(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("GITHUB_OUTPUT", raising=False)
with patch.object(classify_changes_mod, "get_changed_files", return_value=["src/cli.py"]):
runner = CliRunner()
result = runner.invoke(main, ["--base", "v1.0", "--head", "HEAD", "--github-output"])
assert result.exit_code != 0
def test_workflow_only(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
gh_file = tmp_path / "output.txt"
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
with patch.object(
classify_changes_mod, "get_changed_files", return_value=[".gitea/workflows/ci.yml", "AGENTS.md"]
):
runner = CliRunner()
result = runner.invoke(main, ["--base", "v1.0", "--head", "HEAD", "--github-output"])
assert result.exit_code == 0
content = gh_file.read_text()
assert "ansible-changed=false" in content
assert "user-facing-changed=false" in content
+295
View File
@@ -0,0 +1,295 @@
"""Unit tests for devx.cli module."""
from unittest.mock import MagicMock, patch
import click
import pytest
from click.testing import CliRunner
from devx.cli import cli
class TestCliGroups:
def test_cli_help(self) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["--help"])
assert result.exit_code == 0
assert "devx" in result.output
assert "ci" in result.output
assert "tools" in result.output
assert "molecule" in result.output
def test_ci_group_help(self) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["ci", "--help"])
assert result.exit_code == 0
assert "auto-merge" in result.output
assert "release" in result.output
assert "publish" in result.output
def test_tools_group_help(self) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["tools", "--help"])
assert result.exit_code == 0
assert "setup" in result.output
assert "install-tools" in result.output
assert "configure-repo" in result.output
def test_molecule_group_help(self) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["molecule", "--help"])
assert result.exit_code == 0
assert "distribute" in result.output
assert "guard" in result.output
assert "all" in result.output
class TestCiCommands:
@patch("devx.cli._run_module")
def test_ci_auto_merge(self, mock_run: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["ci", "auto-merge", "branch", "title", "repo", "1"])
assert result.exit_code == 0
mock_run.assert_called_once_with("devx.ci.auto_merge", ["branch", "title", "repo", "1"])
@patch("devx.cli._run_module")
def test_ci_check_translations(self, mock_run: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["ci", "check-translations"])
assert result.exit_code == 0
mock_run.assert_called_once_with("devx.ci.check_translations", [])
@patch("devx.cli._run_module")
def test_ci_classify_changes(self, mock_run: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["ci", "classify-changes", "positional"])
assert result.exit_code == 0
mock_run.assert_called_once_with("devx.ci.classify_changes", ["positional"])
@patch("devx.cli._run_module")
def test_ci_detect_release_commit(self, mock_run: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["ci", "detect-release-commit"])
assert result.exit_code == 0
mock_run.assert_called_once_with("devx.ci.detect_release_commit", [])
@patch("devx.cli._run_module")
def test_ci_discover_runners(self, mock_run: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["ci", "discover-runners", "positional"])
assert result.exit_code == 0
mock_run.assert_called_once_with("devx.ci.discover_runners", ["positional"])
@patch("devx.cli._run_module")
def test_ci_doc_coverage(self, mock_run: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["ci", "doc-coverage"])
assert result.exit_code == 0
mock_run.assert_called_once_with("devx.ci.doc_coverage", [])
@patch("devx.cli._run_module")
def test_ci_notify_failure(self, mock_run: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["ci", "notify-failure", "job1"])
assert result.exit_code == 0
mock_run.assert_called_once_with("devx.ci.notify_failure", ["job1"])
@patch("devx.cli._run_module")
def test_ci_post_merge(self, mock_run: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["ci", "post-merge", "DEVX-1"])
assert result.exit_code == 0
mock_run.assert_called_once_with("devx.ci.post_merge", ["DEVX-1"])
@patch("devx.cli._run_module")
def test_ci_pr_review(self, mock_run: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["ci", "pr-review", "42"])
assert result.exit_code == 0
mock_run.assert_called_once_with("devx.ci.pr_review", ["42"])
@patch("devx.cli._run_module")
def test_ci_publish(self, mock_run: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["ci", "publish", "v1.0.0", "owner/repo"])
assert result.exit_code == 0
mock_run.assert_called_once_with("devx.ci.publish", ["v1.0.0", "owner/repo"])
@patch("devx.cli._run_module")
def test_ci_push_badges(self, mock_run: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["ci", "push-badges"])
assert result.exit_code == 0
mock_run.assert_called_once_with("devx.ci.push_badges", [])
@patch("devx.cli._run_module")
def test_ci_release(self, mock_run: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["ci", "release", "positional"])
assert result.exit_code == 0
mock_run.assert_called_once_with("devx.ci.release", ["positional"])
@patch("devx.cli._run_module")
def test_ci_sync_wiki(self, mock_run: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["ci", "sync-wiki"])
assert result.exit_code == 0
mock_run.assert_called_once_with("devx.ci.sync_wiki", [])
@patch("devx.cli._run_module")
def test_ci_validate_commit_msg(self, mock_run: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["ci", "validate-commit-msg", "msg"])
assert result.exit_code == 0
mock_run.assert_called_once_with("devx.ci.validate_commit_msg", ["msg"])
class TestToolsCommands:
@patch("devx.cli._run_module")
def test_tools_check_test_speed(self, mock_run: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["tools", "check-test-speed"])
assert result.exit_code == 0
mock_run.assert_called_once_with("devx.tools.check_test_speed", [])
@patch("devx.cli._run_module")
def test_tools_configure_repo(self, mock_run: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["tools", "configure-repo", "myrepo"])
assert result.exit_code == 0
mock_run.assert_called_once_with("devx.tools.configure_repo", ["myrepo"])
@patch("devx.cli._run_module")
def test_tools_generate_badges(self, mock_run: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["tools", "generate-badges"])
assert result.exit_code == 0
mock_run.assert_called_once_with("devx.tools.generate_badges", [])
@patch("devx.cli._run_module")
def test_tools_install_checkmake(self, mock_run: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["tools", "install-checkmake"])
assert result.exit_code == 0
mock_run.assert_called_once_with("devx.tools.install_checkmake", [])
@patch("devx.cli._run_module")
def test_tools_install_tools(self, mock_run: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["tools", "install-tools"])
assert result.exit_code == 0
mock_run.assert_called_once_with("devx.tools.install_tools", [])
@patch("devx.cli._run_module")
def test_tools_setup(self, mock_run: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["tools", "setup"])
assert result.exit_code == 0
mock_run.assert_called_once_with("devx.tools.setup", [])
class TestMoleculeCommands:
@patch("devx.cli._run_module")
def test_molecule_distribute(self, mock_run: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["molecule", "distribute", "positional"])
assert result.exit_code == 0
mock_run.assert_called_once_with("devx.molecule.distribute_molecule", ["positional"])
@patch("devx.cli._run_module")
def test_molecule_discover_runners(self, mock_run: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["molecule", "discover-runners"])
assert result.exit_code == 0
mock_run.assert_called_once_with("devx.molecule.discover_runners", [])
@patch("devx.cli._run_module")
def test_molecule_guard(self, mock_run: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["molecule", "guard"])
assert result.exit_code == 0
mock_run.assert_called_once_with("devx.molecule.molecule_ci_guard", [])
@patch("devx.cli._run_module")
def test_molecule_all(self, mock_run: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["molecule", "all"])
assert result.exit_code == 0
mock_run.assert_called_once_with("devx.molecule.molecule_all", [])
class TestRunModule:
@patch("importlib.import_module")
def test_run_module_success(self, mock_import: MagicMock) -> None:
from devx.cli import _run_module
mock_module = MagicMock()
mock_main = MagicMock()
mock_module.main = mock_main
mock_import.return_value = mock_module
_run_module("devx.ci.release", ["--dry-run"])
mock_import.assert_called_once_with("devx.ci.release")
mock_main.assert_called_once()
@patch("importlib.import_module")
def test_run_module_no_main_raises(self, mock_import: MagicMock) -> None:
from devx.cli import _run_module
mock_module = MagicMock()
mock_module.main = None
mock_import.return_value = mock_module
with pytest.raises(SystemExit) as exc_info:
_run_module("devx.ci.nonexistent", [])
assert exc_info.value.code == 1
@patch("importlib.import_module")
def test_run_module_click_exception(self, mock_import: MagicMock) -> None:
from devx.cli import _run_module
mock_module = MagicMock()
mock_main = MagicMock()
mock_main.side_effect = click.ClickException("test error")
mock_module.main = mock_main
mock_import.return_value = mock_module
with pytest.raises(SystemExit):
_run_module("devx.ci.release", [])
@patch("importlib.import_module")
def test_run_module_abort(self, mock_import: MagicMock) -> None:
from devx.cli import _run_module
mock_module = MagicMock()
mock_main = MagicMock()
mock_main.side_effect = click.exceptions.Abort()
mock_module.main = mock_main
mock_import.return_value = mock_module
with pytest.raises(SystemExit) as exc_info:
_run_module("devx.ci.release", [])
assert exc_info.value.code == 1
@patch("importlib.import_module")
def test_run_module_click_command_object(self, mock_import: MagicMock) -> None:
"""When main is not callable (a click Command), uses .main() method."""
from devx.cli import _run_module
mock_module = MagicMock()
# Create a non-callable object with a .main method (like click.Command)
# callable() returns False only if __call__ is not defined
class NonCallableMain:
def __init__(self) -> None:
self.main = MagicMock()
non_callable = NonCallableMain()
assert not callable(non_callable)
mock_module.main = non_callable
mock_import.return_value = mock_module
_run_module("devx.ci.release", ["arg1"])
non_callable.main.assert_called_once()
class TestVersionOption:
def test_version_flag(self) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["--version"])
assert result.exit_code == 0
+83
View File
@@ -0,0 +1,83 @@
"""Unit tests for config module constants."""
from devx.config import (
CONVENTIONAL_RE,
DEFAULT_PER_PAGE,
DEFAULT_TIMEOUT,
GITEA_API_URL,
MAX_RETRIES,
REPO_OWNER,
RETRY_BACKOFF_BASE,
RETRY_STATUS_CODES,
TASK_ID_RE,
TASK_PREFIX,
VIKUNJA_API_URL,
VIKUNJA_PROJECT_ID,
)
class TestConfigConstants:
def test_api_urls(self) -> None:
assert "api/v1" in GITEA_API_URL
assert "api/v1" in VIKUNJA_API_URL
def test_project_ids(self) -> None:
assert VIKUNJA_PROJECT_ID == 6
def test_timeouts(self) -> None:
assert DEFAULT_TIMEOUT == 30
assert DEFAULT_PER_PAGE == 50
def test_owner(self) -> None:
assert REPO_OWNER == "oblachno-oss"
def test_task_prefix(self) -> None:
assert TASK_PREFIX == "DEVX"
def test_task_id_re(self) -> None:
assert TASK_ID_RE.search("DEVX-1")
assert TASK_ID_RE.search("DEVX-123")
assert not TASK_ID_RE.search("DEVX-")
assert not TASK_ID_RE.search("other text")
def test_conventional_re(self) -> None:
assert CONVENTIONAL_RE.match("feat: add feature")
assert CONVENTIONAL_RE.match("fix(scope): bug fix")
assert not CONVENTIONAL_RE.match("random message")
assert not CONVENTIONAL_RE.match("feat:")
assert not CONVENTIONAL_RE.match("BREAKING CHANGE: something")
def test_retry_config(self) -> None:
assert MAX_RETRIES == 3
assert RETRY_BACKOFF_BASE == 2
assert 429 in RETRY_STATUS_CODES
assert 500 in RETRY_STATUS_CODES
assert 502 in RETRY_STATUS_CODES
assert 503 in RETRY_STATUS_CODES
assert 504 in RETRY_STATUS_CODES
def test_env_var_override(self, monkeypatch: object) -> None:
"""Test that env vars override defaults at import time."""
# We can't easily re-import the module, but we can verify
# the constants respect env vars by checking the module source.
import devx.config as cfg
assert cfg.GITEA_API_URL # always non-empty
assert cfg.VIKUNJA_API_URL # always non-empty
class TestTaskPrefixOverride:
def test_task_prefix_from_env(self, monkeypatch: object) -> None:
"""Verify TASK_PREFIX reads from DEVX_TASK_PREFIX env var."""
monkeypatch.setenv("DEVX_TASK_PREFIX", "INFRA")
import importlib
import devx.config as cfg
importlib.reload(cfg)
assert cfg.TASK_PREFIX == "INFRA"
assert cfg.TASK_ID_RE.search("INFRA-42")
assert not cfg.TASK_ID_RE.search("DEVX-42")
# Restore
monkeypatch.delenv("DEVX_TASK_PREFIX", raising=False)
importlib.reload(cfg)
+165
View File
@@ -0,0 +1,165 @@
"""Unit tests for devx.tools.configure_repo."""
from unittest.mock import MagicMock, patch
import click
import pytest
from click.testing import CliRunner
from devx.exceptions import APIError
from devx.tools.configure_repo import (
_default_branch_protection_config,
_default_repo_settings_config,
_handle_http_error,
configure_repo,
main,
)
class TestHandleHttpError:
def test_forbidden_raises_click_exception(self) -> None:
with pytest.raises(click.ClickException, match="Forbidden"):
_handle_http_error(APIError(403, "Forbidden"))
def test_other_error_raises_click_exception(self) -> None:
with pytest.raises(click.ClickException, match="HTTP error"):
_handle_http_error(APIError(500, "Server error"))
class TestDefaultConfigs:
def test_default_branch_protection_config(self) -> None:
config = _default_branch_protection_config()
assert config["branch_name"] == "master"
assert config["enable_push"] is True
assert config["enable_push_whitelist"] is True
assert config["required_approvals"] == 0
assert isinstance(config["status_check_contexts"], list)
assert "CI / quality (pull_request)" in config["status_check_contexts"]
def test_default_repo_settings_config(self) -> None:
config = _default_repo_settings_config()
assert config["default_delete_branch_after_merge"] is True
def test_status_checks_from_env(self) -> None:
with patch.dict("os.environ", {"DEVX_STATUS_CHECKS": "check1, check2, check3"}):
config = _default_branch_protection_config()
assert config["status_check_contexts"] == ["check1", "check2", "check3"]
class TestConfigureRepo:
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
@patch("devx.tools.configure_repo.GiteaClient")
def test_configure_repo_success(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client_cls.return_value = mock_client
configure_repo(token="tok", owner="owner", repo="repo")
mock_client.ensure_branch_protection.assert_called_once()
mock_client.update_repo_settings.assert_called_once()
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
@patch("devx.tools.configure_repo.GiteaClient")
def test_configure_repo_api_error(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client.ensure_branch_protection.side_effect = APIError(403, "Forbidden")
mock_client_cls.return_value = mock_client
with pytest.raises(click.ClickException, match="Forbidden"):
configure_repo(token="tok", owner="owner", repo="repo")
def test_configure_repo_no_token(self) -> None:
with pytest.raises(click.ClickException, match="REPO_TOKEN"):
configure_repo(token="", owner="owner", repo="repo")
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
@patch("devx.tools.configure_repo.GiteaClient")
def test_configure_repo_custom_configs(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client_cls.return_value = mock_client
custom_bp = {
"branch_name": "develop",
"enable_push": True,
"enable_push_whitelist": True,
"push_whitelist_usernames": [],
"enable_status_check": True,
"status_check_contexts": ["CI / quality (pull_request)"],
"required_approvals": 2,
}
custom_rs = {"default_delete_branch_after_merge": False}
configure_repo(
token="tok",
owner="owner",
repo="repo",
branch="develop",
branch_protection_config=custom_bp,
repo_settings_config=custom_rs,
)
mock_client.ensure_branch_protection.assert_called_once_with("develop", custom_bp)
mock_client.update_repo_settings.assert_called_once_with(custom_rs)
class TestMain:
@patch.dict("os.environ", {"REPO_TOKEN": "tok", "DEVX_REPO_NAME": "myrepo"}, clear=True)
@patch("devx.tools.configure_repo.GiteaClient")
def test_main_success_with_env_repo(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(main, [])
assert result.exit_code == 0
mock_client.ensure_branch_protection.assert_called_once()
mock_client.update_repo_settings.assert_called_once()
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
@patch("devx.tools.configure_repo.GiteaClient")
def test_main_success_with_cli_repo(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(main, ["--repo", "myrepo", "--owner", "myorg"])
assert result.exit_code == 0
mock_client.ensure_branch_protection.assert_called_once()
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
@patch("devx.tools.configure_repo.GiteaClient")
def test_main_api_error(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client.ensure_branch_protection.side_effect = APIError(403, "Forbidden")
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(main, ["--repo", "myrepo"])
assert result.exit_code != 0
assert "Forbidden" in result.output
@patch.dict("os.environ", {}, clear=True)
def test_main_no_token(self) -> None:
runner = CliRunner()
result = runner.invoke(main, ["--repo", "myrepo"])
assert result.exit_code != 0
assert "REPO_TOKEN" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
def test_main_no_repo(self) -> None:
runner = CliRunner()
result = runner.invoke(main, [])
assert result.exit_code != 0
assert "Repository name not specified" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
@patch("devx.tools.configure_repo.GiteaClient")
def test_main_custom_branch(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(main, ["--repo", "myrepo", "--branch", "develop"])
assert result.exit_code == 0
mock_client.ensure_branch_protection.assert_called_once()
args = mock_client.ensure_branch_protection.call_args
assert args[0][0] == "develop"
+76
View File
@@ -0,0 +1,76 @@
from __future__ import annotations
import subprocess
from pathlib import Path
from unittest.mock import patch
import pytest
from click import ClickException
from click.testing import CliRunner
import devx.ci.detect_release_commit as detect_release_commit
class TestGetCommitMessage:
def test_success(self) -> None:
mock_result = subprocess.CompletedProcess(args=[], returncode=0, stdout="feat: add feature\n", stderr="")
with patch("subprocess.run", return_value=mock_result):
assert detect_release_commit.get_commit_message() == "feat: add feature"
def test_failure(self) -> None:
mock_result = subprocess.CompletedProcess(args=[], returncode=1, stdout="", stderr="git error")
with patch("subprocess.run", return_value=mock_result):
with pytest.raises(ClickException, match="git log failed"):
detect_release_commit.get_commit_message()
class TestIsReleaseCommit:
def test_release_commit(self) -> None:
assert detect_release_commit.is_release_commit("release: v1.0.0 [skip ci]") is True
def test_release_commit_no_skip(self) -> None:
assert detect_release_commit.is_release_commit("release: v0.1.0") is True
def test_regular_commit(self) -> None:
assert detect_release_commit.is_release_commit("feat: add feature") is False
def test_empty(self) -> None:
assert detect_release_commit.is_release_commit("") is False
class TestWriteGithubOutput:
def test_write(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
gh_file = tmp_path / "output.txt"
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
detect_release_commit.write_github_output("is-release", "true")
with open(gh_file) as f:
assert f.read() == "is-release=true\n"
def test_no_env(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("GITHUB_OUTPUT", raising=False)
with pytest.raises(ClickException, match="GITHUB_OUTPUT"):
detect_release_commit.write_github_output("is-release", "true")
class TestMain:
def test_release_commit(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
gh_file = tmp_path / "output.txt"
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
with patch.object(detect_release_commit, "get_commit_message", return_value="release: v1.0.0 [skip ci]"):
runner = CliRunner()
result = runner.invoke(detect_release_commit.main, [])
assert result.exit_code == 0
assert "Release commit" in result.output
with open(gh_file) as f:
assert "is-release=true" in f.read()
def test_regular_commit(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
gh_file = tmp_path / "output.txt"
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
with patch.object(detect_release_commit, "get_commit_message", return_value="feat: add feature"):
runner = CliRunner()
result = runner.invoke(detect_release_commit.main, [])
assert result.exit_code == 0
assert "Regular merge commit" in result.output
with open(gh_file) as f:
assert "is-release=false" in f.read()
+210
View File
@@ -0,0 +1,210 @@
"""Unit tests for scripts/ci/discover_runners.py."""
import json
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from click.testing import CliRunner
from devx.ci.discover_runners import (
DEFAULT_MAX_RUNNERS,
generate_indices,
get_runner_count,
main,
query_runners,
)
class TestGenerateIndices:
def test_zero(self) -> None:
assert generate_indices(0) == []
def test_one(self) -> None:
assert generate_indices(1) == ["1"]
def test_three(self) -> None:
assert generate_indices(3) == ["1", "2", "3"]
def test_five(self) -> None:
assert generate_indices(5) == ["1", "2", "3", "4", "5"]
class TestQueryRunners:
@patch("devx.ci.discover_runners.requests.get")
def test_returns_total_from_all_levels(self, mock_get: MagicMock) -> None:
"""Runners from repo, org, and admin levels are summed."""
responses = [
MagicMock(status_code=200, json=lambda: {"runners": [], "total_count": 2}),
MagicMock(status_code=200, json=lambda: {"runners": [], "total_count": 1}),
MagicMock(status_code=200, json=lambda: {"runners": [], "total_count": 3}),
]
mock_get.side_effect = responses
result = query_runners("https://api.example.com", "token", "owner", "repo")
assert result == 6
@patch("devx.ci.discover_runners.requests.get")
def test_skips_non_200(self, mock_get: MagicMock) -> None:
"""Non-200 responses (e.g., 403 for admin) are skipped."""
responses = [
MagicMock(status_code=200, json=lambda: {"total_count": 2}),
MagicMock(status_code=200, json=lambda: {"total_count": 1}),
MagicMock(status_code=403, json=lambda: {"message": "forbidden"}),
]
mock_get.side_effect = responses
result = query_runners("https://api.example.com", "token", "owner", "repo")
assert result == 3
@patch("devx.ci.discover_runners.requests.get")
def test_handles_request_exception(self, mock_get: MagicMock) -> None:
"""Network errors are caught and don't crash."""
mock_get.side_effect = [
MagicMock(status_code=200, json=lambda: {"total_count": 1}),
MagicMock(side_effect=__import__("requests").RequestException("network error")),
MagicMock(status_code=200, json=lambda: {"total_count": 2}),
]
result = query_runners("https://api.example.com", "token", "owner", "repo")
assert result == 3
@patch("devx.ci.discover_runners.requests.get")
def test_all_failures_return_zero(self, mock_get: MagicMock) -> None:
"""When all API calls fail, returns 0."""
mock_get.side_effect = [
MagicMock(status_code=404),
MagicMock(status_code=404),
MagicMock(status_code=403),
]
result = query_runners("https://api.example.com", "token", "owner", "repo")
assert result == 0
@patch("devx.ci.discover_runners.requests.get")
def test_value_error_on_repo_level(self, mock_get: MagicMock) -> None:
"""JSON parse error on repo level is caught."""
responses = [
MagicMock(status_code=200, json=MagicMock(side_effect=ValueError("bad json"))),
MagicMock(status_code=200, json=lambda: {"total_count": 2}),
MagicMock(status_code=200, json=lambda: {"total_count": 1}),
]
mock_get.side_effect = responses
result = query_runners("https://api.example.com", "token", "owner", "repo")
assert result == 3
@patch("devx.ci.discover_runners.requests.get")
def test_value_error_on_org_level(self, mock_get: MagicMock) -> None:
"""JSON parse error on org level is caught."""
responses = [
MagicMock(status_code=200, json=lambda: {"total_count": 1}),
MagicMock(status_code=200, json=MagicMock(side_effect=ValueError("bad json"))),
MagicMock(status_code=200, json=lambda: {"total_count": 2}),
]
mock_get.side_effect = responses
result = query_runners("https://api.example.com", "token", "owner", "repo")
assert result == 3
@patch("devx.ci.discover_runners.requests.get")
def test_value_error_on_admin_level(self, mock_get: MagicMock) -> None:
"""JSON parse error on admin level is caught."""
responses = [
MagicMock(status_code=200, json=lambda: {"total_count": 1}),
MagicMock(status_code=200, json=lambda: {"total_count": 2}),
MagicMock(status_code=200, json=MagicMock(side_effect=ValueError("bad json"))),
]
mock_get.side_effect = responses
result = query_runners("https://api.example.com", "token", "owner", "repo")
assert result == 3
@patch("devx.ci.discover_runners.requests.get")
def test_request_exception_on_all_levels(self, mock_get: MagicMock) -> None:
"""Network errors on all levels return 0."""
mock_get.side_effect = __import__("requests").RequestException("network error")
result = query_runners("https://api.example.com", "token", "owner", "repo")
assert result == 0
class TestGetRunnerCount:
@patch("devx.ci.discover_runners.query_runners", return_value=5)
def test_uses_api_count_when_positive(self, mock_query: MagicMock) -> None:
result = get_runner_count("https://api.example.com", "token", "owner", "repo")
assert result == 5
@patch("devx.ci.discover_runners.query_runners", return_value=0)
@patch.dict("os.environ", {"MOLECULE_RUNNERS": "4"})
def test_falls_back_to_env_var(self, mock_query: MagicMock) -> None:
result = get_runner_count("https://api.example.com", "token", "owner", "repo")
assert result == 4
@patch("devx.ci.discover_runners.query_runners", return_value=0)
@patch.dict("os.environ", {"MOLECULE_RUNNERS": "invalid"})
def test_falls_back_to_default_on_invalid_env(self, mock_query: MagicMock) -> None:
result = get_runner_count("https://api.example.com", "token", "owner", "repo")
assert result == DEFAULT_MAX_RUNNERS
@patch("devx.ci.discover_runners.query_runners", return_value=0)
@patch.dict("os.environ", {}, clear=True)
def test_falls_back_to_default_when_no_env(self, mock_query: MagicMock) -> None:
result = get_runner_count("https://api.example.com", "token", "owner", "repo")
assert result == DEFAULT_MAX_RUNNERS
@patch("devx.ci.discover_runners.query_runners", return_value=0)
@patch.dict("os.environ", {"MOLECULE_RUNNERS": "0"})
def test_env_var_zero_falls_back_to_default(self, mock_query: MagicMock) -> None:
"""MOLECULE_RUNNERS=0 is invalid, falls back to default."""
result = get_runner_count("https://api.example.com", "token", "owner", "repo")
assert result == DEFAULT_MAX_RUNNERS
@patch("devx.ci.discover_runners.query_runners", return_value=0)
@patch.dict("os.environ", {}, clear=True)
def test_no_token_uses_env_var(self, mock_query: MagicMock) -> None:
"""When no token, skips API and uses env/default."""
with patch.dict("os.environ", {"MOLECULE_RUNNERS": "2"}):
result = get_runner_count("https://api.example.com", "", "owner", "repo")
assert result == 2
class TestMain:
@patch("devx.ci.discover_runners.get_runner_count", return_value=3)
def test_default_output(self, mock_count: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(main, [])
assert result.exit_code == 0
assert "count=3" in result.output
assert 'indices=["1", "2", "3"]' in result.output
@patch("devx.ci.discover_runners.get_runner_count", return_value=5)
def test_count_only(self, mock_count: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(main, ["--count"])
assert result.exit_code == 0
assert result.output.strip() == "5"
@patch("devx.ci.discover_runners.get_runner_count", return_value=4)
def test_indices_only(self, mock_count: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(main, ["--indices"])
assert result.exit_code == 0
assert json.loads(result.output.strip()) == ["1", "2", "3", "4"]
@patch("devx.ci.discover_runners.get_runner_count", return_value=1)
def test_single_runner(self, mock_count: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(main, ["--indices"])
assert result.exit_code == 0
assert json.loads(result.output.strip()) == ["1"]
@patch("devx.ci.discover_runners.get_runner_count", return_value=3)
def test_github_output(self, mock_count: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
gh_file = tmp_path / "output.txt"
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
runner = CliRunner()
result = runner.invoke(main, ["--github-output"])
assert result.exit_code == 0
content = gh_file.read_text()
assert "runner-count=3" in content
assert "runner-indices=" in content
@patch("devx.ci.discover_runners.get_runner_count", return_value=3)
def test_github_output_no_env(self, mock_count: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("GITHUB_OUTPUT", raising=False)
runner = CliRunner()
result = runner.invoke(main, ["--github-output"])
assert result.exit_code != 0
+251
View File
@@ -0,0 +1,251 @@
"""Unit tests for scripts/ci/distribute_molecule.py."""
from pathlib import Path
from unittest.mock import patch
import click
import pytest
from click.testing import CliRunner
from devx.molecule.distribute_molecule import (
MOLECULE_ROOT,
PLATFORMS,
TestPair,
build_pairs,
cli,
discover_scenarios,
distribute,
pairs_for_runner,
)
class TestDiscoverScenarios:
def test_discovers_scenarios(self, tmp_path: Path) -> None:
root = tmp_path / "molecule"
(root / "default").mkdir(parents=True)
(root / "binary").mkdir(parents=True)
(root / "common").mkdir(parents=True)
(root / "_shared").mkdir(parents=True)
result = discover_scenarios(root)
assert result == ["binary", "default"]
def test_raises_when_dir_missing(self, tmp_path: Path) -> None:
with pytest.raises(click.ClickException) as exc:
discover_scenarios(tmp_path / "nonexistent")
assert "not found" in str(exc.value)
def test_default_root_constant(self) -> None:
assert Path("ansible/roles/gitea-runner/molecule") == MOLECULE_ROOT
class TestPairEncoding:
def test_encode_roundtrip(self) -> None:
pair = TestPair("default", {"name": "ubuntu-2204", "image": "ubuntu:22.04", "command": ""})
encoded = pair.encode()
assert encoded == "default|ubuntu-2204|ubuntu:22.04|"
decoded = TestPair.decode(encoded)
assert decoded.scenario == "default"
assert decoded.platform["name"] == "ubuntu-2204"
assert decoded.platform["image"] == "ubuntu:22.04"
assert decoded.platform["command"] == ""
def test_encode_with_command(self) -> None:
pair = TestPair(
"default",
{"name": "archlinux", "image": "archlinux:latest", "command": "/usr/lib/systemd/systemd"},
)
encoded = pair.encode()
assert encoded == "default|archlinux|archlinux:latest|/usr/lib/systemd/systemd"
decoded = TestPair.decode(encoded)
assert decoded.platform["command"] == "/usr/lib/systemd/systemd"
class TestBuildPairs:
def test_cross_product(self) -> None:
scenarios = ["a", "b"]
platforms = [
{"name": "p1", "image": "img1", "command": ""},
{"name": "p2", "image": "img2", "command": ""},
]
pairs = build_pairs(scenarios, platforms)
assert len(pairs) == 4
assert pairs[0].scenario == "a"
assert pairs[0].platform["name"] == "p1"
assert pairs[1].scenario == "a"
assert pairs[1].platform["name"] == "p2"
assert pairs[2].scenario == "b"
assert pairs[2].platform["name"] == "p1"
assert pairs[3].scenario == "b"
assert pairs[3].platform["name"] == "p2"
def test_default_platforms(self) -> None:
pairs = build_pairs(["default"])
assert len(pairs) == len(PLATFORMS)
assert all(p.scenario == "default" for p in pairs)
class TestDistribute:
def test_even_split(self) -> None:
pairs = [TestPair(f"s{i}", {"name": "p", "image": "i", "command": ""}) for i in range(6)]
groups = distribute(pairs, 3)
assert len(groups) == 3
assert len(groups[0]) == 2
assert len(groups[1]) == 2
assert len(groups[2]) == 2
def test_uneven_split(self) -> None:
pairs = [TestPair(f"s{i}", {"name": "p", "image": "i", "command": ""}) for i in range(5)]
groups = distribute(pairs, 3)
assert len(groups[0]) == 2
assert len(groups[1]) == 2
assert len(groups[2]) == 1
def test_more_runners_than_pairs(self) -> None:
pairs = [TestPair("a", {"name": "p", "image": "i", "command": ""})]
groups = distribute(pairs, 5)
assert len(groups) == 5
assert len(groups[0]) == 1
assert all(len(g) == 0 for g in groups[1:])
def test_empty_pairs(self) -> None:
groups = distribute([], 3)
assert groups == [[], [], []]
class TestPairsForRunner:
def test_returns_correct_subset(self) -> None:
pairs = [TestPair(f"s{i}", {"name": "p", "image": "i", "command": ""}) for i in range(6)]
assert len(pairs_for_runner(pairs, 0, 3)) == 2
assert len(pairs_for_runner(pairs, 1, 3)) == 2
assert len(pairs_for_runner(pairs, 2, 3)) == 2
def test_out_of_range_raises(self) -> None:
pairs = [TestPair("a", {"name": "p", "image": "i", "command": ""})]
with pytest.raises(click.ClickException) as exc:
pairs_for_runner(pairs, 5, 3)
assert "out of range" in str(exc.value)
def test_negative_index_raises(self) -> None:
pairs = [TestPair("a", {"name": "p", "image": "i", "command": ""})]
with pytest.raises(click.ClickException) as exc:
pairs_for_runner(pairs, -1, 3)
assert "out of range" in str(exc.value)
class TestCli:
def test_list_flag(self, tmp_path: Path) -> None:
from click.testing import CliRunner
from devx.molecule.distribute_molecule import cli
root = tmp_path / "molecule"
(root / "alpha").mkdir(parents=True)
(root / "beta").mkdir(parents=True)
with patch("devx.molecule.distribute_molecule.MOLECULE_ROOT", root):
runner = CliRunner()
result = runner.invoke(cli, ["--list"])
assert result.exit_code == 0
assert "alpha" in result.output
assert "beta" in result.output
def test_list_platforms_flag(self) -> None:
from click.testing import CliRunner
from devx.molecule.distribute_molecule import cli
with patch("devx.molecule.distribute_molecule.discover_scenarios", return_value=["dummy"]):
runner = CliRunner()
result = runner.invoke(cli, ["--list-platforms"])
assert result.exit_code == 0
assert "ubuntu-2204" in result.output
assert "ubuntu-2404" in result.output
assert "debian-12" in result.output
assert "archlinux" in result.output
def test_no_runner_index_prints_all_groups(self, tmp_path: Path) -> None:
from click.testing import CliRunner
from devx.molecule.distribute_molecule import cli
root = tmp_path / "molecule"
for s in ["a", "b", "c"]:
(root / s).mkdir(parents=True)
with patch("devx.molecule.distribute_molecule.MOLECULE_ROOT", root):
runner = CliRunner()
result = runner.invoke(cli, ["--max-runners", "3"])
assert result.exit_code == 0
assert "Runner 0:" in result.output
assert "Runner 1:" in result.output
assert "Runner 2:" in result.output
def test_runner_index_prints_assigned(self, tmp_path: Path) -> None:
from click.testing import CliRunner
from devx.molecule.distribute_molecule import cli
root = tmp_path / "molecule"
(root / "alpha").mkdir(parents=True)
with patch("devx.molecule.distribute_molecule.MOLECULE_ROOT", root):
runner = CliRunner()
# 1-based index: "1" maps to internal 0
result = runner.invoke(cli, ["--runner-index", "1", "--max-runners", "3"])
assert result.exit_code == 0
# Output should contain encoded pairs with platform info
assert "alpha|" in result.output
assert "ubuntu-2204" in result.output
class TestGithubEnv:
def test_writes_env(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
gh_file = tmp_path / "env.txt"
monkeypatch.setenv("GITHUB_ENV", str(gh_file))
root = tmp_path / "molecule"
scenario = root / "alpha"
scenario.mkdir(parents=True)
(scenario / "molecule.yml").write_text("name: alpha\n")
with patch("devx.molecule.distribute_molecule.MOLECULE_ROOT", root):
runner = CliRunner()
result = runner.invoke(cli, ["--runner-index", "1", "--max-runners", "3", "--github-env"])
assert result.exit_code == 0
content = gh_file.read_text()
assert "TEST_PAIRS=" in content
assert "SKIP=false" in content
def test_skip_if_excess(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
gh_file = tmp_path / "env.txt"
monkeypatch.setenv("GITHUB_ENV", str(gh_file))
root = tmp_path / "molecule"
scenario = root / "alpha"
scenario.mkdir(parents=True)
(scenario / "molecule.yml").write_text("name: alpha\n")
with patch("devx.molecule.distribute_molecule.MOLECULE_ROOT", root):
runner = CliRunner()
result = runner.invoke(
cli, ["--runner-index", "5", "--max-runners", "3", "--github-env", "--skip-if-excess"]
)
assert result.exit_code == 0
content = gh_file.read_text()
assert "TEST_PAIRS=\n" in content
assert "SKIP=true" in content
def test_no_env_var(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("GITHUB_ENV", raising=False)
root = tmp_path / "molecule"
scenario = root / "alpha"
scenario.mkdir(parents=True)
(scenario / "molecule.yml").write_text("name: alpha\n")
with patch("devx.molecule.distribute_molecule.MOLECULE_ROOT", root):
runner = CliRunner()
result = runner.invoke(cli, ["--runner-index", "1", "--max-runners", "3", "--github-env"])
assert result.exit_code != 0
def test_main_module_block() -> None:
import devx.molecule.distribute_molecule as dm
with open(dm.__file__) as f:
source = f.read()
source = source.replace('if __name__ == "__main__":\n cli()\n', "")
namespace = dict(dm.__dict__)
exec(compile(source, dm.__file__, "exec"), namespace)
assert callable(namespace["cli"])
+123
View File
@@ -0,0 +1,123 @@
"""Unit tests for devx.ci.doc_coverage."""
from pathlib import Path
import pytest
from click.testing import CliRunner
from devx.ci.doc_coverage import (
check_command_documented,
check_module_documented,
extract_cli_commands,
main,
)
class TestExtractCliCommands:
def test_extracts_commands(self) -> None:
commands = extract_cli_commands()
# devx CLI has commands under ci, tools, and molecule groups
assert "auto-merge" in commands
assert "release" in commands
assert "publish" in commands
assert "setup" in commands
assert "install-tools" in commands
def test_returns_list(self) -> None:
commands = extract_cli_commands()
assert isinstance(commands, list)
assert len(commands) > 0
def test_no_cli_file(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""Returns empty list when CLI file doesn't exist."""
from devx.ci import doc_coverage
monkeypatch.setattr(doc_coverage, "CLI_FILE", Path("/nonexistent/cli.py"))
commands = extract_cli_commands()
assert commands == []
def test_def_fallback_no_explicit_name(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""When a command decorator has no explicit name, falls back to the def name."""
from devx.ci import doc_coverage
fake_cli = tmp_path / "cli.py"
fake_cli.write_text("@click.group()\ndef cli():\n pass\n@cli.command()\ndef my_command():\n pass\n")
monkeypatch.setattr(doc_coverage, "CLI_FILE", fake_cli)
commands = extract_cli_commands()
assert "my_command" in commands
class TestCheckCommandDocumented:
def test_finds_command_in_heading(self) -> None:
content = "## auto-merge\n\nAuto-merge PR."
assert check_command_documented("auto-merge", content) is True
def test_finds_command_in_code_block(self) -> None:
content = "```bash\ndevx ci release --dry-run\n```"
assert check_command_documented("release", content) is True
def test_finds_command_with_devx_prefix(self) -> None:
content = "Use `devx tools setup` to install."
assert check_command_documented("setup", content) is True
def test_missing_command(self) -> None:
content = "## Other stuff\n\nNo commands here."
assert check_command_documented("release", content) is False
class TestCheckModuleDocumented:
def test_finds_module(self) -> None:
content = "The cli.py module handles..."
assert check_module_documented("cli.py", content) is True
def test_missing_module(self) -> None:
content = "No modules mentioned."
assert check_module_documented("cli.py", content) is False
class TestMain:
def test_all_present(self, tmp_path: Path) -> None:
"""When all docs exist and cover all commands/modules, exit 0."""
docs = tmp_path / "docs"
(docs / "user").mkdir(parents=True)
(docs / "tech").mkdir(parents=True)
# Get actual commands from the CLI
commands = extract_cli_commands()
# Write cli-commands.md with all commands
cli_content = "\n".join(f"## {cmd}" for cmd in commands)
(docs / "user" / "cli-commands.md").write_text(cli_content)
# Write architecture.md with all modules
from devx.ci.doc_coverage import REQUIRED_MODULES, REQUIRED_SCRIPTS
(docs / "tech" / "architecture.md").write_text(" ".join(REQUIRED_MODULES))
# Write ci-cd-workflow.md with all scripts
(docs / "tech" / "ci-cd-workflow.md").write_text(" ".join(REQUIRED_SCRIPTS))
runner = CliRunner()
result = runner.invoke(main, ["--docs-dir", str(docs)])
assert result.exit_code == 0
assert "100%" in result.output
def test_missing_docs_fail(self, tmp_path: Path) -> None:
"""When docs are missing and --fail-on-missing is set, exit 1."""
docs = tmp_path / "docs"
(docs / "user").mkdir(parents=True)
(docs / "tech").mkdir(parents=True)
(docs / "user" / "cli-commands.md").write_text("No commands here.")
(docs / "tech" / "architecture.md").write_text("No modules here.")
(docs / "tech" / "ci-cd-workflow.md").write_text("No scripts here.")
runner = CliRunner()
result = runner.invoke(main, ["--docs-dir", str(docs), "--fail-on-missing"])
assert result.exit_code == 1
def test_missing_docs_warn_only(self, tmp_path: Path) -> None:
"""Without --fail-on-missing, missing docs only warn (exit 0)."""
docs = tmp_path / "docs"
(docs / "user").mkdir(parents=True)
(docs / "tech").mkdir(parents=True)
(docs / "user" / "cli-commands.md").write_text("No commands here.")
(docs / "tech" / "architecture.md").write_text("No modules here.")
(docs / "tech" / "ci-cd-workflow.md").write_text("No scripts here.")
runner = CliRunner()
result = runner.invoke(main, ["--docs-dir", str(docs)])
assert result.exit_code == 0
assert "MISSING" in result.output
+351
View File
@@ -0,0 +1,351 @@
"""Unit tests for scripts/generate_badges.py."""
from pathlib import Path
from unittest.mock import MagicMock, patch
from click.testing import CliRunner
from devx.tools.generate_badges import (
COLOR_HEX,
cli,
coverage_color,
doc_coverage_color,
extract_coverage,
extract_doc_coverage,
extract_test_count,
generate_badges,
make_badge,
read_version,
render_svg,
run_command,
)
class TestRunCommand:
@patch("devx.tools.generate_badges.subprocess.run")
def test_returns_returncode_stdout_stderr(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(returncode=0, stdout="out", stderr="err")
rc, out, err = run_command(["echo", "hello"])
assert rc == 0
assert out == "out"
assert err == "err"
mock_run.assert_called_once()
class TestMakeBadge:
def test_creates_valid_badge_dict(self) -> None:
badge = make_badge("coverage", "100%", "brightgreen")
assert badge == {
"schemaVersion": 1,
"label": "coverage",
"message": "100%",
"color": "brightgreen",
}
class TestRenderSvg:
def test_generates_valid_svg(self) -> None:
svg = render_svg("coverage", "100%", "brightgreen")
assert svg.startswith("<svg")
assert svg.endswith("</svg>\n")
assert "coverage" in svg
assert "100%" in svg
assert COLOR_HEX["brightgreen"] in svg
def test_uses_color_hex_for_known_colors(self) -> None:
svg = render_svg("tests", "573 passing", "brightgreen")
assert "#4c1" in svg
def test_uses_hex_directly_for_unknown_hex_color(self) -> None:
svg = render_svg("label", "msg", "#abc123")
assert "#abc123" in svg
def test_uses_lightgrey_for_unknown_named_color(self) -> None:
svg = render_svg("label", "msg", "nonexistent")
assert "#9f9f9f" in svg
def test_escapes_xml_special_chars(self) -> None:
svg = render_svg("label", "<script>", "red")
assert "<script>" not in svg
assert "&lt;script&gt;" in svg
def test_has_correct_dimensions(self) -> None:
svg = render_svg("coverage", "100%", "brightgreen")
assert 'width="' in svg
assert 'height="20"' in svg
class TestExtractCoverage:
def test_extracts_from_total_line(self) -> None:
output = (
"src/devx/cli.py 118 0 100%\n"
"TOTAL 1798 0 100.00%\n"
)
assert extract_coverage(output) == 100.0
def test_extracts_partial_coverage(self) -> None:
output = "TOTAL 100 20 80.00%\n"
assert extract_coverage(output) == 80.0
def test_extracts_without_decimal(self) -> None:
output = "TOTAL 1900 0 100%\n"
assert extract_coverage(output) == 100.0
def test_returns_none_when_no_match(self) -> None:
assert extract_coverage("no coverage here") is None
class TestExtractTestCount:
def test_extracts_passed_count(self) -> None:
assert extract_test_count("543 passed in 2.32s") == 543
def test_extracts_with_warnings(self) -> None:
assert extract_test_count("543 passed, 1 warning in 2.32s") == 543
def test_returns_none_when_no_match(self) -> None:
assert extract_test_count("no tests here") is None
class TestExtractDocCoverage:
def test_extracts_percentage(self) -> None:
output = "\nDoc coverage: 20/20 (100%)"
assert extract_doc_coverage(output) == 100
def test_extracts_partial(self) -> None:
output = "\nDoc coverage: 18/20 (90%)"
assert extract_doc_coverage(output) == 90
def test_returns_none_when_no_match(self) -> None:
assert extract_doc_coverage("no doc coverage here") is None
class TestCoverageColor:
def test_100_is_brightgreen(self) -> None:
assert coverage_color(100.0) == "brightgreen"
def test_90_is_green(self) -> None:
assert coverage_color(90.0) == "green"
def test_80_is_yellowgreen(self) -> None:
assert coverage_color(80.0) == "yellowgreen"
def test_70_is_yellow(self) -> None:
assert coverage_color(70.0) == "yellow"
def test_60_is_orange(self) -> None:
assert coverage_color(60.0) == "orange"
def test_below_60_is_red(self) -> None:
assert coverage_color(50.0) == "red"
class TestDocCoverageColor:
def test_100_is_brightgreen(self) -> None:
assert doc_coverage_color(100) == "brightgreen"
def test_90_is_green(self) -> None:
assert doc_coverage_color(90) == "green"
def test_80_is_yellowgreen(self) -> None:
assert doc_coverage_color(80) == "yellowgreen"
def test_70_is_yellow(self) -> None:
assert doc_coverage_color(70) == "yellow"
def test_below_70_is_orange(self) -> None:
assert doc_coverage_color(60) == "orange"
class TestReadVersion:
@patch("devx.tools.generate_badges._find_package_init")
def test_reads_version_from_init(self, mock_find: MagicMock) -> None:
mock_init = MagicMock()
mock_init.read_text.return_value = '__version__ = "0.5.0"\n'
mock_find.return_value = mock_init
assert read_version() == "0.5.0"
@patch("devx.tools.generate_badges._find_package_init")
def test_returns_unknown_when_no_version(self, mock_find: MagicMock) -> None:
mock_init = MagicMock()
mock_init.read_text.return_value = "no version here\n"
mock_find.return_value = mock_init
assert read_version() == "unknown"
@patch("devx.tools.generate_badges._find_package_init", return_value=None)
def test_returns_unknown_when_no_init(self, mock_find: MagicMock) -> None:
assert read_version() == "unknown"
class TestFindPackageInit:
@patch("devx.tools.generate_badges.REPO_ROOT")
def test_no_src_dir(self, mock_root: MagicMock) -> None:
"""Returns None when src/ directory doesn't exist."""
from devx.tools.generate_badges import _find_package_init
mock_src = MagicMock()
mock_src.exists.return_value = False
mock_root.__truediv__ = MagicMock(return_value=mock_src)
assert _find_package_init() is None
@patch("devx.tools.generate_badges.REPO_ROOT")
def test_no_version_in_init_files(self, mock_root: MagicMock, tmp_path: Path) -> None:
"""Returns None when no __init__.py has __version__."""
from devx.tools.generate_badges import _find_package_init
src_dir = tmp_path / "src"
src_dir.mkdir()
(src_dir / "__init__.py").write_text("# no version here\n")
mock_root.__truediv__ = MagicMock(return_value=src_dir)
assert _find_package_init() is None
@patch("devx.tools.generate_badges.REPO_ROOT")
def test_finds_init_with_version(self, mock_root: MagicMock, tmp_path: Path) -> None:
"""Returns the __init__.py that has __version__."""
from devx.tools.generate_badges import _find_package_init
src_dir = tmp_path / "src"
pkg_dir = src_dir / "mypkg"
pkg_dir.mkdir(parents=True)
(src_dir / "__init__.py").write_text("# no version\n")
(pkg_dir / "__init__.py").write_text('__version__ = "1.0.0"\n')
mock_root.__truediv__ = MagicMock(return_value=src_dir)
result = _find_package_init()
assert result is not None
assert "__version__" in result.read_text()
@patch("devx.tools.generate_badges.REPO_ROOT")
def test_handles_oserror(self, mock_root: MagicMock, tmp_path: Path) -> None:
"""Handles OSError when reading init files."""
from devx.tools.generate_badges import _find_package_init
src_dir = tmp_path / "src"
src_dir.mkdir()
init_file = src_dir / "__init__.py"
init_file.write_text('__version__ = "1.0.0"\n')
mock_root.__truediv__ = MagicMock(return_value=src_dir)
# Patch Path.read_text to raise OSError
with patch.object(Path, "read_text", side_effect=OSError("permission denied")):
result = _find_package_init()
assert result is None
class TestGenerateBadges:
@patch("devx.tools.generate_badges.run_command")
@patch("devx.tools.generate_badges.read_version", return_value="0.5.0")
@patch("devx.tools.generate_badges.extract_coverage", return_value=100.0)
@patch("devx.tools.generate_badges.extract_test_count", return_value=573)
@patch("devx.tools.generate_badges.extract_doc_coverage", return_value=100)
def test_generates_all_badge_files(
self,
mock_doc_cov: MagicMock,
mock_test_count: MagicMock,
mock_cov: MagicMock,
mock_version: MagicMock,
mock_run: MagicMock,
tmp_path: Path,
) -> None:
mock_run.return_value = (0, "output", "")
badges = generate_badges(tmp_path)
expected = {"coverage", "tests", "docs", "quality", "version", "python"}
assert set(badges.keys()) == expected
# Verify SVG files were written
for name in expected:
svg_file = tmp_path / f"{name}.svg"
assert svg_file.exists()
content = svg_file.read_text()
assert content.startswith("<svg")
assert "</svg>" in content
@patch("devx.tools.generate_badges.run_command")
@patch("devx.tools.generate_badges.read_version", return_value="0.5.0")
@patch("devx.tools.generate_badges.extract_coverage", return_value=100.0)
@patch("devx.tools.generate_badges.extract_test_count", return_value=573)
@patch("devx.tools.generate_badges.extract_doc_coverage", return_value=100)
def test_quality_badge_pass_when_all_lint_passes(
self,
mock_doc_cov: MagicMock,
mock_test_count: MagicMock,
mock_cov: MagicMock,
mock_version: MagicMock,
mock_run: MagicMock,
tmp_path: Path,
) -> None:
mock_run.return_value = (0, "output", "")
badges = generate_badges(tmp_path)
assert badges["quality"]["message"] == "A"
assert badges["quality"]["color"] == "brightgreen"
@patch("devx.tools.generate_badges.run_command")
@patch("devx.tools.generate_badges.read_version", return_value="0.5.0")
@patch("devx.tools.generate_badges.extract_coverage", return_value=100.0)
@patch("devx.tools.generate_badges.extract_test_count", return_value=573)
@patch("devx.tools.generate_badges.extract_doc_coverage", return_value=100)
def test_quality_badge_fails_when_lint_fails(
self,
mock_doc_cov: MagicMock,
mock_test_count: MagicMock,
mock_cov: MagicMock,
mock_version: MagicMock,
mock_run: MagicMock,
tmp_path: Path,
) -> None:
mock_run.side_effect = [
(0, "output", ""),
(0, "output", ""),
(1, "error", ""),
(0, "output", ""),
(0, "output", ""),
(0, "output", ""),
]
badges = generate_badges(tmp_path)
assert badges["quality"]["message"] == "F"
assert badges["quality"]["color"] == "red"
@patch("devx.tools.generate_badges.run_command")
@patch("devx.tools.generate_badges.read_version", return_value="0.5.0")
@patch("devx.tools.generate_badges.extract_coverage", return_value=None)
@patch("devx.tools.generate_badges.extract_test_count", return_value=None)
@patch("devx.tools.generate_badges.extract_doc_coverage", return_value=None)
def test_badges_show_unknown_when_extraction_fails(
self,
mock_doc_cov: MagicMock,
mock_test_count: MagicMock,
mock_cov: MagicMock,
mock_version: MagicMock,
mock_run: MagicMock,
tmp_path: Path,
) -> None:
mock_run.return_value = (1, "garbled output", "")
badges = generate_badges(tmp_path)
assert badges["coverage"]["message"] == "unknown"
assert badges["coverage"]["color"] == "red"
assert badges["tests"]["message"] == "unknown"
assert badges["tests"]["color"] == "red"
assert badges["docs"]["message"] == "unknown"
assert badges["docs"]["color"] == "red"
class TestCli:
@patch("devx.tools.generate_badges.generate_badges")
def test_cli_generates_badges(self, mock_gen: MagicMock, tmp_path: Path) -> None:
mock_gen.return_value = {
"coverage": make_badge("coverage", "100%", "brightgreen"),
"tests": make_badge("tests", "573 passing", "brightgreen"),
}
runner = CliRunner()
result = runner.invoke(cli, ["--output-dir", str(tmp_path)])
assert result.exit_code == 0
assert "Generating badges" in result.output
assert "Generated 2 badges" in result.output
mock_gen.assert_called_once_with(tmp_path)
def test_main_module_block() -> None:
import devx.tools.generate_badges as gb
with patch.object(gb, "cli") as mock_cli:
with patch.object(gb, "__name__", "__main__"):
gb.cli([])
mock_cli.assert_called_once_with([])
+355
View File
@@ -0,0 +1,355 @@
"""Unit tests for scripts/gitea_cli.py."""
from __future__ import annotations
import json
from unittest.mock import MagicMock, patch
import pytest
from devx.gitea_cli import TeaCLI, TeaCLIError, _extract_issue_number, _extract_pr_number
class TestExtractIssueNumber:
def test_extract_from_created_issue(self) -> None:
assert _extract_issue_number("Created issue #42: Bug title") == 42
def test_extract_no_hash(self) -> None:
assert _extract_issue_number("No issue number here") == 0
def test_extract_multiple_hashes(self) -> None:
assert _extract_issue_number("Issue #5 and PR #10") == 5
def test_extract_with_colon(self) -> None:
assert _extract_issue_number("Created issue #7: title") == 7
def test_extract_invalid_number(self) -> None:
assert _extract_issue_number("Issue #abc: title") == 0
class TestExtractPrNumber:
def test_extract_from_created_pr(self) -> None:
assert _extract_pr_number("Created PR #128: Feature") == 128
def test_extract_no_number(self) -> None:
assert _extract_pr_number("No PR number") == 0
class TestTeaCLIInit:
def test_auto_detect_tea(self) -> None:
with patch("shutil.which", return_value="/usr/bin/tea"):
cli = TeaCLI()
assert cli._tea == "/usr/bin/tea"
def test_explicit_tea_bin(self) -> None:
cli = TeaCLI(tea_bin="/custom/tea")
assert cli._tea == "/custom/tea"
def test_fallback_to_tea(self) -> None:
with patch("shutil.which", return_value=None):
cli = TeaCLI()
assert cli._tea == "tea"
def test_with_repo(self) -> None:
cli = TeaCLI(repo="owner/repo")
assert cli._repo == "owner/repo"
class TestTeaCLIRun:
def test_run_success_json(self) -> None:
cli = TeaCLI(tea_bin="/fake/tea")
mock_result = MagicMock(returncode=0, stdout='[{"id": 1}]', stderr="")
with patch("subprocess.run", return_value=mock_result):
output = cli._run(["labels", "list"])
assert output == '[{"id": 1}]'
def test_run_success_raw(self) -> None:
cli = TeaCLI(tea_bin="/fake/tea")
mock_result = MagicMock(returncode=0, stdout="Created issue #42", stderr="")
with patch("subprocess.run", return_value=mock_result):
output = cli._run_raw(["issues", "create"])
assert output == "Created issue #42"
def test_run_failure_raises(self) -> None:
cli = TeaCLI(tea_bin="/fake/tea")
mock_result = MagicMock(returncode=1, stdout="", stderr="auth error")
with patch("subprocess.run", return_value=mock_result):
with pytest.raises(TeaCLIError, match="auth error"):
cli._run(["labels", "list"])
def test_run_includes_json_flag(self) -> None:
cli = TeaCLI(tea_bin="/fake/tea")
mock_result = MagicMock(returncode=0, stdout="[]", stderr="")
with patch("subprocess.run", return_value=mock_result) as mock_run:
cli._run(["labels", "list"])
cmd = mock_run.call_args[0][0]
assert "--output" in cmd
assert "json" in cmd
def test_run_raw_no_json_flag(self) -> None:
cli = TeaCLI(tea_bin="/fake/tea")
mock_result = MagicMock(returncode=0, stdout="ok", stderr="")
with patch("subprocess.run", return_value=mock_result) as mock_run:
cli._run_raw(["whoami"])
cmd = mock_run.call_args[0][0]
assert "--output" not in cmd
class TestRepoArg:
def test_with_repo_arg(self) -> None:
cli = TeaCLI(repo="owner/repo")
assert cli._repo_arg() == ["--repo", "owner/repo"]
def test_with_explicit_repo(self) -> None:
cli = TeaCLI()
assert cli._repo_arg("other/repo") == ["--repo", "other/repo"]
def test_without_repo(self) -> None:
cli = TeaCLI()
assert cli._repo_arg() == []
def test_explicit_overrides_default(self) -> None:
cli = TeaCLI(repo="default/repo")
assert cli._repo_arg("override/repo") == ["--repo", "override/repo"]
class TestCreateIssue:
def test_create_issue_basic(self) -> None:
cli = TeaCLI(tea_bin="/fake/tea", repo="owner/repo")
mock_result = MagicMock(returncode=0, stdout="Created issue #42: Bug", stderr="")
with patch("subprocess.run", return_value=mock_result):
issue = cli.create_issue("owner/repo", title="Bug", body="Description")
assert issue["index"] == 42
assert issue["title"] == "Bug"
def test_create_issue_with_labels(self) -> None:
cli = TeaCLI(tea_bin="/fake/tea")
mock_result = MagicMock(returncode=0, stdout="Created issue #5: Title", stderr="")
with patch("subprocess.run", return_value=mock_result):
issue = cli.create_issue("owner/repo", title="Title", body="Body", labels=["bug"])
assert issue["index"] == 5
class TestListLabels:
def test_list_labels_with_data(self) -> None:
cli = TeaCLI(tea_bin="/fake/tea")
labels_json = json.dumps([{"id": 1, "name": "bug"}, {"id": 2, "name": "enhancement"}])
mock_result = MagicMock(returncode=0, stdout=labels_json, stderr="")
with patch("subprocess.run", return_value=mock_result):
labels = cli.list_labels("owner/repo")
assert len(labels) == 2
assert labels[0]["name"] == "bug"
def test_list_labels_empty(self) -> None:
cli = TeaCLI(tea_bin="/fake/tea")
mock_result = MagicMock(returncode=0, stdout="", stderr="")
with patch("subprocess.run", return_value=mock_result):
labels = cli.list_labels("owner/repo")
assert labels == []
class TestCreateLabel:
def test_create_label_full(self) -> None:
cli = TeaCLI(tea_bin="/fake/tea")
mock_result = MagicMock(returncode=0, stdout="Label created", stderr="")
with patch("subprocess.run", return_value=mock_result):
label = cli.create_label("owner/repo", name="bug", color="ff0000", description="A bug")
assert label["name"] == "bug"
assert label["color"] == "ff0000"
def test_create_label_name_only(self) -> None:
cli = TeaCLI(tea_bin="/fake/tea")
mock_result = MagicMock(returncode=0, stdout="Label created", stderr="")
with patch("subprocess.run", return_value=mock_result):
label = cli.create_label("owner/repo", name="wip")
assert label["name"] == "wip"
assert label["color"] == ""
class TestAddLabel:
def test_add_label_single(self) -> None:
cli = TeaCLI(tea_bin="/fake/tea")
mock_result = MagicMock(returncode=0, stdout="ok", stderr="")
with patch("subprocess.run", return_value=mock_result) as mock_run:
cli.add_label("owner/repo", 42, ["ready-to-merge"])
cmd = mock_run.call_args[0][0]
assert "--add-labels" in cmd
assert "ready-to-merge" in cmd
assert "42" in cmd
def test_add_label_multiple(self) -> None:
cli = TeaCLI(tea_bin="/fake/tea")
mock_result = MagicMock(returncode=0, stdout="ok", stderr="")
with patch("subprocess.run", return_value=mock_result) as mock_run:
cli.add_label("owner/repo", 42, ["bug", "urgent"])
cmd = mock_run.call_args[0][0]
assert "--add-labels" in cmd
def test_add_label_empty_list(self) -> None:
cli = TeaCLI(tea_bin="/fake/tea")
with patch("subprocess.run") as mock_run:
cli.add_label("owner/repo", 42, [])
mock_run.assert_not_called()
class TestCreatePR:
def test_create_pr_basic(self) -> None:
cli = TeaCLI(tea_bin="/fake/tea")
mock_result = MagicMock(returncode=0, stdout="Created PR #128: Feature", stderr="")
with patch("subprocess.run", return_value=mock_result):
pr = cli.create_pr("owner/repo", title="Feature", head="feature-branch", base="master")
assert pr["index"] == 128
def test_create_pr_with_body(self) -> None:
cli = TeaCLI(tea_bin="/fake/tea")
mock_result = MagicMock(returncode=0, stdout="Created PR #10: Title", stderr="")
with patch("subprocess.run", return_value=mock_result) as mock_run:
cli.create_pr("owner/repo", title="Title", head="feat", base="master", body="Description")
cmd = mock_run.call_args[0][0]
assert "--body" in cmd
assert "Description" in cmd
class TestMergePR:
def test_merge_pr_squash(self) -> None:
cli = TeaCLI(tea_bin="/fake/tea")
mock_result = MagicMock(returncode=0, stdout="Merged", stderr="")
with patch("subprocess.run", return_value=mock_result) as mock_run:
cli.merge_pr("owner/repo", 42, style="squash")
cmd = mock_run.call_args[0][0]
assert "--style" in cmd
assert "squash" in cmd
assert "42" in cmd
def test_merge_pr_default_style(self) -> None:
cli = TeaCLI(tea_bin="/fake/tea")
mock_result = MagicMock(returncode=0, stdout="Merged", stderr="")
with patch("subprocess.run", return_value=mock_result) as mock_run:
cli.merge_pr("owner/repo", 42)
cmd = mock_run.call_args[0][0]
assert "squash" in cmd
class TestReviewPR:
def test_review_approve(self) -> None:
cli = TeaCLI(tea_bin="/fake/tea")
mock_result = MagicMock(returncode=0, stdout="Reviewed", stderr="")
with patch("subprocess.run", return_value=mock_result) as mock_run:
cli.review_pr("owner/repo", 42, event="APPROVE", body="LGTM")
cmd = mock_run.call_args[0][0]
assert "--approve" in cmd
assert "--comment" in cmd
def test_review_reject(self) -> None:
cli = TeaCLI(tea_bin="/fake/tea")
mock_result = MagicMock(returncode=0, stdout="Reviewed", stderr="")
with patch("subprocess.run", return_value=mock_result) as mock_run:
cli.review_pr("owner/repo", 42, event="REQUEST_CHANGES", body="Needs work")
cmd = mock_run.call_args[0][0]
assert "--reject" in cmd
def test_review_comment(self) -> None:
cli = TeaCLI(tea_bin="/fake/tea")
mock_result = MagicMock(returncode=0, stdout="Reviewed", stderr="")
with patch("subprocess.run", return_value=mock_result) as mock_run:
cli.review_pr("owner/repo", 42, event="COMMENT", body="Note")
cmd = mock_run.call_args[0][0]
assert "--approve" not in cmd
assert "--reject" not in cmd
assert "--comment" in cmd
def test_review_no_body(self) -> None:
cli = TeaCLI(tea_bin="/fake/tea")
mock_result = MagicMock(returncode=0, stdout="Reviewed", stderr="")
with patch("subprocess.run", return_value=mock_result) as mock_run:
cli.review_pr("owner/repo", 42, event="COMMENT")
cmd = mock_run.call_args[0][0]
assert "--comment" not in cmd
class TestCreateRelease:
def test_create_release_full(self) -> None:
cli = TeaCLI(tea_bin="/fake/tea")
mock_result = MagicMock(returncode=0, stdout="Release created", stderr="")
with patch("subprocess.run", return_value=mock_result) as mock_run:
release = cli.create_release(
"owner/repo",
tag="v1.0.0",
title="Release 1.0.0",
body="Notes",
target="master",
)
cmd = mock_run.call_args[0][0]
assert "v1.0.0" in cmd
assert "--title" in cmd
assert "--note" in cmd
assert "--target" in cmd
assert release["tag"] == "v1.0.0"
def test_create_release_draft(self) -> None:
cli = TeaCLI(tea_bin="/fake/tea")
mock_result = MagicMock(returncode=0, stdout="Release created", stderr="")
with patch("subprocess.run", return_value=mock_result) as mock_run:
cli.create_release("owner/repo", tag="v0.1.0", draft=True)
cmd = mock_run.call_args[0][0]
assert "--draft" in cmd
def test_create_release_prerelease(self) -> None:
cli = TeaCLI(tea_bin="/fake/tea")
mock_result = MagicMock(returncode=0, stdout="Release created", stderr="")
with patch("subprocess.run", return_value=mock_result) as mock_run:
cli.create_release("owner/repo", tag="v0.1.0-rc1", prerelease=True)
cmd = mock_run.call_args[0][0]
assert "--prerelease" in cmd
def test_create_release_minimal(self) -> None:
cli = TeaCLI(tea_bin="/fake/tea")
mock_result = MagicMock(returncode=0, stdout="Release created", stderr="")
with patch("subprocess.run", return_value=mock_result) as mock_run:
release = cli.create_release("owner/repo", tag="v1.0.0")
cmd = mock_run.call_args[0][0]
assert "--title" not in cmd
assert "--note" not in cmd
assert release["tag"] == "v1.0.0"
class TestListReleases:
def test_list_releases_with_data(self) -> None:
cli = TeaCLI(tea_bin="/fake/tea")
releases_json = json.dumps([{"tag": "v1.0.0"}, {"tag": "v0.9.0"}])
mock_result = MagicMock(returncode=0, stdout=releases_json, stderr="")
with patch("subprocess.run", return_value=mock_result):
releases = cli.list_releases("owner/repo")
assert len(releases) == 2
def test_list_releases_empty(self) -> None:
cli = TeaCLI(tea_bin="/fake/tea")
mock_result = MagicMock(returncode=0, stdout="", stderr="")
with patch("subprocess.run", return_value=mock_result):
releases = cli.list_releases("owner/repo")
assert releases == []
class TestListBranches:
def test_list_branches_with_data(self) -> None:
cli = TeaCLI(tea_bin="/fake/tea")
branches_json = json.dumps([{"name": "master"}, {"name": "develop"}])
mock_result = MagicMock(returncode=0, stdout=branches_json, stderr="")
with patch("subprocess.run", return_value=mock_result):
branches = cli.list_branches("owner/repo")
assert len(branches) == 2
def test_list_branches_empty(self) -> None:
cli = TeaCLI(tea_bin="/fake/tea")
mock_result = MagicMock(returncode=0, stdout="", stderr="")
with patch("subprocess.run", return_value=mock_result):
branches = cli.list_branches("owner/repo")
assert branches == []
class TestWhoami:
def test_whoami(self) -> None:
cli = TeaCLI(tea_bin="/fake/tea")
mock_result = MagicMock(returncode=0, stdout="emil", stderr="")
with patch("subprocess.run", return_value=mock_result):
assert cli.whoami() == "emil"
+94
View File
@@ -0,0 +1,94 @@
from __future__ import annotations
import platform
from pathlib import Path
from unittest.mock import patch
import pytest
from click import ClickException
import devx.tools.install_checkmake as install_checkmake
class TestArch:
def test_amd64(self) -> None:
with patch.object(platform, "machine", return_value="x86_64"):
assert install_checkmake._arch() == "amd64"
def test_arm64(self) -> None:
with patch.object(platform, "machine", return_value="aarch64"):
assert install_checkmake._arch() == "arm64"
def test_unsupported(self) -> None:
with patch.object(platform, "machine", return_value="riscv64"):
with pytest.raises(ClickException):
install_checkmake._arch()
class TestInstallWithGo:
def test_no_go(self) -> None:
with patch("shutil.which", return_value=None):
assert install_checkmake._install_with_go() is False
def test_with_go(self) -> None:
with patch("shutil.which", return_value="/usr/bin/go"):
with patch("subprocess.run") as mock_run:
assert install_checkmake._install_with_go() is True
mock_run.assert_called_once_with(
[
"/usr/bin/go",
"install",
"github.com/checkmake/checkmake/cmd/checkmake@latest",
],
check=True,
)
class TestDownloadBinary:
def test_download(self, tmp_path: Path) -> None:
target = tmp_path / "checkmake"
def _write_file(url: str, path: str) -> tuple[str, None]:
Path(path).write_bytes(b"binary")
return path, None
with patch.object(install_checkmake, "TARGET_PATH", target):
with patch.object(platform, "machine", return_value="x86_64"):
with patch("urllib.request.urlretrieve", side_effect=_write_file) as mock_retrieve:
install_checkmake._download_binary()
mock_retrieve.assert_called_once()
assert target.exists()
assert target.stat().st_mode & 0o111
class TestMain:
def test_already_installed(self) -> None:
with patch("shutil.which", return_value="/usr/bin/checkmake"):
install_checkmake.main()
def test_install_with_go(self) -> None:
with patch("shutil.which", side_effect=[None, "/usr/bin/go"]):
with patch("subprocess.run") as mock_run:
install_checkmake.main()
mock_run.assert_called_once_with(
[
"/usr/bin/go",
"install",
"github.com/checkmake/checkmake/cmd/checkmake@latest",
],
check=True,
)
def test_download_when_no_go(self, tmp_path: Path) -> None:
target = tmp_path / "checkmake"
def _write_file(url: str, path: str) -> tuple[str, None]:
Path(path).write_bytes(b"binary")
return path, None
with patch.object(install_checkmake, "TARGET_PATH", target):
with patch("shutil.which", side_effect=[None, None]):
with patch.object(platform, "machine", return_value="x86_64"):
with patch("urllib.request.urlretrieve", side_effect=_write_file) as mock_retrieve:
install_checkmake.main()
mock_retrieve.assert_called_once()
+312
View File
@@ -0,0 +1,312 @@
from __future__ import annotations
import platform
from pathlib import Path
from unittest.mock import patch
import pytest
from click import ClickException
from click.testing import CliRunner
import devx.tools.install_tools as install_tools
class TestArch:
def test_amd64(self) -> None:
with patch.object(platform, "machine", return_value="x86_64"):
assert install_tools._arch() == "amd64"
def test_arm64(self) -> None:
with patch.object(platform, "machine", return_value="aarch64"):
assert install_tools._arch() == "arm64"
def test_unsupported(self) -> None:
with patch.object(platform, "machine", return_value="riscv64"):
with pytest.raises(ClickException):
install_tools._arch()
class TestIsInstalled:
def test_on_path(self) -> None:
with patch("shutil.which", return_value="/usr/bin/actionlint"):
assert install_tools._is_installed("actionlint") is True
def test_in_target_dir(self, tmp_path: Path) -> None:
with patch.object(install_tools, "TARGET_DIR", tmp_path):
(tmp_path / "actionlint").touch()
with patch("shutil.which", return_value=None):
assert install_tools._is_installed("actionlint") is True
def test_not_installed(self, tmp_path: Path) -> None:
with patch.object(install_tools, "TARGET_DIR", tmp_path):
with patch("shutil.which", return_value=None):
assert install_tools._is_installed("actionlint") is False
class TestDownload:
def test_download(self, tmp_path: Path) -> None:
dest = tmp_path / "file.bin"
def _write_file(url: str, path: Path) -> tuple[str, None]:
Path(path).write_bytes(b"data")
return str(path), None
with patch("urllib.request.urlretrieve", side_effect=_write_file) as mock_retrieve:
install_tools._download("https://example.com/file", dest)
mock_retrieve.assert_called_once()
assert dest.read_bytes() == b"data"
class TestDownloadBinary:
def test_download(self, tmp_path: Path) -> None:
dest = tmp_path / "act_runner"
def _write_file(url: str, path: Path) -> tuple[str, None]:
Path(path).write_bytes(b"binary")
return str(path), None
with patch.object(install_tools, "TARGET_DIR", tmp_path):
with patch.object(install_tools, "_download", side_effect=_write_file):
result = install_tools._download_binary("https://example.com/act_runner", "act_runner")
assert result == dest
assert dest.exists()
assert dest.stat().st_mode & 0o111
class TestDownloadAndExtractTarball:
def test_extract(self, tmp_path: Path) -> None:
import tarfile
# Create a fake tarball with a binary
tarball_path = tmp_path / "archive.tar.gz"
binary_content = b"fake binary"
with tarfile.open(tarball_path, "w:gz") as tar:
import io
info = tarfile.TarInfo(name="actionlint")
info.size = len(binary_content)
tar.addfile(info, io.BytesIO(binary_content))
target_dir = tmp_path / "bin"
target_dir.mkdir()
with patch.object(install_tools, "TARGET_DIR", target_dir):
with patch.object(
install_tools,
"_download",
side_effect=lambda url, dest: Path(dest).write_bytes(tarball_path.read_bytes()),
):
result = install_tools._download_and_extract_tarball(
"https://example.com/actionlint.tar.gz", "actionlint"
)
assert result == target_dir / "actionlint"
assert result.exists()
assert result.read_bytes() == binary_content
def test_binary_not_found(self, tmp_path: Path) -> None:
import tarfile
tarball_path = tmp_path / "archive.tar.gz"
with tarfile.open(tarball_path, "w:gz") as tar:
import io
info = tarfile.TarInfo(name="other_file")
info.size = 0
tar.addfile(info, io.BytesIO(b""))
target_dir = tmp_path / "bin"
target_dir.mkdir()
with patch.object(install_tools, "TARGET_DIR", target_dir):
with patch.object(
install_tools,
"_download",
side_effect=lambda url, dest: Path(dest).write_bytes(tarball_path.read_bytes()),
):
with pytest.raises(ClickException, match="not found in archive"):
install_tools._download_and_extract_tarball("https://example.com/actionlint.tar.gz", "actionlint")
class TestInstallActionlint:
def test_already_installed(self) -> None:
with patch.object(install_tools, "_is_installed", return_value=True):
assert install_tools.install_actionlint() is True
def test_install(self, tmp_path: Path) -> None:
import tarfile
tarball_path = tmp_path / "archive.tar.gz"
binary_content = b"fake actionlint"
with tarfile.open(tarball_path, "w:gz") as tar:
import io
info = tarfile.TarInfo(name="actionlint")
info.size = len(binary_content)
tar.addfile(info, io.BytesIO(binary_content))
with patch.object(install_tools, "_is_installed", return_value=False):
with patch.object(install_tools, "TARGET_DIR", tmp_path):
with patch.object(platform, "machine", return_value="x86_64"):
with patch.object(
install_tools,
"_download",
side_effect=lambda url, dest: Path(dest).write_bytes(tarball_path.read_bytes()),
):
assert install_tools.install_actionlint() is True
assert (tmp_path / "actionlint").exists()
class TestInstallGitCliff:
def test_already_installed(self) -> None:
with patch.object(install_tools, "_is_installed", return_value=True):
assert install_tools.install_git_cliff() is True
def test_install(self, tmp_path: Path) -> None:
import tarfile
tarball_path = tmp_path / "archive.tar.gz"
binary_content = b"fake git-cliff"
with tarfile.open(tarball_path, "w:gz") as tar:
import io
info = tarfile.TarInfo(name="git-cliff")
info.size = len(binary_content)
tar.addfile(info, io.BytesIO(binary_content))
with patch.object(install_tools, "_is_installed", return_value=False):
with patch.object(install_tools, "TARGET_DIR", tmp_path):
with patch.object(platform, "machine", return_value="x86_64"):
with patch.object(
install_tools,
"_download",
side_effect=lambda url, dest: Path(dest).write_bytes(tarball_path.read_bytes()),
):
assert install_tools.install_git_cliff() is True
assert (tmp_path / "git-cliff").exists()
class TestInstallActRunner:
def test_already_installed(self) -> None:
with patch.object(install_tools, "_is_installed", return_value=True):
assert install_tools.install_act_runner() is True
def test_install(self, tmp_path: Path) -> None:
def _write_file(url: str, path: Path) -> tuple[str, None]:
Path(path).write_bytes(b"binary")
return str(path), None
with patch.object(install_tools, "_is_installed", return_value=False):
with patch.object(install_tools, "TARGET_DIR", tmp_path):
with patch.object(platform, "machine", return_value="x86_64"):
with patch.object(install_tools, "_download", side_effect=_write_file):
assert install_tools.install_act_runner() is True
assert (tmp_path / "act_runner").exists()
class TestInstallTea:
def test_already_installed(self) -> None:
with patch.object(install_tools, "_is_installed", return_value=True):
assert install_tools.install_tea() is True
def test_install(self, tmp_path: Path) -> None:
def _write_file(url: str, path: Path) -> tuple[str, None]:
Path(path).write_bytes(b"binary")
return str(path), None
with patch.object(install_tools, "_is_installed", return_value=False):
with patch.object(install_tools, "TARGET_DIR", tmp_path):
with patch.object(platform, "machine", return_value="x86_64"):
with patch.object(install_tools, "_download", side_effect=_write_file):
assert install_tools.install_tea() is True
assert (tmp_path / "tea").exists()
class TestListTools:
def test_list(self, tmp_path: Path) -> None:
with patch.object(install_tools, "TARGET_DIR", tmp_path):
with patch("shutil.which", return_value=None):
with patch.object(install_tools, "TOOL_NAMES", ["actionlint", "git-cliff", "act_runner"]):
install_tools.list_tools()
class TestInstallTool:
def test_actionlint(self) -> None:
with patch.object(install_tools, "install_actionlint", return_value=True) as mock:
assert install_tools._install_tool("actionlint") is True
mock.assert_called_once()
def test_git_cliff(self) -> None:
with patch.object(install_tools, "install_git_cliff", return_value=True) as mock:
assert install_tools._install_tool("git-cliff") is True
mock.assert_called_once()
def test_act_runner(self) -> None:
with patch.object(install_tools, "install_act_runner", return_value=True) as mock:
assert install_tools._install_tool("act_runner") is True
mock.assert_called_once()
def test_tea(self) -> None:
with patch.object(install_tools, "install_tea", return_value=True) as mock:
assert install_tools._install_tool("tea") is True
mock.assert_called_once()
def test_unknown_tool(self) -> None:
with pytest.raises(ClickException, match="Unknown tool"):
install_tools._install_tool("unknown")
class TestMain:
def test_list_status(self) -> None:
runner = CliRunner()
with patch.object(install_tools, "_is_installed", return_value=True):
result = runner.invoke(install_tools.main, ["--list"])
assert result.exit_code == 0
assert "actionlint" in result.output
def test_install_all(self) -> None:
runner = CliRunner()
with patch.object(install_tools, "_install_tool", return_value=True) as mock_install:
result = runner.invoke(install_tools.main, [])
assert result.exit_code == 0
assert mock_install.call_count == 4
def test_install_specific_tool(self) -> None:
runner = CliRunner()
with patch.object(install_tools, "_install_tool", return_value=True) as mock_install:
result = runner.invoke(install_tools.main, ["--tool", "actionlint"])
assert result.exit_code == 0
mock_install.assert_called_once_with("actionlint")
def test_install_multiple_specific_tools(self) -> None:
runner = CliRunner()
with patch.object(install_tools, "_install_tool", return_value=True) as mock_install:
result = runner.invoke(install_tools.main, ["--tool", "git-cliff", "--tool", "tea"])
assert result.exit_code == 0
assert mock_install.call_count == 2
def test_install_failure(self) -> None:
runner = CliRunner()
with patch.object(install_tools, "_install_tool", side_effect=Exception("network error")):
result = runner.invoke(install_tools.main, ["--tool", "actionlint"])
assert result.exit_code != 0
def test_path_reminder(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""When TARGET_DIR is not in PATH, a reminder is printed."""
monkeypatch.setenv("PATH", "/usr/bin:/bin")
runner = CliRunner()
with patch.object(install_tools, "_install_tool", return_value=True):
result = runner.invoke(install_tools.main, [])
assert result.exit_code == 0
assert "Add" in result.output
assert "PATH" in result.output
def test_no_path_reminder_when_in_path(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""When TARGET_DIR is in PATH, no reminder is printed."""
target_dir = str(install_tools.TARGET_DIR)
monkeypatch.setenv("PATH", f"/usr/bin:{target_dir}:/bin")
runner = CliRunner()
with patch.object(install_tools, "_install_tool", return_value=True):
result = runner.invoke(install_tools.main, [])
assert result.exit_code == 0
assert "Add" not in result.output
+130
View File
@@ -0,0 +1,130 @@
from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
import pytest
from click.testing import CliRunner
import devx.molecule.molecule_all as molecule_all
class TestRunMolecule:
def test_success(self) -> None:
import subprocess
mock_result = subprocess.CompletedProcess(args=[], returncode=0, stdout="", stderr="")
with patch("subprocess.run", return_value=mock_result) as mock_run:
rc = molecule_all._run_molecule("/bin/molecule", "default", Path("/tmp/role"), {})
assert rc == 0
mock_run.assert_called_once()
def test_failure(self) -> None:
import subprocess
mock_result = subprocess.CompletedProcess(args=[], returncode=1, stdout="", stderr="")
with patch("subprocess.run", return_value=mock_result):
rc = molecule_all._run_molecule("/bin/molecule", "default", Path("/tmp/role"), {})
assert rc == 1
def test_non_default_scenario_adds_flag(self) -> None:
import subprocess
mock_result = subprocess.CompletedProcess(args=[], returncode=0, stdout="", stderr="")
with patch("subprocess.run", return_value=mock_result) as mock_run:
molecule_all._run_molecule("/bin/molecule", "lifecycle", Path("/tmp/role"), {})
cmd = mock_run.call_args[0][0]
assert "-s" in cmd
assert "lifecycle" in cmd
class TestRunPlatform:
def test_all_scenarios_pass(self) -> None:
platform = {"name": "ubuntu-2204", "image": "ubuntu:22.04", "command": "/lib/systemd/systemd"}
with patch("devx.molecule.molecule_all._run_molecule", return_value=0) as mock_run:
rc = molecule_all._run_platform("/bin/molecule", platform, Path("/tmp/role"), ["default", "lifecycle"], {})
assert rc == 0
assert mock_run.call_count == 2
def test_stops_on_failure(self) -> None:
platform = {"name": "ubuntu-2204", "image": "ubuntu:22.04", "command": "/lib/systemd/systemd"}
with patch("devx.molecule.molecule_all._run_molecule", side_effect=[1, 0]) as mock_run:
rc = molecule_all._run_platform("/bin/molecule", platform, Path("/tmp/role"), ["default", "lifecycle"], {})
assert rc == 1
assert mock_run.call_count == 1
def test_sets_env_vars(self) -> None:
platform = {"name": "ubuntu-2204", "image": "ubuntu:22.04", "command": "/lib/systemd/systemd"}
captured_env: dict[str, str] = {}
def _capture_env(cmd, cwd, env):
captured_env.update(env)
import subprocess
return subprocess.CompletedProcess(args=cmd, returncode=0, stdout="", stderr="")
with patch("subprocess.run", side_effect=_capture_env):
molecule_all._run_platform("/bin/molecule", platform, Path("/tmp/role"), ["default"], {"PATH": "/usr/bin"})
assert captured_env["MOLECULE_PLATFORM_NAME"] == "ubuntu-2204"
assert captured_env["MOLECULE_PLATFORM_IMAGE"] == "ubuntu:22.04"
assert captured_env["MOLECULE_PLATFORM_COMMAND"] == "/lib/systemd/systemd"
def test_empty_command_removes_env(self) -> None:
platform = {"name": "custom", "image": "custom:latest", "command": ""}
captured_env: dict[str, str] = {}
def _capture_env(cmd, cwd, env):
captured_env.update(env)
import subprocess
return subprocess.CompletedProcess(args=cmd, returncode=0, stdout="", stderr="")
with patch("subprocess.run", side_effect=_capture_env):
molecule_all._run_platform(
"/bin/molecule", platform, Path("/tmp/role"), ["default"], {"MOLECULE_PLATFORM_COMMAND": "old"}
)
assert "MOLECULE_PLATFORM_COMMAND" not in captured_env
class TestMain:
def test_molecule_not_found(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.chdir(tmp_path)
runner = CliRunner()
result = runner.invoke(molecule_all.main, ["--bin", "nonexistent/bin"])
assert result.exit_code != 0
assert "molecule not found" in result.output
def test_role_dir_not_found(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.chdir(tmp_path)
bin_dir = tmp_path / ".venv" / "bin"
bin_dir.mkdir(parents=True)
(bin_dir / "molecule").touch()
runner = CliRunner()
result = runner.invoke(molecule_all.main, ["--bin", str(bin_dir)])
assert result.exit_code != 0
assert "Role directory not found" in result.output
def test_all_pass(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.chdir(tmp_path)
bin_dir = tmp_path / ".venv" / "bin"
bin_dir.mkdir(parents=True)
(bin_dir / "molecule").touch()
(tmp_path / "ansible" / "roles" / "gitea-runner").mkdir(parents=True)
runner = CliRunner()
with patch("devx.molecule.molecule_all._run_platform", return_value=0):
result = runner.invoke(molecule_all.main, ["--bin", str(bin_dir)])
assert result.exit_code == 0
assert "All molecule scenarios passed" in result.output
def test_platform_failure(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.chdir(tmp_path)
bin_dir = tmp_path / ".venv" / "bin"
bin_dir.mkdir(parents=True)
(bin_dir / "molecule").touch()
(tmp_path / "ansible" / "roles" / "gitea-runner").mkdir(parents=True)
runner = CliRunner()
with patch("devx.molecule.molecule_all._run_platform", return_value=1):
result = runner.invoke(molecule_all.main, ["--bin", str(bin_dir)])
assert result.exit_code != 0
+428
View File
@@ -0,0 +1,428 @@
"""Unit tests for scripts/ci/molecule_ci_guard.py."""
from __future__ import annotations
import os
import subprocess # nosec B404
import time
from unittest.mock import MagicMock, patch
import pytest
import requests
from devx.molecule.molecule_ci_guard import (
any_other_runner_failed,
build_env_for_pair,
build_molecule_cmd,
cli,
get_running_jobs,
poll_for_other_failures,
)
class TestGetRunningJobs:
def test_returns_jobs(self) -> None:
with patch("devx.molecule.molecule_ci_guard.requests.get") as mock_get:
mock_response = MagicMock()
mock_response.json.return_value = {
"jobs": [
{"name": "molecule-tests (0)", "conclusion": "success"},
{"name": "molecule-tests (1)", "conclusion": "failure"},
]
}
mock_response.raise_for_status.return_value = None
mock_get.return_value = mock_response
jobs = get_running_jobs("https://gitea.example", "owner", "repo", "token", 123)
assert len(jobs) == 2
mock_get.assert_called_once()
def test_raises_on_request_error(self) -> None:
with patch("devx.molecule.molecule_ci_guard.requests.get") as mock_get:
mock_get.side_effect = requests.RequestException("boom")
with pytest.raises(requests.RequestException):
get_running_jobs("https://gitea.example", "owner", "repo", "token", 123)
class TestAnyOtherRunnerFailed:
def test_detects_other_failure(self) -> None:
jobs = [
{"name": "molecule-tests (0)", "conclusion": "success"},
{"name": "molecule-tests (1)", "conclusion": "failure"},
{"name": "molecule-tests (2)", "conclusion": "running"},
]
assert any_other_runner_failed(jobs, "molecule-tests", 0) is True
def test_ignores_current_runner(self) -> None:
jobs = [
{"name": "molecule-tests (0)", "conclusion": "failure"},
{"name": "molecule-tests (1)", "conclusion": "success"},
]
assert any_other_runner_failed(jobs, "molecule-tests", 0) is False
def test_ignores_non_molecule_jobs(self) -> None:
jobs = [
{"name": "quality", "conclusion": "failure"},
{"name": "molecule-tests (1)", "conclusion": "success"},
]
assert any_other_runner_failed(jobs, "molecule-tests", 0) is False
class TestBuildMoleculeCmd:
def test_default_scenario(self) -> None:
assert build_molecule_cmd("default") == ["molecule", "test"]
def test_named_scenario(self) -> None:
assert build_molecule_cmd("lifecycle") == ["molecule", "test", "-s", "lifecycle"]
class TestBuildEnvForPair:
def test_with_command(self) -> None:
env = build_env_for_pair("default|ubuntu-2204|img:latest|/lib/systemd/systemd", {})
assert env["MOLECULE_PLATFORM_NAME"] == "ubuntu-2204"
assert env["MOLECULE_PLATFORM_IMAGE"] == "img:latest"
assert env["MOLECULE_PLATFORM_COMMAND"] == "/lib/systemd/systemd"
assert env["ANSIBLE_ALLOW_BROKEN_CONDITIONALS"] == "true"
def test_without_command(self) -> None:
env = build_env_for_pair("default|ubuntu-2204|img:latest|", {})
assert env["MOLECULE_PLATFORM_NAME"] == "ubuntu-2204"
assert "MOLECULE_PLATFORM_COMMAND" not in env
def test_without_command_removes_existing(self) -> None:
env = build_env_for_pair("default|ubuntu-2204|img:latest|", {"MOLECULE_PLATFORM_COMMAND": "old"})
assert "MOLECULE_PLATFORM_COMMAND" not in env
class TestPollForOtherFailures:
def test_sets_failed_event_when_other_runner_fails(self) -> None:
stop_event = MagicMock()
failed_event = MagicMock()
def side_effect(*args, **kwargs):
if stop_event.wait.call_count < 1:
return [
{"name": "molecule-tests (0)", "conclusion": "success"},
{"name": "molecule-tests (1)", "conclusion": "failure"},
]
return []
with patch("devx.molecule.molecule_ci_guard.get_running_jobs") as mock_get_jobs:
mock_get_jobs.side_effect = side_effect
stop_event.is_set.side_effect = [False, False]
stop_event.wait.return_value = True
poll_for_other_failures(
"https://gitea.example",
"owner",
"repo",
"token",
123,
"molecule-tests",
0,
stop_event,
failed_event,
)
failed_event.set.assert_called_once()
def test_poll_warns_on_api_error(self) -> None:
stop_event = MagicMock()
failed_event = MagicMock()
with patch("devx.molecule.molecule_ci_guard.get_running_jobs") as mock_get_jobs:
mock_get_jobs.side_effect = requests.RequestException("boom")
stop_event.is_set.side_effect = [False, True]
stop_event.wait.return_value = True
poll_for_other_failures(
"https://gitea.example",
"owner",
"repo",
"token",
123,
"molecule-tests",
0,
stop_event,
failed_event,
)
failed_event.set.assert_not_called()
class TestCli:
def test_all_pass(self) -> None:
from click.testing import CliRunner
with (
patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen,
patch("time.sleep"),
):
proc = MagicMock()
proc.poll.return_value = 0
proc.returncode = 0
mock_popen.return_value = proc
runner = CliRunner()
result = runner.invoke(cli, ["default|ubuntu-2204|img:latest|"])
assert result.exit_code == 0
assert "All molecule tests passed" in result.output
def test_failure_exits_nonzero(self) -> None:
from click.testing import CliRunner
with (
patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen,
patch("time.sleep"),
):
proc = MagicMock()
proc.poll.return_value = 1
proc.returncode = 1
mock_popen.return_value = proc
runner = CliRunner()
result = runner.invoke(cli, ["default|ubuntu-2204|img:latest|"])
assert result.exit_code == 1
assert "FAILED" in result.output
def test_exits_before_starting_when_already_failed(self) -> None:
from click.testing import CliRunner
with (
patch.dict(
os.environ,
{
"GITEA_URL": "https://gitea.example",
"REPO_TOKEN": "token",
"RUN_ID": "123",
"JOB_NAME": "molecule-tests",
"MATRIX_INDEX": "0",
"GITEA_REPOSITORY": "oblachno-oss/grm",
"PATH": os.environ.get("PATH", ""),
},
clear=True,
),
patch("devx.molecule.molecule_ci_guard.POLL_INTERVAL", 0.01),
patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen,
patch("devx.molecule.molecule_ci_guard.get_running_jobs") as mock_get_jobs,
patch("time.sleep"),
):
mock_get_jobs.return_value = [
{"name": "molecule-tests (0)", "conclusion": "running"},
{"name": "molecule-tests (1)", "conclusion": "failure"},
]
proc = MagicMock()
proc.poll.return_value = 0
proc.returncode = 0
mock_popen.return_value = proc
runner = CliRunner()
result = runner.invoke(cli, ["default|ubuntu-2204|img:latest|"])
assert result.exit_code == 1
assert "Another molecule runner failed" in result.output
def test_keyboard_interrupt_kills_process(self) -> None:
from click.testing import CliRunner
with (
patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen,
patch("time.sleep", side_effect=KeyboardInterrupt),
patch("os.killpg") as mock_killpg,
patch("os.getpgid") as mock_getpgid,
):
mock_getpgid.return_value = 123
proc = MagicMock()
proc.poll.return_value = None
proc.wait.return_value = 0
mock_popen.return_value = proc
runner = CliRunner()
result = runner.invoke(cli, ["default|ubuntu-2204|img:latest|"])
assert result.exit_code == 1
mock_killpg.assert_called()
def test_exits_when_other_runner_fails(self) -> None:
from click.testing import CliRunner
real_sleep = time.sleep
call_count = [0]
def get_jobs_side_effect(*args, **kwargs):
call_count[0] += 1
if call_count[0] < 2:
return [{"name": "molecule-tests (1)", "conclusion": "running"}]
return [
{"name": "molecule-tests (0)", "conclusion": "running"},
{"name": "molecule-tests (1)", "conclusion": "failure"},
]
with (
patch.dict(
os.environ,
{
"GITEA_URL": "https://gitea.example",
"REPO_TOKEN": "token",
"RUN_ID": "123",
"JOB_NAME": "molecule-tests",
"MATRIX_INDEX": "0",
"GITEA_REPOSITORY": "oblachno-oss/grm",
"PATH": os.environ.get("PATH", ""),
},
clear=True,
),
patch("devx.molecule.molecule_ci_guard.POLL_INTERVAL", 0.01),
patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen,
patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
patch("os.killpg") as mock_killpg,
patch("os.getpgid") as mock_getpgid,
patch("time.sleep", side_effect=lambda x: real_sleep(0.1)),
):
mock_getpgid.return_value = 123
proc = MagicMock()
proc.poll.return_value = None
proc.wait.return_value = 0
mock_popen.return_value = proc
runner = CliRunner()
result = runner.invoke(cli, ["default|ubuntu-2204|img:latest|"])
assert result.exit_code == 1
mock_killpg.assert_called()
def test_default_owner_repo_fallback(self) -> None:
from click.testing import CliRunner
real_sleep = time.sleep
with (
patch.dict(
os.environ,
{
"GITEA_URL": "https://gitea.example",
"REPO_TOKEN": "token",
"RUN_ID": "123",
"JOB_NAME": "molecule-tests",
"MATRIX_INDEX": "0",
"GITEA_REPOSITORY": "invalid",
"PATH": os.environ.get("PATH", ""),
},
clear=True,
),
patch("devx.molecule.molecule_ci_guard.POLL_INTERVAL", 0.01),
patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen,
patch("devx.molecule.molecule_ci_guard.get_running_jobs") as mock_get_jobs,
patch("time.sleep", side_effect=lambda x: real_sleep(0.05)),
):
mock_get_jobs.return_value = [{"name": "molecule-tests (1)", "conclusion": "success"}]
proc = MagicMock()
proc.poll.return_value = 0
proc.returncode = 0
mock_popen.return_value = proc
runner = CliRunner()
result = runner.invoke(cli, ["default|ubuntu-2204|img:latest|"])
assert result.exit_code == 0
def test_exits_when_other_runner_fails_with_process_lookup_error(self) -> None:
from click.testing import CliRunner
real_sleep = time.sleep
call_count = [0]
def get_jobs_side_effect(*args, **kwargs):
call_count[0] += 1
if call_count[0] < 2:
return [{"name": "molecule-tests (1)", "conclusion": "running"}]
return [
{"name": "molecule-tests (0)", "conclusion": "running"},
{"name": "molecule-tests (1)", "conclusion": "failure"},
]
with (
patch.dict(
os.environ,
{
"GITEA_URL": "https://gitea.example",
"REPO_TOKEN": "token",
"RUN_ID": "123",
"JOB_NAME": "molecule-tests",
"MATRIX_INDEX": "0",
"GITEA_REPOSITORY": "oblachno-oss/grm",
"PATH": os.environ.get("PATH", ""),
},
clear=True,
),
patch("devx.molecule.molecule_ci_guard.POLL_INTERVAL", 0.01),
patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen,
patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
patch("os.killpg") as mock_killpg,
patch("os.getpgid") as mock_getpgid,
patch("time.sleep", side_effect=lambda x: real_sleep(0.1)),
):
mock_getpgid.return_value = 123
mock_killpg.side_effect = ProcessLookupError("no such process")
proc = MagicMock()
proc.poll.return_value = None
proc.wait.return_value = 0
mock_popen.return_value = proc
runner = CliRunner()
result = runner.invoke(cli, ["default|ubuntu-2204|img:latest|"])
assert result.exit_code == 1
def test_exits_when_other_runner_fails_with_timeout(self) -> None:
from click.testing import CliRunner
real_sleep = time.sleep
call_count = [0]
def get_jobs_side_effect(*args, **kwargs):
call_count[0] += 1
if call_count[0] < 2:
return [{"name": "molecule-tests (1)", "conclusion": "running"}]
return [
{"name": "molecule-tests (0)", "conclusion": "running"},
{"name": "molecule-tests (1)", "conclusion": "failure"},
]
with (
patch.dict(
os.environ,
{
"GITEA_URL": "https://gitea.example",
"REPO_TOKEN": "token",
"RUN_ID": "123",
"JOB_NAME": "molecule-tests",
"MATRIX_INDEX": "0",
"GITEA_REPOSITORY": "oblachno-oss/grm",
"PATH": os.environ.get("PATH", ""),
},
clear=True,
),
patch("devx.molecule.molecule_ci_guard.POLL_INTERVAL", 0.01),
patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen,
patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
patch("os.killpg") as mock_killpg,
patch("os.getpgid") as mock_getpgid,
patch("time.sleep", side_effect=lambda x: real_sleep(0.1)),
):
mock_getpgid.return_value = 123
mock_killpg.side_effect = [None, ProcessLookupError("no such process")]
proc = MagicMock()
proc.poll.return_value = None
proc.wait.side_effect = [subprocess.TimeoutExpired("cmd", 10)]
mock_popen.return_value = proc
runner = CliRunner()
result = runner.invoke(cli, ["default|ubuntu-2204|img:latest|"])
assert result.exit_code == 1
def test_main_module_block() -> None:
import devx.molecule.molecule_ci_guard as mg
with open(mg.__file__) as f:
source = f.read()
source = source.replace('if __name__ == "__main__":\n cli()\n', "")
namespace = dict(mg.__dict__)
exec(compile(source, mg.__file__, "exec"), namespace)
assert callable(namespace["cli"])
@@ -0,0 +1,210 @@
"""Unit tests for devx/molecule/discover_runners.py."""
import json
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from click.testing import CliRunner
from devx.molecule.discover_runners import (
DEFAULT_MAX_RUNNERS,
generate_indices,
get_runner_count,
main,
query_runners,
)
class TestGenerateIndices:
def test_zero(self) -> None:
assert generate_indices(0) == []
def test_one(self) -> None:
assert generate_indices(1) == ["1"]
def test_three(self) -> None:
assert generate_indices(3) == ["1", "2", "3"]
def test_five(self) -> None:
assert generate_indices(5) == ["1", "2", "3", "4", "5"]
class TestQueryRunners:
@patch("devx.molecule.discover_runners.requests.get")
def test_returns_total_from_all_levels(self, mock_get: MagicMock) -> None:
"""Runners from repo, org, and admin levels are summed."""
responses = [
MagicMock(status_code=200, json=lambda: {"runners": [], "total_count": 2}),
MagicMock(status_code=200, json=lambda: {"runners": [], "total_count": 1}),
MagicMock(status_code=200, json=lambda: {"runners": [], "total_count": 3}),
]
mock_get.side_effect = responses
result = query_runners("https://api.example.com", "token", "owner", "repo")
assert result == 6
@patch("devx.molecule.discover_runners.requests.get")
def test_skips_non_200(self, mock_get: MagicMock) -> None:
"""Non-200 responses (e.g., 403 for admin) are skipped."""
responses = [
MagicMock(status_code=200, json=lambda: {"total_count": 2}),
MagicMock(status_code=200, json=lambda: {"total_count": 1}),
MagicMock(status_code=403, json=lambda: {"message": "forbidden"}),
]
mock_get.side_effect = responses
result = query_runners("https://api.example.com", "token", "owner", "repo")
assert result == 3
@patch("devx.molecule.discover_runners.requests.get")
def test_handles_request_exception(self, mock_get: MagicMock) -> None:
"""Network errors are caught and don't crash."""
mock_get.side_effect = [
MagicMock(status_code=200, json=lambda: {"total_count": 1}),
MagicMock(side_effect=__import__("requests").RequestException("network error")),
MagicMock(status_code=200, json=lambda: {"total_count": 2}),
]
result = query_runners("https://api.example.com", "token", "owner", "repo")
assert result == 3
@patch("devx.molecule.discover_runners.requests.get")
def test_all_failures_return_zero(self, mock_get: MagicMock) -> None:
"""When all API calls fail, returns 0."""
mock_get.side_effect = [
MagicMock(status_code=404),
MagicMock(status_code=404),
MagicMock(status_code=403),
]
result = query_runners("https://api.example.com", "token", "owner", "repo")
assert result == 0
@patch("devx.molecule.discover_runners.requests.get")
def test_value_error_on_repo_level(self, mock_get: MagicMock) -> None:
"""JSON parse error on repo level is caught."""
responses = [
MagicMock(status_code=200, json=MagicMock(side_effect=ValueError("bad json"))),
MagicMock(status_code=200, json=lambda: {"total_count": 2}),
MagicMock(status_code=200, json=lambda: {"total_count": 1}),
]
mock_get.side_effect = responses
result = query_runners("https://api.example.com", "token", "owner", "repo")
assert result == 3
@patch("devx.molecule.discover_runners.requests.get")
def test_value_error_on_org_level(self, mock_get: MagicMock) -> None:
"""JSON parse error on org level is caught."""
responses = [
MagicMock(status_code=200, json=lambda: {"total_count": 1}),
MagicMock(status_code=200, json=MagicMock(side_effect=ValueError("bad json"))),
MagicMock(status_code=200, json=lambda: {"total_count": 2}),
]
mock_get.side_effect = responses
result = query_runners("https://api.example.com", "token", "owner", "repo")
assert result == 3
@patch("devx.molecule.discover_runners.requests.get")
def test_value_error_on_admin_level(self, mock_get: MagicMock) -> None:
"""JSON parse error on admin level is caught."""
responses = [
MagicMock(status_code=200, json=lambda: {"total_count": 1}),
MagicMock(status_code=200, json=lambda: {"total_count": 2}),
MagicMock(status_code=200, json=MagicMock(side_effect=ValueError("bad json"))),
]
mock_get.side_effect = responses
result = query_runners("https://api.example.com", "token", "owner", "repo")
assert result == 3
@patch("devx.molecule.discover_runners.requests.get")
def test_request_exception_on_all_levels(self, mock_get: MagicMock) -> None:
"""Network errors on all levels return 0."""
mock_get.side_effect = __import__("requests").RequestException("network error")
result = query_runners("https://api.example.com", "token", "owner", "repo")
assert result == 0
class TestGetRunnerCount:
@patch("devx.molecule.discover_runners.query_runners", return_value=5)
def test_uses_api_count_when_positive(self, mock_query: MagicMock) -> None:
result = get_runner_count("https://api.example.com", "token", "owner", "repo")
assert result == 5
@patch("devx.molecule.discover_runners.query_runners", return_value=0)
@patch.dict("os.environ", {"MOLECULE_RUNNERS": "4"})
def test_falls_back_to_env_var(self, mock_query: MagicMock) -> None:
result = get_runner_count("https://api.example.com", "token", "owner", "repo")
assert result == 4
@patch("devx.molecule.discover_runners.query_runners", return_value=0)
@patch.dict("os.environ", {"MOLECULE_RUNNERS": "invalid"})
def test_falls_back_to_default_on_invalid_env(self, mock_query: MagicMock) -> None:
result = get_runner_count("https://api.example.com", "token", "owner", "repo")
assert result == DEFAULT_MAX_RUNNERS
@patch("devx.molecule.discover_runners.query_runners", return_value=0)
@patch.dict("os.environ", {}, clear=True)
def test_falls_back_to_default_when_no_env(self, mock_query: MagicMock) -> None:
result = get_runner_count("https://api.example.com", "token", "owner", "repo")
assert result == DEFAULT_MAX_RUNNERS
@patch("devx.molecule.discover_runners.query_runners", return_value=0)
@patch.dict("os.environ", {"MOLECULE_RUNNERS": "0"})
def test_env_var_zero_falls_back_to_default(self, mock_query: MagicMock) -> None:
"""MOLECULE_RUNNERS=0 is invalid, falls back to default."""
result = get_runner_count("https://api.example.com", "token", "owner", "repo")
assert result == DEFAULT_MAX_RUNNERS
@patch("devx.molecule.discover_runners.query_runners", return_value=0)
@patch.dict("os.environ", {}, clear=True)
def test_no_token_uses_env_var(self, mock_query: MagicMock) -> None:
"""When no token, skips API and uses env/default."""
with patch.dict("os.environ", {"MOLECULE_RUNNERS": "2"}):
result = get_runner_count("https://api.example.com", "", "owner", "repo")
assert result == 2
class TestMain:
@patch("devx.molecule.discover_runners.get_runner_count", return_value=3)
def test_default_output(self, mock_count: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(main, [])
assert result.exit_code == 0
assert "count=3" in result.output
assert 'indices=["1", "2", "3"]' in result.output
@patch("devx.molecule.discover_runners.get_runner_count", return_value=5)
def test_count_only(self, mock_count: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(main, ["--count"])
assert result.exit_code == 0
assert result.output.strip() == "5"
@patch("devx.molecule.discover_runners.get_runner_count", return_value=4)
def test_indices_only(self, mock_count: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(main, ["--indices"])
assert result.exit_code == 0
assert json.loads(result.output.strip()) == ["1", "2", "3", "4"]
@patch("devx.molecule.discover_runners.get_runner_count", return_value=1)
def test_single_runner(self, mock_count: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(main, ["--indices"])
assert result.exit_code == 0
assert json.loads(result.output.strip()) == ["1"]
@patch("devx.molecule.discover_runners.get_runner_count", return_value=3)
def test_github_output(self, mock_count: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
gh_file = tmp_path / "output.txt"
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
runner = CliRunner()
result = runner.invoke(main, ["--github-output"])
assert result.exit_code == 0
content = gh_file.read_text()
assert "runner-count=3" in content
assert "runner-indices=" in content
@patch("devx.molecule.discover_runners.get_runner_count", return_value=3)
def test_github_output_no_env(self, mock_count: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("GITHUB_OUTPUT", raising=False)
runner = CliRunner()
result = runner.invoke(main, ["--github-output"])
assert result.exit_code != 0
+116
View File
@@ -0,0 +1,116 @@
"""Unit tests for scripts/ci/notify_failure.py."""
from unittest.mock import MagicMock, patch
from click.testing import CliRunner
from devx.ci.notify_failure import main
from devx.gitea_cli import TeaCLIError
class TestNotifyFailure:
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
@patch("devx.ci.notify_failure.TeaCLI")
def test_creates_issue_with_tea(self, mock_tea_cls: MagicMock) -> None:
mock_tea = MagicMock()
mock_tea.list_labels.return_value = [{"id": 5, "name": "bug"}]
mock_tea.create_issue.return_value = {"index": 42, "title": "test"}
mock_tea_cls.return_value = mock_tea
runner = CliRunner()
result = runner.invoke(
main,
[
"--repo",
"owner/repo",
"--run-id",
"123",
"--workflow",
"release",
"--commit",
"abc123def456",
],
)
assert result.exit_code == 0
assert "issue #42" in result.output
mock_tea.create_issue.assert_called_once()
mock_tea.add_label.assert_called_once_with("owner/repo", 42, ["bug"])
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
@patch("devx.ci.notify_failure.TeaCLI")
def test_tea_creates_issue_without_bug_label(self, mock_tea_cls: MagicMock) -> None:
mock_tea = MagicMock()
mock_tea.list_labels.return_value = [{"id": 1, "name": "enhancement"}]
mock_tea.create_issue.return_value = {"index": 43, "title": "test"}
mock_tea_cls.return_value = mock_tea
runner = CliRunner()
result = runner.invoke(
main,
["--repo", "owner/repo", "--run-id", "124", "--workflow", "publish", "--commit", "def789"],
)
assert result.exit_code == 0
assert "issue #43" in result.output
mock_tea.add_label.assert_not_called()
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
@patch("devx.ci.notify_failure.TeaCLI")
def test_tea_error_raises(self, mock_tea_cls: MagicMock) -> None:
"""When tea fails, the workflow fails — no fallback."""
mock_tea = MagicMock()
mock_tea.list_labels.side_effect = TeaCLIError("network error")
mock_tea.create_issue.side_effect = TeaCLIError("network error")
mock_tea_cls.return_value = mock_tea
runner = CliRunner()
result = runner.invoke(
main,
["--repo", "owner/repo", "--run-id", "125", "--workflow", "release", "--commit", "abc"],
)
assert result.exit_code != 0
assert "tea" in result.output.lower()
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
@patch("devx.ci.notify_failure.TeaCLI")
def test_tea_list_labels_error_continues_without_labels(self, mock_tea_cls: MagicMock) -> None:
"""If listing labels fails via tea, issue is still created without labels."""
mock_tea = MagicMock()
mock_tea.list_labels.side_effect = TeaCLIError("network error")
mock_tea.create_issue.return_value = {"index": 50, "title": "test"}
mock_tea_cls.return_value = mock_tea
runner = CliRunner()
result = runner.invoke(
main,
["--repo", "owner/repo", "--run-id", "128", "--workflow", "release", "--commit", "abc"],
)
assert result.exit_code == 0
assert "issue #50" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
@patch("devx.ci.notify_failure.TeaCLI")
def test_tea_add_label_error_is_ignored(self, mock_tea_cls: MagicMock) -> None:
"""If adding label fails via tea, issue is still reported as created."""
mock_tea = MagicMock()
mock_tea.list_labels.return_value = [{"id": 5, "name": "bug"}]
mock_tea.create_issue.return_value = {"index": 51, "title": "test"}
mock_tea.add_label.side_effect = TeaCLIError("permission denied")
mock_tea_cls.return_value = mock_tea
runner = CliRunner()
result = runner.invoke(
main,
["--repo", "owner/repo", "--run-id", "129", "--workflow", "release", "--commit", "abc"],
)
assert result.exit_code == 0
assert "issue #51" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": ""}, clear=True)
def test_missing_token_exits(self) -> None:
runner = CliRunner()
result = runner.invoke(
main,
["--repo", "owner/repo", "--run-id", "1", "--workflow", "release", "--commit", "abc"],
)
assert result.exit_code != 0
assert "REPO_TOKEN" in result.output
+25
View File
@@ -0,0 +1,25 @@
"""Unit tests for scripts/ci/platforms.py."""
from devx.molecule.platforms import PLATFORMS
class TestPlatforms:
def test_platforms_not_empty(self) -> None:
assert len(PLATFORMS) >= 4
def test_each_platform_has_required_keys(self) -> None:
for p in PLATFORMS:
assert "name" in p
assert "image" in p
assert "command" in p
def test_platform_names_unique(self) -> None:
names = [p["name"] for p in PLATFORMS]
assert len(names) == len(set(names))
def test_known_platforms_present(self) -> None:
names = {p["name"] for p in PLATFORMS}
assert "ubuntu-2204" in names
assert "ubuntu-2404" in names
assert "debian-12" in names
assert "archlinux" in names
+328
View File
@@ -0,0 +1,328 @@
"""Unit tests for scripts/ci/post_merge.py."""
import http
import subprocess
from unittest.mock import MagicMock, patch
import click
import pytest
from click.testing import CliRunner
from devx.ci.post_merge import (
_get_git_commit_message,
_get_git_commit_sha,
build_comment,
extract_conventional_msg,
extract_task_id,
main,
resolve_task_id,
)
from devx.exceptions import APIError
class TestExtractTaskId:
def test_extracts_from_first_line(self) -> None:
assert extract_task_id("DEVX-19: fix: resolve bug\n\nBody") == "DEVX-19"
def test_returns_empty_when_missing(self) -> None:
assert extract_task_id("fix: resolve bug") == ""
class TestExtractConventionalMsg:
def test_strips_colon_prefix(self) -> None:
"""Legacy format: DEVX-N: <message>"""
assert extract_conventional_msg("DEVX-19: fix: resolve bug") == "fix: resolve bug"
def test_strips_space_prefix(self) -> None:
"""Current format: DEVX-N <message>"""
assert extract_conventional_msg("DEVX-19 fix: resolve bug") == "fix: resolve bug"
def test_returns_unchanged_without_prefix(self) -> None:
assert extract_conventional_msg("fix: resolve bug") == "fix: resolve bug"
class TestBuildComment:
def test_html_format(self) -> None:
html = build_comment("DEVX-19", "fix: bug", "abc123")
assert "<strong>DEVX-19</strong>" in html
assert "fix: bug" in html
assert "<code>abc123</code>" in html
class TestResolveTaskId:
def test_found(self) -> None:
mock_client = MagicMock()
mock_client.list_project_tasks.return_value = [
{"id": 42, "identifier": "DEVX-19"},
]
assert resolve_task_id(mock_client, "DEVX-19") == 42
mock_client.list_project_tasks.assert_called_once()
def test_not_found_raises(self) -> None:
"""Missing Vikunja task is a fatal error — every PR must have a task."""
mock_client = MagicMock()
mock_client.list_project_tasks.return_value = []
with pytest.raises(click.ClickException, match="Could not find"):
resolve_task_id(mock_client, "DEVX-99")
def test_found_on_second_page(self) -> None:
"""Task is on page 2 when project has more than 50 tasks."""
mock_client = MagicMock()
page1 = [{"id": i, "identifier": f"DEVX-{i}"} for i in range(50)]
page2 = [{"id": 100, "identifier": "DEVX-99"}]
mock_client.list_project_tasks.side_effect = [page1, page2]
assert resolve_task_id(mock_client, "DEVX-99") == 100
assert mock_client.list_project_tasks.call_count == 2
def test_stops_when_page_is_partial(self) -> None:
"""Stops paginating when a page has fewer than DEFAULT_PER_PAGE results."""
mock_client = MagicMock()
page1 = [{"id": i, "identifier": f"DEVX-{i}"} for i in range(10)]
mock_client.list_project_tasks.return_value = page1
with pytest.raises(click.ClickException, match="Could not find"):
resolve_task_id(mock_client, "DEVX-99")
assert mock_client.list_project_tasks.call_count == 1
def test_http_error_propagates(self) -> None:
mock_client = MagicMock()
mock_client.list_project_tasks.side_effect = APIError(http.HTTPStatus.INTERNAL_SERVER_ERROR, "server error")
with pytest.raises(APIError):
resolve_task_id(mock_client, "DEVX-19")
class TestMain:
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
@patch("devx.ci.post_merge.VikunjaClient")
def test_full_flow(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client.list_project_tasks.return_value = [
{"id": 267, "identifier": "DEVX-20"},
]
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(
main,
["DEVX-20: fix: resolve bug\n\nBody", "--commit-sha", "abc123"],
)
assert result.exit_code == 0
assert "updated and marked done" in result.output
mock_client.post_comment.assert_called_once()
mock_client.update_task.assert_called_once_with(267, done=True)
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
@patch("devx.ci.post_merge.VikunjaClient")
def test_no_commit_sha(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client.list_project_tasks.return_value = [
{"id": 267, "identifier": "DEVX-20"},
]
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(main, ["DEVX-20: fix: resolve bug"])
assert result.exit_code == 0
mock_client.post_comment.assert_called_once()
args, _ = mock_client.post_comment.call_args
assert "unknown" in args[1]
@patch.dict("os.environ", {"VIKUNJA_TOKEN": ""}, clear=True)
def test_missing_token_exits(self) -> None:
runner = CliRunner()
result = runner.invoke(main, ["DEVX-20: fix: bug"])
assert result.exit_code == 1
assert "VIKUNJA_TOKEN" in result.output
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
def test_no_task_id_non_release_warns(self) -> None:
"""Non-release commits without DEVX-N prefix should warn, not fail."""
runner = CliRunner()
result = runner.invoke(main, ["fix: resolve bug"])
assert result.exit_code == 0
assert "No task ID" in result.output
assert "Skipping" in result.output
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
def test_release_commit_without_task_id_skips(self) -> None:
"""Release commits without DEVX-N prefix should skip gracefully."""
runner = CliRunner()
result = runner.invoke(main, ["release: v0.3.2"])
assert result.exit_code == 0
assert "skipping" in result.output
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
def test_revert_commit_skips(self) -> None:
"""Revert commits without DEVX-N prefix should skip gracefully."""
runner = CliRunner()
result = runner.invoke(main, ["revert: remove v0.6.0 release"])
assert result.exit_code == 0
assert "Infrastructure commit" in result.output
assert "skipping" in result.output
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
def test_merge_commit_skips(self) -> None:
"""Merge commits without DEVX-N prefix should skip gracefully."""
runner = CliRunner()
result = runner.invoke(main, ["Merge pull request #42"])
assert result.exit_code == 0
assert "Infrastructure commit" in result.output
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
@patch("devx.ci.post_merge.VikunjaClient")
def test_resolve_failure_fails(self, mock_client_cls: MagicMock) -> None:
"""Missing Vikunja task is a fatal error — every PR must have a task."""
mock_client = MagicMock()
mock_client.list_project_tasks.return_value = []
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(main, ["DEVX-20: fix: bug"])
assert result.exit_code != 0
assert "Could not find" in result.output
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
@patch("devx.ci.post_merge.VikunjaClient")
def test_post_comment_failure_warns(self, mock_client_cls: MagicMock) -> None:
"""Vikunja API errors should warn, not fail — the merge already succeeded."""
mock_client = MagicMock()
mock_client.list_project_tasks.return_value = [
{"id": 267, "identifier": "DEVX-20"},
]
mock_client.post_comment.side_effect = APIError(http.HTTPStatus.INTERNAL_SERVER_ERROR, "server error")
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(main, ["DEVX-20: fix: bug"])
assert result.exit_code == 0
assert "Warning" in result.output
assert "not updated" in result.output.lower()
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
@patch("devx.ci.post_merge.VikunjaClient")
def test_mark_done_failure_warns(self, mock_client_cls: MagicMock) -> None:
"""Vikunja API errors should warn, not fail — the merge already succeeded."""
mock_client = MagicMock()
mock_client.list_project_tasks.return_value = [
{"id": 267, "identifier": "DEVX-20"},
]
mock_client.post_comment.return_value = None
mock_client.update_task.side_effect = APIError(http.HTTPStatus.INTERNAL_SERVER_ERROR, "server error")
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(main, ["DEVX-20: fix: bug"])
assert result.exit_code == 0
assert "Warning" in result.output
assert "not updated" in result.output.lower()
class TestGetGitCommitMessage:
def test_success(self) -> None:
mock_result = subprocess.CompletedProcess(args=[], returncode=0, stdout="DEVX-20: fix: bug\n", stderr="")
with patch("subprocess.run", return_value=mock_result):
assert _get_git_commit_message() == "DEVX-20: fix: bug"
def test_failure(self) -> None:
mock_result = subprocess.CompletedProcess(args=[], returncode=1, stdout="", stderr="git error")
with patch("subprocess.run", return_value=mock_result):
with pytest.raises(click.ClickException, match="git log failed"):
_get_git_commit_message()
class TestGetGitCommitSha:
def test_success(self) -> None:
mock_result = subprocess.CompletedProcess(args=[], returncode=0, stdout="abc123\n", stderr="")
with patch("subprocess.run", return_value=mock_result):
assert _get_git_commit_sha() == "abc123"
def test_failure(self) -> None:
mock_result = subprocess.CompletedProcess(args=[], returncode=1, stdout="", stderr="git error")
with patch("subprocess.run", return_value=mock_result):
with pytest.raises(click.ClickException, match="git rev-parse failed"):
_get_git_commit_sha()
class TestFromGit:
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
@patch("devx.ci.post_merge.VikunjaClient")
@patch("devx.ci.post_merge._get_git_commit_sha", return_value="abc123")
@patch("devx.ci.post_merge._get_git_commit_message", return_value="DEVX-20: fix: bug")
def test_from_git(self, mock_msg: MagicMock, mock_sha: MagicMock, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client.list_project_tasks.return_value = [
{"id": 267, "identifier": "DEVX-20"},
]
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(main, ["--from-git"])
assert result.exit_code == 0
assert "updated and marked done" in result.output
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
@patch("devx.ci.post_merge.VikunjaClient")
@patch("devx.ci.post_merge._get_git_commit_sha", return_value="abc123")
@patch("devx.ci.post_merge._get_git_commit_message", return_value="DEVX-20: fix: bug")
def test_from_git_with_explicit_sha(
self, mock_msg: MagicMock, mock_sha: MagicMock, mock_client_cls: MagicMock
) -> None:
mock_client = MagicMock()
mock_client.list_project_tasks.return_value = [
{"id": 267, "identifier": "DEVX-20"},
]
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(main, ["--from-git", "--commit-sha", "explicit_sha"])
assert result.exit_code == 0
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
def test_no_msg_and_no_from_git(self) -> None:
runner = CliRunner()
result = runner.invoke(main, [])
assert result.exit_code != 0
assert "commit_msg" in result.output
class TestGitSha:
"""Tests for the --git-sha option (race condition fix)."""
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
@patch("devx.ci.post_merge.VikunjaClient")
def test_git_sha_reads_commit_from_specific_sha(self, mock_client_cls: MagicMock) -> None:
"""--git-sha reads commit message from a specific SHA, not HEAD."""
mock_client = MagicMock()
mock_client.list_project_tasks.return_value = [
{"id": 267, "identifier": "DEVX-20"},
]
mock_client_cls.return_value = mock_client
mock_result = subprocess.CompletedProcess(args=[], returncode=0, stdout="DEVX-20: fix: bug\n", stderr="")
with patch("subprocess.run", return_value=mock_result):
runner = CliRunner()
result = runner.invoke(main, ["--git-sha", "abc123"])
assert result.exit_code == 0
assert "updated and marked done" in result.output
mock_client.post_comment.assert_called_once()
# Verify the SHA was passed to the comment
args, _ = mock_client.post_comment.call_args
assert "abc123" in args[1]
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
def test_git_sha_failure_raises(self) -> None:
"""--git-sha with invalid SHA should raise."""
mock_result = subprocess.CompletedProcess(args=[], returncode=1, stdout="", stderr="bad sha")
with patch("subprocess.run", return_value=mock_result):
runner = CliRunner()
result = runner.invoke(main, ["--git-sha", "badsha"])
assert result.exit_code != 0
assert "git log failed" in result.output
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
@patch("devx.ci.post_merge.VikunjaClient")
def test_git_sha_with_explicit_commit_sha(self, mock_client_cls: MagicMock) -> None:
"""--git-sha with --commit-sha uses the explicit SHA for the comment."""
mock_client = MagicMock()
mock_client.list_project_tasks.return_value = [
{"id": 267, "identifier": "DEVX-20"},
]
mock_client_cls.return_value = mock_client
mock_result = subprocess.CompletedProcess(args=[], returncode=0, stdout="DEVX-20: fix: bug\n", stderr="")
with patch("subprocess.run", return_value=mock_result):
runner = CliRunner()
result = runner.invoke(main, ["--git-sha", "abc123", "--commit-sha", "explicit_sha"])
assert result.exit_code == 0
args, _ = mock_client.post_comment.call_args
assert "explicit_sha" in args[1]
+681
View File
@@ -0,0 +1,681 @@
"""Unit tests for scripts/ci/pr_review.py."""
from unittest.mock import MagicMock, patch
from click.testing import CliRunner
from devx.ci.pr_review import (
ReviewResult,
build_review_body,
check_architecture_compliance,
check_best_practices,
check_commit_conventions,
check_documentation,
check_function_length,
check_i18n,
check_resource_management,
check_security,
check_test_coverage,
is_python_file,
is_workflow_only,
main,
post_review,
run_review,
)
from devx.exceptions import APIError
class TestIsPythonFile:
def test_python_file_in_src(self) -> None:
assert is_python_file("src/devx/cli.py") is True
def test_python_file_in_scripts(self) -> None:
assert is_python_file("scripts/ci/release.py") is True
def test_test_file_excluded(self) -> None:
assert is_python_file("tests/unit/test_cli.py") is False
def test_non_python_file(self) -> None:
assert is_python_file("README.md") is False
def test_yaml_file(self) -> None:
assert is_python_file(".gitea/workflows/ci.yml") is False
class TestIsWorkflowOnly:
def test_yaml_is_workflow(self) -> None:
assert is_workflow_only(".gitea/workflows/ci.yml") is True
def test_md_is_workflow(self) -> None:
assert is_workflow_only("README.md") is True
def test_python_is_not_workflow(self) -> None:
assert is_workflow_only("src/devx/cli.py") is False
def test_ansible_is_workflow(self) -> None:
assert is_workflow_only("ansible/tasks/main.yml") is True
class TestReviewResult:
def test_empty_result_has_no_issues(self) -> None:
result = ReviewResult()
assert result.has_issues is False
def test_add_issue_makes_has_issues_true(self) -> None:
result = ReviewResult()
result.add_issue("src/foo.py", 10, "bad code")
assert result.has_issues is True
assert len(result.issues) == 1
assert result.issues[0]["path"] == "src/foo.py"
assert result.issues[0]["new_position"] == 10
def test_add_summary(self) -> None:
result = ReviewResult()
result.add_summary("all good")
assert "all good" in result.summary
class TestCheckArchitectureCompliance:
def test_subprocess_in_cli_triggers_issue(self) -> None:
result = ReviewResult()
files = [
{
"filename": "src/devx/cli.py",
"patch": "@@ -10,3 +10,4 @@\n def foo():\n pass\n+ subprocess.run(['ls'])\n",
}
]
check_architecture_compliance(files, result)
assert result.has_issues
assert "subprocess" in result.issues[0]["body"].lower()
def test_subprocess_in_other_file_ok(self) -> None:
result = ReviewResult()
files = [
{
"filename": "src/devx/executor.py",
"patch": "@@ -10,3 +10,4 @@\n def foo():\n pass\n+ subprocess.run(['ls'])\n",
}
]
check_architecture_compliance(files, result)
assert not result.has_issues
def test_no_changes_adds_ok_summary(self) -> None:
result = ReviewResult()
files = [{"filename": "src/devx/cli.py", "patch": ""}]
check_architecture_compliance(files, result)
assert any("Architecture compliance: OK" in s for s in result.summary)
def test_non_python_file_skipped(self) -> None:
result = ReviewResult()
files = [{"filename": "README.md", "patch": "@@ -1,1 +1,2 @@\n+subprocess.run(['ls'])\n"}]
check_architecture_compliance(files, result)
assert not result.has_issues
def test_empty_patch_skipped(self) -> None:
result = ReviewResult()
files = [{"filename": "src/devx/cli.py", "patch": ""}]
check_architecture_compliance(files, result)
assert not result.has_issues
def test_os_system_in_cli_triggers_issue(self) -> None:
result = ReviewResult()
files = [
{
"filename": "src/devx/cli.py",
"patch": "@@ -10,3 +10,4 @@\n def foo():\n pass\n+ os.system('ls')\n",
}
]
check_architecture_compliance(files, result)
assert result.has_issues
assert "os.system" in result.issues[0]["body"]
class TestCheckBestPractices:
def test_print_triggers_warning(self) -> None:
result = ReviewResult()
files = [
{
"filename": "src/devx/cli.py",
"patch": "@@ -10,3 +10,4 @@\n def foo():\n pass\n+ print('hello')\n",
}
]
check_best_practices(files, result)
assert result.has_issues
assert "print()" in result.issues[0]["body"]
def test_bare_except_triggers_warning(self) -> None:
result = ReviewResult()
files = [
{
"filename": "src/devx/runner_manager.py",
"patch": "@@ -10,3 +10,4 @@\n def foo():\n pass\n+ except:\n pass\n",
}
]
check_best_practices(files, result)
assert result.has_issues
assert "bare except" in result.issues[0]["body"]
def test_todo_triggers_warning(self) -> None:
result = ReviewResult()
files = [
{
"filename": "src/devx/cli.py",
"patch": "@@ -10,3 +10,4 @@\n def foo():\n pass\n+ # TODO: fix this\n",
}
]
check_best_practices(files, result)
assert result.has_issues
assert "TODO" in result.issues[0]["body"]
def test_clean_code_no_issues(self) -> None:
result = ReviewResult()
files = [
{
"filename": "src/devx/cli.py",
"patch": "@@ -10,3 +10,4 @@\n def foo():\n pass\n+ click.echo('hello')\n",
}
]
check_best_practices(files, result)
assert not result.has_issues
def test_empty_patch_skipped(self) -> None:
result = ReviewResult()
files = [{"filename": "src/devx/cli.py", "patch": ""}]
check_best_practices(files, result)
assert not result.has_issues
def test_non_python_file_skipped(self) -> None:
result = ReviewResult()
files = [{"filename": "README.md", "patch": "@@ -1,1 +1,2 @@\n+print('hello')\n"}]
check_best_practices(files, result)
assert not result.has_issues
class TestCheckSecurity:
def test_hardcoded_secret_triggers_error(self) -> None:
result = ReviewResult()
files = [
{
"filename": "src/devx/config.py",
"patch": "@@ -10,3 +10,4 @@\n def foo():\n pass\n+ token = 'abc123secrettoken456'\n",
}
]
check_security(files, result)
assert result.has_issues
assert "secret" in result.issues[0]["body"].lower()
def test_example_token_not_flagged(self) -> None:
result = ReviewResult()
files = [
{
"filename": ".env.example",
"patch": "@@ -1,1 +1,2 @@\n+token = your-example-token\n",
}
]
check_security(files, result)
assert not result.has_issues
def test_shell_true_triggers_warning(self) -> None:
result = ReviewResult()
files = [
{
"filename": "src/devx/executor.py",
"patch": "@@ -10,3 +10,4 @@\n def foo():\n pass\n+ subprocess.run('ls', shell=True)\n",
}
]
check_best_practices(files, result)
assert result.has_issues
assert "shell=True" in result.issues[0]["body"]
def test_empty_patch_skipped(self) -> None:
result = ReviewResult()
files = [{"filename": "src/devx/config.py", "patch": ""}]
check_security(files, result)
assert not result.has_issues
def test_non_python_file_skipped(self) -> None:
result = ReviewResult()
files = [{"filename": "docs/config.md", "patch": "@@ -1,1 +1,2 @@\n+token = 'abc123secrettoken456'\n"}]
check_security(files, result)
assert not result.has_issues
class TestCheckI18n:
def test_raw_string_in_echo_triggers_warning(self) -> None:
result = ReviewResult()
files = [{"filename": "src/devx/cli.py", "patch": '@@ -1,1 +1,1 @@\n+click.echo("Hello world")\n'}]
check_i18n(files, result)
assert result.has_issues
assert any("i18n" in i["body"] for i in result.issues)
def test_translated_string_no_warning(self) -> None:
result = ReviewResult()
files = [{"filename": "src/devx/cli.py", "patch": '@@ -1,1 +1,1 @@\n+click.echo(_("Hello world"))\n'}]
check_i18n(files, result)
assert not result.has_issues
def test_fstring_in_echo_triggers_warning(self) -> None:
result = ReviewResult()
files = [{"filename": "src/devx/cli.py", "patch": '@@ -1,1 +1,1 @@\n+click.echo(f"Hello {name}")\n'}]
check_i18n(files, result)
assert result.has_issues
def test_raw_exception_triggers_warning(self) -> None:
result = ReviewResult()
files = [
{
"filename": "src/devx/cli.py",
"patch": '@@ -1,1 +1,1 @@\n+raise click.ClickException("Something went wrong")\n',
}
]
check_i18n(files, result)
assert result.has_issues
def test_non_src_file_skipped(self) -> None:
result = ReviewResult()
files = [{"filename": "scripts/ci/test.py", "patch": '@@ -1,1 +1,1 @@\n+click.echo("Hello world")\n'}]
check_i18n(files, result)
assert not result.has_issues
def test_comment_skipped(self) -> None:
result = ReviewResult()
files = [{"filename": "src/devx/cli.py", "patch": '@@ -1,1 +1,1 @@\n+# click.echo("Hello world")\n'}]
check_i18n(files, result)
assert not result.has_issues
def test_empty_patch_skipped(self) -> None:
result = ReviewResult()
files = [{"filename": "src/devx/cli.py", "patch": ""}]
check_i18n(files, result)
assert not result.has_issues
def test_clean_code_adds_ok_summary(self) -> None:
result = ReviewResult()
files = [{"filename": "src/devx/cli.py", "patch": '@@ -1,1 +1,1 @@\n+click.echo(_("Hello world"))\n'}]
check_i18n(files, result)
assert any("i18n: OK" in s for s in result.summary)
class TestCheckResourceManagement:
def test_open_without_with_triggers_warning(self) -> None:
result = ReviewResult()
files = [{"filename": "src/devx/cli.py", "patch": '@@ -1,1 +1,1 @@\n+f = open("file.txt")\n'}]
check_resource_management(files, result)
assert result.has_issues
assert any("resource" in i["body"].lower() for i in result.issues)
def test_open_with_with_no_warning(self) -> None:
result = ReviewResult()
files = [
{
"filename": "src/devx/cli.py",
"patch": '@@ -1,1 +1,1 @@\n+with open("file.txt") as f:\n+ pass\n',
}
]
check_resource_management(files, result)
assert not result.has_issues
def test_popen_without_cleanup_triggers_warning(self) -> None:
result = ReviewResult()
files = [
{
"filename": "src/devx/executor.py",
"patch": '@@ -1,1 +1,1 @@\n+proc = subprocess.Popen(["cmd"])\n',
}
]
check_resource_management(files, result)
assert result.has_issues
def test_popen_with_communicate_no_warning(self) -> None:
result = ReviewResult()
files = [
{
"filename": "src/devx/executor.py",
"patch": '@@ -1,1 +1,1 @@\n+out, err = subprocess.Popen(["cmd"], stdout=PIPE).communicate()\n',
}
]
check_resource_management(files, result)
assert not result.has_issues
def test_comment_skipped(self) -> None:
result = ReviewResult()
files = [{"filename": "src/devx/cli.py", "patch": '@@ -1,1 +1,1 @@\n+# f = open("file.txt")\n'}]
check_resource_management(files, result)
assert not result.has_issues
def test_empty_patch_skipped(self) -> None:
result = ReviewResult()
files = [{"filename": "src/devx/cli.py", "patch": ""}]
check_resource_management(files, result)
assert not result.has_issues
def test_non_python_file_skipped(self) -> None:
result = ReviewResult()
files = [{"filename": "docs/config.md", "patch": '@@ -1,1 +1,1 @@\n+f = open("file.txt")\n'}]
check_resource_management(files, result)
assert not result.has_issues
def test_clean_code_adds_ok_summary(self) -> None:
result = ReviewResult()
files = [
{
"filename": "src/devx/cli.py",
"patch": '@@ -1,1 +1,1 @@\n+with open("file.txt") as f:\n+ data = f.read()\n',
}
]
check_resource_management(files, result)
assert any("Resource management: OK" in s for s in result.summary)
class TestCheckFunctionLength:
def test_long_function_triggers_warning(self) -> None:
result = ReviewResult()
# Create a patch with a function that adds > 50 lines
added_lines = "\n".join(f"+ x = {i}" for i in range(55))
patch = f"@@ -10,3 +10,59 @@\n+def foo():\n+ pass\n{added_lines}\n"
files = [{"filename": "src/devx/cli.py", "patch": patch}]
check_function_length(files, result)
assert result.has_issues
assert "foo" in result.issues[0]["body"]
def test_short_function_no_warning(self) -> None:
result = ReviewResult()
patch = "@@ -10,3 +10,8 @@\n def foo():\n pass\n+ x = 1\n+ y = 2\n+ z = 3\n"
files = [{"filename": "src/devx/cli.py", "patch": patch}]
check_function_length(files, result)
assert not result.has_issues
def test_empty_patch_skipped(self) -> None:
result = ReviewResult()
files = [{"filename": "src/devx/cli.py", "patch": ""}]
check_function_length(files, result)
assert not result.has_issues
def test_non_python_file_skipped(self) -> None:
result = ReviewResult()
added_lines = "\n".join(f"+ x = {i}" for i in range(55))
patch = f"@@ -10,3 +10,59 @@\n+def foo():\n+ pass\n{added_lines}\n"
files = [{"filename": "README.md", "patch": patch}]
check_function_length(files, result)
assert not result.has_issues
def test_multiple_functions_resets_count(self) -> None:
"""Two short functions back-to-back should not trigger the length warning."""
result = ReviewResult()
patch = "@@ -10,3 +10,15 @@\n+def foo():\n+ x = 1\n+def bar():\n+ y = 2\n"
files = [{"filename": "src/devx/cli.py", "patch": patch}]
check_function_length(files, result)
assert not result.has_issues
def test_long_function_followed_by_new_hunk(self) -> None:
"""Long function followed by @@ header triggers the warning at hunk boundary."""
result = ReviewResult()
added_lines = "\n".join(f"+ x = {i}" for i in range(55))
patch = (
f"@@ -10,3 +10,59 @@\n+def foo():\n+ pass\n{added_lines}\n@@ -100,3 +100,5 @@\n+def bar():\n+ pass\n"
)
files = [{"filename": "src/devx/cli.py", "patch": patch}]
check_function_length(files, result)
assert result.has_issues
assert "foo" in result.issues[0]["body"]
def test_long_function_followed_by_new_def(self) -> None:
"""Long function followed by another def triggers the warning at def boundary."""
result = ReviewResult()
added_lines = "\n".join(f"+ x = {i}" for i in range(55))
patch = f"@@ -10,3 +10,60 @@\n+def foo():\n+ pass\n{added_lines}\n+def bar():\n+ pass\n"
files = [{"filename": "src/devx/cli.py", "patch": patch}]
check_function_length(files, result)
assert result.has_issues
assert "foo" in result.issues[0]["body"]
class TestCheckDocumentation:
def test_src_changes_without_docs_warns(self) -> None:
result = ReviewResult()
files = [{"filename": "src/devx/cli.py"}]
check_documentation(files, result)
assert any("WARNING" in s for s in result.summary)
def test_src_changes_with_docs_ok(self) -> None:
result = ReviewResult()
files = [{"filename": "src/devx/cli.py"}, {"filename": "docs/user/cli-commands.md"}]
check_documentation(files, result)
assert any("Documentation: OK" in s for s in result.summary)
def test_ansible_changes_without_docs_warns(self) -> None:
result = ReviewResult()
files = [{"filename": "ansible/roles/gitea-runner/tasks/main.yml"}]
check_documentation(files, result)
assert any("WARNING" in s for s in result.summary)
def test_only_doc_changes_ok(self) -> None:
result = ReviewResult()
files = [{"filename": "README.md"}]
check_documentation(files, result)
assert any("Documentation: OK" in s for s in result.summary)
class TestCheckTestCoverage:
def test_src_changes_without_tests_warns(self) -> None:
result = ReviewResult()
files = [{"filename": "src/devx/cli.py"}]
check_test_coverage(files, result)
assert any("WARNING" in s for s in result.summary)
def test_src_changes_with_tests_ok(self) -> None:
result = ReviewResult()
files = [{"filename": "src/devx/cli.py"}, {"filename": "tests/unit/test_cli.py"}]
check_test_coverage(files, result)
assert any("Tests: OK" in s for s in result.summary)
def test_only_test_changes_ok(self) -> None:
result = ReviewResult()
files = [{"filename": "tests/unit/test_cli.py"}]
check_test_coverage(files, result)
assert any("Tests: OK" in s for s in result.summary)
class TestBuildReviewBody:
def test_body_contains_summary(self) -> None:
result = ReviewResult()
result.add_summary("- Architecture compliance: OK")
body = build_review_body(result)
assert "Architecture compliance: OK" in body
assert "Automated PR Review" in body
def test_body_contains_issues(self) -> None:
result = ReviewResult()
result.add_issue("src/foo.py", 10, "bad code")
body = build_review_body(result)
assert "1 issue(s) found" in body
assert "src/foo.py:10" in body
assert "bad code" in body
def test_body_contains_no_issues_message(self) -> None:
result = ReviewResult()
body = build_review_body(result)
assert "No issues found" in body
def test_body_contains_auto_merge_note(self) -> None:
"""Review body must mention auto-merge."""
result = ReviewResult()
body = build_review_body(result)
assert "Auto-merge" in body
class TestRunReview:
@patch("devx.ci.pr_review.GiteaClient")
def test_run_review_with_no_files(self, mock_client_class: MagicMock) -> None:
mock_client = mock_client_class.return_value
mock_client.get_pr_files.return_value = []
result = run_review(mock_client, "42")
assert "No files changed" in result.summary[0]
@patch("devx.ci.pr_review.GiteaClient")
def test_run_review_finds_issues(self, mock_client_class: MagicMock) -> None:
mock_client = mock_client_class.return_value
mock_client.get_pr_files.return_value = [
{
"filename": "src/devx/cli.py",
"patch": "@@ -10,3 +10,4 @@\n def foo():\n pass\n+ print('hello')\n",
}
]
mock_client.get_pr_commits.return_value = [{"commit": {"message": "fix: resolve print issue"}}]
result = run_review(mock_client, "42")
assert result.has_issues
def test_run_review_handles_api_error(self) -> None:
client = MagicMock()
client.get_pr_files.side_effect = APIError(404, "Not found")
result = run_review(client, "42")
assert any("ERROR" in s for s in result.summary)
class TestCheckCommitConventions:
def test_conventional_commit_found(self) -> None:
"""Should report OK when at least one commit is conventional."""
client = MagicMock()
client.get_pr_commits.return_value = [
{"commit": {"message": "fix: resolve bug\n\nDetails"}},
{"commit": {"message": "wip: testing"}},
]
result = ReviewResult()
check_commit_conventions(client, "42", result)
assert any("OK" in s for s in result.summary)
def test_no_conventional_commit(self) -> None:
"""Should warn when no commits are conventional."""
client = MagicMock()
client.get_pr_commits.return_value = [
{"commit": {"message": "updated stuff"}},
{"commit": {"message": "wip: testing"}},
]
result = ReviewResult()
check_commit_conventions(client, "42", result)
assert any("WARNING" in s for s in result.summary)
def test_merge_commits_excluded(self) -> None:
"""Merge commits should be excluded from the check."""
client = MagicMock()
client.get_pr_commits.return_value = [
{"commit": {"message": "Merge branch 'feature' into master"}},
{"commit": {"message": "fix: resolve bug"}},
]
result = ReviewResult()
check_commit_conventions(client, "42", result)
assert any("OK" in s for s in result.summary)
def test_all_merges_and_reverts(self) -> None:
"""Should report OK when all commits are merges/reverts."""
client = MagicMock()
client.get_pr_commits.return_value = [
{"commit": {"message": "Merge branch 'feature' into master"}},
{"commit": {"message": "Revert: bad commit"}},
]
result = ReviewResult()
check_commit_conventions(client, "42", result)
assert any("merges/reverts" in s for s in result.summary)
def test_no_commits(self) -> None:
"""Should report OK when there are no commits."""
client = MagicMock()
client.get_pr_commits.return_value = []
result = ReviewResult()
check_commit_conventions(client, "42", result)
assert any("no commits" in s for s in result.summary)
def test_api_error(self) -> None:
"""Should report ERROR when API call fails."""
client = MagicMock()
client.get_pr_commits.side_effect = APIError(500, "server error")
result = ReviewResult()
check_commit_conventions(client, "42", result)
assert any("ERROR" in s for s in result.summary)
class TestPostReview:
def test_post_review_with_issues(self) -> None:
client = MagicMock()
result = ReviewResult()
result.add_issue("src/foo.py", 10, "bad code")
post_review(client, "42", result)
client.create_review.assert_called_once()
call_args = client.create_review.call_args
assert call_args[1]["event"] == "REQUEST_CHANGES"
assert call_args[1]["comments"] == result.issues
def test_post_review_without_issues_uses_comment_not_approve(self) -> None:
"""Automated review posts COMMENT, not APPROVE (self-approval not allowed)."""
client = MagicMock()
result = ReviewResult()
post_review(client, "42", result)
client.create_review.assert_called_once()
call_args = client.create_review.call_args
assert call_args[1]["event"] == "COMMENT"
assert call_args[1]["comments"] == []
class TestMain:
@patch("devx.ci.pr_review.run_review")
@patch("devx.ci.pr_review.GiteaClient")
def test_dry_run_does_not_post(self, mock_client_class: MagicMock, mock_run: MagicMock) -> None:
mock_run.return_value = ReviewResult()
runner = CliRunner()
result = runner.invoke(main, ["42", "oblachno-oss/grm", "--dry-run"], env={"REPO_TOKEN": "fake"})
assert result.exit_code == 0
assert "[dry-run]" in result.output
mock_client_class.return_value.create_review.assert_not_called()
@patch("devx.ci.pr_review.run_review")
@patch("devx.ci.pr_review.GiteaClient")
def test_post_review_on_success(self, mock_client_class: MagicMock, mock_run: MagicMock) -> None:
mock_run.return_value = ReviewResult()
mock_client_class.return_value.create_review.return_value = {"id": 123}
runner = CliRunner()
result = runner.invoke(main, ["42", "oblachno-oss/grm"], env={"REPO_TOKEN": "fake"})
assert result.exit_code == 0
assert "Review #123" in result.output
mock_client_class.return_value.create_review.assert_called_once()
@patch("devx.ci.pr_review.run_review")
@patch("devx.ci.pr_review.GiteaClient")
def test_self_approval_falls_back_to_comment(self, mock_client_class: MagicMock, mock_run: MagicMock) -> None:
"""If REQUEST_CHANGES fails with 422 (self-approval), fall back to COMMENT."""
mock_run.return_value = ReviewResult()
client = mock_client_class.return_value
client.create_review.side_effect = [
APIError(422, "approve your own pull is not allowed"),
{"id": 124},
]
runner = CliRunner()
result = runner.invoke(main, ["42", "oblachno-oss/grm"], env={"REPO_TOKEN": "fake"})
assert result.exit_code == 0
assert "Review #124" in result.output
assert client.create_review.call_count == 2
@patch("devx.ci.pr_review.run_review")
@patch("devx.ci.pr_review.GiteaClient")
def test_other_api_error_re_raises(self, mock_client_class: MagicMock, mock_run: MagicMock) -> None:
"""Non-approval API errors should re-raise, not fall back."""
mock_run.return_value = ReviewResult()
client = mock_client_class.return_value
client.create_review.side_effect = APIError(500, "Internal server error")
runner = CliRunner()
result = runner.invoke(main, ["42", "oblachno-oss/grm"], env={"REPO_TOKEN": "fake"})
assert result.exit_code != 0
def test_no_token_raises(self) -> None:
runner = CliRunner()
result = runner.invoke(main, ["42", "oblachno-oss/grm"], env={"REPO_TOKEN": ""})
assert result.exit_code != 0
assert "REPO_TOKEN" in result.output
def test_main_module_block() -> None:
import devx.ci.pr_review as pr
with patch.object(pr, "main") as mock_main:
with patch.object(pr, "__name__", "__main__"):
pr.main([])
mock_main.assert_called_once_with([])
+302
View File
@@ -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
+225
View File
@@ -0,0 +1,225 @@
from __future__ import annotations
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from click import ClickException
from click.testing import CliRunner
import devx.ci.push_badges as push_badges
class TestFetchLatestMaster:
def test_fetch_and_reset(self) -> None:
with patch("subprocess.run") as mock_run:
push_badges.fetch_latest_master("master")
# Should call git fetch and git reset --hard
calls = [str(c.args[0]) for c in mock_run.call_args_list]
assert any("fetch" in c for c in calls)
assert any("reset" in c for c in calls)
def test_custom_branch(self) -> None:
with patch("subprocess.run") as mock_run:
push_badges.fetch_latest_master("develop")
calls = [list(c.args[0]) for c in mock_run.call_args_list]
# fetch call should include the branch name
fetch_call = [c for c in calls if "fetch" in c][0]
assert "develop" in fetch_call
# reset call should include origin/develop
reset_call = [c for c in calls if "reset" in c][0]
assert "origin/develop" in reset_call
def test_fetch_failure_raises(self) -> None:
import subprocess
with patch("subprocess.run", side_effect=subprocess.CalledProcessError(1, "git fetch")):
with pytest.raises(subprocess.CalledProcessError):
push_badges.fetch_latest_master()
class TestGenerateBadges:
def test_success(self, tmp_path: Path) -> None:
output_dir = tmp_path / ".badges"
output_dir.mkdir()
(output_dir / "badge1.svg").touch()
with patch("subprocess.run") as mock_run:
push_badges.generate_badges(str(output_dir))
mock_run.assert_called_once()
def test_no_badges_generated(self, tmp_path: Path) -> None:
output_dir = tmp_path / ".badges"
output_dir.mkdir()
with patch("subprocess.run"):
with pytest.raises(ClickException, match="No badge SVG files generated"):
push_badges.generate_badges(str(output_dir))
class TestPushToBadgesBranch:
def test_success(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.chdir(tmp_path)
badges_dir = tmp_path / ".badges"
badges_dir.mkdir()
svg = badges_dir / "badge1.svg"
svg.write_text("<svg></svg>")
sha_result = MagicMock()
sha_result.stdout = "abc123\n"
default_result = MagicMock()
with patch(
"subprocess.run",
side_effect=[default_result] * 7 + [sha_result],
) as mock_run:
sha = push_badges.push_to_badges_branch(str(badges_dir))
# Should have called git config x2, checkout, rm, add, commit, push, rev-parse
assert mock_run.call_count >= 8
# Verify the SVG was copied to cwd
assert (tmp_path / "badge1.svg").exists()
assert sha == "abc123"
class TestUpdateBadgeUrls:
def test_replaces_branch_url(self) -> None:
content = "[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/branch/badges/tests.svg)]"
result = push_badges.update_badge_urls(content, "abc123def456")
assert "raw/commit/abc123def456/tests.svg" in result
assert "raw/branch/badges" not in result
def test_replaces_commit_url(self) -> None:
"""Old commit SHA URLs should be replaced with the new one."""
old_sha = "aabb123456789012345678901234567890123456" # 40 hex chars
new_sha = "ccdd123456789012345678901234567890123456" # 40 hex chars
content = f"[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/{old_sha}/tests.svg)]"
result = push_badges.update_badge_urls(content, new_sha)
assert f"raw/commit/{new_sha}/tests.svg" in result
assert old_sha not in result
def test_no_badge_urls(self) -> None:
content = "# No badges here\nJust text."
result = push_badges.update_badge_urls(content, "abc123")
assert result == content
def test_multiple_badges(self) -> None:
content = (
"[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/branch/badges/coverage.svg)]\n"
"[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/branch/badges/tests.svg)]\n"
"[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/branch/badges/version.svg)]"
)
result = push_badges.update_badge_urls(content, "abc123def456")
assert result.count("raw/commit/abc123def456/") == 3
assert "raw/branch/badges" not in result
def test_preserves_non_badge_urls(self) -> None:
content = "[![CI](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions/workflows/ci.yml/badge.svg)]"
result = push_badges.update_badge_urls(content, "abc123")
assert result == content
class TestUpdateReadmeWithBadgeSha:
def test_updates_readme(self, tmp_path: Path) -> None:
readme = tmp_path / "README.md"
readme.write_text("[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/branch/badges/tests.svg)]")
with patch("subprocess.run"):
push_badges.update_readme_with_badge_sha("abc123def456", repo_root=tmp_path)
content = readme.read_text()
assert "raw/commit/abc123def456/tests.svg" in content
def test_no_badge_urls_skips_commit(self, tmp_path: Path) -> None:
readme = tmp_path / "README.md"
readme.write_text("# No badges here")
with patch("subprocess.run") as mock_run:
push_badges.update_readme_with_badge_sha("abc123def456", repo_root=tmp_path)
# Should checkout master but not commit/push
calls = [list(c.args[0]) for c in mock_run.call_args_list]
assert not any("commit" in c for c in calls)
assert not any("push" in c for c in calls)
def test_missing_readme_skips(self, tmp_path: Path) -> None:
with patch("subprocess.run"):
push_badges.update_readme_with_badge_sha("abc123def456", repo_root=tmp_path)
# Should not raise
class TestMain:
def test_success(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.chdir(tmp_path)
badges_dir = tmp_path / ".badges"
badges_dir.mkdir()
(badges_dir / "badge1.svg").touch()
runner = CliRunner()
with patch("subprocess.run"):
result = runner.invoke(push_badges.main, ["--output-dir", str(badges_dir), "--no-readme-update"])
assert result.exit_code == 0
def test_no_badges(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.chdir(tmp_path)
badges_dir = tmp_path / ".badges"
badges_dir.mkdir()
runner = CliRunner()
with patch("subprocess.run"):
result = runner.invoke(push_badges.main, ["--output-dir", str(badges_dir), "--no-readme-update"])
assert result.exit_code != 0
def test_custom_branch(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.chdir(tmp_path)
badges_dir = tmp_path / ".badges"
badges_dir.mkdir()
(badges_dir / "badge1.svg").touch()
runner = CliRunner()
with patch("subprocess.run") as mock_run:
result = runner.invoke(
push_badges.main,
["--output-dir", str(badges_dir), "--branch", "develop", "--no-readme-update"],
)
assert result.exit_code == 0
# Verify fetch was called with the custom branch
calls = [list(c.args[0]) for c in mock_run.call_args_list]
fetch_call = [c for c in calls if "fetch" in c][0]
assert "develop" in fetch_call
def test_fetch_failure(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.chdir(tmp_path)
import subprocess
runner = CliRunner()
with patch("subprocess.run", side_effect=subprocess.CalledProcessError(1, "git fetch")):
result = runner.invoke(push_badges.main, ["--no-readme-update"])
assert result.exit_code != 0
def test_readme_update_called_by_default(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Without --no-readme-update, update_readme_with_badge_sha is called."""
monkeypatch.chdir(tmp_path)
badges_dir = tmp_path / ".badges"
badges_dir.mkdir()
(badges_dir / "badge1.svg").touch()
runner = CliRunner()
with (
patch("subprocess.run"),
patch("devx.ci.push_badges.update_readme_with_badge_sha") as mock_update,
):
result = runner.invoke(push_badges.main, ["--output-dir", str(badges_dir)])
assert result.exit_code == 0
mock_update.assert_called_once()
def test_readme_update_skipped_with_flag(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""With --no-readme-update, update_readme_with_badge_sha is not called."""
monkeypatch.chdir(tmp_path)
badges_dir = tmp_path / ".badges"
badges_dir.mkdir()
(badges_dir / "badge1.svg").touch()
runner = CliRunner()
with (
patch("subprocess.run"),
patch("devx.ci.push_badges.update_readme_with_badge_sha") as mock_update,
):
result = runner.invoke(push_badges.main, ["--output-dir", str(badges_dir), "--no-readme-update"])
assert result.exit_code == 0
mock_update.assert_not_called()
+606
View File
@@ -0,0 +1,606 @@
"""Unit tests for scripts/ci/release.py."""
from unittest.mock import MagicMock, patch
import click
import pytest
from click.testing import CliRunner
from devx.ci.release import (
commit_release_changes,
create_and_push_tag,
get_bumped_version,
get_changelog,
get_latest_tag,
has_unreleased_changes,
main,
run_cmd,
run_tests,
tag_exists,
update_changelog,
update_init_version,
)
class TestRunCmd:
@patch("devx.ci.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("devx.ci.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("devx.ci.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("devx.ci.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("devx.ci.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 TestTagExists:
@patch("devx.ci.release.run_cmd")
def test_exists(self, mock_run_cmd: MagicMock) -> None:
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="v0.1.0\n")
assert tag_exists("v0.1.0") is True
@patch("devx.ci.release.run_cmd")
def test_not_exists(self, mock_run_cmd: MagicMock) -> None:
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="")
assert tag_exists("v0.2.0") is False
class TestGetBumpedVersion:
@patch("devx.ci.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("devx.ci.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("devx.ci.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("devx.ci.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("devx.ci.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("devx.ci.release.get_latest_tag")
def test_no_tags_has_changes(self, mock_latest: MagicMock) -> None:
mock_latest.return_value = ""
assert has_unreleased_changes() is True
@patch("devx.ci.release.get_latest_tag")
@patch("devx.ci.release.run_cmd")
def test_no_commits_since_tag(self, mock_run_cmd: MagicMock, mock_latest: MagicMock) -> None:
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="")
mock_latest.return_value = "v0.2.0"
assert has_unreleased_changes() is False
@patch("devx.ci.release.get_latest_tag")
@patch("devx.ci.release.run_cmd")
def test_commits_since_tag(self, mock_run_cmd: MagicMock, mock_latest: MagicMock) -> None:
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="abc123 fix: bug\n")
mock_latest.return_value = "v0.2.0"
assert has_unreleased_changes() is True
@patch("devx.ci.release.get_latest_tag")
@patch("devx.ci.release.run_cmd")
def test_git_log_fails_returns_false(self, mock_run_cmd: MagicMock, mock_latest: MagicMock) -> None:
mock_run_cmd.return_value = MagicMock(returncode=1, stdout="")
mock_latest.return_value = "v0.2.0"
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("devx.ci.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("devx.ci.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("devx.ci.release.INIT_FILE", str(init_file))
with pytest.raises(click.ClickException):
update_init_version("0.2.0")
class TestUpdateChangelog:
def test_creates_new_file(self, tmp_path, monkeypatch) -> None:
changelog_file = tmp_path / "CHANGELOG.md"
monkeypatch.setattr("devx.ci.release.CHANGELOG_FILE", str(changelog_file))
update_changelog("## [0.2.0] - 2026-06-21\n\n### Features\n- new thing")
content = changelog_file.read_text()
assert "## [0.2.0]" in content
assert "new thing" in content
def test_prepends_to_existing(self, tmp_path, monkeypatch) -> None:
changelog_file = tmp_path / "CHANGELOG.md"
changelog_file.write_text("# Changelog\n\n## [0.1.0] - 2026-06-20\n\n### Features\n- old thing\n")
monkeypatch.setattr("devx.ci.release.CHANGELOG_FILE", str(changelog_file))
update_changelog("## [0.2.0] - 2026-06-21\n\n### Features\n- new thing")
content = changelog_file.read_text()
assert "# Changelog" in content
# New version should be before old version
assert content.index("0.2.0") < content.index("0.1.0")
assert "new thing" in content
assert "old thing" in content
def test_appends_when_no_version_sections(self, tmp_path, monkeypatch) -> None:
changelog_file = tmp_path / "CHANGELOG.md"
changelog_file.write_text("# Changelog\n\nSome intro text.\n")
monkeypatch.setattr("devx.ci.release.CHANGELOG_FILE", str(changelog_file))
update_changelog("## [0.2.0] - 2026-06-21\n\n### Features\n- new thing")
content = changelog_file.read_text()
assert "Some intro text" in content
assert "## [0.2.0]" in content
def test_strips_git_cliff_header(self, tmp_path, monkeypatch) -> None:
"""git-cliff output includes a header — should be stripped before inserting."""
changelog_file = tmp_path / "CHANGELOG.md"
changelog_file.write_text("# Changelog\n\n## [0.1.0] - 2026-06-20\n\n### Features\n- old thing\n")
monkeypatch.setattr("devx.ci.release.CHANGELOG_FILE", str(changelog_file))
# Simulate git-cliff output with header
cliff_output = "# Changelog\n\nAll notable changes...\n\n## [0.2.0] - 2026-06-21\n\n### Features\n- new thing"
update_changelog(cliff_output)
content = changelog_file.read_text()
# Header should appear only once (from the existing file)
assert content.count("# Changelog") == 1
assert "## [0.2.0]" in content
assert "new thing" in content
def test_strips_header_when_creating_new_file(self, tmp_path, monkeypatch) -> None:
"""When creating a new file, strip the git-cliff header."""
changelog_file = tmp_path / "CHANGELOG.md"
monkeypatch.setattr("devx.ci.release.CHANGELOG_FILE", str(changelog_file))
cliff_output = "# Changelog\n\nAll notable changes...\n\n## [0.2.0] - 2026-06-21\n\n### Features\n- new thing"
update_changelog(cliff_output)
content = changelog_file.read_text()
assert "# Changelog" not in content
assert "## [0.2.0]" in content
class TestCommitReleaseChanges:
@patch("devx.ci.release.run_cmd")
def test_commits_when_changes(self, mock_run_cmd: MagicMock) -> None:
# git diff --cached --quiet returns 1 (changes exist)
mock_run_cmd.return_value = MagicMock(returncode=1, stdout="", stderr="")
result = commit_release_changes("0.2.0")
assert result is True
calls = [c.args[0] for c in mock_run_cmd.call_args_list]
assert ["git", "add", "src/devx/__init__.py", "CHANGELOG.md"] in calls
assert ["git", "commit", "--no-verify", "-m", "release: v0.2.0 [skip ci]"] in calls
@patch("devx.ci.release.run_cmd")
def test_skips_when_no_changes(self, mock_run_cmd: MagicMock) -> None:
# git diff --cached --quiet returns 0 (no changes)
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="", stderr="")
result = commit_release_changes("0.1.0")
assert result is False
calls = [c.args[0] for c in mock_run_cmd.call_args_list]
assert ["git", "commit", "--no-verify", "-m", "release: v0.1.0"] not in calls
class TestCreateAndPushTag:
@patch("devx.ci.release.tag_exists", return_value=False)
@patch("devx.ci.release.run_cmd")
def test_creates_tag(self, mock_run_cmd: MagicMock, mock_tag_exists: MagicMock) -> None:
create_and_push_tag("0.2.0", "changelog", dry_run=False)
calls = [c.args[0] for c in mock_run_cmd.call_args_list]
assert ["git", "tag", "-a", "v0.2.0", "-m", "Release v0.2.0\n\nchangelog"] in calls
assert ["git", "push", "origin", "v0.2.0"] in calls
@patch("devx.ci.release.tag_exists", return_value=False)
@patch("devx.ci.release.run_cmd")
def test_dry_run_no_push(self, mock_run_cmd: MagicMock, mock_tag_exists: MagicMock) -> None:
result = create_and_push_tag("0.2.0", "changelog", dry_run=True)
assert result is True
for call in mock_run_cmd.call_args_list:
assert call.args[0][0:2] != ["git", "push"]
assert call.args[0][0:2] != ["git", "tag"]
@patch("devx.ci.release.tag_exists", return_value=True)
@patch("devx.ci.release.run_cmd")
def test_tag_exists_skips_creation(self, mock_run_cmd: MagicMock, mock_tag_exists: MagicMock) -> None:
result = create_and_push_tag("0.1.0", "changelog", dry_run=False)
assert result is False
# Should not create tag, but should ensure it's pushed
calls = [c.args[0] for c in mock_run_cmd.call_args_list]
assert ["git", "tag", "-a"] not in [c[:3] for c in calls]
assert ["git", "push", "origin", "v0.1.0"] in calls
@patch("devx.ci.release.tag_exists", return_value=True)
@patch("devx.ci.release.run_cmd")
def test_tag_exists_dry_run_no_push(self, mock_run_cmd: MagicMock, mock_tag_exists: MagicMock) -> None:
result = create_and_push_tag("0.1.0", "changelog", dry_run=True)
assert result is False
# No git commands at all in dry-run when tag exists
mock_run_cmd.assert_not_called()
class TestRunTests:
@patch("devx.ci.release.run_cmd")
def test_lint_and_tests_pass(self, mock_run_cmd: MagicMock) -> None:
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="", stderr="")
run_tests() # should not raise
@patch("devx.ci.release.run_cmd")
def test_lint_fails_raises(self, mock_run_cmd: MagicMock) -> None:
mock_run_cmd.return_value = MagicMock(returncode=1, stdout="", stderr="lint error")
with pytest.raises(click.ClickException, match="Lint failed"):
run_tests()
@patch("devx.ci.release.run_cmd")
def test_tests_fail_raises(self, mock_run_cmd: MagicMock) -> None:
mock_run_cmd.side_effect = [
MagicMock(returncode=0, stdout="", stderr=""), # lint passes
MagicMock(returncode=1, stdout="", stderr="test failure"), # tests fail
]
with pytest.raises(click.ClickException, match="Tests failed"):
run_tests()
class TestMain:
@patch.dict("os.environ", {})
@patch("devx.ci.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("devx.ci.release.has_user_facing_changes", return_value=False)
@patch("devx.ci.release.run_cmd")
def test_dry_run_on_non_master_warns(self, mock_run_cmd: MagicMock, mock_uf: MagicMock) -> None:
"""Dry-run mode should not fail on non-master branches."""
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="feature-branch\n", stderr="")
runner = CliRunner()
result = runner.invoke(main, ["--dry-run"])
assert result.exit_code == 0
assert "Dry-run mode" in result.output
@patch.dict("os.environ", {})
@patch("devx.ci.release.has_user_facing_changes", return_value=True)
@patch("devx.ci.release.run_cmd")
def test_release_lock_skips_when_head_is_release_commit(self, mock_run_cmd: MagicMock, mock_uf: MagicMock) -> None:
"""If HEAD is already a release commit, should skip to prevent duplicate releases."""
# First call: git rev-parse (master), second: git log -1 (release commit)
mock_run_cmd.side_effect = [
MagicMock(returncode=0, stdout="master\n", stderr=""),
MagicMock(returncode=0, stdout="release: v0.5.0\n", stderr=""),
]
runner = CliRunner()
result = runner.invoke(main, [])
assert result.exit_code == 0
assert "already a release commit" in result.output
assert "Skipping" in result.output
@patch.dict("os.environ", {})
@patch("devx.ci.release.has_user_facing_changes", return_value=True)
@patch("devx.ci.release.has_unreleased_changes", return_value=False)
@patch("devx.ci.release.get_bumped_version", return_value="0.2.0")
@patch("devx.ci.release.run_cmd")
def test_no_unreleased_changes(
self,
mock_run_cmd: MagicMock,
mock_bumped: MagicMock,
mock_has: MagicMock,
mock_user: 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 "No unreleased changes" in result.output
@patch.dict("os.environ", {})
@patch("devx.ci.release.has_user_facing_changes", return_value=True)
@patch("devx.ci.release.create_and_push_tag")
@patch("devx.ci.release.commit_release_changes")
@patch("devx.ci.release.update_changelog")
@patch("devx.ci.release.update_init_version")
@patch("devx.ci.release.get_changelog", return_value="")
@patch("devx.ci.release.get_latest_tag", return_value="v0.1.0")
@patch("devx.ci.release.get_bumped_version", return_value="0.2.0")
@patch("devx.ci.release.has_unreleased_changes", return_value=True)
@patch("devx.ci.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_update_changelog: MagicMock,
mock_commit: MagicMock,
mock_tag: MagicMock,
mock_user: 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("devx.ci.release.has_user_facing_changes", return_value=True)
@patch("devx.ci.release.create_and_push_tag")
@patch("devx.ci.release.commit_release_changes")
@patch("devx.ci.release.update_changelog")
@patch("devx.ci.release.update_init_version")
@patch("devx.ci.release.get_changelog", return_value="changelog")
@patch("devx.ci.release.get_latest_tag", return_value="v0.1.0")
@patch("devx.ci.release.get_bumped_version", return_value="0.2.0")
@patch("devx.ci.release.has_unreleased_changes", return_value=True)
@patch("devx.ci.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_update_changelog: MagicMock,
mock_commit: MagicMock,
mock_tag: MagicMock,
mock_user: 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_update_changelog.assert_not_called()
mock_commit.assert_not_called()
mock_tag.assert_not_called()
@patch.dict("os.environ", {})
@patch("devx.ci.release.get_latest_tag", return_value="v0.3.0")
@patch("devx.ci.release.has_user_facing_changes", return_value=False)
@patch("devx.ci.release.run_cmd")
def test_skips_release_when_workflow_only(
self,
mock_run_cmd: MagicMock,
mock_user_facing: MagicMock,
mock_latest: MagicMock,
) -> None:
"""Release is skipped when only workflow/infra files changed."""
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="")
runner = CliRunner()
result = runner.invoke(main, [])
assert result.exit_code == 0
assert "No user-facing changes" in result.output
assert "Skipping release" in result.output
@patch.dict("os.environ", {})
@patch("devx.ci.release.has_user_facing_changes", return_value=True)
@patch("devx.ci.release.run_tests")
@patch("devx.ci.release.create_and_push_tag", return_value=True)
@patch("devx.ci.release.commit_release_changes", return_value=True)
@patch("devx.ci.release.update_changelog")
@patch("devx.ci.release.update_init_version")
@patch("devx.ci.release.get_changelog", return_value="changelog")
@patch("devx.ci.release.get_latest_tag", return_value="v0.1.0")
@patch("devx.ci.release.get_bumped_version", return_value="0.2.0")
@patch("devx.ci.release.has_unreleased_changes", return_value=True)
@patch("devx.ci.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_update_changelog: MagicMock,
mock_commit: MagicMock,
mock_tag: MagicMock,
mock_run_tests: MagicMock,
mock_user: 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_update_changelog.assert_called_once_with("changelog")
mock_run_tests.assert_called_once()
mock_commit.assert_called_once_with("0.2.0")
mock_tag.assert_called_once_with("0.2.0", "changelog", False)
@patch.dict("os.environ", {})
@patch("devx.ci.release.has_user_facing_changes", return_value=True)
@patch("devx.ci.release.run_tests")
@patch("devx.ci.release.create_and_push_tag", return_value=False)
@patch("devx.ci.release.commit_release_changes", return_value=False)
@patch("devx.ci.release.update_changelog")
@patch("devx.ci.release.update_init_version")
@patch("devx.ci.release.get_changelog", return_value="changelog")
@patch("devx.ci.release.get_latest_tag", return_value="v0.1.0")
@patch("devx.ci.release.get_bumped_version", return_value="0.1.0")
@patch("devx.ci.release.has_unreleased_changes", return_value=True)
@patch("devx.ci.release.run_cmd")
def test_full_flow_tag_exists(
self,
mock_run_cmd: MagicMock,
mock_has: MagicMock,
mock_bumped: MagicMock,
mock_latest: MagicMock,
mock_changelog: MagicMock,
mock_update_init: MagicMock,
mock_update_changelog: MagicMock,
mock_commit: MagicMock,
mock_tag: MagicMock,
mock_run_tests: MagicMock,
mock_user: MagicMock,
) -> None:
"""When tag already exists, still update files but report existing tag."""
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="")
runner = CliRunner()
result = runner.invoke(main, [])
assert result.exit_code == 0
assert "already existed" in result.output
mock_tag.assert_called_once_with("0.1.0", "changelog", False)
@patch.dict("os.environ", {})
@patch("devx.ci.release.has_user_facing_changes", return_value=True)
@patch("devx.ci.release.create_and_push_tag", return_value=True)
@patch("devx.ci.release.commit_release_changes", return_value=True)
@patch("devx.ci.release.update_changelog")
@patch("devx.ci.release.update_init_version")
@patch("devx.ci.release.get_changelog", return_value="changelog")
@patch("devx.ci.release.get_latest_tag", return_value="v0.1.0")
@patch("devx.ci.release.get_bumped_version", return_value="0.2.0")
@patch("devx.ci.release.has_unreleased_changes", return_value=True)
@patch("devx.ci.release.run_cmd")
def test_full_flow_skip_tests(
self,
mock_run_cmd: MagicMock,
mock_has: MagicMock,
mock_bumped: MagicMock,
mock_latest: MagicMock,
mock_changelog: MagicMock,
mock_update_init: MagicMock,
mock_update_changelog: MagicMock,
mock_commit: MagicMock,
mock_tag: MagicMock,
mock_user: MagicMock,
) -> None:
"""--skip-tests bypasses test verification."""
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="")
runner = CliRunner()
result = runner.invoke(main, ["--skip-tests"])
assert result.exit_code == 0
assert "WARNING: --skip-tests" in result.output
# run_tests should NOT be called — verify no "make lint-ruff" or "make pytest-cov" calls
make_calls = [c.args[0] for c in mock_run_cmd.call_args_list if c.args[0][:1] == ["make"]]
assert make_calls == []
@patch.dict("os.environ", {})
@patch("devx.ci.release.has_user_facing_changes", return_value=True)
@patch("devx.ci.release.create_and_push_tag")
@patch("devx.ci.release.commit_release_changes")
@patch("devx.ci.release.update_changelog")
@patch("devx.ci.release.update_init_version")
@patch("devx.ci.release.get_changelog", return_value="changelog")
@patch("devx.ci.release.get_latest_tag", return_value="v0.1.0")
@patch("devx.ci.release.get_bumped_version", return_value="0.2.0")
@patch("devx.ci.release.has_unreleased_changes", return_value=True)
@patch("devx.ci.release.run_cmd")
def test_tests_fail_aborts_before_tag(
self,
mock_run_cmd: MagicMock,
mock_has: MagicMock,
mock_bumped: MagicMock,
mock_latest: MagicMock,
mock_changelog: MagicMock,
mock_update_init: MagicMock,
mock_update_changelog: MagicMock,
mock_commit: MagicMock,
mock_tag: MagicMock,
mock_user: MagicMock,
) -> None:
"""If tests fail, release aborts — no commit, no tag."""
# Calls: git rev-parse (master), git log -1 (release lock check),
# make lint-ruff (success), make pytest-cov (failure)
mock_run_cmd.side_effect = [
MagicMock(returncode=0, stdout="master\n", stderr=""),
MagicMock(returncode=0, stdout="DEVX-50 fix: something\n", stderr=""),
MagicMock(returncode=0, stdout="", stderr=""),
MagicMock(returncode=1, stdout="", stderr="test failure"),
]
runner = CliRunner()
result = runner.invoke(main, [])
assert result.exit_code != 0
assert "Tests failed" in result.output
mock_commit.assert_not_called()
mock_tag.assert_not_called()
@patch.dict("os.environ", {})
@patch("devx.ci.release.has_user_facing_changes", return_value=True)
@patch("devx.ci.release.create_and_push_tag")
@patch("devx.ci.release.commit_release_changes")
@patch("devx.ci.release.update_changelog")
@patch("devx.ci.release.update_init_version")
@patch("devx.ci.release.get_changelog", return_value="changelog")
@patch("devx.ci.release.get_latest_tag", return_value="v0.1.0")
@patch("devx.ci.release.get_bumped_version", return_value="0.2.0")
@patch("devx.ci.release.has_unreleased_changes", return_value=True)
@patch("devx.ci.release.run_cmd")
def test_lint_fail_aborts_before_tag(
self,
mock_run_cmd: MagicMock,
mock_has: MagicMock,
mock_bumped: MagicMock,
mock_latest: MagicMock,
mock_changelog: MagicMock,
mock_update_init: MagicMock,
mock_update_changelog: MagicMock,
mock_commit: MagicMock,
mock_tag: MagicMock,
mock_user: MagicMock,
) -> None:
"""If lint fails, release aborts — no commit, no tag."""
# Calls: git rev-parse (master), git log -1 (release lock check),
# make lint-ruff (failure)
mock_run_cmd.side_effect = [
MagicMock(returncode=0, stdout="master\n", stderr=""),
MagicMock(returncode=0, stdout="DEVX-50 fix: something\n", stderr=""),
MagicMock(returncode=1, stdout="", stderr="lint error"),
]
runner = CliRunner()
result = runner.invoke(main, [])
assert result.exit_code != 0
assert "Lint failed" in result.output
mock_commit.assert_not_called()
mock_tag.assert_not_called()
+169
View File
@@ -0,0 +1,169 @@
"""Unit tests for devx.tools.setup."""
import subprocess
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from click.testing import CliRunner
from devx.tools.setup import (
_install_pre_commit_hooks,
_install_python_deps,
_run,
_verify,
main,
)
class TestRun:
@patch("devx.tools.setup.subprocess.run")
def test_run_success(self, mock_run: MagicMock) -> None:
_run(["echo", "hello"])
mock_run.assert_called_once_with(["echo", "hello"], check=True)
@patch("devx.tools.setup.subprocess.run")
def test_run_failure_raises(self, mock_run: MagicMock) -> None:
mock_run.side_effect = subprocess.CalledProcessError(1, ["echo", "hello"])
with pytest.raises(subprocess.CalledProcessError):
_run(["echo", "hello"])
class TestInstallPythonDeps:
@patch("devx.tools.setup._run")
def test_install_dev(self, mock_run: MagicMock) -> None:
_install_python_deps(".venv/bin", "dev")
mock_run.assert_called_once_with([".venv/bin/pip", "install", "-e", ".[dev]"])
@patch("devx.tools.setup._run")
def test_install_ci(self, mock_run: MagicMock) -> None:
_install_python_deps(".venv/bin", "ci")
mock_run.assert_called_once_with([".venv/bin/pip", "install", "-e", ".[ci]"])
@patch("devx.tools.setup._run")
def test_install_custom_extras(self, mock_run: MagicMock) -> None:
_install_python_deps(".venv/bin", "ci,lint")
mock_run.assert_called_once_with([".venv/bin/pip", "install", "-e", ".[ci,lint]"])
class TestInstallPreCommitHooks:
@patch("devx.tools.setup._run")
def test_installs_all_hook_types(self, mock_run: MagicMock) -> None:
_install_pre_commit_hooks(".venv/bin")
assert mock_run.call_count == 3
hook_types = [call.args[0][3] for call in mock_run.call_args_list]
assert "pre-commit" in hook_types
assert "commit-msg" in hook_types
assert "pre-push" in hook_types
class TestVerify:
@patch("devx.tools.setup.subprocess.run")
def test_verify_runs_tools(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(returncode=0, stdout="devx 0.1.0", stderr="")
_verify(".venv/bin")
# Should try to run --version for devx and pre-commit
assert mock_run.call_count >= 1
@patch("devx.tools.setup.subprocess.run")
def test_verify_handles_missing_tools(self, mock_run: MagicMock) -> None:
mock_run.side_effect = FileNotFoundError("not found")
_verify(".venv/bin") # Should not raise
@patch("devx.tools.setup.subprocess.run")
def test_verify_handles_timeout(self, mock_run: MagicMock) -> None:
mock_run.side_effect = subprocess.TimeoutExpired(cmd="devx", timeout=10)
_verify(".venv/bin") # Should not raise
class TestMain:
@patch("devx.tools.setup._verify")
@patch("devx.tools.setup._install_pre_commit_hooks")
@patch("devx.tools.setup._install_python_deps")
def test_main_success(
self,
mock_install_deps: MagicMock,
mock_install_hooks: MagicMock,
mock_verify: MagicMock,
tmp_path: Path,
) -> None:
bin_dir = tmp_path / "bin"
bin_dir.mkdir()
runner = CliRunner()
result = runner.invoke(main, ["--bin", str(bin_dir)])
assert result.exit_code == 0
mock_install_deps.assert_called_once()
mock_install_hooks.assert_called_once()
mock_verify.assert_called_once()
@patch("devx.tools.setup._verify")
@patch("devx.tools.setup._install_pre_commit_hooks")
@patch("devx.tools.setup._install_python_deps")
def test_main_no_pre_commit(
self,
mock_install_deps: MagicMock,
mock_install_hooks: MagicMock,
mock_verify: MagicMock,
tmp_path: Path,
) -> None:
bin_dir = tmp_path / "bin"
bin_dir.mkdir()
runner = CliRunner()
result = runner.invoke(main, ["--bin", str(bin_dir), "--no-pre-commit"])
assert result.exit_code == 0
mock_install_deps.assert_called_once()
mock_install_hooks.assert_not_called()
@patch("devx.tools.setup._verify")
@patch("devx.tools.setup._install_pre_commit_hooks")
@patch("devx.tools.setup._install_python_deps")
def test_main_custom_extras(
self,
mock_install_deps: MagicMock,
mock_install_hooks: MagicMock,
mock_verify: MagicMock,
tmp_path: Path,
) -> None:
bin_dir = tmp_path / "bin"
bin_dir.mkdir()
runner = CliRunner()
result = runner.invoke(main, ["--bin", str(bin_dir), "--extras", "ci"])
assert result.exit_code == 0
mock_install_deps.assert_called_once_with(str(bin_dir), "ci")
@patch("devx.tools.setup._verify")
@patch("devx.tools.setup._install_pre_commit_hooks")
@patch("devx.tools.setup._install_python_deps")
def test_main_no_tea_login_noop(
self,
mock_install_deps: MagicMock,
mock_install_hooks: MagicMock,
mock_verify: MagicMock,
tmp_path: Path,
) -> None:
"""--no-tea-login is a no-op (kept for backwards compatibility)."""
bin_dir = tmp_path / "bin"
bin_dir.mkdir()
runner = CliRunner()
result = runner.invoke(main, ["--bin", str(bin_dir), "--no-tea-login"])
assert result.exit_code == 0
def test_main_missing_bin_dir(self) -> None:
runner = CliRunner()
result = runner.invoke(main, ["--bin", "/nonexistent/path"])
assert result.exit_code != 0
assert "Bin directory not found" in result.output
def test_main_module_block(tmp_path: Path) -> None:
"""Test the __main__ block execution."""
bin_dir = tmp_path / "bin"
bin_dir.mkdir()
with patch.dict("os.environ", {}, clear=True):
with patch("devx.tools.setup._install_python_deps") as mock_deps:
with patch("devx.tools.setup._install_pre_commit_hooks"):
with patch("devx.tools.setup._verify"):
runner = CliRunner()
result = runner.invoke(main, ["--bin", str(bin_dir)])
assert result.exit_code == 0
mock_deps.assert_called_once()
+483
View File
@@ -0,0 +1,483 @@
"""Unit tests for scripts/ci/sync_wiki.py."""
import base64
import json
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from click.testing import CliRunner
from devx.ci.sync_wiki import (
decode_content,
encode_content,
fetch_page_content,
list_wiki_pages,
load_mapping,
main,
read_doc_content,
sync_page,
verify_wiki_integrity,
verify_wiki_page,
)
class TestEncodeContent:
def test_encodes_utf8_to_base64(self) -> None:
result = encode_content("# Hello World")
assert result == base64.b64encode(b"# Hello World").decode("ascii")
def test_encodes_empty_string(self) -> None:
assert encode_content("") == ""
def test_encodes_unicode(self) -> None:
result = encode_content("# Café — résumé")
decoded = base64.b64decode(result).decode("utf-8")
assert decoded == "# Café — résumé"
class TestDecodeContent:
def test_decodes_base64_to_utf8(self) -> None:
encoded = base64.b64encode(b"# Hello").decode("ascii")
assert decode_content(encoded) == "# Hello"
def test_empty_string_returns_empty(self) -> None:
assert decode_content("") == ""
def test_roundtrip(self) -> None:
original = "# Wiki Page\n\nContent with **markdown**."
encoded = encode_content(original)
assert decode_content(encoded) == original
class TestLoadMapping:
def test_loads_mapping(self, tmp_path: Path) -> None:
mapping_file = tmp_path / "mapping.json"
mapping_file.write_text(json.dumps({"user/getting-started.md": "Getting-Started"}))
with patch("devx.ci.sync_wiki.MAPPING_FILE", mapping_file):
result = load_mapping()
assert result == {"user/getting-started.md": "Getting-Started"}
def test_missing_mapping_raises(self, tmp_path: Path) -> None:
with patch("devx.ci.sync_wiki.MAPPING_FILE", tmp_path / "nonexistent.json"):
with pytest.raises(FileNotFoundError):
load_mapping()
class TestReadDocContent:
def test_reads_file(self, tmp_path: Path) -> None:
docs_dir = tmp_path / "docs"
docs_dir.mkdir()
(docs_dir / "test.md").write_text("# Test\n\nContent")
with patch("devx.ci.sync_wiki.DOCS_DIR", docs_dir):
content = read_doc_content("test.md")
assert content == "# Test\n\nContent"
def test_missing_file_raises(self, tmp_path: Path) -> None:
with patch("devx.ci.sync_wiki.DOCS_DIR", tmp_path):
with pytest.raises(FileNotFoundError):
read_doc_content("nonexistent.md")
class TestListWikiPages:
def test_returns_empty_on_api_error(self) -> None:
from devx.exceptions import APIError
client = MagicMock()
client._request.side_effect = APIError(404, "not found")
result = list_wiki_pages(client)
assert result == {}
def test_returns_page_dict(self) -> None:
client = MagicMock()
client._request.return_value.json.return_value = [
{"title": "Home", "sub_url": "Home"},
{"title": "Getting-Started", "sub_url": "Getting-Started.-"},
]
result = list_wiki_pages(client)
assert result == {"Home": "Home", "Getting-Started": "Getting-Started.-"}
class TestFetchPageContent:
def test_fetches_and_decodes_content(self) -> None:
client = MagicMock()
encoded = base64.b64encode(b"# Hello Wiki").decode("ascii")
client._request.return_value.json.return_value = {"content_base64": encoded}
result = fetch_page_content(client, "Home")
assert result == "# Hello Wiki"
def test_returns_empty_on_api_error(self) -> None:
from devx.exceptions import APIError
client = MagicMock()
client._request.side_effect = APIError(404, "not found")
assert fetch_page_content(client, "Missing") == ""
def test_returns_empty_for_empty_content(self) -> None:
client = MagicMock()
client._request.return_value.json.return_value = {"content_base64": ""}
assert fetch_page_content(client, "Home") == ""
class TestSyncPage:
def test_dry_run_skips(self) -> None:
client = MagicMock()
result = sync_page(client, "Test-Page", "# Content", {}, dry_run=True)
assert result == "skipped"
client._request.assert_not_called()
def test_creates_new_page_with_base64(self) -> None:
client = MagicMock()
result = sync_page(client, "New-Page", "# Content", {}, dry_run=False)
assert result == "created"
client._request.assert_called_once()
call_args = client._request.call_args
assert call_args.args[0] == "POST"
assert call_args.args[1] == "/wiki/new"
# Verify content_base64 is used, not content
payload = call_args.kwargs["json"]
assert "content_base64" in payload
assert "content" not in payload
assert base64.b64decode(payload["content_base64"]).decode("utf-8") == "# Content"
def test_updates_existing_page_with_base64(self) -> None:
client = MagicMock()
existing = {"Existing-Page": "Existing-Page.-"}
result = sync_page(client, "Existing-Page", "# Updated", existing, dry_run=False)
assert result == "updated"
client._request.assert_called_once()
call_args = client._request.call_args
assert call_args.args[0] == "PATCH"
assert "/wiki/page/Existing-Page.-" in call_args.args[1]
# Verify content_base64 is used
payload = call_args.kwargs["json"]
assert "content_base64" in payload
assert "content" not in payload
assert base64.b64decode(payload["content_base64"]).decode("utf-8") == "# Updated"
class TestVerifyWikiPage:
def test_verifies_matching_content(self) -> None:
client = MagicMock()
encoded = base64.b64encode(b"# Hello Wiki").decode("ascii")
client._request.return_value.json.return_value = {"content_base64": encoded}
existing = {"Home": "Home"}
assert verify_wiki_page(client, "Home", "# Hello Wiki", existing) is True
def test_fails_on_mismatch(self) -> None:
client = MagicMock()
encoded = base64.b64encode(b"# Old Content").decode("ascii")
client._request.return_value.json.return_value = {"content_base64": encoded}
existing = {"Home": "Home"}
assert verify_wiki_page(client, "Home", "# New Content", existing) is False
def test_fails_on_empty_wiki_content(self) -> None:
client = MagicMock()
client._request.return_value.json.return_value = {"content_base64": ""}
existing = {"Home": "Home"}
assert verify_wiki_page(client, "Home", "# Expected", existing) is False
def test_fails_when_page_not_in_existing(self) -> None:
client = MagicMock()
assert verify_wiki_page(client, "Missing", "# Content", {}) is False
class TestVerifyWikiIntegrity:
def _make_client(self, pages: dict[str, str], contents: dict[str, str]) -> MagicMock:
"""Create a mock client that returns the given pages and contents."""
client = MagicMock()
# list_wiki_pages calls GET /wiki/pages
page_list = [{"title": t, "sub_url": s} for t, s in pages.items()]
# fetch_page_content calls GET /wiki/page/{sub_url}
def mock_request(method, path, **kwargs):
resp = MagicMock()
if path == "/wiki/pages":
resp.json.return_value = page_list
elif path.startswith("/wiki/page/"):
sub_url = path.replace("/wiki/page/", "")
content = contents.get(sub_url, "")
encoded = base64.b64encode(content.encode()).decode("ascii") if content else ""
resp.json.return_value = {"content_base64": encoded}
return resp
client._request.side_effect = mock_request
return client
def test_all_good_no_failures(self) -> None:
pages = {"Home": "Home", "FAQ": "FAQ"}
contents = {"Home": "# Home", "FAQ": "# FAQ"}
client = self._make_client(pages, contents)
mapping = {"index.md": "Home", "faq.md": "FAQ"}
synced = {"Home": "# Home", "FAQ": "# FAQ"}
failures = verify_wiki_integrity(client, mapping, synced)
assert failures == []
def test_missing_page_detected(self) -> None:
pages = {"Home": "Home"} # FAQ missing from wiki
contents = {"Home": "# Home"}
client = self._make_client(pages, contents)
mapping = {"index.md": "Home", "faq.md": "FAQ"}
synced = {"Home": "# Home"}
failures = verify_wiki_integrity(client, mapping, synced)
assert any("Missing page: FAQ" in f for f in failures)
def test_stale_page_detected(self) -> None:
pages = {"Home": "Home", "Old-Page": "Old-Page"} # Old-Page not in mapping
contents = {"Home": "# Home", "Old-Page": "# Old"}
client = self._make_client(pages, contents)
mapping = {"index.md": "Home"}
synced = {"Home": "# Home"}
failures = verify_wiki_integrity(client, mapping, synced)
assert any("Stale page" in f and "Old-Page" in f for f in failures)
def test_page_count_mismatch_detected(self) -> None:
pages = {"Home": "Home", "Extra": "Extra"}
contents = {"Home": "# Home", "Extra": "# Extra"}
client = self._make_client(pages, contents)
mapping = {"index.md": "Home"}
synced = {"Home": "# Home"}
failures = verify_wiki_integrity(client, mapping, synced)
assert any("Page count mismatch" in f for f in failures)
def test_empty_content_detected(self) -> None:
pages = {"Home": "Home"}
contents = {"Home": ""} # Empty content
client = self._make_client(pages, contents)
mapping = {"index.md": "Home"}
synced = {"Home": "# Expected Content"}
failures = verify_wiki_integrity(client, mapping, synced)
assert any("Empty content: Home" in f for f in failures)
def test_content_mismatch_detected(self) -> None:
pages = {"Home": "Home"}
contents = {"Home": "# Wrong Content"}
client = self._make_client(pages, contents)
mapping = {"index.md": "Home"}
synced = {"Home": "# Correct Content"}
failures = verify_wiki_integrity(client, mapping, synced)
assert any("Content mismatch: Home" in f for f in failures)
def test_multiple_failures_all_reported(self) -> None:
pages = {"Home": "Home", "Stale": "Stale"}
contents = {"Home": "", "Stale": "# Stale"}
client = self._make_client(pages, contents)
mapping = {"index.md": "Home", "faq.md": "FAQ"} # FAQ missing
synced = {"Home": "# Home Content"}
failures = verify_wiki_integrity(client, mapping, synced)
assert len(failures) >= 3 # count mismatch, missing FAQ, stale Stale, empty Home
class TestMain:
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
@patch("devx.ci.sync_wiki.MAPPING_FILE")
@patch("devx.ci.sync_wiki.DOCS_DIR")
@patch("devx.ci.sync_wiki.GiteaClient")
def test_dry_run(self, mock_client_cls: MagicMock, mock_docs_dir: Path, mock_mapping_file: Path) -> None:
mock_mapping_file.exists.return_value = True
mock_mapping_file.__str__ = lambda _: "/docs/mapping.json"
with patch("devx.ci.sync_wiki.load_mapping", return_value={"index.md": "Home"}):
with patch("devx.ci.sync_wiki.read_doc_content", return_value="# Home"):
with patch("devx.ci.sync_wiki.list_wiki_pages", return_value={}):
runner = CliRunner()
result = runner.invoke(main, ["--dry-run", "--repo", "owner/repo"])
assert result.exit_code == 0
assert "dry-run" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": ""}, clear=True)
def test_missing_token_exits(self) -> None:
runner = CliRunner()
result = runner.invoke(main, ["--repo", "owner/repo"])
assert result.exit_code == 1
assert "REPO_TOKEN" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok", "DEVX_REPO_OWNER": "me", "DEVX_REPO_NAME": "myrepo"}, clear=True)
@patch("devx.ci.sync_wiki.GiteaClient")
def test_auto_detect_repo(self, mock_client_cls: MagicMock) -> None:
"""Test that repo is auto-detected from env vars when --repo is not passed."""
with patch("devx.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
mock_mapping.exists.return_value = True
with patch("devx.ci.sync_wiki.load_mapping", return_value={"index.md": "Home"}):
with patch("devx.ci.sync_wiki.read_doc_content", return_value="# Home"):
with patch("devx.ci.sync_wiki.list_wiki_pages", return_value={}):
runner = CliRunner()
result = runner.invoke(main, ["--dry-run"])
assert result.exit_code == 0
mock_client_cls.assert_called_once()
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
@patch("devx.ci.sync_wiki.GiteaClient")
def test_missing_mapping_file(self, mock_client_cls: MagicMock) -> None:
"""Test that missing mapping.json exits with error."""
with patch("devx.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
mock_mapping.exists.return_value = False
runner = CliRunner()
result = runner.invoke(main, ["--repo", "owner/repo"])
assert result.exit_code == 1
assert "mapping.json" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
@patch("devx.ci.sync_wiki.GiteaClient")
def test_existing_pages_message(self, mock_client_cls: MagicMock) -> None:
"""Test that existing wiki pages are reported."""
with patch("devx.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
mock_mapping.exists.return_value = True
with patch("devx.ci.sync_wiki.load_mapping", return_value={"index.md": "Home"}):
with patch("devx.ci.sync_wiki.read_doc_content", return_value="# Home"):
with patch("devx.ci.sync_wiki.list_wiki_pages", return_value={"Home": "Home"}):
runner = CliRunner()
result = runner.invoke(main, ["--dry-run", "--repo", "owner/repo"])
assert result.exit_code == 0
assert "existing wiki pages" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
@patch("devx.ci.sync_wiki.GiteaClient")
def test_file_not_found_warning(self, mock_client_cls: MagicMock) -> None:
"""Test that missing doc files are skipped with a warning."""
with patch("devx.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
mock_mapping.exists.return_value = True
with patch("devx.ci.sync_wiki.load_mapping", return_value={"missing.md": "Missing"}):
with patch("devx.ci.sync_wiki.read_doc_content", side_effect=FileNotFoundError):
with patch("devx.ci.sync_wiki.list_wiki_pages", return_value={}):
runner = CliRunner()
result = runner.invoke(main, ["--dry-run", "--repo", "owner/repo"])
assert result.exit_code == 0
assert "not found" in result.output
assert "Skipped: 1" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
@patch("devx.ci.sync_wiki.GiteaClient")
def test_empty_doc_file_skipped(self, mock_client_cls: MagicMock) -> None:
"""Test that empty doc files are skipped with a warning."""
with patch("devx.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
mock_mapping.exists.return_value = True
with patch("devx.ci.sync_wiki.load_mapping", return_value={"empty.md": "Empty-Page"}):
with patch("devx.ci.sync_wiki.read_doc_content", return_value=" \n "):
with patch("devx.ci.sync_wiki.list_wiki_pages", return_value={}):
runner = CliRunner()
result = runner.invoke(main, ["--dry-run", "--repo", "owner/repo"])
assert result.exit_code == 0
assert "empty" in result.output.lower()
assert "Skipped: 1" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
@patch("devx.ci.sync_wiki.GiteaClient")
def test_create_and_update(self, mock_client_cls: MagicMock) -> None:
"""Test that pages are created and updated correctly (non-dry-run)."""
mock_client = MagicMock()
mock_client_cls.return_value = mock_client
with patch("devx.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
mock_mapping.exists.return_value = True
mapping = {"new.md": "New-Page", "existing.md": "Existing-Page"}
with patch("devx.ci.sync_wiki.load_mapping", return_value=mapping):
with patch("devx.ci.sync_wiki.read_doc_content", return_value="# Content"):
with patch("devx.ci.sync_wiki.list_wiki_pages", return_value={"Existing-Page": "Existing-Page"}):
runner = CliRunner()
result = runner.invoke(main, ["--repo", "owner/repo"])
assert result.exit_code == 0
assert "Created: 1" in result.output
assert "Updated: 1" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
@patch("devx.ci.sync_wiki.GiteaClient")
def test_verify_passes(self, mock_client_cls: MagicMock) -> None:
"""Test that --verify passes when content matches."""
mock_client = MagicMock()
mock_client_cls.return_value = mock_client
encoded = base64.b64encode(b"# Home Content").decode("ascii")
# list_wiki_pages returns {"Home": "Home"}, fetch returns encoded content
mock_client._request.return_value.json.return_value = {"content_base64": encoded}
with patch("devx.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
mock_mapping.exists.return_value = True
with patch("devx.ci.sync_wiki.load_mapping", return_value={"index.md": "Home"}):
with patch("devx.ci.sync_wiki.read_doc_content", return_value="# Home Content"):
with patch("devx.ci.sync_wiki.list_wiki_pages", return_value={"Home": "Home"}):
with patch("devx.ci.sync_wiki.verify_wiki_page", return_value=True):
runner = CliRunner()
result = runner.invoke(main, ["--repo", "owner/repo", "--verify"])
assert result.exit_code == 0
assert "Verification passed" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
@patch("devx.ci.sync_wiki.GiteaClient")
def test_verify_fails_on_empty_content(self, mock_client_cls: MagicMock) -> None:
"""Test that --verify fails when wiki pages have empty content."""
mock_client = MagicMock()
mock_client_cls.return_value = mock_client
with patch("devx.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
mock_mapping.exists.return_value = True
with patch("devx.ci.sync_wiki.load_mapping", return_value={"index.md": "Home"}):
with patch("devx.ci.sync_wiki.read_doc_content", return_value="# Home Content"):
with patch("devx.ci.sync_wiki.list_wiki_pages", return_value={"Home": "Home"}):
with patch("devx.ci.sync_wiki.verify_wiki_page", return_value=False):
runner = CliRunner()
result = runner.invoke(main, ["--repo", "owner/repo", "--verify"])
assert result.exit_code == 1
assert "FAIL" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
@patch("devx.ci.sync_wiki.GiteaClient")
def test_verify_skipped_in_dry_run(self, mock_client_cls: MagicMock) -> None:
"""Test that --verify is skipped during dry-run."""
with patch("devx.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
mock_mapping.exists.return_value = True
with patch("devx.ci.sync_wiki.load_mapping", return_value={"index.md": "Home"}):
with patch("devx.ci.sync_wiki.read_doc_content", return_value="# Home"):
with patch("devx.ci.sync_wiki.list_wiki_pages", return_value={}):
runner = CliRunner()
result = runner.invoke(main, ["--dry-run", "--verify", "--repo", "owner/repo"])
assert result.exit_code == 0
assert "Verification" not in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
@patch("devx.ci.sync_wiki.GiteaClient")
def test_strict_passes(self, mock_client_cls: MagicMock) -> None:
"""Test that --strict passes when integrity check succeeds."""
mock_client = MagicMock()
mock_client_cls.return_value = mock_client
with patch("devx.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
mock_mapping.exists.return_value = True
with patch("devx.ci.sync_wiki.load_mapping", return_value={"index.md": "Home"}):
with patch("devx.ci.sync_wiki.read_doc_content", return_value="# Home"):
with patch("devx.ci.sync_wiki.list_wiki_pages", return_value={"Home": "Home"}):
with patch("devx.ci.sync_wiki.verify_wiki_integrity", return_value=[]):
runner = CliRunner()
result = runner.invoke(main, ["--repo", "owner/repo", "--strict"])
assert result.exit_code == 0
assert "Integrity check passed" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
@patch("devx.ci.sync_wiki.GiteaClient")
def test_strict_fails_on_integrity_issues(self, mock_client_cls: MagicMock) -> None:
"""Test that --strict fails when integrity check finds issues."""
mock_client = MagicMock()
mock_client_cls.return_value = mock_client
with patch("devx.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
mock_mapping.exists.return_value = True
with patch("devx.ci.sync_wiki.load_mapping", return_value={"index.md": "Home"}):
with patch("devx.ci.sync_wiki.read_doc_content", return_value="# Home"):
with patch("devx.ci.sync_wiki.list_wiki_pages", return_value={"Home": "Home"}):
with patch(
"devx.ci.sync_wiki.verify_wiki_integrity",
return_value=["Missing page: FAQ", "Stale page: Old-Page"],
):
runner = CliRunner()
result = runner.invoke(main, ["--repo", "owner/repo", "--strict"])
assert result.exit_code == 1
assert "Integrity check FAILED" in result.output
assert "Missing page: FAQ" in result.output
assert "Stale page: Old-Page" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
@patch("devx.ci.sync_wiki.GiteaClient")
def test_strict_skipped_in_dry_run(self, mock_client_cls: MagicMock) -> None:
"""Test that --strict verification is skipped during dry-run."""
with patch("devx.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
mock_mapping.exists.return_value = True
with patch("devx.ci.sync_wiki.load_mapping", return_value={"index.md": "Home"}):
with patch("devx.ci.sync_wiki.read_doc_content", return_value="# Home"):
with patch("devx.ci.sync_wiki.list_wiki_pages", return_value={}):
runner = CliRunner()
result = runner.invoke(main, ["--dry-run", "--strict", "--repo", "owner/repo"])
assert result.exit_code == 0
assert "Integrity check" not in result.output
+176
View File
@@ -0,0 +1,176 @@
"""Unit tests for scripts/ci/validate_commit_msg.py."""
import os
import subprocess
import tempfile
from unittest.mock import patch
from click.testing import CliRunner
from devx.ci.validate_commit_msg import first_line, get_branch, main
from devx.config import CONVENTIONAL_RE, TASK_ID_RE
class TestHelpers:
def test_first_line_single(self) -> None:
assert first_line("feat: add something") == "feat: add something"
def test_first_line_multiline(self) -> None:
msg = "feat: add something\n\nBody text here.\nMore body."
assert first_line(msg) == "feat: add something"
def test_conventional_re_matches_valid(self) -> None:
assert CONVENTIONAL_RE.match("feat: add feature")
assert CONVENTIONAL_RE.match("fix: bug fix")
assert CONVENTIONAL_RE.match("chore: update deps")
assert CONVENTIONAL_RE.match("docs: update readme")
assert CONVENTIONAL_RE.match("style: format code")
assert CONVENTIONAL_RE.match("refactor: simplify")
assert CONVENTIONAL_RE.match("perf: speed up")
assert CONVENTIONAL_RE.match("test: add tests")
assert CONVENTIONAL_RE.match("ci: update workflow")
assert CONVENTIONAL_RE.match("build: update deps")
assert CONVENTIONAL_RE.match("revert: undo change")
def test_conventional_re_allows_scope(self) -> None:
assert CONVENTIONAL_RE.match("feat(cli): add --url option")
assert CONVENTIONAL_RE.match("fix(api): handle timeout")
def test_conventional_re_rejects_invalid(self) -> None:
assert not CONVENTIONAL_RE.match("DEVX-19: feat: something")
assert not CONVENTIONAL_RE.match("random message")
assert not CONVENTIONAL_RE.match("feat:")
assert not CONVENTIONAL_RE.match(": description")
def test_task_id_re_matches(self) -> None:
assert TASK_ID_RE.match("DEVX-19: feat: something")
assert TASK_ID_RE.match("DEVX-123: fix: bug")
def test_task_id_re_rejects(self) -> None:
assert not TASK_ID_RE.match("feat: something")
assert not TASK_ID_RE.match("GRM: something")
class TestGetBranch:
def test_returns_branch_name(self) -> None:
with patch("subprocess.run") as mock_run:
mock_run.return_value.stdout = "feature-branch\n"
mock_run.return_value.returncode = 0
assert get_branch() == "feature-branch"
def test_returns_empty_on_error(self) -> None:
with patch("subprocess.run", side_effect=subprocess.CalledProcessError(1, "git")):
assert get_branch() == ""
class TestMain:
def _write_msg(self, content: str) -> str:
fd, path = tempfile.mkstemp()
with os.fdopen(fd, "w") as f:
f.write(content)
return path
def test_rejects_task_id_on_feature_branch(self) -> None:
msg_path = self._write_msg("DEVX-19: feat: add feature")
with patch("devx.ci.validate_commit_msg.get_branch", return_value="DEVX-19"):
runner = CliRunner()
result = runner.invoke(main, [msg_path])
assert result.exit_code == 1
assert "task ID" in result.output
def test_accepts_conventional_on_feature_branch(self) -> None:
msg_path = self._write_msg("feat: add feature")
with patch("devx.ci.validate_commit_msg.get_branch", return_value="DEVX-19"):
runner = CliRunner()
result = runner.invoke(main, [msg_path])
assert result.exit_code == 0
def test_accepts_valid_master_commit(self) -> None:
msg_path = self._write_msg("DEVX-19: feat: add feature")
with patch("devx.ci.validate_commit_msg.get_branch", return_value="master"):
runner = CliRunner()
result = runner.invoke(main, [msg_path])
assert result.exit_code == 0
def test_rejects_master_without_task_id(self) -> None:
msg_path = self._write_msg("feat: add feature")
with patch("devx.ci.validate_commit_msg.get_branch", return_value="master"):
runner = CliRunner()
result = runner.invoke(main, [msg_path])
assert result.exit_code == 1
assert "task ID" in result.output
def test_rejects_master_with_non_conventional_after_task_id(self) -> None:
msg_path = self._write_msg("DEVX-19: random message")
with patch("devx.ci.validate_commit_msg.get_branch", return_value="master"):
runner = CliRunner()
result = runner.invoke(main, [msg_path])
assert result.exit_code == 1
assert "conventional" in result.output
def test_rejects_non_conventional_on_feature_branch(self) -> None:
msg_path = self._write_msg("random message")
with patch("devx.ci.validate_commit_msg.get_branch", return_value="feature"):
runner = CliRunner()
result = runner.invoke(main, [msg_path])
assert result.exit_code == 1
assert "conventional" in result.output
def test_accepts_multiline_conventional(self) -> None:
msg_path = self._write_msg("feat: add feature\n\nBody text.\nMore text.")
with patch("devx.ci.validate_commit_msg.get_branch", return_value="feature"):
runner = CliRunner()
result = runner.invoke(main, [msg_path])
assert result.exit_code == 0
def test_usage_message_without_args(self) -> None:
runner = CliRunner()
result = runner.invoke(main, [])
assert result.exit_code == 2
def test_branch_override_accepts_master_commit(self) -> None:
"""--branch master overrides branch detection (for CI use)."""
msg_path = self._write_msg("DEVX-19: feat: add feature")
runner = CliRunner()
result = runner.invoke(main, [msg_path, "--branch", "master"])
assert result.exit_code == 0
def test_branch_override_rejects_missing_task_id(self) -> None:
"""--branch master still enforces DEVX-N: prefix."""
msg_path = self._write_msg("feat: add feature")
runner = CliRunner()
result = runner.invoke(main, [msg_path, "--branch", "master"])
assert result.exit_code == 1
assert "task ID" in result.output
def test_branch_override_feature_accepts_conventional(self) -> None:
"""--branch feature still rejects DEVX-N prefix."""
msg_path = self._write_msg("DEVX-19: feat: add feature")
runner = CliRunner()
result = runner.invoke(main, [msg_path, "--branch", "feature"])
assert result.exit_code == 1
assert "task ID" in result.output
def test_main_module_block() -> None:
import tempfile
with tempfile.NamedTemporaryFile(mode="w", delete=False) as f:
f.write("DEVX-1: feat: test")
msg_path = f.name
with patch("devx.ci.validate_commit_msg.get_branch", return_value="master"):
import devx.ci.validate_commit_msg as vcm
with open(vcm.__file__) as f:
source = f.read()
# Remove __main__ block so exec doesn't call main() before we control sys.argv
source = source.replace('if __name__ == "__main__":\n main()\n', "")
namespace = dict(vcm.__dict__)
exec(compile(source, vcm.__file__, "exec"), namespace)
# exec() redefines get_branch() from source, overwriting the mock.
# Restore the patched mock so main() uses it.
namespace["get_branch"] = vcm.get_branch
namespace["main"]([msg_path], standalone_mode=False)
os.unlink(msg_path)