feat(qg): ORCH-045 — poll check_ci_green with retry to fix CI race (pending->success)
All checks were successful
CI / test (push) Successful in 12s
CI / test (pull_request) Successful in 12s

This commit is contained in:
stream
2026-06-05 19:59:06 +00:00
parent b9c61fc1f1
commit 0eff781d13
6 changed files with 169 additions and 26 deletions

View File

@@ -93,38 +93,82 @@ class TestCheckArchitectureDone:
assert passed is False
def _ci_status_resp(state, status_code=200):
"""Build a MagicMock httpx response for the Gitea combined-status endpoint."""
mock_resp = MagicMock()
mock_resp.status_code = status_code
mock_resp.json.return_value = {"state": state}
mock_resp.raise_for_status = MagicMock()
return mock_resp
class TestCheckCIGreen:
"""ORCH-045: check_ci_green now polls with retry to ride out a transient
`pending` right after the developer push (race fix, see ORCH-017)."""
@patch("src.qg.checks.time.sleep")
@patch("src.qg.checks.httpx.get")
def test_ci_success(self, mock_get):
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.json.return_value = {"state": "success"}
mock_resp.raise_for_status = MagicMock()
mock_get.return_value = mock_resp
def test_ci_success_first_attempt(self, mock_get, mock_sleep):
mock_get.return_value = _ci_status_resp("success")
passed, reason = check_ci_green("enduro-trails", "feature/ET-001-test")
assert passed is True
assert "green" in reason.lower()
assert mock_get.call_count == 1
mock_sleep.assert_not_called()
@patch("src.qg.checks.time.sleep")
@patch("src.qg.checks.httpx.get")
def test_ci_pending(self, mock_get):
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.json.return_value = {"state": "pending"}
mock_resp.raise_for_status = MagicMock()
mock_get.return_value = mock_resp
def test_ci_pending_then_success(self, mock_get, mock_sleep):
# pending on the 1st poll, green on the 2nd -> success after one retry.
mock_get.side_effect = [
_ci_status_resp("pending"),
_ci_status_resp("success"),
]
passed, reason = check_ci_green("enduro-trails", "feature/ET-001-test")
assert passed is True
assert "green" in reason.lower()
assert mock_get.call_count == 2
assert mock_sleep.call_count == 1 # slept once between the two polls
@patch("src.qg.checks.time.sleep")
@patch("src.qg.checks.httpx.get")
def test_ci_failure_no_retry(self, mock_get, mock_sleep):
# CI is red -> terminal, return immediately without sleeping/retrying.
mock_get.return_value = _ci_status_resp("failure")
passed, reason = check_ci_green("enduro-trails", "feature/ET-001-test")
assert passed is False
assert "failure" in reason
assert mock_get.call_count == 1
mock_sleep.assert_not_called()
@patch("src.qg.checks.time.sleep")
@patch("src.qg.checks.httpx.get")
def test_ci_branch_not_found(self, mock_get):
def test_ci_pending_exhausts_attempts(self, mock_get, mock_sleep):
# Always pending -> after ci_poll_max_attempts polls return an explicit
# (False, "...pending...") so the operator sees the reason (no silent stall).
from src.qg.checks import settings as checks_settings
mock_get.return_value = _ci_status_resp("pending")
passed, reason = check_ci_green("enduro-trails", "feature/ET-001-test")
assert passed is False
assert "pending" in reason.lower()
assert mock_get.call_count == checks_settings.ci_poll_max_attempts
@patch("src.qg.checks.time.sleep")
@patch("src.qg.checks.httpx.get")
def test_ci_branch_not_found(self, mock_get, mock_sleep):
mock_resp = MagicMock()
mock_resp.status_code = 404
mock_get.return_value = mock_resp
passed, reason = check_ci_green("enduro-trails", "nonexistent")
assert passed is False
assert "not found" in reason.lower()
assert mock_get.call_count == 1
class TestCheckReviewApproved: