fix(self-heal): make the CI regression signal deterministic

The loop read the latest completed Actions run with per_page=1 and an
empty default workflow scope, so the conclusion flickered: on RoboCo's
8-workflow repo a green run from an unrelated workflow (or a green run on
an older commit) masked a red ci.yml run, and a single transient GitHub
error silently skipped the whole cycle — so self-heal sometimes fired on
a real regression and sometimes did not.

- Default self_heal_ci_workflow to "ci.yml" so the signal is scoped to
  the gate workflow, not "latest run across all workflows".
- Fetch a window of recent completed runs and resolve the conclusion
  against the branch's current HEAD (newest commit's latest attempt), so
  a stale/unrelated green run can't mask the HEAD failure and a green
  re-run supersedes the original failure.
- Retry transient network / 429 / 5xx errors within the cycle instead of
  treating one blip as "all green".
This commit is contained in:
Renn F
2026-06-20 19:10:21 +02:00
parent a211fc17c2
commit 65683394d4
3 changed files with 166 additions and 32 deletions
+86 -4
View File
@@ -13,7 +13,7 @@ from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from roboco.services.git import GitService
from roboco.services.git import _CI_RUN_WINDOW, GitService
_PR = 7
@@ -195,13 +195,16 @@ def _patch_project_ci() -> Any:
return patch("roboco.services.git.get_project_service", return_value=fake)
def _run(conclusion: str) -> dict[str, Any]:
def _run(
conclusion: str, *, head_sha: str = "abc123", attempt: int = 1
) -> dict[str, Any]:
return {
"conclusion": conclusion,
"head_sha": "abc123",
"head_sha": head_sha,
"html_url": "https://github.com/acme/repo/actions/runs/99",
"name": "CI",
"updated_at": "2026-06-17T00:00:00Z",
"run_attempt": attempt,
}
@@ -227,7 +230,7 @@ async def test_get_latest_ci_conclusion_normalizes_and_requests_correctly() -> N
assert call.kwargs["params"] == {
"branch": "master",
"status": "completed",
"per_page": 1,
"per_page": _CI_RUN_WINDOW,
}
assert call.kwargs["headers"]["Authorization"] == "Bearer tok"
@@ -276,3 +279,82 @@ async def test_get_latest_ci_conclusion_scopes_to_workflow() -> None:
assert out is not None
assert out["conclusion"] == "success"
assert client.get.await_args.args[0].endswith("/actions/workflows/ci.yml/runs")
@pytest.mark.asyncio
async def test_get_latest_ci_conclusion_requests_a_window() -> None:
# The signal pulls a window of recent runs, not just the single newest, so a
# green run on an older commit can't mask the HEAD's failure.
svc = _service()
client = _client(_resp(200, json_payload={"workflow_runs": [_run("failure")]}))
with (
_patch_project_ci(),
patch("roboco.services.git.httpx.AsyncClient", return_value=client),
):
await svc.get_latest_ci_conclusion("roboco", workflow="ci.yml")
assert client.get.await_args.kwargs["params"]["per_page"] == _CI_RUN_WINDOW
@pytest.mark.asyncio
async def test_get_latest_ci_conclusion_red_head_not_masked_by_older_green() -> None:
# Newest commit's run FAILED; older commits' runs (different sha) are green.
# The signal must follow the newest commit — not whatever completed last — so
# a stale/unrelated green run cannot mask the regression.
svc = _service()
payload = {
"workflow_runs": [
_run("failure", head_sha="newsha"),
_run("success", head_sha="oldsha1"),
_run("success", head_sha="oldsha2"),
]
}
client = _client(_resp(200, json_payload=payload))
with (
_patch_project_ci(),
patch("roboco.services.git.httpx.AsyncClient", return_value=client),
):
out = await svc.get_latest_ci_conclusion("roboco", workflow="ci.yml")
assert out is not None
assert out["conclusion"] == "failure"
assert out["head_sha"] == "newsha"
@pytest.mark.asyncio
async def test_get_latest_ci_conclusion_green_rerun_supersedes_failure() -> None:
# Same commit re-run green after a red first attempt → reads green.
svc = _service()
payload = {
"workflow_runs": [
_run("success", head_sha="sha", attempt=2),
_run("failure", head_sha="sha", attempt=1),
]
}
client = _client(_resp(200, json_payload=payload))
with (
_patch_project_ci(),
patch("roboco.services.git.httpx.AsyncClient", return_value=client),
):
out = await svc.get_latest_ci_conclusion("roboco", workflow="ci.yml")
assert out is not None
assert out["conclusion"] == "success"
@pytest.mark.asyncio
async def test_get_latest_ci_conclusion_retries_transient_5xx() -> None:
# A transient 503 must not silently skip the cycle — retry, then succeed.
svc = _service()
ok = _resp(200, json_payload={"workflow_runs": [_run("failure")]})
client = MagicMock()
client.__aenter__ = AsyncMock(return_value=client)
client.__aexit__ = AsyncMock(return_value=False)
client.get = AsyncMock(side_effect=[_resp(503, text="busy"), ok])
with (
_patch_project_ci(),
patch("roboco.services.git.httpx.AsyncClient", return_value=client),
patch("roboco.services.git.asyncio.sleep", AsyncMock()),
):
out = await svc.get_latest_ci_conclusion("roboco", workflow="ci.yml")
assert out is not None
assert out["conclusion"] == "failure"
expected_get_calls = 2
assert client.get.await_count == expected_get_calls