Public Access
The v0.49.x tag line diverged from origin/master, leaving many features only accessible via tags but not on the master branch. New modules: - ci/cancel_superseded_runs.py — cancel superseded CI runs - ci/check_workflow_artifact_deps.py — validate artifact deps - ci/check_workflow_tofu_init.py — validate tofu init steps - tools/check_alert_rules.py — validate Prometheus alert rules - tools/check_ansible_set_fact_to_json.py — lint set_fact usage - tools/check_docker_init.py — validate Docker init scripts - utils/jinja.py — Jinja2 template utilities - utils/ui.py — UI/console utilities Modified modules: - distribute_molecule.py: add --include-roles/--exclude-roles - utils/api.py: add container.credentials for private registry auth - install_tools.py: retry ansible-galaxy on transient timeouts - setup_image.py: skip dep resolution with --no-deps - cli.py: register new commands - i18n.py: add new translation keys Also removes accidentally committed .vale/styles/Google/ files. Test results: 2195 passed, 100% coverage. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
173 lines
8.2 KiB
Python
173 lines
8.2 KiB
Python
"""Unit tests for devx.ci.cancel_superseded_runs."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import urllib.error
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
import devx.ci.cancel_superseded_runs as mod
|
|
from devx.ci.cancel_superseded_runs import _api_request, cancel_run, list_running_runs, main
|
|
|
|
_HTTP_NO_CONTENT = mod._HTTP_NO_CONTENT
|
|
_PAGE_SIZE = mod._PAGE_SIZE
|
|
|
|
|
|
class TestConstants:
|
|
def test_http_no_content_is_204(self) -> None:
|
|
assert _HTTP_NO_CONTENT == 204
|
|
|
|
def test_page_size_is_50(self) -> None:
|
|
assert _PAGE_SIZE == 50
|
|
|
|
|
|
class TestApiRequest:
|
|
def test_returns_empty_for_204(self) -> None:
|
|
mock_resp = MagicMock()
|
|
mock_resp.status = _HTTP_NO_CONTENT
|
|
mock_resp.read.return_value = b""
|
|
mock_resp.__enter__ = MagicMock(return_value=mock_resp)
|
|
mock_resp.__exit__ = MagicMock(return_value=None)
|
|
with patch("urllib.request.urlopen", return_value=mock_resp):
|
|
result = _api_request("POST", "/repos/test/actions/runs/1/cancel", "tok", "https://x")
|
|
assert result == {}
|
|
|
|
def test_returns_json_for_200(self) -> None:
|
|
mock_resp = MagicMock()
|
|
mock_resp.status = 200
|
|
mock_resp.read.return_value = json.dumps({"id": 1}).encode()
|
|
mock_resp.__enter__ = MagicMock(return_value=mock_resp)
|
|
mock_resp.__exit__ = MagicMock(return_value=None)
|
|
with patch("urllib.request.urlopen", return_value=mock_resp):
|
|
result = _api_request("GET", "/repos/test/actions/runs", "tok", "https://x")
|
|
assert result == {"id": 1}
|
|
|
|
def test_http_error_raises(self) -> None:
|
|
err = urllib.error.HTTPError("x", 500, "err", {}, None)
|
|
err.read = MagicMock(return_value=b"error body")
|
|
with patch("urllib.request.urlopen", side_effect=err):
|
|
with pytest.raises(urllib.error.HTTPError):
|
|
_api_request("GET", "/repos/test/actions/runs", "tok", "https://x")
|
|
|
|
def test_url_error_raises(self) -> None:
|
|
with patch("urllib.request.urlopen", side_effect=urllib.error.URLError("fail")):
|
|
with pytest.raises(urllib.error.URLError):
|
|
_api_request("GET", "/repos/test/actions/runs", "tok", "https://x")
|
|
|
|
|
|
class TestListRunningRuns:
|
|
def test_paginates_until_empty(self) -> None:
|
|
page1 = {"workflow_runs": [{"id": 1}, {"id": 2}], "total_count": 2}
|
|
page2 = {"workflow_runs": [], "total_count": 2}
|
|
responses = iter([page1, page2])
|
|
with patch.object(mod, "_api_request", side_effect=lambda *a, **k: next(responses)):
|
|
runs = list_running_runs("owner/repo", "tok", "https://x")
|
|
assert len(runs) == 2
|
|
|
|
def test_empty_first_page(self) -> None:
|
|
with patch.object(mod, "_api_request", return_value={"workflow_runs": [], "total_count": 0}):
|
|
runs = list_running_runs("owner/repo", "tok", "https://x")
|
|
assert runs == []
|
|
|
|
def test_stops_at_page_size(self) -> None:
|
|
full_page = {"workflow_runs": [{"id": i} for i in range(_PAGE_SIZE)], "total_count": _PAGE_SIZE + 1}
|
|
half_page = {"workflow_runs": [{"id": 99}], "total_count": _PAGE_SIZE + 1}
|
|
responses = iter([full_page, half_page])
|
|
with patch.object(mod, "_api_request", side_effect=lambda *a, **k: next(responses)):
|
|
runs = list_running_runs("owner/repo", "tok", "https://x")
|
|
assert len(runs) == _PAGE_SIZE + 1
|
|
|
|
def test_uses_in_progress_status(self) -> None:
|
|
with patch.object(mod, "_api_request", return_value={"workflow_runs": [], "total_count": 0}) as mock_req:
|
|
list_running_runs("owner/repo", "tok", "https://x")
|
|
path = mock_req.call_args.args[1]
|
|
assert "status=in_progress" in path
|
|
assert "status=running" not in path
|
|
|
|
def test_accepts_bare_list(self) -> None:
|
|
with patch.object(mod, "_api_request", return_value=[{"id": 1}, {"id": 2}]):
|
|
runs = list_running_runs("owner/repo", "tok", "https://x")
|
|
assert len(runs) == 2
|
|
|
|
|
|
class TestCancelRun:
|
|
def test_success_returns_true(self) -> None:
|
|
with patch.object(mod, "_api_request", return_value={}):
|
|
assert cancel_run("owner/repo", 123, "tok", "https://x") is True
|
|
|
|
def test_http_error_returns_false(self) -> None:
|
|
with patch.object(mod, "_api_request", side_effect=urllib.error.HTTPError("x", 500, "err", {}, None)):
|
|
assert cancel_run("owner/repo", 123, "tok", "https://x") is False
|
|
|
|
|
|
class TestMain:
|
|
def test_no_token_exits_zero(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.delenv("CI_GITEA_API_TOKEN", raising=False)
|
|
monkeypatch.delenv("CI_GITEA_TOKEN", raising=False)
|
|
monkeypatch.setattr("sys.argv", ["cancel", "--repo", "o/r", "--current-run-id", "1", "--head-branch", "feat"])
|
|
assert main() == 0
|
|
|
|
def test_no_superseded_runs(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setenv("CI_GITEA_API_TOKEN", "tok")
|
|
monkeypatch.setattr("sys.argv", ["cancel", "--repo", "o/r", "--current-run-id", "10", "--head-branch", "feat"])
|
|
with patch.object(mod, "list_running_runs", return_value=[]):
|
|
assert main() == 0
|
|
|
|
def test_cancels_superseded(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setenv("CI_GITEA_API_TOKEN", "tok")
|
|
runs = [
|
|
{"id": 5, "head_branch": "feat"},
|
|
{"id": 8, "head_branch": "feat"},
|
|
{"id": 12, "head_branch": "other"},
|
|
]
|
|
monkeypatch.setattr("sys.argv", ["cancel", "--repo", "o/r", "--current-run-id", "10", "--head-branch", "feat"])
|
|
with patch.object(mod, "list_running_runs", return_value=runs):
|
|
with patch.object(mod, "cancel_run", return_value=True) as mock_cancel:
|
|
assert main() == 0
|
|
cancelled_ids = [call.args[1] for call in mock_cancel.call_args_list]
|
|
assert cancelled_ids == [5, 8]
|
|
|
|
def test_dry_run_does_not_cancel(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setenv("CI_GITEA_API_TOKEN", "tok")
|
|
runs = [{"id": 5, "head_branch": "feat"}]
|
|
monkeypatch.setattr(
|
|
"sys.argv",
|
|
["cancel", "--repo", "o/r", "--current-run-id", "10", "--head-branch", "feat", "--dry-run"],
|
|
)
|
|
with patch.object(mod, "list_running_runs", return_value=runs):
|
|
with patch.object(mod, "cancel_run", return_value=True) as mock_cancel:
|
|
assert main() == 0
|
|
assert mock_cancel.call_count == 0
|
|
|
|
def test_cancel_failure_continues(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setenv("CI_GITEA_API_TOKEN", "tok")
|
|
runs = [{"id": 5, "head_branch": "feat"}, {"id": 8, "head_branch": "feat"}]
|
|
monkeypatch.setattr("sys.argv", ["cancel", "--repo", "o/r", "--current-run-id", "10", "--head-branch", "feat"])
|
|
with patch.object(mod, "list_running_runs", return_value=runs):
|
|
with patch.object(mod, "cancel_run", side_effect=[False, True]):
|
|
assert main() == 0
|
|
|
|
def test_404_returns_zero(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setenv("CI_GITEA_API_TOKEN", "tok")
|
|
monkeypatch.setattr("sys.argv", ["cancel", "--repo", "o/r", "--current-run-id", "10", "--head-branch", "feat"])
|
|
err = urllib.error.HTTPError("x", 404, "Not Found", {}, None)
|
|
with patch.object(mod, "list_running_runs", side_effect=err):
|
|
assert main() == 0
|
|
|
|
def test_400_returns_zero(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setenv("CI_GITEA_API_TOKEN", "tok")
|
|
monkeypatch.setattr("sys.argv", ["cancel", "--repo", "o/r", "--current-run-id", "10", "--head-branch", "feat"])
|
|
err = urllib.error.HTTPError("x", 400, "Bad Request", {}, None)
|
|
with patch.object(mod, "list_running_runs", side_effect=err):
|
|
assert main() == 0
|
|
|
|
def test_500_raises(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setenv("CI_GITEA_API_TOKEN", "tok")
|
|
monkeypatch.setattr("sys.argv", ["cancel", "--repo", "o/r", "--current-run-id", "10", "--head-branch", "feat"])
|
|
err = urllib.error.HTTPError("x", 500, "Server Error", {}, None)
|
|
with patch.object(mod, "list_running_runs", side_effect=err):
|
|
with pytest.raises(urllib.error.HTTPError):
|
|
main()
|