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

This commit is contained in:
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: