GRM-64: refactor: migrate from scripts/ to devx package
This commit is contained in:
@@ -1,345 +0,0 @@
|
||||
"""Unit tests for scripts/ci/auto_merge.py."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import click
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from gitea_runner_manager.exceptions import APIError
|
||||
from scripts.ci.auto_merge import (
|
||||
extract_conventional_msg,
|
||||
extract_task_id,
|
||||
main,
|
||||
read_taskid,
|
||||
run_cmd,
|
||||
validate_pr_title,
|
||||
validate_pr_title_matches_vikunja,
|
||||
)
|
||||
|
||||
# -- 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("GRM-60\n")
|
||||
assert read_taskid("some-branch") == "GRM-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("GRM-19-fix-bug") == "GRM-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("GRM-42-test") == "GRM-42"
|
||||
|
||||
|
||||
# -- extract_task_id (legacy fallback) --
|
||||
|
||||
|
||||
class TestExtractTaskId:
|
||||
def test_extracts_from_branch(self) -> None:
|
||||
assert extract_task_id("GRM-19-fix-bug") == "GRM-19"
|
||||
assert extract_task_id("GRM-123") == "GRM-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("GRM-19: Add new feature", "GRM-19")
|
||||
|
||||
def test_missing_colon(self) -> None:
|
||||
with pytest.raises(click.ClickException, match="format"):
|
||||
validate_pr_title("GRM-19 Add new feature", "GRM-19")
|
||||
|
||||
def test_task_id_mismatch(self) -> None:
|
||||
with pytest.raises(click.ClickException, match="mismatch"):
|
||||
validate_pr_title("GRM-20: Add feature", "GRM-19")
|
||||
|
||||
def test_no_task_id_in_title(self) -> None:
|
||||
with pytest.raises(click.ClickException, match="format"):
|
||||
validate_pr_title("Add new feature", "GRM-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("GRM-19: test", "GRM-19")
|
||||
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True)
|
||||
@patch("scripts.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": "GRM-19", "title": "Add new feature"},
|
||||
]
|
||||
mock_client_cls.return_value = mock_client
|
||||
validate_pr_title_matches_vikunja("GRM-19: Add new feature", "GRM-19")
|
||||
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True)
|
||||
@patch("scripts.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": "GRM-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("GRM-19: Add new feature", "GRM-19")
|
||||
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True)
|
||||
@patch("scripts.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("GRM-99: test", "GRM-99")
|
||||
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True)
|
||||
@patch("scripts.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"GRM-{i}", "title": f"Title {i}"} for i in range(50)]
|
||||
page2 = [{"id": 100, "identifier": "GRM-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("GRM-99: Found me", "GRM-99")
|
||||
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True)
|
||||
@patch("scripts.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": "GRM-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("GRM-99: test", "GRM-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("scripts.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("GRM-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,
|
||||
["GRM-19-fix-bug", "GRM-19: Fix timeout", "owner/repo", "7"],
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
mock_client.merge_pr.assert_called_once_with("7", "GRM-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, ["GRM-19-fix", "GRM-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("scripts.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 GRM-N in branch name
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["feature-branch", "GRM-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("scripts.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("GRM-19\n")
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["GRM-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("scripts.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("GRM-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("scripts.ci.auto_merge.run_cmd") as mock_run:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
["GRM-19-fix-bug", "GRM-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("scripts.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("GRM-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,
|
||||
["GRM-19-fix-bug", "GRM-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("scripts.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("GRM-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,
|
||||
["GRM-19-fix-bug", "GRM-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("scripts.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("GRM-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("scripts.ci.auto_merge.run_cmd") as mock_run:
|
||||
mock_run.side_effect = click.ClickException("git rebase failed")
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
["GRM-19-fix-bug", "GRM-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 scripts.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"])
|
||||
@@ -1,140 +0,0 @@
|
||||
"""Unit tests for scripts/check_test_speed.py."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import click
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from scripts.check_test_speed import (
|
||||
DEFAULT_MAX_SECONDS,
|
||||
TEST_COMMAND,
|
||||
check_speed,
|
||||
cli,
|
||||
parse_duration,
|
||||
run_tests,
|
||||
)
|
||||
|
||||
|
||||
class TestRunTests:
|
||||
@patch("scripts.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 scripts.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("scripts.check_test_speed.run_tests")
|
||||
@patch("scripts.check_test_speed.parse_duration")
|
||||
@patch("scripts.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("scripts.check_test_speed.run_tests")
|
||||
@patch("scripts.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("scripts.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("scripts.check_test_speed.run_tests")
|
||||
@patch("scripts.check_test_speed.parse_duration")
|
||||
@patch("scripts.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)
|
||||
@@ -1,273 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
import scripts.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 scripts.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) -> None:
|
||||
"""The actual repo should pass (with warnings for missing langs)."""
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(check_translations.main, [])
|
||||
assert result.exit_code == 0
|
||||
|
||||
def test_strict_fails_on_warnings(self) -> None:
|
||||
"""--strict should fail if there are missing language warnings."""
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(check_translations.main, ["--strict"])
|
||||
# The repo has missing language warnings, so --strict should fail
|
||||
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="mock",
|
||||
src_dir=Path("/tmp"),
|
||||
trans_file=Path("/tmp/t.json"),
|
||||
errors=["Missing key: 'foo'"],
|
||||
)
|
||||
ok_result = check_translations.TranslationCheckResult(
|
||||
name="mock2",
|
||||
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 == "GRM tool" 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="mock",
|
||||
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
|
||||
|
||||
|
||||
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 TestScriptsI18n:
|
||||
"""Test the scripts/i18n.py module."""
|
||||
|
||||
def test_english_default(self) -> None:
|
||||
from scripts.i18n import _
|
||||
|
||||
assert _("Running tests...") == "Running tests..."
|
||||
|
||||
def test_format_kwargs(self) -> None:
|
||||
from scripts.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 scripts.i18n import _
|
||||
|
||||
assert _("Nonexistent key 12345") == "Nonexistent key 12345"
|
||||
|
||||
def test_grm_lang_override(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
import importlib
|
||||
|
||||
monkeypatch.setenv("GRM_LANG", "de")
|
||||
import scripts.i18n
|
||||
|
||||
importlib.reload(scripts.i18n)
|
||||
# "ERROR: REPO_TOKEN is not set." has a German translation
|
||||
result = scripts.i18n._("ERROR: REPO_TOKEN is not set.")
|
||||
assert "FEHLER" in result
|
||||
|
||||
# Restore
|
||||
monkeypatch.delenv("GRM_LANG", raising=False)
|
||||
importlib.reload(scripts.i18n)
|
||||
|
||||
def test_unsupported_lang_fallback(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
import importlib
|
||||
|
||||
monkeypatch.setenv("GRM_LANG", "xx")
|
||||
import scripts.i18n
|
||||
|
||||
importlib.reload(scripts.i18n)
|
||||
result = scripts.i18n._("Running tests...")
|
||||
assert result == "Running tests..." # Falls back to English
|
||||
|
||||
monkeypatch.delenv("GRM_LANG", raising=False)
|
||||
importlib.reload(scripts.i18n)
|
||||
|
||||
|
||||
class TestGrmI18nSeparation:
|
||||
"""Test that GRM and CI translations are properly separated."""
|
||||
|
||||
def test_grm_translations_exist(self) -> None:
|
||||
trans = Path("src/gitea_runner_manager/translations.json")
|
||||
assert trans.exists()
|
||||
data = json.loads(trans.read_text())
|
||||
assert len(data) > 0
|
||||
|
||||
def test_ci_translations_exist(self) -> None:
|
||||
trans = Path("scripts/translations.json")
|
||||
assert trans.exists()
|
||||
data = json.loads(trans.read_text())
|
||||
assert len(data) > 0
|
||||
|
||||
def test_no_overlap_between_sets(self) -> None:
|
||||
grm_data = json.loads(Path("src/gitea_runner_manager/translations.json").read_text())
|
||||
ci_data = json.loads(Path("scripts/translations.json").read_text())
|
||||
grm_keys = set(grm_data)
|
||||
ci_keys = set(ci_data)
|
||||
overlap = grm_keys & ci_keys
|
||||
assert not overlap, f"Keys found in both translation sets: {overlap}"
|
||||
|
||||
def test_grm_translations_only_grm_keys(self) -> None:
|
||||
"""GRM translations should not contain CI-only keys."""
|
||||
grm_data = json.loads(Path("src/gitea_runner_manager/translations.json").read_text())
|
||||
# These are CI-only keys that should NOT be in GRM translations
|
||||
ci_only_keys = {"Running tests...", "Lint passed.", "Published to PyPI."}
|
||||
for key in ci_only_keys:
|
||||
assert key not in grm_data, f"CI key {key!r} found in GRM translations"
|
||||
|
||||
def test_ci_translations_only_ci_keys(self) -> None:
|
||||
"""CI translations should not contain GRM-only keys."""
|
||||
ci_data = json.loads(Path("scripts/translations.json").read_text())
|
||||
# These are GRM-only keys that should NOT be in CI translations
|
||||
grm_only_keys = {"Installing Gitea Runner on {host}", "SSH user", "NAME"}
|
||||
for key in grm_only_keys:
|
||||
assert key not in ci_data, f"GRM key {key!r} found in CI translations"
|
||||
@@ -1,369 +0,0 @@
|
||||
"""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 scripts.ci.classify_changes as classify_changes_mod
|
||||
from scripts.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/gitea_runner_manager/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/gitea_runner_manager/."""
|
||||
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/gitea_runner_manager/__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/gitea_runner_manager/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/gitea_runner_manager/cli.py") is False
|
||||
assert is_workflow_only("pyproject.toml") is False
|
||||
|
||||
|
||||
class TestClassifyChanges:
|
||||
def test_all_user_facing(self) -> None:
|
||||
files = ["src/gitea_runner_manager/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/gitea_runner_manager/cli.py",
|
||||
".gitea/workflows/ci.yml",
|
||||
"pyproject.toml",
|
||||
"docs/index.md",
|
||||
]
|
||||
result = classify_changes(files)
|
||||
assert "src/gitea_runner_manager/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("scripts.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("scripts.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("scripts.ci.classify_changes.get_changed_files")
|
||||
def test_true_when_user_facing(self, mock_get: MagicMock) -> None:
|
||||
mock_get.return_value = ["src/gitea_runner_manager/cli.py", "docs/index.md"]
|
||||
assert has_user_facing_changes("v0.1.0", "HEAD") is True
|
||||
|
||||
@patch("scripts.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("scripts.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("scripts.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("scripts.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("scripts.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("scripts.ci.classify_changes.get_changed_files", return_value=[])
|
||||
@patch("scripts.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("scripts.ci.classify_changes.get_changed_files")
|
||||
@patch("scripts.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("scripts.ci.classify_changes.get_changed_files")
|
||||
@patch("scripts.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/gitea_runner_manager/cli.py", "docs/index.md"]
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, [])
|
||||
assert result.exit_code == 0
|
||||
assert "release needed" in result.output
|
||||
|
||||
@patch("scripts.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("scripts.ci.classify_changes.get_changed_files", return_value=[])
|
||||
@patch("scripts.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("scripts.ci.classify_changes.get_changed_files")
|
||||
@patch("scripts.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/gitea_runner_manager/cli.py"]
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--quiet"])
|
||||
assert result.exit_code == 0
|
||||
assert "true" in result.output
|
||||
|
||||
@patch("scripts.ci.classify_changes.get_changed_files")
|
||||
@patch("scripts.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("scripts.ci.classify_changes.get_changed_files")
|
||||
@patch("scripts.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/gitea_runner_manager/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("scripts.ci.classify_changes.get_changed_files")
|
||||
@patch("scripts.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("scripts.ci.classify_changes.get_changed_files")
|
||||
@patch("scripts.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/gitea_runner_manager/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("scripts.ci.classify_changes.get_changed_files")
|
||||
@patch("scripts.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/gitea_runner_manager/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("scripts.ci.classify_changes.get_changed_files")
|
||||
@patch("scripts.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("scripts.ci.classify_changes.get_changed_files")
|
||||
@patch("scripts.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("scripts.ci.classify_changes.get_changed_files")
|
||||
@patch("scripts.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/gitea_runner_manager/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
|
||||
@@ -1,67 +0,0 @@
|
||||
"""Unit tests for scripts/configure_repo.py."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import click
|
||||
import pytest
|
||||
|
||||
from gitea_runner_manager.config import BRANCH_PROTECTION_CONFIG, REPO_SETTINGS_CONFIG
|
||||
from gitea_runner_manager.exceptions import APIError
|
||||
from scripts.configure_repo import _handle_http_error, 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 TestMain:
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
|
||||
@patch("scripts.configure_repo.GiteaClient")
|
||||
def test_main_success(self, mock_client_cls: MagicMock) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
with click.Context(click.Command("test")):
|
||||
main()
|
||||
|
||||
mock_client.ensure_branch_protection.assert_called_once_with("master", BRANCH_PROTECTION_CONFIG)
|
||||
mock_client.update_repo_settings.assert_called_once_with(REPO_SETTINGS_CONFIG)
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
|
||||
@patch("scripts.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
|
||||
|
||||
with pytest.raises(click.ClickException, match="Forbidden"):
|
||||
main()
|
||||
|
||||
@patch.dict("os.environ", {}, clear=True)
|
||||
def test_main_no_token(self) -> None:
|
||||
with pytest.raises(click.ClickException, match="REPO_TOKEN"):
|
||||
main()
|
||||
|
||||
|
||||
def test_main_module_block() -> None:
|
||||
with patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True):
|
||||
with patch("scripts.configure_repo.GiteaClient") as mock_client_cls:
|
||||
mock_client = MagicMock()
|
||||
mock_client_cls.return_value = mock_client
|
||||
import scripts.configure_repo as cr
|
||||
|
||||
with open(cr.__file__) as f:
|
||||
source = f.read()
|
||||
source = source.replace('if __name__ == "__main__":\n main() # pragma: no cover\n', "")
|
||||
namespace = dict(cr.__dict__)
|
||||
exec(compile(source, cr.__file__, "exec"), namespace)
|
||||
namespace["GiteaClient"] = mock_client_cls
|
||||
namespace["main"]()
|
||||
mock_client.ensure_branch_protection.assert_called_once_with("master", BRANCH_PROTECTION_CONFIG)
|
||||
mock_client.update_repo_settings.assert_called_once_with(REPO_SETTINGS_CONFIG)
|
||||
@@ -1,76 +0,0 @@
|
||||
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 scripts.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()
|
||||
@@ -1,210 +0,0 @@
|
||||
"""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 scripts.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("scripts.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("scripts.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("scripts.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("scripts.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("scripts.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("scripts.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("scripts.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("scripts.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("scripts.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("scripts.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("scripts.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("scripts.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("scripts.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("scripts.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("scripts.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("scripts.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("scripts.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("scripts.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("scripts.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("scripts.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
|
||||
@@ -1,250 +0,0 @@
|
||||
"""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 scripts.ci.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 scripts.ci.distribute_molecule import cli
|
||||
|
||||
root = tmp_path / "molecule"
|
||||
(root / "alpha").mkdir(parents=True)
|
||||
(root / "beta").mkdir(parents=True)
|
||||
with patch("scripts.ci.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 scripts.ci.distribute_molecule import cli
|
||||
|
||||
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 scripts.ci.distribute_molecule import cli
|
||||
|
||||
root = tmp_path / "molecule"
|
||||
for s in ["a", "b", "c"]:
|
||||
(root / s).mkdir(parents=True)
|
||||
with patch("scripts.ci.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 scripts.ci.distribute_molecule import cli
|
||||
|
||||
root = tmp_path / "molecule"
|
||||
(root / "alpha").mkdir(parents=True)
|
||||
with patch("scripts.ci.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("scripts.ci.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("scripts.ci.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("scripts.ci.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 scripts.ci.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"])
|
||||
@@ -1,112 +0,0 @@
|
||||
"""Unit tests for scripts/ci/doc_coverage.py."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
from scripts.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()
|
||||
# Should find all 9 CLI commands
|
||||
assert "install" in commands
|
||||
assert "update" in commands
|
||||
assert "start" in commands
|
||||
assert "stop" in commands
|
||||
assert "enable" in commands
|
||||
assert "disable" in commands
|
||||
assert "status" in commands
|
||||
assert "remove" in commands
|
||||
assert "list" in commands
|
||||
|
||||
def test_returns_list(self) -> None:
|
||||
commands = extract_cli_commands()
|
||||
assert isinstance(commands, list)
|
||||
assert len(commands) == 9
|
||||
|
||||
|
||||
class TestCheckCommandDocumented:
|
||||
def test_finds_command_in_heading(self) -> None:
|
||||
content = "## install\n\nInstall a runner."
|
||||
assert check_command_documented("install", content) is True
|
||||
|
||||
def test_finds_command_in_code_block(self) -> None:
|
||||
content = "```bash\ngrm install 192.168.1.10\n```"
|
||||
assert check_command_documented("install", content) is True
|
||||
|
||||
def test_finds_command_with_grm_prefix(self) -> None:
|
||||
content = "Use `grm start prod-runner` to start."
|
||||
assert check_command_documented("start", content) is True
|
||||
|
||||
def test_missing_command(self) -> None:
|
||||
content = "## Other stuff\n\nNo commands here."
|
||||
assert check_command_documented("install", 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)
|
||||
# Write cli-commands.md with all commands
|
||||
(docs / "user" / "cli-commands.md").write_text(
|
||||
"## install\n## update\n## start\n## stop\n## enable\n## disable\n## status\n## remove\n## list\n"
|
||||
)
|
||||
# Write architecture.md with all modules
|
||||
(docs / "tech" / "architecture.md").write_text(
|
||||
"cli.py runner_manager.py executor.py registry.py i18n.py exceptions.py api_clients.py config.py"
|
||||
)
|
||||
# Write ci-cd-workflow.md with all scripts
|
||||
(docs / "tech" / "ci-cd-workflow.md").write_text(
|
||||
"auto_merge.py release.py publish.py review_pr.py "
|
||||
"notify_failure.py post_merge.py classify_changes.py discover_runners.py "
|
||||
"detect_release_commit.py push_badges.py "
|
||||
"distribute_molecule.py molecule_ci_guard.py validate_commit_msg.py"
|
||||
)
|
||||
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
|
||||
@@ -1,290 +0,0 @@
|
||||
"""Unit tests for scripts/generate_badges.py."""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
from scripts.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("scripts.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 "<script>" 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/gitea_runner_manager/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:
|
||||
def test_reads_version_from_init(self) -> None:
|
||||
with patch("scripts.generate_badges.INIT_FILE") as mock_file:
|
||||
mock_file.read_text.return_value = '__version__ = "0.5.0"\n'
|
||||
assert read_version() == "0.5.0"
|
||||
|
||||
def test_returns_unknown_when_no_version(self) -> None:
|
||||
with patch("scripts.generate_badges.INIT_FILE") as mock_file:
|
||||
mock_file.read_text.return_value = "no version here\n"
|
||||
assert read_version() == "unknown"
|
||||
|
||||
|
||||
class TestGenerateBadges:
|
||||
@patch("scripts.generate_badges.run_command")
|
||||
@patch("scripts.generate_badges.read_version", return_value="0.5.0")
|
||||
@patch("scripts.generate_badges.extract_coverage", return_value=100.0)
|
||||
@patch("scripts.generate_badges.extract_test_count", return_value=573)
|
||||
@patch("scripts.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("scripts.generate_badges.run_command")
|
||||
@patch("scripts.generate_badges.read_version", return_value="0.5.0")
|
||||
@patch("scripts.generate_badges.extract_coverage", return_value=100.0)
|
||||
@patch("scripts.generate_badges.extract_test_count", return_value=573)
|
||||
@patch("scripts.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("scripts.generate_badges.run_command")
|
||||
@patch("scripts.generate_badges.read_version", return_value="0.5.0")
|
||||
@patch("scripts.generate_badges.extract_coverage", return_value=100.0)
|
||||
@patch("scripts.generate_badges.extract_test_count", return_value=573)
|
||||
@patch("scripts.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("scripts.generate_badges.run_command")
|
||||
@patch("scripts.generate_badges.read_version", return_value="0.5.0")
|
||||
@patch("scripts.generate_badges.extract_coverage", return_value=None)
|
||||
@patch("scripts.generate_badges.extract_test_count", return_value=None)
|
||||
@patch("scripts.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("scripts.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 scripts.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([])
|
||||
@@ -1,355 +0,0 @@
|
||||
"""Unit tests for scripts/gitea_cli.py."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from scripts.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"
|
||||
@@ -1,94 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import platform
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from click import ClickException
|
||||
|
||||
import scripts.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()
|
||||
@@ -1,312 +0,0 @@
|
||||
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 scripts.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
|
||||
@@ -1,130 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
import scripts.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("scripts.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("scripts.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("scripts.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("scripts.molecule_all._run_platform", return_value=1):
|
||||
result = runner.invoke(molecule_all.main, ["--bin", str(bin_dir)])
|
||||
assert result.exit_code != 0
|
||||
@@ -1,428 +0,0 @@
|
||||
"""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 scripts.ci.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("scripts.ci.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("scripts.ci.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("scripts.ci.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("scripts.ci.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("scripts.ci.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("scripts.ci.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("scripts.ci.molecule_ci_guard.POLL_INTERVAL", 0.01),
|
||||
patch("scripts.ci.molecule_ci_guard.subprocess.Popen") as mock_popen,
|
||||
patch("scripts.ci.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("scripts.ci.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("scripts.ci.molecule_ci_guard.POLL_INTERVAL", 0.01),
|
||||
patch("scripts.ci.molecule_ci_guard.subprocess.Popen") as mock_popen,
|
||||
patch("scripts.ci.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("scripts.ci.molecule_ci_guard.POLL_INTERVAL", 0.01),
|
||||
patch("scripts.ci.molecule_ci_guard.subprocess.Popen") as mock_popen,
|
||||
patch("scripts.ci.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("scripts.ci.molecule_ci_guard.POLL_INTERVAL", 0.01),
|
||||
patch("scripts.ci.molecule_ci_guard.subprocess.Popen") as mock_popen,
|
||||
patch("scripts.ci.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("scripts.ci.molecule_ci_guard.POLL_INTERVAL", 0.01),
|
||||
patch("scripts.ci.molecule_ci_guard.subprocess.Popen") as mock_popen,
|
||||
patch("scripts.ci.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 scripts.ci.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"])
|
||||
@@ -1,116 +0,0 @@
|
||||
"""Unit tests for scripts/ci/notify_failure.py."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
from scripts.ci.notify_failure import main
|
||||
from scripts.gitea_cli import TeaCLIError
|
||||
|
||||
|
||||
class TestNotifyFailure:
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch("scripts.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("scripts.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("scripts.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("scripts.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("scripts.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
|
||||
@@ -1,25 +0,0 @@
|
||||
"""Unit tests for scripts/ci/platforms.py."""
|
||||
|
||||
from scripts.ci.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
|
||||
@@ -1,328 +0,0 @@
|
||||
"""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 gitea_runner_manager.exceptions import APIError
|
||||
from scripts.ci.post_merge import (
|
||||
_get_git_commit_message,
|
||||
_get_git_commit_sha,
|
||||
build_comment,
|
||||
extract_conventional_msg,
|
||||
extract_task_id,
|
||||
main,
|
||||
resolve_task_id,
|
||||
)
|
||||
|
||||
|
||||
class TestExtractTaskId:
|
||||
def test_extracts_from_first_line(self) -> None:
|
||||
assert extract_task_id("GRM-19: fix: resolve bug\n\nBody") == "GRM-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: GRM-N: <message>"""
|
||||
assert extract_conventional_msg("GRM-19: fix: resolve bug") == "fix: resolve bug"
|
||||
|
||||
def test_strips_space_prefix(self) -> None:
|
||||
"""Current format: GRM-N <message>"""
|
||||
assert extract_conventional_msg("GRM-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("GRM-19", "fix: bug", "abc123")
|
||||
assert "<strong>GRM-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": "GRM-19"},
|
||||
]
|
||||
assert resolve_task_id(mock_client, "GRM-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, "GRM-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"GRM-{i}"} for i in range(50)]
|
||||
page2 = [{"id": 100, "identifier": "GRM-99"}]
|
||||
mock_client.list_project_tasks.side_effect = [page1, page2]
|
||||
assert resolve_task_id(mock_client, "GRM-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"GRM-{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, "GRM-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, "GRM-19")
|
||||
|
||||
|
||||
class TestMain:
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||
@patch("scripts.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": "GRM-20"},
|
||||
]
|
||||
mock_client_cls.return_value = mock_client
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
["GRM-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("scripts.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": "GRM-20"},
|
||||
]
|
||||
mock_client_cls.return_value = mock_client
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["GRM-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, ["GRM-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 GRM-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 GRM-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 GRM-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 GRM-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("scripts.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, ["GRM-20: fix: bug"])
|
||||
assert result.exit_code != 0
|
||||
assert "Could not find" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||
@patch("scripts.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": "GRM-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, ["GRM-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("scripts.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": "GRM-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, ["GRM-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="GRM-20: fix: bug\n", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
assert _get_git_commit_message() == "GRM-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("scripts.ci.post_merge.VikunjaClient")
|
||||
@patch("scripts.ci.post_merge._get_git_commit_sha", return_value="abc123")
|
||||
@patch("scripts.ci.post_merge._get_git_commit_message", return_value="GRM-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": "GRM-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("scripts.ci.post_merge.VikunjaClient")
|
||||
@patch("scripts.ci.post_merge._get_git_commit_sha", return_value="abc123")
|
||||
@patch("scripts.ci.post_merge._get_git_commit_message", return_value="GRM-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": "GRM-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("scripts.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": "GRM-20"},
|
||||
]
|
||||
mock_client_cls.return_value = mock_client
|
||||
mock_result = subprocess.CompletedProcess(args=[], returncode=0, stdout="GRM-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("scripts.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": "GRM-20"},
|
||||
]
|
||||
mock_client_cls.return_value = mock_client
|
||||
mock_result = subprocess.CompletedProcess(args=[], returncode=0, stdout="GRM-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]
|
||||
@@ -1,691 +0,0 @@
|
||||
"""Unit tests for scripts/ci/pr_review.py."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
from gitea_runner_manager.exceptions import APIError
|
||||
from scripts.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,
|
||||
)
|
||||
|
||||
|
||||
class TestIsPythonFile:
|
||||
def test_python_file_in_src(self) -> None:
|
||||
assert is_python_file("src/gitea_runner_manager/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/gitea_runner_manager/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/gitea_runner_manager/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/gitea_runner_manager/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/gitea_runner_manager/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/gitea_runner_manager/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/gitea_runner_manager/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/gitea_runner_manager/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/gitea_runner_manager/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/gitea_runner_manager/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/gitea_runner_manager/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/gitea_runner_manager/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/gitea_runner_manager/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/gitea_runner_manager/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/gitea_runner_manager/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/gitea_runner_manager/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/gitea_runner_manager/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/gitea_runner_manager/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/gitea_runner_manager/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/gitea_runner_manager/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/gitea_runner_manager/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/gitea_runner_manager/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/gitea_runner_manager/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/gitea_runner_manager/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/gitea_runner_manager/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/gitea_runner_manager/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/gitea_runner_manager/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/gitea_runner_manager/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/gitea_runner_manager/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/gitea_runner_manager/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/gitea_runner_manager/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/gitea_runner_manager/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/gitea_runner_manager/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/gitea_runner_manager/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/gitea_runner_manager/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/gitea_runner_manager/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/gitea_runner_manager/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/gitea_runner_manager/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/gitea_runner_manager/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("scripts.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("scripts.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/gitea_runner_manager/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("scripts.ci.pr_review.run_review")
|
||||
@patch("scripts.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("scripts.ci.pr_review.run_review")
|
||||
@patch("scripts.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("scripts.ci.pr_review.run_review")
|
||||
@patch("scripts.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("scripts.ci.pr_review.run_review")
|
||||
@patch("scripts.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 scripts.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([])
|
||||
@@ -1,190 +0,0 @@
|
||||
"""Unit tests for scripts/ci/publish.py."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import click
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from scripts.ci.publish import (
|
||||
build_package,
|
||||
generate_release_notes,
|
||||
main,
|
||||
publish_to_pypi,
|
||||
)
|
||||
from scripts.gitea_cli import TeaCLIError
|
||||
|
||||
|
||||
class TestGenerateReleaseNotes:
|
||||
@patch("scripts.ci.publish.subprocess.run")
|
||||
@patch("scripts.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("scripts.ci.publish.subprocess.run")
|
||||
@patch("scripts.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("scripts.ci.publish.subprocess.run")
|
||||
@patch("scripts.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("scripts.ci.publish.subprocess.run")
|
||||
@patch("scripts.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("scripts.ci.publish.subprocess.run", side_effect=FileNotFoundError)
|
||||
@patch("scripts.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("scripts.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("scripts.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("scripts.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("scripts.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("scripts.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 TestMain:
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"})
|
||||
@patch("scripts.ci.publish.generate_release_notes", return_value="Release notes")
|
||||
@patch("scripts.ci.publish.TeaCLI")
|
||||
@patch("scripts.ci.publish.publish_to_pypi")
|
||||
@patch("scripts.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("scripts.ci.publish.generate_release_notes", return_value="Release notes")
|
||||
@patch("scripts.ci.publish.TeaCLI")
|
||||
@patch("scripts.ci.publish.build_package")
|
||||
def test_without_pypi(
|
||||
self,
|
||||
mock_build: 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
|
||||
mock_build.assert_called_once()
|
||||
mock_tea.create_release.assert_called_once()
|
||||
assert "PYPI_TOKEN not set" in result.output
|
||||
|
||||
@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("scripts.ci.publish.generate_release_notes", return_value="Release notes")
|
||||
@patch("scripts.ci.publish.TeaCLI")
|
||||
@patch("scripts.ci.publish.publish_to_pypi")
|
||||
@patch("scripts.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("scripts.ci.publish.generate_release_notes", return_value="Release notes")
|
||||
@patch("scripts.ci.publish.TeaCLI")
|
||||
@patch("scripts.ci.publish.publish_to_pypi")
|
||||
@patch("scripts.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("scripts.ci.publish.generate_release_notes", return_value="Release notes")
|
||||
@patch("scripts.ci.publish.TeaCLI")
|
||||
@patch("scripts.ci.publish.publish_to_pypi")
|
||||
@patch("scripts.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
|
||||
@@ -1,225 +0,0 @@
|
||||
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 scripts.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 = "[]"
|
||||
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"[]"
|
||||
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 = (
|
||||
"[]\n"
|
||||
"[]\n"
|
||||
"[]"
|
||||
)
|
||||
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 = "[]"
|
||||
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("[]")
|
||||
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("scripts.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("scripts.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()
|
||||
@@ -1,606 +0,0 @@
|
||||
"""Unit tests for scripts/ci/release.py."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import click
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from scripts.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("scripts.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("scripts.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("scripts.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("scripts.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("scripts.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("scripts.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("scripts.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("scripts.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("scripts.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("scripts.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("scripts.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("scripts.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("scripts.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("scripts.ci.release.get_latest_tag")
|
||||
@patch("scripts.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("scripts.ci.release.get_latest_tag")
|
||||
@patch("scripts.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("scripts.ci.release.get_latest_tag")
|
||||
@patch("scripts.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("scripts.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("scripts.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("scripts.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("scripts.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("scripts.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("scripts.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("scripts.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("scripts.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("scripts.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/gitea_runner_manager/__init__.py", "CHANGELOG.md"] in calls
|
||||
assert ["git", "commit", "--no-verify", "-m", "release: v0.2.0 [skip ci]"] in calls
|
||||
|
||||
@patch("scripts.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("scripts.ci.release.tag_exists", return_value=False)
|
||||
@patch("scripts.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("scripts.ci.release.tag_exists", return_value=False)
|
||||
@patch("scripts.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("scripts.ci.release.tag_exists", return_value=True)
|
||||
@patch("scripts.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("scripts.ci.release.tag_exists", return_value=True)
|
||||
@patch("scripts.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("scripts.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("scripts.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("scripts.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("scripts.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("scripts.ci.release.has_user_facing_changes", return_value=False)
|
||||
@patch("scripts.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("scripts.ci.release.has_user_facing_changes", return_value=True)
|
||||
@patch("scripts.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("scripts.ci.release.has_user_facing_changes", return_value=True)
|
||||
@patch("scripts.ci.release.has_unreleased_changes", return_value=False)
|
||||
@patch("scripts.ci.release.get_bumped_version", return_value="0.2.0")
|
||||
@patch("scripts.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("scripts.ci.release.has_user_facing_changes", return_value=True)
|
||||
@patch("scripts.ci.release.create_and_push_tag")
|
||||
@patch("scripts.ci.release.commit_release_changes")
|
||||
@patch("scripts.ci.release.update_changelog")
|
||||
@patch("scripts.ci.release.update_init_version")
|
||||
@patch("scripts.ci.release.get_changelog", return_value="")
|
||||
@patch("scripts.ci.release.get_latest_tag", return_value="v0.1.0")
|
||||
@patch("scripts.ci.release.get_bumped_version", return_value="0.2.0")
|
||||
@patch("scripts.ci.release.has_unreleased_changes", return_value=True)
|
||||
@patch("scripts.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("scripts.ci.release.has_user_facing_changes", return_value=True)
|
||||
@patch("scripts.ci.release.create_and_push_tag")
|
||||
@patch("scripts.ci.release.commit_release_changes")
|
||||
@patch("scripts.ci.release.update_changelog")
|
||||
@patch("scripts.ci.release.update_init_version")
|
||||
@patch("scripts.ci.release.get_changelog", return_value="changelog")
|
||||
@patch("scripts.ci.release.get_latest_tag", return_value="v0.1.0")
|
||||
@patch("scripts.ci.release.get_bumped_version", return_value="0.2.0")
|
||||
@patch("scripts.ci.release.has_unreleased_changes", return_value=True)
|
||||
@patch("scripts.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("scripts.ci.release.get_latest_tag", return_value="v0.3.0")
|
||||
@patch("scripts.ci.release.has_user_facing_changes", return_value=False)
|
||||
@patch("scripts.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("scripts.ci.release.has_user_facing_changes", return_value=True)
|
||||
@patch("scripts.ci.release.run_tests")
|
||||
@patch("scripts.ci.release.create_and_push_tag", return_value=True)
|
||||
@patch("scripts.ci.release.commit_release_changes", return_value=True)
|
||||
@patch("scripts.ci.release.update_changelog")
|
||||
@patch("scripts.ci.release.update_init_version")
|
||||
@patch("scripts.ci.release.get_changelog", return_value="changelog")
|
||||
@patch("scripts.ci.release.get_latest_tag", return_value="v0.1.0")
|
||||
@patch("scripts.ci.release.get_bumped_version", return_value="0.2.0")
|
||||
@patch("scripts.ci.release.has_unreleased_changes", return_value=True)
|
||||
@patch("scripts.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("scripts.ci.release.has_user_facing_changes", return_value=True)
|
||||
@patch("scripts.ci.release.run_tests")
|
||||
@patch("scripts.ci.release.create_and_push_tag", return_value=False)
|
||||
@patch("scripts.ci.release.commit_release_changes", return_value=False)
|
||||
@patch("scripts.ci.release.update_changelog")
|
||||
@patch("scripts.ci.release.update_init_version")
|
||||
@patch("scripts.ci.release.get_changelog", return_value="changelog")
|
||||
@patch("scripts.ci.release.get_latest_tag", return_value="v0.1.0")
|
||||
@patch("scripts.ci.release.get_bumped_version", return_value="0.1.0")
|
||||
@patch("scripts.ci.release.has_unreleased_changes", return_value=True)
|
||||
@patch("scripts.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("scripts.ci.release.has_user_facing_changes", return_value=True)
|
||||
@patch("scripts.ci.release.create_and_push_tag", return_value=True)
|
||||
@patch("scripts.ci.release.commit_release_changes", return_value=True)
|
||||
@patch("scripts.ci.release.update_changelog")
|
||||
@patch("scripts.ci.release.update_init_version")
|
||||
@patch("scripts.ci.release.get_changelog", return_value="changelog")
|
||||
@patch("scripts.ci.release.get_latest_tag", return_value="v0.1.0")
|
||||
@patch("scripts.ci.release.get_bumped_version", return_value="0.2.0")
|
||||
@patch("scripts.ci.release.has_unreleased_changes", return_value=True)
|
||||
@patch("scripts.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("scripts.ci.release.has_user_facing_changes", return_value=True)
|
||||
@patch("scripts.ci.release.create_and_push_tag")
|
||||
@patch("scripts.ci.release.commit_release_changes")
|
||||
@patch("scripts.ci.release.update_changelog")
|
||||
@patch("scripts.ci.release.update_init_version")
|
||||
@patch("scripts.ci.release.get_changelog", return_value="changelog")
|
||||
@patch("scripts.ci.release.get_latest_tag", return_value="v0.1.0")
|
||||
@patch("scripts.ci.release.get_bumped_version", return_value="0.2.0")
|
||||
@patch("scripts.ci.release.has_unreleased_changes", return_value=True)
|
||||
@patch("scripts.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="GRM-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("scripts.ci.release.has_user_facing_changes", return_value=True)
|
||||
@patch("scripts.ci.release.create_and_push_tag")
|
||||
@patch("scripts.ci.release.commit_release_changes")
|
||||
@patch("scripts.ci.release.update_changelog")
|
||||
@patch("scripts.ci.release.update_init_version")
|
||||
@patch("scripts.ci.release.get_changelog", return_value="changelog")
|
||||
@patch("scripts.ci.release.get_latest_tag", return_value="v0.1.0")
|
||||
@patch("scripts.ci.release.get_bumped_version", return_value="0.2.0")
|
||||
@patch("scripts.ci.release.has_unreleased_changes", return_value=True)
|
||||
@patch("scripts.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="GRM-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()
|
||||
@@ -1,269 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import click
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
import scripts.setup as setup
|
||||
|
||||
|
||||
class TestRun:
|
||||
def test_run_success(self) -> None:
|
||||
with patch("subprocess.run") as mock_run:
|
||||
setup._run(["echo", "hello"], ".venv/bin")
|
||||
mock_run.assert_called_once_with(["echo", "hello"], check=True)
|
||||
|
||||
def test_run_failure(self) -> None:
|
||||
import subprocess
|
||||
|
||||
with patch("subprocess.run", side_effect=subprocess.CalledProcessError(1, ["echo"])):
|
||||
with pytest.raises(subprocess.CalledProcessError):
|
||||
setup._run(["echo", "hello"], ".venv/bin")
|
||||
|
||||
|
||||
class TestInstallPythonDeps:
|
||||
def test_install_default_dev(self) -> None:
|
||||
with patch("scripts.setup._run") as mock_run:
|
||||
setup._install_python_deps(".venv/bin")
|
||||
mock_run.assert_called_once_with([".venv/bin/pip", "install", "-e", ".[dev]"], ".venv/bin")
|
||||
|
||||
def test_install_ci_extras(self) -> None:
|
||||
with patch("scripts.setup._run") as mock_run:
|
||||
setup._install_python_deps(".venv/bin", extras="ci")
|
||||
mock_run.assert_called_once_with([".venv/bin/pip", "install", "-e", ".[ci]"], ".venv/bin")
|
||||
|
||||
def test_install_lint_extras(self) -> None:
|
||||
with patch("scripts.setup._run") as mock_run:
|
||||
setup._install_python_deps(".venv/bin", extras="ci,lint")
|
||||
mock_run.assert_called_once_with([".venv/bin/pip", "install", "-e", ".[ci,lint]"], ".venv/bin")
|
||||
|
||||
|
||||
class TestInstallAnsibleCollections:
|
||||
def test_install(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / "ansible").mkdir()
|
||||
(tmp_path / "ansible/requirements.yml").write_text("collections: []")
|
||||
with patch("scripts.setup._run") as mock_run:
|
||||
setup._install_ansible_collections(".venv/bin")
|
||||
mock_run.assert_called_once()
|
||||
|
||||
def test_no_requirements(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
with patch("scripts.setup._run") as mock_run:
|
||||
setup._install_ansible_collections(".venv/bin")
|
||||
mock_run.assert_not_called()
|
||||
|
||||
|
||||
class TestInstallPreCommitHooks:
|
||||
def test_install(self) -> None:
|
||||
with patch("scripts.setup._run") as mock_run:
|
||||
setup._install_pre_commit_hooks(".venv/bin")
|
||||
assert mock_run.call_count == 3
|
||||
calls = [c.args[0] for c in mock_run.call_args_list]
|
||||
# Each call should have the pre-commit binary and --hook-type flag
|
||||
for call in calls:
|
||||
assert ".venv/bin/pre-commit" in call[0]
|
||||
assert "--hook-type" in call
|
||||
|
||||
|
||||
class TestVerify:
|
||||
def test_verify_success(self) -> None:
|
||||
import subprocess
|
||||
|
||||
mock_result = subprocess.CompletedProcess(
|
||||
args=["grm", "--version"], returncode=0, stdout="grm 1.0.0", stderr=""
|
||||
)
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
setup._verify(".venv/bin")
|
||||
|
||||
def test_verify_not_found(self) -> None:
|
||||
|
||||
with patch("subprocess.run", side_effect=FileNotFoundError()):
|
||||
setup._verify(".venv/bin")
|
||||
|
||||
def test_verify_timeout(self) -> None:
|
||||
import subprocess
|
||||
|
||||
with patch("subprocess.run", side_effect=subprocess.TimeoutExpired(cmd=["grm", "--version"], timeout=10)):
|
||||
setup._verify(".venv/bin")
|
||||
|
||||
|
||||
class TestConfigureTeaLogin:
|
||||
def test_tea_not_installed_raises(self) -> None:
|
||||
"""tea must be installed — setup fails if it's missing."""
|
||||
with patch("shutil.which", return_value=None):
|
||||
with patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True):
|
||||
with pytest.raises(click.ClickException, match="not installed"):
|
||||
setup._configure_tea_login()
|
||||
|
||||
def test_no_repo_token(self) -> None:
|
||||
with patch("shutil.which", return_value="/usr/bin/tea"):
|
||||
with patch.dict("os.environ", {}, clear=True):
|
||||
setup._configure_tea_login()
|
||||
|
||||
def test_login_already_exists(self) -> None:
|
||||
import subprocess
|
||||
|
||||
mock_result = subprocess.CompletedProcess(
|
||||
args=["tea", "login", "list"], returncode=0, stdout="grm https://git.example.com", stderr=""
|
||||
)
|
||||
with patch("shutil.which", return_value="/usr/bin/tea"):
|
||||
with patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True):
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
setup._configure_tea_login()
|
||||
|
||||
def test_login_added_successfully(self) -> None:
|
||||
import subprocess
|
||||
|
||||
list_result = subprocess.CompletedProcess(args=["tea", "login", "list"], returncode=0, stdout="", stderr="")
|
||||
add_result = subprocess.CompletedProcess(
|
||||
args=["tea", "login", "add"], returncode=0, stdout="Login added", stderr=""
|
||||
)
|
||||
default_result = subprocess.CompletedProcess(
|
||||
args=["tea", "login", "default"], returncode=0, stdout="", stderr=""
|
||||
)
|
||||
with patch("shutil.which", return_value="/usr/bin/tea"):
|
||||
with patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True):
|
||||
with patch("subprocess.run", side_effect=[list_result, add_result, default_result]):
|
||||
setup._configure_tea_login()
|
||||
|
||||
def test_login_add_failure(self) -> None:
|
||||
import subprocess
|
||||
|
||||
list_result = subprocess.CompletedProcess(args=["tea", "login", "list"], returncode=0, stdout="", stderr="")
|
||||
add_result = subprocess.CompletedProcess(
|
||||
args=["tea", "login", "add"], returncode=1, stdout="", stderr="auth failed"
|
||||
)
|
||||
with patch("shutil.which", return_value="/usr/bin/tea"):
|
||||
with patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True):
|
||||
with patch("subprocess.run", side_effect=[list_result, add_result]):
|
||||
setup._configure_tea_login()
|
||||
|
||||
|
||||
class TestMain:
|
||||
def test_bin_not_found(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(setup.main, ["--bin", "nonexistent/bin"])
|
||||
assert result.exit_code != 0
|
||||
assert "Bin directory not found" in result.output
|
||||
|
||||
def test_success(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 / "grm").touch()
|
||||
(bin_dir / "pre-commit").touch()
|
||||
(tmp_path / "ansible").mkdir()
|
||||
(tmp_path / "ansible/requirements.yml").write_text("collections: []")
|
||||
|
||||
runner = CliRunner()
|
||||
with patch("scripts.setup._install_python_deps"):
|
||||
with patch("scripts.setup._install_ansible_collections"):
|
||||
with patch("scripts.setup._install_pre_commit_hooks"):
|
||||
with patch("scripts.setup._configure_tea_login"):
|
||||
with patch("scripts.setup._verify"):
|
||||
result = runner.invoke(setup.main, ["--bin", str(bin_dir)])
|
||||
assert result.exit_code == 0
|
||||
assert "Setup complete" in result.output
|
||||
|
||||
def test_extras_ci(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""--extras ci should pass 'ci' to _install_python_deps."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
bin_dir = tmp_path / ".venv" / "bin"
|
||||
bin_dir.mkdir(parents=True)
|
||||
runner = CliRunner()
|
||||
with patch("scripts.setup._install_python_deps") as mock_deps:
|
||||
with patch("scripts.setup._install_ansible_collections"):
|
||||
with patch("scripts.setup._install_pre_commit_hooks"):
|
||||
with patch("scripts.setup._configure_tea_login"):
|
||||
with patch("scripts.setup._verify"):
|
||||
result = runner.invoke(setup.main, ["--bin", str(bin_dir), "--extras", "ci"])
|
||||
assert result.exit_code == 0
|
||||
mock_deps.assert_called_once_with(str(bin_dir), "ci")
|
||||
|
||||
def test_no_ansible_collections(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""--no-ansible-collections should skip ansible collection install."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
bin_dir = tmp_path / ".venv" / "bin"
|
||||
bin_dir.mkdir(parents=True)
|
||||
runner = CliRunner()
|
||||
with patch("scripts.setup._install_python_deps"):
|
||||
with patch("scripts.setup._install_ansible_collections") as mock_ansible:
|
||||
with patch("scripts.setup._install_pre_commit_hooks"):
|
||||
with patch("scripts.setup._configure_tea_login"):
|
||||
with patch("scripts.setup._verify"):
|
||||
result = runner.invoke(
|
||||
setup.main,
|
||||
["--bin", str(bin_dir), "--no-ansible-collections"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
mock_ansible.assert_not_called()
|
||||
|
||||
def test_no_pre_commit(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""--no-pre-commit should skip pre-commit hook install."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
bin_dir = tmp_path / ".venv" / "bin"
|
||||
bin_dir.mkdir(parents=True)
|
||||
runner = CliRunner()
|
||||
with patch("scripts.setup._install_python_deps"):
|
||||
with patch("scripts.setup._install_ansible_collections"):
|
||||
with patch("scripts.setup._install_pre_commit_hooks") as mock_hooks:
|
||||
with patch("scripts.setup._configure_tea_login"):
|
||||
with patch("scripts.setup._verify"):
|
||||
result = runner.invoke(
|
||||
setup.main,
|
||||
["--bin", str(bin_dir), "--no-pre-commit"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
mock_hooks.assert_not_called()
|
||||
|
||||
def test_no_tea_login(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""--no-tea-login should skip tea login configuration."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
bin_dir = tmp_path / ".venv" / "bin"
|
||||
bin_dir.mkdir(parents=True)
|
||||
runner = CliRunner()
|
||||
with patch("scripts.setup._install_python_deps"):
|
||||
with patch("scripts.setup._install_ansible_collections"):
|
||||
with patch("scripts.setup._install_pre_commit_hooks"):
|
||||
with patch("scripts.setup._configure_tea_login") as mock_tea:
|
||||
with patch("scripts.setup._verify"):
|
||||
result = runner.invoke(
|
||||
setup.main,
|
||||
["--bin", str(bin_dir), "--no-tea-login"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
mock_tea.assert_not_called()
|
||||
|
||||
def test_lean_ci_setup(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Simulate the setup-ci make target: all skip flags together."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
bin_dir = tmp_path / ".venv" / "bin"
|
||||
bin_dir.mkdir(parents=True)
|
||||
runner = CliRunner()
|
||||
with patch("scripts.setup._install_python_deps") as mock_deps:
|
||||
with patch("scripts.setup._install_ansible_collections") as mock_ansible:
|
||||
with patch("scripts.setup._install_pre_commit_hooks") as mock_hooks:
|
||||
with patch("scripts.setup._configure_tea_login") as mock_tea:
|
||||
with patch("scripts.setup._verify"):
|
||||
result = runner.invoke(
|
||||
setup.main,
|
||||
[
|
||||
"--bin",
|
||||
str(bin_dir),
|
||||
"--extras",
|
||||
"ci",
|
||||
"--no-ansible-collections",
|
||||
"--no-pre-commit",
|
||||
"--no-tea-login",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
mock_deps.assert_called_once_with(str(bin_dir), "ci")
|
||||
mock_ansible.assert_not_called()
|
||||
mock_hooks.assert_not_called()
|
||||
mock_tea.assert_not_called()
|
||||
@@ -1,483 +0,0 @@
|
||||
"""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 scripts.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("scripts.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("scripts.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("scripts.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("scripts.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 gitea_runner_manager.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 gitea_runner_manager.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("scripts.ci.sync_wiki.MAPPING_FILE")
|
||||
@patch("scripts.ci.sync_wiki.DOCS_DIR")
|
||||
@patch("scripts.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("scripts.ci.sync_wiki.load_mapping", return_value={"index.md": "Home"}):
|
||||
with patch("scripts.ci.sync_wiki.read_doc_content", return_value="# Home"):
|
||||
with patch("scripts.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", "GRM_REPO_OWNER": "me", "GRM_REPO_NAME": "myrepo"}, clear=True)
|
||||
@patch("scripts.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("scripts.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
|
||||
mock_mapping.exists.return_value = True
|
||||
with patch("scripts.ci.sync_wiki.load_mapping", return_value={"index.md": "Home"}):
|
||||
with patch("scripts.ci.sync_wiki.read_doc_content", return_value="# Home"):
|
||||
with patch("scripts.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("scripts.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("scripts.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("scripts.ci.sync_wiki.GiteaClient")
|
||||
def test_existing_pages_message(self, mock_client_cls: MagicMock) -> None:
|
||||
"""Test that existing wiki pages are reported."""
|
||||
with patch("scripts.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
|
||||
mock_mapping.exists.return_value = True
|
||||
with patch("scripts.ci.sync_wiki.load_mapping", return_value={"index.md": "Home"}):
|
||||
with patch("scripts.ci.sync_wiki.read_doc_content", return_value="# Home"):
|
||||
with patch("scripts.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("scripts.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("scripts.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
|
||||
mock_mapping.exists.return_value = True
|
||||
with patch("scripts.ci.sync_wiki.load_mapping", return_value={"missing.md": "Missing"}):
|
||||
with patch("scripts.ci.sync_wiki.read_doc_content", side_effect=FileNotFoundError):
|
||||
with patch("scripts.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("scripts.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("scripts.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
|
||||
mock_mapping.exists.return_value = True
|
||||
with patch("scripts.ci.sync_wiki.load_mapping", return_value={"empty.md": "Empty-Page"}):
|
||||
with patch("scripts.ci.sync_wiki.read_doc_content", return_value=" \n "):
|
||||
with patch("scripts.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("scripts.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("scripts.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("scripts.ci.sync_wiki.load_mapping", return_value=mapping):
|
||||
with patch("scripts.ci.sync_wiki.read_doc_content", return_value="# Content"):
|
||||
with patch("scripts.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("scripts.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("scripts.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
|
||||
mock_mapping.exists.return_value = True
|
||||
with patch("scripts.ci.sync_wiki.load_mapping", return_value={"index.md": "Home"}):
|
||||
with patch("scripts.ci.sync_wiki.read_doc_content", return_value="# Home Content"):
|
||||
with patch("scripts.ci.sync_wiki.list_wiki_pages", return_value={"Home": "Home"}):
|
||||
with patch("scripts.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("scripts.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("scripts.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
|
||||
mock_mapping.exists.return_value = True
|
||||
with patch("scripts.ci.sync_wiki.load_mapping", return_value={"index.md": "Home"}):
|
||||
with patch("scripts.ci.sync_wiki.read_doc_content", return_value="# Home Content"):
|
||||
with patch("scripts.ci.sync_wiki.list_wiki_pages", return_value={"Home": "Home"}):
|
||||
with patch("scripts.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("scripts.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("scripts.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
|
||||
mock_mapping.exists.return_value = True
|
||||
with patch("scripts.ci.sync_wiki.load_mapping", return_value={"index.md": "Home"}):
|
||||
with patch("scripts.ci.sync_wiki.read_doc_content", return_value="# Home"):
|
||||
with patch("scripts.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("scripts.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("scripts.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
|
||||
mock_mapping.exists.return_value = True
|
||||
with patch("scripts.ci.sync_wiki.load_mapping", return_value={"index.md": "Home"}):
|
||||
with patch("scripts.ci.sync_wiki.read_doc_content", return_value="# Home"):
|
||||
with patch("scripts.ci.sync_wiki.list_wiki_pages", return_value={"Home": "Home"}):
|
||||
with patch("scripts.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("scripts.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("scripts.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
|
||||
mock_mapping.exists.return_value = True
|
||||
with patch("scripts.ci.sync_wiki.load_mapping", return_value={"index.md": "Home"}):
|
||||
with patch("scripts.ci.sync_wiki.read_doc_content", return_value="# Home"):
|
||||
with patch("scripts.ci.sync_wiki.list_wiki_pages", return_value={"Home": "Home"}):
|
||||
with patch(
|
||||
"scripts.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("scripts.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("scripts.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
|
||||
mock_mapping.exists.return_value = True
|
||||
with patch("scripts.ci.sync_wiki.load_mapping", return_value={"index.md": "Home"}):
|
||||
with patch("scripts.ci.sync_wiki.read_doc_content", return_value="# Home"):
|
||||
with patch("scripts.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
|
||||
@@ -1,176 +0,0 @@
|
||||
"""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 gitea_runner_manager.config import CONVENTIONAL_RE, TASK_ID_RE
|
||||
from scripts.ci.validate_commit_msg import first_line, get_branch, main
|
||||
|
||||
|
||||
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("GRM-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("GRM-19: feat: something")
|
||||
assert TASK_ID_RE.match("GRM-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("GRM-19: feat: add feature")
|
||||
with patch("scripts.ci.validate_commit_msg.get_branch", return_value="GRM-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("scripts.ci.validate_commit_msg.get_branch", return_value="GRM-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("GRM-19: feat: add feature")
|
||||
with patch("scripts.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("scripts.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("GRM-19: random message")
|
||||
with patch("scripts.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("scripts.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("scripts.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("GRM-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 GRM-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 GRM-N prefix."""
|
||||
msg_path = self._write_msg("GRM-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("GRM-1: feat: test")
|
||||
msg_path = f.name
|
||||
|
||||
with patch("scripts.ci.validate_commit_msg.get_branch", return_value="master"):
|
||||
import scripts.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)
|
||||
Reference in New Issue
Block a user