mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
feat(self-heal): scope CI signal to a workflow + warn on missing target
Two hardening fixes from the gap review: - Optional self_heal_ci_workflow scopes the CI signal to one workflow file (the workflow-scoped Actions endpoint). Without it, "latest completed run across all workflows" could miss a red CI run masked by a later passing workflow, or false-trigger on a non-CI workflow — unreliable on a multi-workflow repo. - The loop logs a warning when self-heal is armed but self_heal_project_slug is unset, so a misconfiguration isn't mistaken for "all green". Tests cover the workflow-scoped endpoint.
This commit is contained in:
@@ -379,6 +379,16 @@ class Settings(BaseSettings):
|
|||||||
"even when enabled."
|
"even when enabled."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
self_heal_ci_workflow: str = Field(
|
||||||
|
default="",
|
||||||
|
description=(
|
||||||
|
"Optional GitHub Actions workflow file name (e.g. 'ci.yml') to scope "
|
||||||
|
"the CI signal to. Empty = use the latest completed run across ALL "
|
||||||
|
"workflows on the default branch, which is imprecise when the repo "
|
||||||
|
"has several workflows; set this to the real CI workflow for a "
|
||||||
|
"reliable signal."
|
||||||
|
),
|
||||||
|
)
|
||||||
self_heal_originate_enabled: bool = Field(
|
self_heal_originate_enabled: bool = Field(
|
||||||
default=False,
|
default=False,
|
||||||
description=(
|
description=(
|
||||||
|
|||||||
@@ -4682,6 +4682,15 @@ Start by:
|
|||||||
from roboco.db import get_db_context
|
from roboco.db import get_db_context
|
||||||
from roboco.services.self_heal_engine import get_self_heal_engine
|
from roboco.services.self_heal_engine import get_self_heal_engine
|
||||||
|
|
||||||
|
# Operability: self-heal is armed but has no target → it will silently
|
||||||
|
# no-op every cycle. Say so once at startup so a misconfiguration (unset
|
||||||
|
# or wrong ROBOCO_SELF_HEAL_PROJECT_SLUG) isn't mistaken for "all green".
|
||||||
|
if not settings.self_heal_project_slug.strip():
|
||||||
|
logger.warning(
|
||||||
|
"self-heal enabled but self_heal_project_slug is unset — the loop "
|
||||||
|
"will not detect anything until the target project is configured"
|
||||||
|
)
|
||||||
|
|
||||||
interval = settings.self_heal_interval_seconds
|
interval = settings.self_heal_interval_seconds
|
||||||
while self._running:
|
while self._running:
|
||||||
try:
|
try:
|
||||||
|
|||||||
+23
-9
@@ -1642,7 +1642,7 @@ class GitService(BaseService):
|
|||||||
}
|
}
|
||||||
|
|
||||||
async def get_latest_ci_conclusion(
|
async def get_latest_ci_conclusion(
|
||||||
self, project_slug: str
|
self, project_slug: str, *, workflow: str | None = None
|
||||||
) -> dict[str, Any] | None:
|
) -> dict[str, Any] | None:
|
||||||
"""Latest completed CI (GitHub Actions) run on a project's default branch.
|
"""Latest completed CI (GitHub Actions) run on a project's default branch.
|
||||||
|
|
||||||
@@ -1650,10 +1650,12 @@ class GitService(BaseService):
|
|||||||
workflow run on the project's default branch, normalized to
|
workflow run on the project's default branch, normalized to
|
||||||
``conclusion`` (``success`` / ``failure`` / ``timed_out`` / ...),
|
``conclusion`` (``success`` / ``failure`` / ``timed_out`` / ...),
|
||||||
``head_sha``, ``run_url``, ``run_name``, ``branch`` and ``completed_at``.
|
``head_sha``, ``run_url``, ``run_name``, ``branch`` and ``completed_at``.
|
||||||
Repo-agnostic — resolves owner/repo and the git token PER PROJECT, so it
|
Resolves owner/repo and the git token PER PROJECT. ``workflow`` (a
|
||||||
works on any registered repo, never a hardcoded one. Returns ``None`` on a
|
workflow file name like ``ci.yml``) scopes the signal to one workflow —
|
||||||
missing token, unparseable remote, GitHub error, or a repo with no Actions
|
without it the latest run across ALL workflows is used, which is
|
||||||
runs (a repo that doesn't use GitHub Actions yields no signal, not a false
|
imprecise on a multi-workflow repo. Returns ``None`` on a missing token,
|
||||||
|
unparseable remote, GitHub error, or a repo with no matching Actions runs
|
||||||
|
(a repo that doesn't use GitHub Actions yields no signal, not a false
|
||||||
one). It never raises into the poll loop.
|
one). It never raises into the poll loop.
|
||||||
"""
|
"""
|
||||||
project = await get_project_service(self.session).get_by_slug(project_slug)
|
project = await get_project_service(self.session).get_by_slug(project_slug)
|
||||||
@@ -1668,7 +1670,7 @@ class GitService(BaseService):
|
|||||||
return None
|
return None
|
||||||
branch = project.default_branch or "main"
|
branch = project.default_branch or "main"
|
||||||
run = await self._fetch_latest_ci_run(
|
run = await self._fetch_latest_ci_run(
|
||||||
project_slug, owner, repo, branch, git_token
|
project_slug, (owner, repo), branch, git_token, workflow
|
||||||
)
|
)
|
||||||
if run is None:
|
if run is None:
|
||||||
return None
|
return None
|
||||||
@@ -1682,14 +1684,26 @@ class GitService(BaseService):
|
|||||||
}
|
}
|
||||||
|
|
||||||
async def _fetch_latest_ci_run(
|
async def _fetch_latest_ci_run(
|
||||||
self, project_slug: str, owner: str, repo: str, branch: str, git_token: str
|
self,
|
||||||
|
project_slug: str,
|
||||||
|
owner_repo: tuple[str, str],
|
||||||
|
branch: str,
|
||||||
|
git_token: str,
|
||||||
|
workflow: str | None = None,
|
||||||
) -> dict[str, Any] | None:
|
) -> dict[str, Any] | None:
|
||||||
"""GET the most recent completed Actions run on ``branch``; None on error."""
|
"""GET the most recent completed Actions run on ``branch``; None on error.
|
||||||
|
|
||||||
|
Scopes to ``workflow`` (a workflow file name) when given — the precise
|
||||||
|
signal — otherwise looks across all workflows.
|
||||||
|
"""
|
||||||
|
owner, repo = owner_repo
|
||||||
api_base = settings.github_api_base_url.rstrip("/")
|
api_base = settings.github_api_base_url.rstrip("/")
|
||||||
|
base = f"{api_base}/repos/{owner}/{repo}/actions"
|
||||||
|
url = f"{base}/workflows/{workflow}/runs" if workflow else f"{base}/runs"
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=_default_git_timeout()) as client:
|
async with httpx.AsyncClient(timeout=_default_git_timeout()) as client:
|
||||||
resp = await client.get(
|
resp = await client.get(
|
||||||
f"{api_base}/repos/{owner}/{repo}/actions/runs",
|
url,
|
||||||
headers={
|
headers={
|
||||||
"Authorization": f"Bearer {git_token}",
|
"Authorization": f"Bearer {git_token}",
|
||||||
"Accept": "application/vnd.github+json",
|
"Accept": "application/vnd.github+json",
|
||||||
|
|||||||
@@ -78,7 +78,10 @@ class GitHubCITelemetrySource:
|
|||||||
slug = settings.self_heal_project_slug.strip()
|
slug = settings.self_heal_project_slug.strip()
|
||||||
if not slug:
|
if not slug:
|
||||||
return []
|
return []
|
||||||
ci = await GitService(self.session).get_latest_ci_conclusion(slug)
|
workflow = settings.self_heal_ci_workflow.strip() or None
|
||||||
|
ci = await GitService(self.session).get_latest_ci_conclusion(
|
||||||
|
slug, workflow=workflow
|
||||||
|
)
|
||||||
if ci is None:
|
if ci is None:
|
||||||
return []
|
return []
|
||||||
conclusion = (ci.get("conclusion") or "").lower()
|
conclusion = (ci.get("conclusion") or "").lower()
|
||||||
|
|||||||
@@ -241,3 +241,18 @@ async def test_get_latest_ci_conclusion_none_when_no_runs() -> None:
|
|||||||
patch("roboco.services.git.httpx.AsyncClient", return_value=client),
|
patch("roboco.services.git.httpx.AsyncClient", return_value=client),
|
||||||
):
|
):
|
||||||
assert await svc.get_latest_ci_conclusion("roboco") is None
|
assert await svc.get_latest_ci_conclusion("roboco") is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_latest_ci_conclusion_scopes_to_workflow() -> None:
|
||||||
|
# With a workflow file given, hit the workflow-scoped endpoint (precise signal).
|
||||||
|
svc = _service()
|
||||||
|
client = _client(_resp(200, json_payload={"workflow_runs": [_run("success")]}))
|
||||||
|
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"
|
||||||
|
assert client.get.await_args.args[0].endswith("/actions/workflows/ci.yml/runs")
|
||||||
|
|||||||
Reference in New Issue
Block a user