feat(self-heal): CI telemetry source for RoboCo's own repo (dormant)

First slice of the production self-healing loop: a read-only telemetry source
that watches RoboCo's OWN repo CI and normalizes the latest GitHub Actions run
conclusion into breach / no-breach samples for the regression detector. It
targets only the single project named by self_heal_project_slug — RoboCo
healing itself, never other/client repos; the org's repo-agnostic delivery flow
is untouched.

- config: self_heal_enabled / self_heal_project_slug / self_heal_originate_enabled
  plus interval and open-task / per-cycle caps, all default-off
- GitService.get_latest_ci_conclusion: per-project Actions-run lookup (graceful
  None on missing token / no runs / error; never raises into the loop)
- TelemetrySample + TelemetrySource contract + GitHubCITelemetrySource
- 5 unit tests
This commit is contained in:
Renn F
2026-06-17 20:50:07 +02:00
parent 47fda2e3fa
commit 7e02713cc6
5 changed files with 335 additions and 0 deletions
@@ -0,0 +1,81 @@
"""Self-heal telemetry source — CI conclusion normalized to samples.
The GitHub-CI source watches ONLY RoboCo's own project
(``settings.self_heal_project_slug``): a failing run is a breaching sample, a
passing run a non-breaching one, and no target / no run yields nothing. It is
read-only and never raises.
"""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock
import pytest
from roboco.config import settings as cfg
from roboco.services.git import GitService
from roboco.services.telemetry import GitHubCITelemetrySource
def _ci(conclusion: str) -> dict[str, str]:
return {
"conclusion": conclusion,
"head_sha": "abc123",
"run_url": "https://github.com/x/roboco/actions/runs/1",
"run_name": "CI",
"branch": "master",
"completed_at": "2026-06-17T00:00:00Z",
}
@pytest.mark.asyncio
async def test_no_target_yields_no_samples(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(cfg, "self_heal_project_slug", "")
assert await GitHubCITelemetrySource(MagicMock()).fetch() == []
@pytest.mark.asyncio
async def test_failing_ci_is_a_breach(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(cfg, "self_heal_project_slug", "roboco")
monkeypatch.setattr(
GitService, "get_latest_ci_conclusion", AsyncMock(return_value=_ci("failure"))
)
samples = await GitHubCITelemetrySource(MagicMock()).fetch()
assert len(samples) == 1
sample = samples[0]
assert sample.is_breach is True
assert sample.repo_hint == "roboco"
assert sample.signal_name == "ci_conclusion:roboco"
assert "failure" in sample.detail
assert sample.raw_ref.endswith("/runs/1")
@pytest.mark.asyncio
async def test_passing_ci_is_not_a_breach(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(cfg, "self_heal_project_slug", "roboco")
monkeypatch.setattr(
GitService, "get_latest_ci_conclusion", AsyncMock(return_value=_ci("success"))
)
samples = await GitHubCITelemetrySource(MagicMock()).fetch()
assert len(samples) == 1
assert samples[0].is_breach is False
@pytest.mark.asyncio
async def test_cancelled_run_is_not_a_breach(monkeypatch: pytest.MonkeyPatch) -> None:
# A cancelled run is not a failing build — it must not count as a regression.
monkeypatch.setattr(cfg, "self_heal_project_slug", "roboco")
monkeypatch.setattr(
GitService, "get_latest_ci_conclusion", AsyncMock(return_value=_ci("cancelled"))
)
samples = await GitHubCITelemetrySource(MagicMock()).fetch()
assert len(samples) == 1
assert samples[0].is_breach is False
@pytest.mark.asyncio
async def test_no_run_yields_no_samples(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(cfg, "self_heal_project_slug", "roboco")
monkeypatch.setattr(
GitService, "get_latest_ci_conclusion", AsyncMock(return_value=None)
)
assert await GitHubCITelemetrySource(MagicMock()).fetch() == []