fix(ci): retry Vikunja lookups and surface self-approval merge failures
CI / validate (pull_request) Successful in 1m1s
CI / auto-merge (pull_request) Successful in 1m14s

Implements: DEVX-164 REQ-1..3

- get_vikunja_task_title retries list_project_tasks up to 4 attempts
  with backoff on APIError/RequestException — a Vikunja restart window
  (404/502) no longer strands an otherwise-valid PR
- merge HTTP 405 now fetches PR reviews and, when no APPROVED review
  exists, explains the same-user self-approval rejection and the
  remediation (approve via a non-author account)
- regression tests for retry-then-success, retry exhaustion, missing
  task, and all three 405 review states
This commit is contained in:
Emil Simeonov
2026-09-19 03:59:23 +02:00
parent edb9205ce6
commit dc37137a31
5 changed files with 267 additions and 30 deletions
+135
View File
@@ -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