ariadne/ariadne/services/hermes_code_repair.py
codex d3a2c94f80
All checks were successful
Tests / Declarative: Post Actions passed: 1205
feat(hermes): stop the triage system talking about itself, and raise the PR ceiling
Two changes to how this reads and behaves on real service repositories.

The fixture rules were stated to Hermes on every job, so it reasoned about
them out loud and that reasoning was published verbatim into service issue
trackers - ariadne/404 opened with 'The job is ariadne, not
hermes-triage-demo, so the reserved demo fixture classification and repair
action are forbidden'. That reads as though the system exists to serve a
demonstration. Those rules are now appended only for the fixture job, so a
real service is never told about them and cannot repeat them; the demo
classification is unreachable elsewhere by construction rather than by
instruction. The prompt also asks for language aimed at a maintainer who
knows nothing about how triage is configured, and points at the structured
test evidence first now that junit publishes it.

The duplicate guard refused a proposal whenever any repair pull request was
open, which meant one unreviewed fix blocked every later one across the
repository. It now enforces a ceiling instead, ARIADNE_HERMES_CODE_MAX_OPEN_PROPOSALS,
default 64. That is a review-capacity limit, not a correctness one: proposals
are cheap to make and expensive to read.

Auto-triage settings move to their own module; they had grown a section's
worth and pushed settings_sections.py past its size budget.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 13:08:18 -03:00

327 lines
12 KiB
Python

