All checks were successful
Tests / Declarative: Post Actions passed: 1205
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>
327 lines
12 KiB
Python
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
|