GRM-37: refactor: Split CI scripts, fix release PYTHONPATH, dynamic runner discovery

This commit is contained in:
emil
2026-06-21 20:44:39 +00:00
parent 56eb241b77
commit 5511cba1b0
38 changed files with 908 additions and 422 deletions
+52 -52
View File
@@ -1,4 +1,4 @@
"""Unit tests for scripts/auto_merge.py."""
"""Unit tests for scripts/ci/auto_merge.py."""
import http
from unittest.mock import MagicMock, patch
@@ -9,7 +9,7 @@ from click.testing import CliRunner
from gitea_runner_manager.config import CONVENTIONAL_RE, TASK_ID_RE
from gitea_runner_manager.exceptions import APIError
from scripts.auto_merge import (
from scripts.ci.auto_merge import (
PR_TITLE_RE,
extract_conventional_msg,
extract_task_id,
@@ -162,25 +162,25 @@ class TestHasApprovalReview:
class TestValidatePrTitleMatchesVikunja:
@patch("scripts.auto_merge.get_vikunja_task_title", return_value="")
@patch("scripts.ci.auto_merge.get_vikunja_task_title", return_value="")
@patch.dict("os.environ", {}, clear=True)
def test_no_vikunja_token_skips(self, mock_get: MagicMock) -> None:
"""Should skip validation when VIKUNJA_TOKEN is not set."""
validate_pr_title_matches_vikunja("GRM-19: Some title", "GRM-19")
@patch("scripts.auto_merge.get_vikunja_task_title", return_value="Some task title")
@patch("scripts.ci.auto_merge.get_vikunja_task_title", return_value="Some task title")
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True)
def test_matching_title_passes(self, mock_get: MagicMock) -> None:
validate_pr_title_matches_vikunja("GRM-19: Some task title", "GRM-19")
@patch("scripts.auto_merge.get_vikunja_task_title", return_value="Some task title")
@patch("scripts.ci.auto_merge.get_vikunja_task_title", return_value="Some task title")
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True)
def test_mismatched_title_raises(self, mock_get: MagicMock) -> None:
with pytest.raises(click.ClickException) as exc:
validate_pr_title_matches_vikunja("GRM-19: Different title", "GRM-19")
assert "does not match" in str(exc.value)
@patch("scripts.auto_merge.get_vikunja_task_title", return_value="")
@patch("scripts.ci.auto_merge.get_vikunja_task_title", return_value="")
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True)
def test_task_not_found_skips(self, mock_get: MagicMock) -> None:
"""Should skip validation when Vikunja task is not found."""
@@ -190,14 +190,14 @@ class TestValidatePrTitleMatchesVikunja:
class TestGetVikunjaTaskTitle:
@patch.dict("os.environ", {}, clear=True)
def test_no_token_returns_empty(self) -> None:
from scripts.auto_merge import get_vikunja_task_title
from scripts.ci.auto_merge import get_vikunja_task_title
assert get_vikunja_task_title("GRM-19") == ""
@patch("scripts.auto_merge.VikunjaClient")
@patch("scripts.ci.auto_merge.VikunjaClient")
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True)
def test_finds_task(self, mock_client_cls: MagicMock) -> None:
from scripts.auto_merge import get_vikunja_task_title
from scripts.ci.auto_merge import get_vikunja_task_title
mock_client = MagicMock()
mock_client.list_project_tasks.return_value = [
@@ -206,10 +206,10 @@ class TestGetVikunjaTaskTitle:
mock_client_cls.return_value = mock_client
assert get_vikunja_task_title("GRM-19") == "Some task title"
@patch("scripts.auto_merge.VikunjaClient")
@patch("scripts.ci.auto_merge.VikunjaClient")
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True)
def test_task_not_found_returns_empty(self, mock_client_cls: MagicMock) -> None:
from scripts.auto_merge import get_vikunja_task_title
from scripts.ci.auto_merge import get_vikunja_task_title
mock_client = MagicMock()
mock_client.list_project_tasks.return_value = [
@@ -218,10 +218,10 @@ class TestGetVikunjaTaskTitle:
mock_client_cls.return_value = mock_client
assert get_vikunja_task_title("GRM-19") == ""
@patch("scripts.auto_merge.VikunjaClient")
@patch("scripts.ci.auto_merge.VikunjaClient")
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True)
def test_paginates_to_find_task(self, mock_client_cls: MagicMock) -> None:
from scripts.auto_merge import get_vikunja_task_title
from scripts.ci.auto_merge import get_vikunja_task_title
mock_client = MagicMock()
# First page: full page of 50 tasks, no match; second page: match
@@ -231,10 +231,10 @@ class TestGetVikunjaTaskTitle:
mock_client_cls.return_value = mock_client
assert get_vikunja_task_title("GRM-99") == "Found task"
@patch("scripts.auto_merge.VikunjaClient")
@patch("scripts.ci.auto_merge.VikunjaClient")
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True)
def test_empty_pages_returns_empty(self, mock_client_cls: MagicMock) -> None:
from scripts.auto_merge import get_vikunja_task_title
from scripts.ci.auto_merge import get_vikunja_task_title
mock_client = MagicMock()
mock_client.list_project_tasks.return_value = []
@@ -257,7 +257,7 @@ class TestWaitForCi:
[_status("CI / quality (pull_request)", CI_PENDING)],
[_status("CI / quality (pull_request)", CI_SUCCESS)],
]
with patch("scripts.auto_merge.time.sleep"):
with patch("scripts.ci.auto_merge.time.sleep"):
assert wait_for_ci(client, "abc123", max_wait=10, poll_interval=5) is True
def test_fails_on_failed_check(self) -> None:
@@ -273,7 +273,7 @@ class TestWaitForCi:
client.get_commit_status.return_value = [
_status("CI / quality (pull_request)", CI_PENDING),
]
with patch("scripts.auto_merge.time.sleep"):
with patch("scripts.ci.auto_merge.time.sleep"):
assert wait_for_ci(client, "abc123", max_wait=5) is False
def test_no_statuses_waits(self) -> None:
@@ -282,7 +282,7 @@ class TestWaitForCi:
[],
[_status("CI / quality (pull_request)", CI_SUCCESS)],
]
with patch("scripts.auto_merge.time.sleep"):
with patch("scripts.ci.auto_merge.time.sleep"):
assert wait_for_ci(client, "abc123", max_wait=10, poll_interval=5) is True
def test_ignores_non_ci_contexts(self) -> None:
@@ -307,7 +307,7 @@ class TestWaitForCi:
[_status("Auto-merge / merge (pull_request)", CI_PENDING)],
[_status("CI / quality (pull_request)", CI_SUCCESS)],
]
with patch("scripts.auto_merge.time.sleep"):
with patch("scripts.ci.auto_merge.time.sleep"):
assert wait_for_ci(client, "abc123", max_wait=10, poll_interval=5) is True
@@ -325,9 +325,9 @@ def _mock_commits() -> list[dict[str, dict[str, str]]]:
class TestMain:
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
@patch("scripts.auto_merge.validate_pr_title_matches_vikunja")
@patch("scripts.auto_merge.has_approval_review", return_value=True)
@patch("scripts.auto_merge.GiteaClient")
@patch("scripts.ci.auto_merge.validate_pr_title_matches_vikunja")
@patch("scripts.ci.auto_merge.has_approval_review", return_value=True)
@patch("scripts.ci.auto_merge.GiteaClient")
def test_successful_flow_with_label_arg(
self, mock_client_cls: MagicMock, mock_approval: MagicMock, mock_vikunja: MagicMock
) -> None:
@@ -346,9 +346,9 @@ class TestMain:
mock_client.merge_pr.assert_called_once_with("7", "GRM-19 fix: resolve timeout")
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
@patch("scripts.auto_merge.validate_pr_title_matches_vikunja")
@patch("scripts.auto_merge.has_approval_review", return_value=True)
@patch("scripts.auto_merge.GiteaClient")
@patch("scripts.ci.auto_merge.validate_pr_title_matches_vikunja")
@patch("scripts.ci.auto_merge.has_approval_review", return_value=True)
@patch("scripts.ci.auto_merge.GiteaClient")
def test_successful_flow_label_fallback(
self, mock_client_cls: MagicMock, mock_approval: MagicMock, mock_vikunja: MagicMock
) -> None:
@@ -369,9 +369,9 @@ class TestMain:
mock_client.merge_pr.assert_called_once_with("7", "GRM-19 fix: resolve timeout")
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
@patch("scripts.auto_merge.validate_pr_title_matches_vikunja")
@patch("scripts.auto_merge.has_approval_review", return_value=True)
@patch("scripts.auto_merge.GiteaClient")
@patch("scripts.ci.auto_merge.validate_pr_title_matches_vikunja")
@patch("scripts.ci.auto_merge.has_approval_review", return_value=True)
@patch("scripts.ci.auto_merge.GiteaClient")
def test_wrong_label_skips_merge(
self, mock_client_cls: MagicMock, mock_approval: MagicMock, mock_vikunja: MagicMock
) -> None:
@@ -389,9 +389,9 @@ class TestMain:
mock_client.merge_pr.assert_not_called()
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
@patch("scripts.auto_merge.validate_pr_title_matches_vikunja")
@patch("scripts.auto_merge.has_approval_review", return_value=True)
@patch("scripts.auto_merge.GiteaClient")
@patch("scripts.ci.auto_merge.validate_pr_title_matches_vikunja")
@patch("scripts.ci.auto_merge.has_approval_review", return_value=True)
@patch("scripts.ci.auto_merge.GiteaClient")
def test_empty_label_falls_back_to_api(
self, mock_client_cls: MagicMock, mock_approval: MagicMock, mock_vikunja: MagicMock
) -> None:
@@ -418,7 +418,7 @@ class TestMain:
assert "REPO_TOKEN" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
@patch("scripts.auto_merge.GiteaClient")
@patch("scripts.ci.auto_merge.GiteaClient")
def test_missing_task_id_exits(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client.get_pr_labels.return_value = [{"name": "ready-to-merge"}]
@@ -429,7 +429,7 @@ class TestMain:
assert "task ID" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
@patch("scripts.auto_merge.GiteaClient")
@patch("scripts.ci.auto_merge.GiteaClient")
def test_invalid_pr_title_exits(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client.get_pr_labels.return_value = [{"name": "ready-to-merge"}]
@@ -440,7 +440,7 @@ class TestMain:
assert "GRM-N" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
@patch("scripts.auto_merge.GiteaClient")
@patch("scripts.ci.auto_merge.GiteaClient")
def test_pr_title_task_id_mismatch_exits(self, mock_client_cls: MagicMock) -> None:
"""PR title has a different task ID than the branch."""
mock_client = MagicMock()
@@ -452,9 +452,9 @@ class TestMain:
assert "mismatch" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
@patch("scripts.auto_merge.validate_pr_title_matches_vikunja")
@patch("scripts.auto_merge.has_approval_review", return_value=False)
@patch("scripts.auto_merge.GiteaClient")
@patch("scripts.ci.auto_merge.validate_pr_title_matches_vikunja")
@patch("scripts.ci.auto_merge.has_approval_review", return_value=False)
@patch("scripts.ci.auto_merge.GiteaClient")
def test_no_approval_review_blocks_merge(
self, mock_client_cls: MagicMock, mock_approval: MagicMock, mock_vikunja: MagicMock
) -> None:
@@ -469,9 +469,9 @@ class TestMain:
mock_client.merge_pr.assert_not_called()
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
@patch("scripts.auto_merge.validate_pr_title_matches_vikunja")
@patch("scripts.auto_merge.has_approval_review", return_value=True)
@patch("scripts.auto_merge.GiteaClient")
@patch("scripts.ci.auto_merge.validate_pr_title_matches_vikunja")
@patch("scripts.ci.auto_merge.has_approval_review", return_value=True)
@patch("scripts.ci.auto_merge.GiteaClient")
def test_empty_commits_exits(
self, mock_client_cls: MagicMock, mock_approval: MagicMock, mock_vikunja: MagicMock
) -> None:
@@ -489,9 +489,9 @@ class TestMain:
mock_client.merge_pr.assert_not_called()
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
@patch("scripts.auto_merge.validate_pr_title_matches_vikunja")
@patch("scripts.auto_merge.has_approval_review", return_value=True)
@patch("scripts.auto_merge.GiteaClient")
@patch("scripts.ci.auto_merge.validate_pr_title_matches_vikunja")
@patch("scripts.ci.auto_merge.has_approval_review", return_value=True)
@patch("scripts.ci.auto_merge.GiteaClient")
def test_merge_pr_failure_raises_click(
self, mock_client_cls: MagicMock, mock_approval: MagicMock, mock_vikunja: MagicMock
) -> None:
@@ -508,9 +508,9 @@ class TestMain:
assert "HTTP" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
@patch("scripts.auto_merge.validate_pr_title_matches_vikunja")
@patch("scripts.auto_merge.has_approval_review", return_value=True)
@patch("scripts.auto_merge.GiteaClient")
@patch("scripts.ci.auto_merge.validate_pr_title_matches_vikunja")
@patch("scripts.ci.auto_merge.has_approval_review", return_value=True)
@patch("scripts.ci.auto_merge.GiteaClient")
def test_merge_pr_json_parse_failure(
self, mock_client_cls: MagicMock, mock_approval: MagicMock, mock_vikunja: MagicMock
) -> None:
@@ -527,9 +527,9 @@ class TestMain:
assert str(http.HTTPStatus.BAD_GATEWAY) in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
@patch("scripts.auto_merge.validate_pr_title_matches_vikunja")
@patch("scripts.auto_merge.has_approval_review", return_value=True)
@patch("scripts.auto_merge.GiteaClient")
@patch("scripts.ci.auto_merge.validate_pr_title_matches_vikunja")
@patch("scripts.ci.auto_merge.has_approval_review", return_value=True)
@patch("scripts.ci.auto_merge.GiteaClient")
def test_ci_failure_blocks_merge(
self, mock_client_cls: MagicMock, mock_approval: MagicMock, mock_vikunja: MagicMock
) -> None:
@@ -548,9 +548,9 @@ class TestMain:
mock_client.merge_pr.assert_not_called()
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
@patch("scripts.auto_merge.validate_pr_title_matches_vikunja")
@patch("scripts.auto_merge.has_approval_review", return_value=True)
@patch("scripts.auto_merge.GiteaClient")
@patch("scripts.ci.auto_merge.validate_pr_title_matches_vikunja")
@patch("scripts.ci.auto_merge.has_approval_review", return_value=True)
@patch("scripts.ci.auto_merge.GiteaClient")
def test_no_sha_proceeds_without_wait(
self, mock_client_cls: MagicMock, mock_approval: MagicMock, mock_vikunja: MagicMock
) -> None:
+52 -27
View File
@@ -1,4 +1,4 @@
"""Unit tests for scripts/classify_changes.py."""
"""Unit tests for scripts/ci/classify_changes.py."""
from unittest.mock import MagicMock, patch
@@ -6,12 +6,13 @@ import click
import pytest
from click.testing import CliRunner
from scripts.classify_changes import (
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,
)
@@ -30,8 +31,22 @@ class TestIsUserFacing:
def test_workflow_is_not_user_facing(self) -> None:
assert is_user_facing(".gitea/workflows/ci.yml") is False
def test_scripts_are_not_user_facing(self) -> None:
assert is_user_facing("scripts/release.py") 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_user_facing(self) -> None:
"""Dev scripts (check_test_speed, configure_repo) are NOT in the
workflow-only allowlist, so they default to user-facing."""
assert is_user_facing("scripts/check_test_speed.py") is True
assert is_user_facing("scripts/configure_repo.py") is True
assert is_user_facing("scripts/install_checkmake.py") is True
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_docs_are_not_user_facing(self) -> None:
assert is_user_facing("docs/user/getting-started.md") is False
@@ -45,6 +60,16 @@ class TestIsUserFacing:
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:
@@ -78,13 +103,13 @@ class TestClassifyChanges:
class TestGetChangedFiles:
@patch("scripts.classify_changes.run_git")
@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.classify_changes.run_git")
@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")
@@ -92,17 +117,17 @@ class TestGetChangedFiles:
class TestHasUserFacingChanges:
@patch("scripts.classify_changes.get_changed_files")
@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.classify_changes.get_changed_files")
@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.classify_changes.get_changed_files")
@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
@@ -121,13 +146,13 @@ class TestGetLatestTag:
class TestRunGit:
@patch("scripts.classify_changes.subprocess.run")
@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.classify_changes.subprocess.run")
@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):
@@ -135,23 +160,23 @@ class TestRunGit:
class TestMain:
@patch("scripts.classify_changes.get_latest_tag", return_value="")
@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.classify_changes.get_changed_files", return_value=[])
@patch("scripts.classify_changes.get_latest_tag", return_value="v0.3.0")
@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.classify_changes.get_changed_files")
@patch("scripts.classify_changes.get_latest_tag", return_value="v0.3.0")
@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()
@@ -159,8 +184,8 @@ class TestMain:
assert result.exit_code == 2
assert "no release needed" in result.output
@patch("scripts.classify_changes.get_changed_files")
@patch("scripts.classify_changes.get_latest_tag", return_value="v0.3.0")
@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()
@@ -168,7 +193,7 @@ class TestMain:
assert result.exit_code == 0
assert "release needed" in result.output
@patch("scripts.classify_changes.get_latest_tag", return_value="")
@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()
@@ -176,8 +201,8 @@ class TestMain:
assert result.exit_code == 0
assert "No tags found" in result.output
@patch("scripts.classify_changes.get_changed_files", return_value=[])
@patch("scripts.classify_changes.get_latest_tag", return_value="v0.3.0")
@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()
@@ -185,8 +210,8 @@ class TestMain:
assert result.exit_code == 0
assert "No changes" in result.output
@patch("scripts.classify_changes.get_changed_files")
@patch("scripts.classify_changes.get_latest_tag", return_value="v0.3.0")
@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"]
@@ -195,8 +220,8 @@ class TestMain:
assert result.exit_code == 0
assert "true" in result.output
@patch("scripts.classify_changes.get_changed_files")
@patch("scripts.classify_changes.get_latest_tag", return_value="v0.3.0")
@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"]
@@ -205,8 +230,8 @@ class TestMain:
assert result.exit_code == 0
assert "false" in result.output
@patch("scripts.classify_changes.get_changed_files")
@patch("scripts.classify_changes.get_latest_tag", return_value="v0.3.0")
@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"]
+190
View File
@@ -0,0 +1,190 @@
"""Unit tests for scripts/ci/discover_runners.py."""
import json
from unittest.mock import MagicMock, patch
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) == [0]
def test_three(self) -> None:
assert generate_indices(3) == [0, 1, 2]
def test_five(self) -> None:
assert generate_indices(5) == [0, 1, 2, 3, 4]
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=[0, 1, 2]" 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()) == [0, 1, 2, 3]
@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()) == [0]
+10 -10
View File
@@ -1,4 +1,4 @@
"""Unit tests for scripts/distribute_molecule.py."""
"""Unit tests for scripts/ci/distribute_molecule.py."""
from pathlib import Path
from unittest.mock import patch
@@ -6,7 +6,7 @@ from unittest.mock import patch
import click
import pytest
from scripts.distribute_molecule import (
from scripts.ci.distribute_molecule import (
MOLECULE_ROOT,
PLATFORMS,
TestPair,
@@ -134,12 +134,12 @@ class TestCli:
def test_list_flag(self, tmp_path: Path) -> None:
from click.testing import CliRunner
from scripts.distribute_molecule import cli
from scripts.ci.distribute_molecule import cli
root = tmp_path / "molecule"
(root / "alpha").mkdir(parents=True)
(root / "beta").mkdir(parents=True)
with patch("scripts.distribute_molecule.MOLECULE_ROOT", root):
with patch("scripts.ci.distribute_molecule.MOLECULE_ROOT", root):
runner = CliRunner()
result = runner.invoke(cli, ["--list"])
assert result.exit_code == 0
@@ -149,7 +149,7 @@ class TestCli:
def test_list_platforms_flag(self) -> None:
from click.testing import CliRunner
from scripts.distribute_molecule import cli
from scripts.ci.distribute_molecule import cli
runner = CliRunner()
result = runner.invoke(cli, ["--list-platforms"])
@@ -162,12 +162,12 @@ class TestCli:
def test_no_runner_index_prints_all_groups(self, tmp_path: Path) -> None:
from click.testing import CliRunner
from scripts.distribute_molecule import cli
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.distribute_molecule.MOLECULE_ROOT", root):
with patch("scripts.ci.distribute_molecule.MOLECULE_ROOT", root):
runner = CliRunner()
result = runner.invoke(cli, ["--max-runners", "3"])
assert result.exit_code == 0
@@ -178,11 +178,11 @@ class TestCli:
def test_runner_index_prints_assigned(self, tmp_path: Path) -> None:
from click.testing import CliRunner
from scripts.distribute_molecule import cli
from scripts.ci.distribute_molecule import cli
root = tmp_path / "molecule"
(root / "alpha").mkdir(parents=True)
with patch("scripts.distribute_molecule.MOLECULE_ROOT", root):
with patch("scripts.ci.distribute_molecule.MOLECULE_ROOT", root):
runner = CliRunner()
result = runner.invoke(cli, ["--runner-index", "0", "--max-runners", "3"])
assert result.exit_code == 0
@@ -192,7 +192,7 @@ class TestCli:
def test_main_module_block() -> None:
import scripts.distribute_molecule as dm
import scripts.ci.distribute_molecule as dm
with open(dm.__file__) as f:
source = f.read()
+4 -3
View File
@@ -1,10 +1,10 @@
"""Unit tests for scripts/doc_coverage.py."""
"""Unit tests for scripts/ci/doc_coverage.py."""
from pathlib import Path
from click.testing import CliRunner
from scripts.doc_coverage import (
from scripts.ci.doc_coverage import (
check_command_documented,
check_module_documented,
extract_cli_commands,
@@ -76,7 +76,8 @@ class TestMain:
)
# 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"
"auto_merge.py release.py publish.py review_pr.py "
"notify_failure.py post_merge.py classify_changes.py discover_runners.py"
)
runner = CliRunner()
result = runner.invoke(main, ["--docs-dir", str(docs)])
+25 -25
View File
@@ -1,4 +1,4 @@
"""Unit tests for scripts/molecule_ci_guard.py."""
"""Unit tests for scripts/ci/molecule_ci_guard.py."""
from __future__ import annotations
@@ -10,7 +10,7 @@ from unittest.mock import MagicMock, patch
import pytest
import requests
from scripts.molecule_ci_guard import (
from scripts.ci.molecule_ci_guard import (
any_other_runner_failed,
build_env_for_pair,
build_molecule_cmd,
@@ -22,7 +22,7 @@ from scripts.molecule_ci_guard import (
class TestGetRunningJobs:
def test_returns_jobs(self) -> None:
with patch("scripts.molecule_ci_guard.requests.get") as mock_get:
with patch("scripts.ci.molecule_ci_guard.requests.get") as mock_get:
mock_response = MagicMock()
mock_response.json.return_value = {
"jobs": [
@@ -38,7 +38,7 @@ class TestGetRunningJobs:
mock_get.assert_called_once()
def test_raises_on_request_error(self) -> None:
with patch("scripts.molecule_ci_guard.requests.get") as mock_get:
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)
@@ -107,7 +107,7 @@ class TestPollForOtherFailures:
]
return []
with patch("scripts.molecule_ci_guard.get_running_jobs") as mock_get_jobs:
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
@@ -130,7 +130,7 @@ class TestPollForOtherFailures:
stop_event = MagicMock()
failed_event = MagicMock()
with patch("scripts.molecule_ci_guard.get_running_jobs") as mock_get_jobs:
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
@@ -155,7 +155,7 @@ class TestCli:
from click.testing import CliRunner
with (
patch("scripts.molecule_ci_guard.subprocess.Popen") as mock_popen,
patch("scripts.ci.molecule_ci_guard.subprocess.Popen") as mock_popen,
patch("time.sleep"),
):
proc = MagicMock()
@@ -172,7 +172,7 @@ class TestCli:
from click.testing import CliRunner
with (
patch("scripts.molecule_ci_guard.subprocess.Popen") as mock_popen,
patch("scripts.ci.molecule_ci_guard.subprocess.Popen") as mock_popen,
patch("time.sleep"),
):
proc = MagicMock()
@@ -202,9 +202,9 @@ class TestCli:
},
clear=True,
),
patch("scripts.molecule_ci_guard.POLL_INTERVAL", 0.01),
patch("scripts.molecule_ci_guard.subprocess.Popen") as mock_popen,
patch("scripts.molecule_ci_guard.get_running_jobs") as mock_get_jobs,
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 = [
@@ -225,7 +225,7 @@ class TestCli:
from click.testing import CliRunner
with (
patch("scripts.molecule_ci_guard.subprocess.Popen") as mock_popen,
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,
@@ -270,9 +270,9 @@ class TestCli:
},
clear=True,
),
patch("scripts.molecule_ci_guard.POLL_INTERVAL", 0.01),
patch("scripts.molecule_ci_guard.subprocess.Popen") as mock_popen,
patch("scripts.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
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)),
@@ -307,9 +307,9 @@ class TestCli:
},
clear=True,
),
patch("scripts.molecule_ci_guard.POLL_INTERVAL", 0.01),
patch("scripts.molecule_ci_guard.subprocess.Popen") as mock_popen,
patch("scripts.molecule_ci_guard.get_running_jobs") as mock_get_jobs,
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"}]
@@ -351,9 +351,9 @@ class TestCli:
},
clear=True,
),
patch("scripts.molecule_ci_guard.POLL_INTERVAL", 0.01),
patch("scripts.molecule_ci_guard.subprocess.Popen") as mock_popen,
patch("scripts.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
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)),
@@ -398,9 +398,9 @@ class TestCli:
},
clear=True,
),
patch("scripts.molecule_ci_guard.POLL_INTERVAL", 0.01),
patch("scripts.molecule_ci_guard.subprocess.Popen") as mock_popen,
patch("scripts.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
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)),
@@ -418,7 +418,7 @@ class TestCli:
def test_main_module_block() -> None:
import scripts.molecule_ci_guard as mg
import scripts.ci.molecule_ci_guard as mg
with open(mg.__file__) as f:
source = f.read()
+5 -5
View File
@@ -1,16 +1,16 @@
"""Unit tests for scripts/notify_failure.py."""
"""Unit tests for scripts/ci/notify_failure.py."""
from unittest.mock import MagicMock, patch
from click.testing import CliRunner
from gitea_runner_manager.exceptions import APIError
from scripts.notify_failure import main
from scripts.ci.notify_failure import main
class TestNotifyFailure:
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
@patch("scripts.notify_failure.GiteaClient")
@patch("scripts.ci.notify_failure.GiteaClient")
def test_creates_issue_with_labels(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client.list_labels.return_value = [{"id": 5, "name": "bug"}]
@@ -38,7 +38,7 @@ class TestNotifyFailure:
assert call_kwargs.kwargs["labels"] == [5]
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
@patch("scripts.notify_failure.GiteaClient")
@patch("scripts.ci.notify_failure.GiteaClient")
def test_creates_issue_without_bug_label(self, mock_client_cls: MagicMock) -> None:
"""When 'bug' label doesn't exist, create issue without labels."""
mock_client = MagicMock()
@@ -67,7 +67,7 @@ class TestNotifyFailure:
assert call_kwargs.kwargs.get("labels") is None
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
@patch("scripts.notify_failure.GiteaClient")
@patch("scripts.ci.notify_failure.GiteaClient")
def test_api_error_raises(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client.list_labels.return_value = []
+7 -7
View File
@@ -1,4 +1,4 @@
"""Unit tests for scripts/post_merge.py."""
"""Unit tests for scripts/ci/post_merge.py."""
import http
from unittest.mock import MagicMock, patch
@@ -8,7 +8,7 @@ import pytest
from click.testing import CliRunner
from gitea_runner_manager.exceptions import APIError
from scripts.post_merge import (
from scripts.ci.post_merge import (
build_comment,
extract_conventional_msg,
extract_task_id,
@@ -90,7 +90,7 @@ class TestResolveTaskId:
class TestMain:
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
@patch("scripts.post_merge.VikunjaClient")
@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 = [
@@ -108,7 +108,7 @@ class TestMain:
mock_client.update_task.assert_called_once_with(267, done=True)
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
@patch("scripts.post_merge.VikunjaClient")
@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 = [
@@ -137,7 +137,7 @@ class TestMain:
assert "skipping" in result.output
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
@patch("scripts.post_merge.VikunjaClient")
@patch("scripts.ci.post_merge.VikunjaClient")
def test_resolve_failure_propagates(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client.list_project_tasks.return_value = []
@@ -148,7 +148,7 @@ class TestMain:
assert "Could not find" in result.output
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
@patch("scripts.post_merge.VikunjaClient")
@patch("scripts.ci.post_merge.VikunjaClient")
def test_post_comment_failure_raises_click(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client.list_project_tasks.return_value = [
@@ -162,7 +162,7 @@ class TestMain:
assert "HTTP" in result.output
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
@patch("scripts.post_merge.VikunjaClient")
@patch("scripts.ci.post_merge.VikunjaClient")
def test_mark_done_failure_raises_click(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client.list_project_tasks.return_value = [
+40 -40
View File
@@ -1,4 +1,4 @@
"""Unit tests for scripts/publish.py."""
"""Unit tests for scripts/ci/publish.py."""
import http
from unittest.mock import MagicMock, patch
@@ -7,7 +7,7 @@ import click
import pytest
from click.testing import CliRunner
from scripts.publish import (
from scripts.ci.publish import (
build_package,
generate_release_notes,
main,
@@ -16,44 +16,44 @@ from scripts.publish import (
class TestGenerateReleaseNotes:
@patch("scripts.publish.subprocess.run")
@patch("scripts.publish.shutil.which", return_value="/usr/local/bin/git-cliff")
@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.publish.subprocess.run")
@patch("scripts.publish.shutil.which", return_value="/usr/local/bin/git-cliff")
@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.publish.subprocess.run")
@patch("scripts.publish.shutil.which", return_value="/usr/local/bin/git-cliff")
@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.publish.subprocess.run")
@patch("scripts.publish.shutil.which", return_value="/usr/local/bin/git-cliff")
@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.publish.subprocess.run", side_effect=FileNotFoundError)
@patch("scripts.publish.shutil.which", return_value="/usr/local/bin/git-cliff")
@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.publish.shutil.which", return_value=None)
@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
@@ -61,7 +61,7 @@ class TestGenerateReleaseNotes:
class TestBuildPackage:
@patch("scripts.publish.subprocess.run")
@patch("scripts.ci.publish.subprocess.run")
def test_success(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(returncode=0, stderr="")
build_package()
@@ -69,7 +69,7 @@ class TestBuildPackage:
assert args[0][1] == "-m"
assert args[0][2] == "build"
@patch("scripts.publish.subprocess.run")
@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:
@@ -78,7 +78,7 @@ class TestBuildPackage:
class TestPublishToPypi:
@patch("scripts.publish.subprocess.run")
@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")
@@ -86,7 +86,7 @@ class TestPublishToPypi:
assert "twine" in args[0]
assert "pypi-tok" in args[0]
@patch("scripts.publish.subprocess.run")
@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:
@@ -96,10 +96,10 @@ class TestPublishToPypi:
class TestMain:
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"})
@patch("scripts.publish.generate_release_notes", return_value="Release notes")
@patch("scripts.publish.GiteaClient")
@patch("scripts.publish.publish_to_pypi")
@patch("scripts.publish.build_package")
@patch("scripts.ci.publish.generate_release_notes", return_value="Release notes")
@patch("scripts.ci.publish.GiteaClient")
@patch("scripts.ci.publish.publish_to_pypi")
@patch("scripts.ci.publish.build_package")
def test_full_flow_with_pypi(
self,
mock_build: MagicMock,
@@ -119,9 +119,9 @@ class TestMain:
assert call_args.kwargs["body"] == "Release notes"
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok"}, clear=True)
@patch("scripts.publish.generate_release_notes", return_value="Release notes")
@patch("scripts.publish.GiteaClient")
@patch("scripts.publish.build_package")
@patch("scripts.ci.publish.generate_release_notes", return_value="Release notes")
@patch("scripts.ci.publish.GiteaClient")
@patch("scripts.ci.publish.build_package")
def test_without_pypi(
self,
mock_build: MagicMock,
@@ -143,10 +143,10 @@ class TestMain:
assert "REPO_TOKEN" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"})
@patch("scripts.publish.generate_release_notes", return_value="Release notes")
@patch("scripts.publish.GiteaClient")
@patch("scripts.publish.publish_to_pypi")
@patch("scripts.publish.build_package")
@patch("scripts.ci.publish.generate_release_notes", return_value="Release notes")
@patch("scripts.ci.publish.GiteaClient")
@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_client_cls: MagicMock, mock_notes: MagicMock
) -> None:
@@ -157,10 +157,10 @@ class TestMain:
assert "build" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"})
@patch("scripts.publish.generate_release_notes", return_value="Release notes")
@patch("scripts.publish.GiteaClient")
@patch("scripts.publish.publish_to_pypi")
@patch("scripts.publish.build_package")
@patch("scripts.ci.publish.generate_release_notes", return_value="Release notes")
@patch("scripts.ci.publish.GiteaClient")
@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_client_cls: MagicMock, mock_notes: MagicMock
) -> None:
@@ -171,10 +171,10 @@ class TestMain:
assert "publish" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"})
@patch("scripts.publish.generate_release_notes", return_value="Release notes")
@patch("scripts.publish.GiteaClient")
@patch("scripts.publish.publish_to_pypi")
@patch("scripts.publish.build_package")
@patch("scripts.ci.publish.generate_release_notes", return_value="Release notes")
@patch("scripts.ci.publish.GiteaClient")
@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_client_cls: MagicMock, mock_notes: MagicMock
) -> None:
@@ -189,10 +189,10 @@ class TestMain:
assert "HTTP" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"})
@patch("scripts.publish.generate_release_notes", return_value="Release notes")
@patch("scripts.publish.GiteaClient")
@patch("scripts.publish.publish_to_pypi")
@patch("scripts.publish.build_package")
@patch("scripts.ci.publish.generate_release_notes", return_value="Release notes")
@patch("scripts.ci.publish.GiteaClient")
@patch("scripts.ci.publish.publish_to_pypi")
@patch("scripts.ci.publish.build_package")
def test_release_json_parse_failure(
self, mock_build: MagicMock, mock_publish: MagicMock, mock_client_cls: MagicMock, mock_notes: MagicMock
) -> None:
+123 -123
View File
@@ -1,4 +1,4 @@
"""Unit tests for scripts/release.py."""
"""Unit tests for scripts/ci/release.py."""
from unittest.mock import MagicMock, patch
@@ -6,7 +6,7 @@ import click
import pytest
from click.testing import CliRunner
from scripts.release import (
from scripts.ci.release import (
commit_release_changes,
create_and_push_tag,
get_bumped_version,
@@ -23,20 +23,20 @@ from scripts.release import (
class TestRunCmd:
@patch("scripts.release.subprocess.run")
@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.release.subprocess.run")
@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.release.subprocess.run")
@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)
@@ -44,41 +44,41 @@ class TestRunCmd:
class TestGetLatestTag:
@patch("scripts.release.run_cmd")
@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.release.run_cmd")
@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.release.run_cmd")
@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.release.run_cmd")
@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.release.run_cmd")
@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.release.run_cmd")
@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.release.run_cmd")
@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):
@@ -86,48 +86,48 @@ class TestGetBumpedVersion:
class TestGetChangelog:
@patch("scripts.release.run_cmd")
@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.release.run_cmd")
@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.release.get_latest_tag")
@patch("scripts.ci.release.get_latest_tag")
def test_with_bumped_version_no_changes(self, mock_latest: MagicMock) -> None:
mock_latest.return_value = "v0.2.0"
assert has_unreleased_changes(bumped_version="0.2.0") is False
@patch("scripts.release.get_latest_tag")
@patch("scripts.ci.release.get_latest_tag")
def test_with_bumped_version_has_changes(self, mock_latest: MagicMock) -> None:
mock_latest.return_value = "v0.2.0"
assert has_unreleased_changes(bumped_version="0.3.0") is True
@patch("scripts.release.get_latest_tag")
@patch("scripts.ci.release.get_latest_tag")
def test_with_bumped_version_no_tags(self, mock_latest: MagicMock) -> None:
mock_latest.return_value = ""
assert has_unreleased_changes(bumped_version="0.1.0") is True
@patch("scripts.release.get_latest_tag")
@patch("scripts.release.run_cmd")
@patch("scripts.ci.release.get_latest_tag")
@patch("scripts.ci.release.run_cmd")
def test_without_bumped_version_no_changes(self, mock_run_cmd: MagicMock, mock_latest: MagicMock) -> None:
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="v0.2.0\n")
mock_latest.return_value = "v0.2.0"
assert has_unreleased_changes() is False
@patch("scripts.release.get_latest_tag")
@patch("scripts.release.run_cmd")
@patch("scripts.ci.release.get_latest_tag")
@patch("scripts.ci.release.run_cmd")
def test_without_bumped_version_has_changes(self, mock_run_cmd: MagicMock, mock_latest: MagicMock) -> None:
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="v0.3.0\n")
mock_latest.return_value = "v0.2.0"
assert has_unreleased_changes() is True
@patch("scripts.release.run_cmd")
@patch("scripts.ci.release.run_cmd")
def test_cliff_fails_returns_false(self, mock_run_cmd: MagicMock) -> None:
mock_run_cmd.return_value = MagicMock(returncode=1, stdout="")
assert has_unreleased_changes() is False
@@ -137,7 +137,7 @@ 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.release.INIT_FILE", str(init_file))
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()
@@ -145,14 +145,14 @@ class TestUpdateInitVersion:
"""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.release.INIT_FILE", str(init_file))
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.release.INIT_FILE", str(init_file))
monkeypatch.setattr("scripts.ci.release.INIT_FILE", str(init_file))
with pytest.raises(click.ClickException):
update_init_version("0.2.0")
@@ -160,7 +160,7 @@ class TestUpdateInitVersion:
class TestUpdateChangelog:
def test_creates_new_file(self, tmp_path, monkeypatch) -> None:
changelog_file = tmp_path / "CHANGELOG.md"
monkeypatch.setattr("scripts.release.CHANGELOG_FILE", str(changelog_file))
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
@@ -169,7 +169,7 @@ class TestUpdateChangelog:
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.release.CHANGELOG_FILE", str(changelog_file))
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
@@ -181,7 +181,7 @@ class TestUpdateChangelog:
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.release.CHANGELOG_FILE", str(changelog_file))
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
@@ -191,7 +191,7 @@ class TestUpdateChangelog:
"""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.release.CHANGELOG_FILE", str(changelog_file))
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)
@@ -204,7 +204,7 @@ class TestUpdateChangelog:
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.release.CHANGELOG_FILE", str(changelog_file))
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()
@@ -213,7 +213,7 @@ class TestUpdateChangelog:
class TestCommitReleaseChanges:
@patch("scripts.release.run_cmd")
@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="")
@@ -223,7 +223,7 @@ class TestCommitReleaseChanges:
assert ["git", "add", "src/gitea_runner_manager/__init__.py", "CHANGELOG.md"] in calls
assert ["git", "commit", "--no-verify", "-m", "release: v0.2.0"] in calls
@patch("scripts.release.run_cmd")
@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="")
@@ -234,16 +234,16 @@ class TestCommitReleaseChanges:
class TestCreateAndPushTag:
@patch("scripts.release.tag_exists", return_value=False)
@patch("scripts.release.run_cmd")
@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.release.tag_exists", return_value=False)
@patch("scripts.release.run_cmd")
@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
@@ -251,8 +251,8 @@ class TestCreateAndPushTag:
assert call.args[0][0:2] != ["git", "push"]
assert call.args[0][0:2] != ["git", "tag"]
@patch("scripts.release.tag_exists", return_value=True)
@patch("scripts.release.run_cmd")
@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
@@ -261,8 +261,8 @@ class TestCreateAndPushTag:
assert ["git", "tag", "-a"] not in [c[:3] for c in calls]
assert ["git", "push", "origin", "v0.1.0"] in calls
@patch("scripts.release.tag_exists", return_value=True)
@patch("scripts.release.run_cmd")
@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
@@ -271,18 +271,18 @@ class TestCreateAndPushTag:
class TestRunTests:
@patch("scripts.release.run_cmd")
@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.release.run_cmd")
@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.release.run_cmd")
@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
@@ -294,7 +294,7 @@ class TestRunTests:
class TestMain:
@patch.dict("os.environ", {})
@patch("scripts.release.run_cmd")
@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()
@@ -303,10 +303,10 @@ class TestMain:
assert "master" in result.output
@patch.dict("os.environ", {})
@patch("scripts.release.has_user_facing_changes", return_value=True)
@patch("scripts.release.has_unreleased_changes", return_value=False)
@patch("scripts.release.get_bumped_version", return_value="0.2.0")
@patch("scripts.release.run_cmd")
@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,
@@ -321,16 +321,16 @@ class TestMain:
assert "No unreleased changes" in result.output
@patch.dict("os.environ", {})
@patch("scripts.release.has_user_facing_changes", return_value=True)
@patch("scripts.release.create_and_push_tag")
@patch("scripts.release.commit_release_changes")
@patch("scripts.release.update_changelog")
@patch("scripts.release.update_init_version")
@patch("scripts.release.get_changelog", return_value="")
@patch("scripts.release.get_latest_tag", return_value="v0.1.0")
@patch("scripts.release.get_bumped_version", return_value="0.2.0")
@patch("scripts.release.has_unreleased_changes", return_value=True)
@patch("scripts.release.run_cmd")
@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,
@@ -351,16 +351,16 @@ class TestMain:
assert "empty changelog" in result.output
@patch.dict("os.environ", {})
@patch("scripts.release.has_user_facing_changes", return_value=True)
@patch("scripts.release.create_and_push_tag")
@patch("scripts.release.commit_release_changes")
@patch("scripts.release.update_changelog")
@patch("scripts.release.update_init_version")
@patch("scripts.release.get_changelog", return_value="changelog")
@patch("scripts.release.get_latest_tag", return_value="v0.1.0")
@patch("scripts.release.get_bumped_version", return_value="0.2.0")
@patch("scripts.release.has_unreleased_changes", return_value=True)
@patch("scripts.release.run_cmd")
@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,
@@ -385,9 +385,9 @@ class TestMain:
mock_tag.assert_not_called()
@patch.dict("os.environ", {})
@patch("scripts.release.get_latest_tag", return_value="v0.3.0")
@patch("scripts.release.has_user_facing_changes", return_value=False)
@patch("scripts.release.run_cmd")
@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,
@@ -403,17 +403,17 @@ class TestMain:
assert "Skipping release" in result.output
@patch.dict("os.environ", {})
@patch("scripts.release.has_user_facing_changes", return_value=True)
@patch("scripts.release.run_tests")
@patch("scripts.release.create_and_push_tag", return_value=True)
@patch("scripts.release.commit_release_changes", return_value=True)
@patch("scripts.release.update_changelog")
@patch("scripts.release.update_init_version")
@patch("scripts.release.get_changelog", return_value="changelog")
@patch("scripts.release.get_latest_tag", return_value="v0.1.0")
@patch("scripts.release.get_bumped_version", return_value="0.2.0")
@patch("scripts.release.has_unreleased_changes", return_value=True)
@patch("scripts.release.run_cmd")
@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,
@@ -440,17 +440,17 @@ class TestMain:
mock_tag.assert_called_once_with("0.2.0", "changelog", False)
@patch.dict("os.environ", {})
@patch("scripts.release.has_user_facing_changes", return_value=True)
@patch("scripts.release.run_tests")
@patch("scripts.release.create_and_push_tag", return_value=False)
@patch("scripts.release.commit_release_changes", return_value=False)
@patch("scripts.release.update_changelog")
@patch("scripts.release.update_init_version")
@patch("scripts.release.get_changelog", return_value="changelog")
@patch("scripts.release.get_latest_tag", return_value="v0.1.0")
@patch("scripts.release.get_bumped_version", return_value="0.1.0")
@patch("scripts.release.has_unreleased_changes", return_value=True)
@patch("scripts.release.run_cmd")
@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,
@@ -474,16 +474,16 @@ class TestMain:
mock_tag.assert_called_once_with("0.1.0", "changelog", False)
@patch.dict("os.environ", {})
@patch("scripts.release.has_user_facing_changes", return_value=True)
@patch("scripts.release.create_and_push_tag", return_value=True)
@patch("scripts.release.commit_release_changes", return_value=True)
@patch("scripts.release.update_changelog")
@patch("scripts.release.update_init_version")
@patch("scripts.release.get_changelog", return_value="changelog")
@patch("scripts.release.get_latest_tag", return_value="v0.1.0")
@patch("scripts.release.get_bumped_version", return_value="0.2.0")
@patch("scripts.release.has_unreleased_changes", return_value=True)
@patch("scripts.release.run_cmd")
@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,
@@ -508,16 +508,16 @@ class TestMain:
assert make_calls == []
@patch.dict("os.environ", {})
@patch("scripts.release.has_user_facing_changes", return_value=True)
@patch("scripts.release.create_and_push_tag")
@patch("scripts.release.commit_release_changes")
@patch("scripts.release.update_changelog")
@patch("scripts.release.update_init_version")
@patch("scripts.release.get_changelog", return_value="changelog")
@patch("scripts.release.get_latest_tag", return_value="v0.1.0")
@patch("scripts.release.get_bumped_version", return_value="0.2.0")
@patch("scripts.release.has_unreleased_changes", return_value=True)
@patch("scripts.release.run_cmd")
@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,
@@ -547,16 +547,16 @@ class TestMain:
mock_tag.assert_not_called()
@patch.dict("os.environ", {})
@patch("scripts.release.has_user_facing_changes", return_value=True)
@patch("scripts.release.create_and_push_tag")
@patch("scripts.release.commit_release_changes")
@patch("scripts.release.update_changelog")
@patch("scripts.release.update_init_version")
@patch("scripts.release.get_changelog", return_value="changelog")
@patch("scripts.release.get_latest_tag", return_value="v0.1.0")
@patch("scripts.release.get_bumped_version", return_value="0.2.0")
@patch("scripts.release.has_unreleased_changes", return_value=True)
@patch("scripts.release.run_cmd")
@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,
+14 -14
View File
@@ -1,4 +1,4 @@
"""Unit tests for scripts/review_pr.py."""
"""Unit tests for scripts/ci/review_pr.py."""
import http
import json
@@ -9,7 +9,7 @@ import pytest
from click.testing import CliRunner
from gitea_runner_manager.exceptions import APIError
from scripts.review_pr import main, parse_comments
from scripts.ci.review_pr import main, parse_comments
class TestParseComments:
@@ -21,7 +21,7 @@ class TestParseComments:
def test_parse_from_stdin(self) -> None:
comments = [{"path": "a.py", "body": "fix", "new_position": 1}]
with patch("scripts.review_pr.sys.stdin") as mock_stdin:
with patch("scripts.ci.review_pr.sys.stdin") as mock_stdin:
mock_stdin.read.return_value = json.dumps(comments)
assert parse_comments(None, True) == comments
@@ -41,26 +41,26 @@ class TestParseComments:
parse_comments(str(f), False)
def test_stdin_non_list_raises(self) -> None:
with patch("scripts.review_pr.sys.stdin") as mock_stdin:
with patch("scripts.ci.review_pr.sys.stdin") as mock_stdin:
mock_stdin.read.return_value = json.dumps({"path": "a.py"})
with pytest.raises(click.ClickException):
parse_comments(None, True)
def test_stdin_invalid_json_raises(self) -> None:
with patch("scripts.review_pr.sys.stdin") as mock_stdin:
with patch("scripts.ci.review_pr.sys.stdin") as mock_stdin:
mock_stdin.read.return_value = "not json{"
with pytest.raises(click.ClickException):
parse_comments(None, True)
def test_stdin_empty_returns_empty(self) -> None:
with patch("scripts.review_pr.sys.stdin") as mock_stdin:
with patch("scripts.ci.review_pr.sys.stdin") as mock_stdin:
mock_stdin.read.return_value = " "
assert parse_comments(None, True) == []
class TestMain:
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
@patch("scripts.review_pr.GiteaClient")
@patch("scripts.ci.review_pr.GiteaClient")
def test_successful_comment_review(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client.create_review.return_value = {"id": 42}
@@ -75,7 +75,7 @@ class TestMain:
mock_client.create_review.assert_called_once_with("5", event="COMMENT", body="LGTM", comments=[])
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
@patch("scripts.review_pr.GiteaClient")
@patch("scripts.ci.review_pr.GiteaClient")
def test_successful_approve_review(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client.create_review.return_value = {"id": 7}
@@ -87,7 +87,7 @@ class TestMain:
mock_client.create_review.assert_called_once_with("5", event="APPROVE", body="", comments=[])
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
@patch("scripts.review_pr.GiteaClient")
@patch("scripts.ci.review_pr.GiteaClient")
def test_successful_with_inline_comments(self, mock_client_cls: MagicMock, tmp_path) -> None:
comments = [{"path": "a.py", "body": "fix", "new_position": 1}]
f = tmp_path / "comments.json"
@@ -104,7 +104,7 @@ class TestMain:
mock_client.create_review.assert_called_once_with("5", event="COMMENT", body="", comments=comments)
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
@patch("scripts.review_pr.GiteaClient")
@patch("scripts.ci.review_pr.GiteaClient")
def test_successful_with_stdin_comments(self, mock_client_cls: MagicMock) -> None:
comments = [{"path": "a.py", "body": "fix", "new_position": 1}]
mock_client = MagicMock()
@@ -127,7 +127,7 @@ class TestMain:
assert "REPO_TOKEN" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
@patch("scripts.review_pr.GiteaClient")
@patch("scripts.ci.review_pr.GiteaClient")
def test_no_body_or_comments_for_comment_event(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client_cls.return_value = mock_client
@@ -138,7 +138,7 @@ class TestMain:
mock_client.create_review.assert_not_called()
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
@patch("scripts.review_pr.GiteaClient")
@patch("scripts.ci.review_pr.GiteaClient")
def test_no_body_or_comments_for_request_changes(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client_cls.return_value = mock_client
@@ -149,7 +149,7 @@ class TestMain:
mock_client.create_review.assert_not_called()
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
@patch("scripts.review_pr.GiteaClient")
@patch("scripts.ci.review_pr.GiteaClient")
def test_api_error_raises_click(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client.create_review.side_effect = APIError(http.HTTPStatus.INTERNAL_SERVER_ERROR, "server error")
@@ -160,7 +160,7 @@ class TestMain:
assert "HTTP" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
@patch("scripts.review_pr.GiteaClient")
@patch("scripts.ci.review_pr.GiteaClient")
def test_invalid_event_choice(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client_cls.return_value = mock_client
+34 -34
View File
@@ -1,4 +1,4 @@
"""Unit tests for scripts/sync_wiki.py."""
"""Unit tests for scripts/ci/sync_wiki.py."""
import json
from pathlib import Path
@@ -7,7 +7,7 @@ from unittest.mock import MagicMock, patch
import pytest
from click.testing import CliRunner
from scripts.sync_wiki import (
from scripts.ci.sync_wiki import (
list_wiki_pages,
load_mapping,
main,
@@ -20,12 +20,12 @@ 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.sync_wiki.MAPPING_FILE", mapping_file):
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.sync_wiki.MAPPING_FILE", tmp_path / "nonexistent.json"):
with patch("scripts.ci.sync_wiki.MAPPING_FILE", tmp_path / "nonexistent.json"):
with pytest.raises(FileNotFoundError):
load_mapping()
@@ -35,12 +35,12 @@ class TestReadDocContent:
docs_dir = tmp_path / "docs"
docs_dir.mkdir()
(docs_dir / "test.md").write_text("# Test\n\nContent")
with patch("scripts.sync_wiki.DOCS_DIR", docs_dir):
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.sync_wiki.DOCS_DIR", tmp_path):
with patch("scripts.ci.sync_wiki.DOCS_DIR", tmp_path):
with pytest.raises(FileNotFoundError):
read_doc_content("nonexistent.md")
@@ -93,15 +93,15 @@ class TestSyncPage:
class TestMain:
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
@patch("scripts.sync_wiki.MAPPING_FILE")
@patch("scripts.sync_wiki.DOCS_DIR")
@patch("scripts.sync_wiki.GiteaClient")
@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.sync_wiki.load_mapping", return_value={"index.md": "Home"}):
with patch("scripts.sync_wiki.read_doc_content", return_value="# Home"):
with patch("scripts.sync_wiki.list_wiki_pages", return_value={}):
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
@@ -115,24 +115,24 @@ class TestMain:
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.sync_wiki.GiteaClient")
@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.sync_wiki.MAPPING_FILE") as mock_mapping:
with patch("scripts.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
mock_mapping.exists.return_value = True
with patch("scripts.sync_wiki.load_mapping", return_value={"index.md": "Home"}):
with patch("scripts.sync_wiki.read_doc_content", return_value="# Home"):
with patch("scripts.sync_wiki.list_wiki_pages", return_value={}):
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.sync_wiki.GiteaClient")
@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.sync_wiki.MAPPING_FILE") as mock_mapping:
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"])
@@ -140,28 +140,28 @@ class TestMain:
assert "mapping.json" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
@patch("scripts.sync_wiki.GiteaClient")
@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.sync_wiki.MAPPING_FILE") as mock_mapping:
with patch("scripts.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
mock_mapping.exists.return_value = True
with patch("scripts.sync_wiki.load_mapping", return_value={"index.md": "Home"}):
with patch("scripts.sync_wiki.read_doc_content", return_value="# Home"):
with patch("scripts.sync_wiki.list_wiki_pages", return_value={"Home": "Home"}):
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.sync_wiki.GiteaClient")
@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.sync_wiki.MAPPING_FILE") as mock_mapping:
with patch("scripts.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
mock_mapping.exists.return_value = True
with patch("scripts.sync_wiki.load_mapping", return_value={"missing.md": "Missing"}):
with patch("scripts.sync_wiki.read_doc_content", side_effect=FileNotFoundError):
with patch("scripts.sync_wiki.list_wiki_pages", return_value={}):
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
@@ -169,17 +169,17 @@ class TestMain:
assert "Skipped: 1" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
@patch("scripts.sync_wiki.GiteaClient")
@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.sync_wiki.MAPPING_FILE") as mock_mapping:
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.sync_wiki.load_mapping", return_value=mapping):
with patch("scripts.sync_wiki.read_doc_content", return_value="# Content"):
with patch("scripts.sync_wiki.list_wiki_pages", return_value={"Existing-Page": "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
+11 -11
View File
@@ -1,4 +1,4 @@
"""Unit tests for scripts/validate_commit_msg.py."""
"""Unit tests for scripts/ci/validate_commit_msg.py."""
import os
import subprocess
@@ -8,7 +8,7 @@ from unittest.mock import patch
from click.testing import CliRunner
from gitea_runner_manager.config import CONVENTIONAL_RE, TASK_ID_RE
from scripts.validate_commit_msg import first_line, get_branch, main
from scripts.ci.validate_commit_msg import first_line, get_branch, main
class TestHelpers:
@@ -72,7 +72,7 @@ class TestMain:
def test_rejects_task_id_on_feature_branch(self) -> None:
msg_path = self._write_msg("GRM-19: feat: add feature")
with patch("scripts.validate_commit_msg.get_branch", return_value="GRM-19"):
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
@@ -80,21 +80,21 @@ class TestMain:
def test_accepts_conventional_on_feature_branch(self) -> None:
msg_path = self._write_msg("feat: add feature")
with patch("scripts.validate_commit_msg.get_branch", return_value="GRM-19"):
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.validate_commit_msg.get_branch", return_value="master"):
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.validate_commit_msg.get_branch", return_value="master"):
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
@@ -102,7 +102,7 @@ class TestMain:
def test_rejects_master_with_non_conventional_after_task_id(self) -> None:
msg_path = self._write_msg("GRM-19: random message")
with patch("scripts.validate_commit_msg.get_branch", return_value="master"):
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
@@ -110,7 +110,7 @@ class TestMain:
def test_rejects_non_conventional_on_feature_branch(self) -> None:
msg_path = self._write_msg("random message")
with patch("scripts.validate_commit_msg.get_branch", return_value="feature"):
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
@@ -118,7 +118,7 @@ class TestMain:
def test_accepts_multiline_conventional(self) -> None:
msg_path = self._write_msg("feat: add feature\n\nBody text.\nMore text.")
with patch("scripts.validate_commit_msg.get_branch", return_value="feature"):
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
@@ -136,8 +136,8 @@ def test_main_module_block() -> None:
f.write("GRM-1: feat: test")
msg_path = f.name
with patch("scripts.validate_commit_msg.get_branch", return_value="master"):
import scripts.validate_commit_msg as vcm
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()