from __future__ import annotations
import base64
from typing import Any
import httpx
from ..utils.logging import get_logger
logger = get_logger(__name__)
HTTP_OK = 200
HTTP_CREATED = 201
HTTP_NOT_FOUND = 404
HTTP_CONFLICT = 409
HTTP_UNPROCESSABLE = 422
_DEFAULT_TIMEOUT_SECONDS = 15.0
_PROTECTED_BRANCHES = {"master", "main"}
_REPAIR_BRANCH_PREFIX = "hermes-repair/"
_OPEN_PULLS_LIMIT = 50
_COMMIT_IDENTITY = {"name": "Hermes Agent", "email": "hermes@bstein.dev"}
_COMMIT_OK_STATUSES = {HTTP_OK, HTTP_CREATED}
_BRANCH_RETRY_STATUSES = {HTTP_NOT_FOUND, HTTP_UNPROCESSABLE}
def fetch_file(cfg: dict, path: str) -> tuple[str | None, str | None]:
"""Fetch one file's raw contents from Gitea at the base branch.
Inputs: `cfg` with gitea_base_url, gitea_token, owner, repo, base_branch,
and timeout_seconds; the repository-relative file path.
Outputs: (contents, error) with exactly one side set. Never raises and
never logs the token.
"""
base_url = _base_url(cfg)
if not base_url:
return None, "gitea base url is empty"
url = f"{base_url}/api/v1/repos/{_owner(cfg)}/{_repo(cfg)}/raw/{path}"
try:
with httpx.Client(timeout=_timeout(cfg)) as client:
response = client.get(url, headers=_headers(cfg), params={"ref": _base_branch(cfg)})
except Exception as exc:
return None, f"file fetch failed: {exc}"
if response.status_code != HTTP_OK:
return None, f"file fetch http {response.status_code}"
return response.text, None
def find_open_proposal(cfg: dict) -> dict[str, Any]:
"""Find an already-open Hermes repair pull request for this repository.
Inputs: `cfg` as for `fetch_file`. Outputs:
{"found", "pr_number", "url", "branch", "error"}. An open pull request
counts as an existing proposal when its head branch starts with
`hermes-repair/` and its base branch equals cfg["base_branch"]; the
lowest-numbered match (the original proposal) is returned so repeated
checks stay stable while the branch sits unmerged.
Fails open by design: every HTTP, parse, or transport failure returns
found=False with `error` set, because a false "duplicate" would silently
suppress legitimate repair work — a missed duplicate is only noise, a
wrong duplicate is lost work. Never raises and never logs the token.
"""
base_url = _base_url(cfg)
if not base_url:
return _no_proposal("gitea base url is empty")
try:
with httpx.Client(timeout=_timeout(cfg)) as client:
response = client.get(
f"{base_url}/api/v1/repos/{_owner(cfg)}/{_repo(cfg)}/pulls",
headers=_headers(cfg),
params={"state": "open", "limit": _OPEN_PULLS_LIMIT},
)
except Exception as exc:
return _no_proposal(f"open proposal lookup failed: {exc}")
if response.status_code != HTTP_OK:
return _no_proposal(f"open proposal lookup http {response.status_code}")
return _oldest_repair_pull(response, _base_branch(cfg))
def push_branch(
cfg: dict, incident_id: str, build_number: int, patch: Any, patched_contents: str
) -> dict[str, Any]:
"""Commit the patched file to a fresh repair branch via the Gitea API.
Inputs: `cfg` as for `fetch_file`; the incident id, the failed build
number that names the branch, the validated ProposedPatch, and the fully
patched file contents. Outputs: {"branch", "committed", "error"}. Never
force-pushes, refuses master/main or empty branch names, never raises,
and never logs the token.
"""
branch = _branch_name(build_number)
if not branch.strip() or branch in _PROTECTED_BRANCHES:
return {"branch": branch, "committed": False, "error": f"refusing branch {branch!r}"}
base_url = _base_url(cfg)
if not base_url:
return {"branch": branch, "committed": False, "error": "gitea base url is empty"}
try:
with httpx.Client(timeout=_timeout(cfg)) as client:
error = _commit_patch(client, cfg, branch, incident_id, patch, patched_contents)
except Exception as exc:
error = f"branch push failed: {exc}"
if error is not None:
logger.info(
"hermes code repair branch push failed",
extra={"event": "hermes_code_repair", "status": "error", "branch": branch, "detail": error},
)
return {"branch": branch, "committed": error is None, "error": error}
def open_pull_request( # noqa: PLR0913 - flow contract mirrors push_branch identity fields
cfg: dict, incident_id: str, build_number: int, branch: str, patch: Any, analysis: str
) -> dict[str, Any]:
"""Open the human-review pull request for a pushed repair branch.
Inputs: `cfg` as for `fetch_file`; the incident id, failed build number,
pushed branch name, the validated ProposedPatch, and the model analysis.
Outputs: {"pr_number", "url", "error"}; an existing PR reported by a 409
counts as success when the payload identifies it. Never raises and never
logs the token.
"""
base_url = _base_url(cfg)
if not base_url:
return {"pr_number": None, "url": None, "error": "gitea base url is empty"}
payload = {
"head": branch,
"base": _base_branch(cfg),
"title": f"fix(hermes): repair {incident_id}",
"body": _pr_body(incident_id, patch, analysis),
}
try:
with httpx.Client(timeout=_timeout(cfg)) as client:
response = client.post(
f"{base_url}/api/v1/repos/{_owner(cfg)}/{_repo(cfg)}/pulls",
headers=_headers(cfg),
json=payload,
)
except Exception as exc:
return {"pr_number": None, "url": None, "error": f"pull request failed: {exc}"}
if response.status_code == HTTP_CREATED:
return _pr_result(response)
if response.status_code == HTTP_CONFLICT:
existing = _pr_result(response)
if existing["pr_number"] is not None:
return existing
return {"pr_number": None, "url": None, "error": "pull request already exists"}
return {"pr_number": None, "url": None, "error": f"pull request http {response.status_code}"}
def _commit_patch( # noqa: PLR0913 - commit needs the full branch/identity context
client: httpx.Client,
cfg: dict,
branch: str,
incident_id: str,
patch: Any,
patched_contents: str,
) -> str | None:
"""PUT the patched file onto the new branch, trying both branch shapes."""
url = f"{_base_url(cfg)}/api/v1/repos/{_owner(cfg)}/{_repo(cfg)}/contents/{patch.path}"
sha, sha_error = _file_sha(client, cfg, patch.path)
if sha_error is not None:
return sha_error
body = {
"message": f"fix(hermes): {patch.rationale} (incident {incident_id})",
"content": base64.b64encode(patched_contents.encode("utf-8")).decode("ascii"),
"sha": sha,
"author": dict(_COMMIT_IDENTITY),
"committer": dict(_COMMIT_IDENTITY),
}
first = client.put(url, headers=_headers(cfg), json={**body, "branch": _base_branch(cfg), "new_branch": branch})
if first.status_code in _COMMIT_OK_STATUSES:
return None
if first.status_code not in _BRANCH_RETRY_STATUSES:
return f"commit http {first.status_code}"
fallback = client.put(url, headers=_headers(cfg), json={**body, "branch": branch})
if fallback.status_code in _COMMIT_OK_STATUSES:
return None
return f"commit http {first.status_code} then fallback http {fallback.status_code}"
def _file_sha(client: httpx.Client, cfg: dict, path: str) -> tuple[str | None, str | None]:
"""Read the file's current blob sha at the base branch."""
url = f"{_base_url(cfg)}/api/v1/repos/{_owner(cfg)}/{_repo(cfg)}/contents/{path}"
response = client.get(url, headers=_headers(cfg), params={"ref": _base_branch(cfg)})
if response.status_code != HTTP_OK:
return None, f"file sha http {response.status_code}"
sha = _json_payload(response).get("sha")
if not sha:
return None, "file sha missing from contents response"
return str(sha), None
def _oldest_repair_pull(response: Any, base_branch: str) -> dict[str, Any]:
"""Pick the lowest-numbered repair proposal out of an open-pulls payload."""
try:
payload = response.json()
except Exception as exc:
return _no_proposal(f"open proposal parse failed: {exc}")
if not isinstance(payload, list):
return _no_proposal("open proposal payload is not a list")
matches = [pull for pull in payload if _is_repair_pull(pull, base_branch)]
if not matches:
return {**_no_proposal(None), "open_count": 0}
oldest = min(matches, key=lambda pull: int(pull["number"]))
return {
"found": True,
"open_count": len(matches),
"pr_number": int(oldest["number"]),
"url": str(oldest.get("html_url") or "") or None,
"branch": str(oldest["head"].get("ref") or "") or None,
"error": None,
}
def _is_repair_pull(pull: Any, base_branch: str) -> bool:
"""Report whether one open pull request is a Hermes repair proposal."""
if not isinstance(pull, dict):
return False
number = pull.get("number")
if isinstance(number, bool) or not isinstance(number, int):
return False
head, base = pull.get("head"), pull.get("base")
if not isinstance(head, dict) or not isinstance(base, dict):
return False
head_ref = str(head.get("ref") or "")
return head_ref.startswith(_REPAIR_BRANCH_PREFIX) and str(base.get("ref") or "") == base_branch
def _no_proposal(error: str | None) -> dict[str, Any]:
"""Build the fail-open result meaning "no existing proposal found"."""
return {"found": False, "open_count": 0, "pr_number": None, "url": None, "branch": None, "error": error}
def _pr_result(response: Any) -> dict[str, Any]:
"""Map a pull-request response payload to the result shape."""
payload = _json_payload(response)
number = payload.get("number")
url = str(payload.get("html_url") or "") or None
if isinstance(number, bool) or not isinstance(number, int):
return {"pr_number": None, "url": url, "error": None}
return {"pr_number": number, "url": url, "error": None}
def _pr_body(incident_id: str, patch: Any, analysis: str) -> str:
"""Render the markdown pull-request body for human review."""
return "\n".join(
[
f"## Hermes repair proposal for incident {incident_id}",
"",
f"**Incident:** {incident_id}",
f"**File:** `{patch.path}`",
f"**Analysis:** {analysis}",
f"**Rationale:** {patch.rationale}",
"",
"Proposed by Hermes; validated and pushed by Ariadne; "
"requires human review — no automatic merge.",
]
)
def _branch_name(build_number: int) -> str:
"""Derive the repair branch name for one failed build number."""
return f"hermes-repair/{build_number}"
def _json_payload(response: Any) -> dict[str, Any]:
"""Return a response's JSON body as a dict, tolerating garbage."""
try:
payload = response.json()
except Exception:
return {}
return payload if isinstance(payload, dict) else {}
def _headers(cfg: dict) -> dict[str, str]:
"""Build the Gitea token authorization header."""
return {"Authorization": f"token {cfg.get('gitea_token') or ''}"}
def _base_url(cfg: dict) -> str:
"""Return the configured Gitea base URL without a trailing slash."""
return str(cfg.get("gitea_base_url") or "").strip().rstrip("/")
def _base_branch(cfg: dict) -> str:
"""Return the configured base branch name."""
return str(cfg.get("base_branch") or "")
def _owner(cfg: dict) -> str:
"""Return the configured repository owner."""
return str(cfg.get("owner") or "")
def _repo(cfg: dict) -> str:
"""Return the configured repository name."""
return str(cfg.get("repo") or "")
def _timeout(cfg: dict) -> float:
"""Return the bounded request timeout in seconds."""
try:
value = float(cfg.get("timeout_seconds"))
except (TypeError, ValueError):
return _DEFAULT_TIMEOUT_SECONDS
return value if value > 0 else _DEFAULT_TIMEOUT_SECONDS