Public Access
DEVX-164: fix(ci): retry Vikunja lookups and surface self-approval merge failures
This commit was merged in pull request #325.
This commit is contained in:
@@ -468,3 +468,138 @@ def test_main_module_block() -> None:
|
||||
exec(compile(source, am.__file__, "exec"), namespace)
|
||||
# Verify main is callable
|
||||
assert callable(namespace["main"])
|
||||
|
||||
|
||||
# -- DEVX-164: Vikunja outage resilience + self-approval diagnostics --
|
||||
|
||||
|
||||
class TestVikunjaLookupRetry:
|
||||
"""REQ-1: transient Vikunja API failures are retried, not fatal."""
|
||||
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True)
|
||||
@patch("devx.ci.auto_merge.time.sleep")
|
||||
@patch("devx.ci.auto_merge.VikunjaClient")
|
||||
def test_retries_transient_api_error_then_succeeds(self, mock_client_cls: MagicMock, mock_sleep: MagicMock) -> None:
|
||||
"""A 404/502 during a Vikunja restart is retried until tasks list."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.list_project_tasks.side_effect = [
|
||||
APIError(404, "Not Found"),
|
||||
APIError(502, "Bad Gateway"),
|
||||
[{"id": 1, "identifier": "DEVX-19", "title": "Add new feature"}],
|
||||
]
|
||||
mock_client_cls.return_value = mock_client
|
||||
validate_pr_title_matches_vikunja("DEVX-19: Add new feature", "DEVX-19")
|
||||
assert mock_client.list_project_tasks.call_count == 3
|
||||
assert mock_sleep.call_count == 2
|
||||
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True)
|
||||
@patch("devx.ci.auto_merge.time.sleep")
|
||||
@patch("devx.ci.auto_merge.VikunjaClient")
|
||||
def test_retry_exhaustion_propagates_error(self, mock_client_cls: MagicMock, _mock_sleep: MagicMock) -> None:
|
||||
"""Persistent outage still fails after the bounded attempt count."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.list_project_tasks.side_effect = APIError(502, "Bad Gateway")
|
||||
mock_client_cls.return_value = mock_client
|
||||
with pytest.raises(APIError, match="Bad Gateway"):
|
||||
validate_pr_title_matches_vikunja("DEVX-19: test", "DEVX-19")
|
||||
assert mock_client.list_project_tasks.call_count == 4
|
||||
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True)
|
||||
@patch("devx.ci.auto_merge.time.sleep")
|
||||
@patch("devx.ci.auto_merge.VikunjaClient")
|
||||
def test_retries_connection_error(self, mock_client_cls: MagicMock, mock_sleep: MagicMock) -> None:
|
||||
"""Connection-level failures during restart are also retried."""
|
||||
import requests as req
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.list_project_tasks.side_effect = [
|
||||
req.ConnectionError("refused"),
|
||||
[{"id": 1, "identifier": "DEVX-19", "title": "Found me"}],
|
||||
]
|
||||
mock_client_cls.return_value = mock_client
|
||||
validate_pr_title_matches_vikunja("DEVX-19: Found me", "DEVX-19")
|
||||
assert mock_sleep.call_count == 1
|
||||
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True)
|
||||
@patch("devx.ci.auto_merge.VikunjaClient")
|
||||
def test_missing_task_still_fails_without_retry_sleep(self, mock_client_cls: MagicMock) -> None:
|
||||
"""A healthy Vikunja that simply lacks the task fails as before."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.list_project_tasks.return_value = []
|
||||
mock_client_cls.return_value = mock_client
|
||||
with pytest.raises(click.ClickException, match="Could not find"):
|
||||
validate_pr_title_matches_vikunja("DEVX-99: test", "DEVX-99")
|
||||
|
||||
|
||||
class TestMergeApprovalDiagnostics:
|
||||
"""REQ-2: merge 405 reports approval state + self-approval remediation."""
|
||||
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok", "VIKUNJA_TOKEN": "tok"}, clear=True)
|
||||
@patch("devx.ci.auto_merge.validate_pr_title_matches_vikunja")
|
||||
@patch("devx.ci.auto_merge.GiteaClient")
|
||||
def test_405_without_approvals_shows_self_approval_hint(
|
||||
self, mock_client_cls: MagicMock, _mock_validate: MagicMock, tmp_path, monkeypatch
|
||||
) -> None: # type: ignore[no-untyped-def]
|
||||
monkeypatch.chdir(tmp_path)
|
||||
mock_client = MagicMock()
|
||||
mock_client.get_pr_commits.return_value = [
|
||||
{"commit": {"message": "fix: resolve timeout"}},
|
||||
]
|
||||
mock_client.merge_pr.side_effect = APIError(405, "Does not have enough approvals")
|
||||
mock_client.get_pr_reviews.return_value = []
|
||||
mock_client_cls.return_value = mock_client
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
["DEVX-19-fix-bug", "DEVX-19: Fix timeout", "owner/repo", "7"],
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
assert "Merge failed" in result.output
|
||||
assert "APPROVED" in result.output
|
||||
assert "non-author account" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok", "VIKUNJA_TOKEN": "tok"}, clear=True)
|
||||
@patch("devx.ci.auto_merge.validate_pr_title_matches_vikunja")
|
||||
@patch("devx.ci.auto_merge.GiteaClient")
|
||||
def test_405_with_approvals_omits_hint(
|
||||
self, mock_client_cls: MagicMock, _mock_validate: MagicMock, tmp_path, monkeypatch
|
||||
) -> None: # type: ignore[no-untyped-def]
|
||||
monkeypatch.chdir(tmp_path)
|
||||
mock_client = MagicMock()
|
||||
mock_client.get_pr_commits.return_value = [
|
||||
{"commit": {"message": "fix: resolve timeout"}},
|
||||
]
|
||||
mock_client.merge_pr.side_effect = APIError(405, "Does not have enough approvals")
|
||||
mock_client.get_pr_reviews.return_value = [{"state": "APPROVED", "user": {"login": "kireto"}}]
|
||||
mock_client_cls.return_value = mock_client
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
["DEVX-19-fix-bug", "DEVX-19: Fix timeout", "owner/repo", "7"],
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
assert "Merge failed" in result.output
|
||||
assert "non-author account" not in result.output
|
||||
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok", "VIKUNJA_TOKEN": "tok"}, clear=True)
|
||||
@patch("devx.ci.auto_merge.validate_pr_title_matches_vikunja")
|
||||
@patch("devx.ci.auto_merge.GiteaClient")
|
||||
def test_405_reviews_fetch_failure_still_raises(
|
||||
self, mock_client_cls: MagicMock, _mock_validate: MagicMock, tmp_path, monkeypatch
|
||||
) -> None: # type: ignore[no-untyped-def]
|
||||
"""If the reviews lookup itself fails, the merge error still surfaces."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
mock_client = MagicMock()
|
||||
mock_client.get_pr_commits.return_value = [
|
||||
{"commit": {"message": "fix: resolve timeout"}},
|
||||
]
|
||||
mock_client.merge_pr.side_effect = APIError(405, "Does not have enough approvals")
|
||||
mock_client.get_pr_reviews.side_effect = APIError(403, "Forbidden")
|
||||
mock_client_cls.return_value = mock_client
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
["DEVX-19-fix-bug", "DEVX-19: Fix timeout", "owner/repo", "7"],
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
assert "Merge failed" in result.output
|
||||
|
||||
Reference in New Issue
Block a user