feat(hermes-code): bounded source-patch proposal path

Hermes proposes a minimal anchored patch; Ariadne validates it structurally
and opens a pull request for human review. Nothing merges automatically and
Hermes never holds Git credentials or executes anything.

- hermes_code_patch: response parsing + patch validation (path prefix/suffix
  allowlist, size and changed-line caps, exact-single-occurrence anchor,
  replacement-differs) and exact application
- hermes_code_repair: Gitea contents-API client (fetch, branch push with
  hermes-repair/<build> prefix and base-branch refusal, PR creation)
- hermes_code_flow: proposal orchestration + audit event, no patch bodies
  or tokens recorded
- hermes_autotriage: code-path branch for the configured repo job; a PR
  records human_required/code_fix_proposed, never auto-resolution, and is
  kept out of the fixture-action accounting
- settings: ARIADNE_HERMES_CODE_* configuration, disabled by default

74 new tests; 214 pass in the hermes suite.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
codex 2026-08-05 19:18:26 -03:00
parent e89de924c0
commit 2cb5d50fa8
11 changed files with 1710 additions and 1 deletions

View File

@ -8,7 +8,7 @@ import httpx
from ..settings import settings from ..settings import settings
from ..utils.logging import get_logger from ..utils.logging import get_logger
from . import hermes_agent_client, hermes_autotriage_repair from . import hermes_agent_client, hermes_autotriage_repair, hermes_code_flow
from . import hermes_autotriage_decision as hermes_decision from . import hermes_autotriage_decision as hermes_decision
from . import hermes_autotriage_evidence as hermes_evidence from . import hermes_autotriage_evidence as hermes_evidence
from .hermes_autotriage_metrics import ( from .hermes_autotriage_metrics import (
@ -29,6 +29,7 @@ DIAGNOSIS_EVENT_TYPE = "hermes_autotriage_diagnosis"
ACTION_EVENT_TYPE = "hermes_autotriage_action" ACTION_EVENT_TYPE = "hermes_autotriage_action"
EXPECTED_CLASSIFICATION = "known_demo_fixture_failure" EXPECTED_CLASSIFICATION = "known_demo_fixture_failure"
REBUILD_FAILED_REASON = "repair rebuild failed" REBUILD_FAILED_REASON = "repair rebuild failed"
CODE_FIX_PROPOSED_REASON = "code_fix_proposed"
_LAST_BUILD_TREE = "lastBuild[number,result,building,timestamp,duration,url]" _LAST_BUILD_TREE = "lastBuild[number,result,building,timestamp,duration,url]"
_EVENT_SCAN_LIMIT = 500 _EVENT_SCAN_LIMIT = 500
@ -239,6 +240,8 @@ def _run_pipeline(
phase_started = time.time() phase_started = time.time()
bundle = hermes_evidence.collect_evidence(incident_id, job, last_build) bundle = hermes_evidence.collect_evidence(incident_id, job, last_build)
HERMES_TRIAGE_DURATION_SECONDS.labels(phase="evidence").set(time.time() - phase_started) HERMES_TRIAGE_DURATION_SECONDS.labels(phase="evidence").set(time.time() - phase_started)
if settings.hermes_code_enabled and job == settings.hermes_code_job:
return _propose_code_fix(storage, base, bundle)
phase_started = time.time() phase_started = time.time()
run = hermes_agent_client.run_triage(_hermes_run_config(), _build_prompt(incident_id, bundle)) run = hermes_agent_client.run_triage(_hermes_run_config(), _build_prompt(incident_id, bundle))
HERMES_TRIAGE_DURATION_SECONDS.labels(phase="diagnosis").set(time.time() - phase_started) HERMES_TRIAGE_DURATION_SECONDS.labels(phase="diagnosis").set(time.time() - phase_started)
@ -266,6 +269,38 @@ def _run_pipeline(
return _execute_action(storage, base, outcome) return _execute_action(storage, base, outcome)
def _propose_code_fix(storage: Any, base: dict[str, Any], bundle: dict[str, Any]) -> dict[str, Any]:
"""Run the code-repair proposal flow and park the incident with humans.
A pull request always awaits human review, so the incident lands in
human_required either way; this path never touches the fixture-repair
action accounting.
"""
result = hermes_code_flow.propose_code_fix(
storage,
str(base["incident_id"]),
str(base["job"]),
_int_value(base["build_number"]),
bundle,
_hermes_run_config(),
hermes_code_flow.code_config(settings),
)
if result.get("status") == "pr_opened":
reason = CODE_FIX_PROPOSED_REASON
phase = {
"reason": reason,
"branch": result.get("branch"),
"pr_number": result.get("pr_number"),
"url": result.get("url"),
}
else:
reason = str(result.get("reason") or "code_fix_not_proposed")
phase = {"reason": reason}
_record_incident(storage, base, "human_required", phase)
return {"status": "human_required", "incident_id": str(base["incident_id"]), "reason": reason}
def _execute_action(storage: Any, base: dict[str, Any], outcome: Any) -> dict[str, Any]: def _execute_action(storage: Any, base: dict[str, Any], outcome: Any) -> dict[str, Any]:
"""Run the authorized repair action and request the verification rebuild.""" """Run the authorized repair action and request the verification rebuild."""

View File

@ -0,0 +1,205 @@
from __future__ import annotations
from dataclasses import dataclass
import json
from typing import Any
from ..utils.logging import get_logger
from . import hermes_agent_client, hermes_code_patch, hermes_code_repair
logger = get_logger(__name__)
CODE_PROPOSAL_EVENT_TYPE = "hermes_autotriage_code_proposal"
_RUN_COMPLETED = "completed"
_GITEA_TIMEOUT_SECONDS = 15.0
_PATCH_PROMPT_TEMPLATE = """Use $triage-titan-test-failures.
You are proposing a MINIMAL source fix for incident __INCIDENT_ID__.
The repository is __OWNER__/__REPO__ branch __BASE_BRANCH__.
Return ONLY a single JSON object with exactly these keys and no others:
{"incident_id": "<must equal __INCIDENT_ID__>", "analysis": "<string>", "patch": {"path": "<repository-relative file path>", "original": "<exact snippet from the file shown>", "replacement": "<replacement snippet>", "rationale": "<string>"} or null, "human_required": <bool>, "reason": "<string>"}
`original` must be an exact substring of the file shown below, appearing exactly once.
Change as few lines as possible; do not reformat; do not add dependencies.
Ariadne validates and pushes the change you do not execute anything.
Set human_required to true if the fix is not a small localized source change.
Failing test evidence bundle:
__BUNDLE__
Current content of the candidate file __PATH__:
__FILE_CONTENTS__"""
@dataclass(frozen=True)
class _Proposal:
patch: hermes_code_patch.ProposedPatch
analysis: str
patched_contents: str
run_id: str | None
def propose_code_fix( # noqa: PLR0913 - flow contract carries the full incident context
storage: Any,
incident_id: str,
job: str,
build_number: int,
bundle: dict,
hermes_cfg: dict,
code_cfg: dict,
) -> dict[str, Any]:
"""Run the bounded code-repair proposal flow for one failed build.
Inputs: the incident event storage, incident identity (id/job/build), the
already-built evidence bundle, the Hermes agent run config, and the code
path config from `code_config`. Outputs: {"status": "pr_opened", ...}
when a branch and pull request were created, otherwise
{"status": "human_required", "reason": ...}. Always records one
hermes_autotriage_code_proposal event without patch bodies or tokens.
"""
result, event = _propose(incident_id, build_number, bundle, hermes_cfg, code_cfg)
storage.record_event(
CODE_PROPOSAL_EVENT_TYPE,
{"incident_id": incident_id, "job": job, "build_number": build_number, **event},
)
logger.info(
"hermes code fix proposal finished",
extra={"event": "hermes_code_flow", "status": result["status"], "incident_id": incident_id},
)
return result
def code_config(config: Any) -> dict[str, Any]:
"""Build the code-repair cfg dict from a settings-like object.
Inputs: an object exposing the hermes_code_* and hermes_gitea_* settings.
Outputs: the cfg dict consumed by the patch validator and the Gitea
client (validation limits, repository identity, and credentials).
"""
return {
"candidate_path": config.hermes_code_candidate_path,
"allowed_path_prefixes": list(config.hermes_code_allowed_prefixes),
"allowed_suffixes": list(config.hermes_code_allowed_suffixes),
"max_patch_bytes": config.hermes_code_max_patch_bytes,
"max_changed_lines": config.hermes_code_max_changed_lines,
"gitea_base_url": config.hermes_gitea_base_url,
"gitea_token": config.hermes_gitea_token,
"owner": config.hermes_code_owner,
"repo": config.hermes_code_repo,
"base_branch": config.hermes_code_base_branch,
"timeout_seconds": _GITEA_TIMEOUT_SECONDS,
}
def _propose(
incident_id: str, build_number: int, bundle: dict, hermes_cfg: dict, code_cfg: dict
) -> tuple[dict[str, Any], dict[str, Any]]:
"""Run fetch, diagnosis, validation, and publication for one incident."""
path = str(code_cfg.get("candidate_path") or "")
contents, fetch_error = hermes_code_repair.fetch_file(code_cfg, path)
if contents is None:
return _human_required(f"candidate_fetch_failed: {fetch_error}", None, validated=False)
run = hermes_agent_client.run_triage(
hermes_cfg, _build_patch_prompt(incident_id, bundle, code_cfg, contents)
)
if run.status != _RUN_COMPLETED or not run.output:
return _human_required(f"hermes_run_{run.status}", run.run_id, validated=False)
patch, reject_reason = _validated_patch(run.output, incident_id, path, code_cfg, contents)
if patch is None:
return _human_required(reject_reason, run.run_id, validated=False)
proposal = _Proposal(
patch=patch,
analysis=hermes_code_patch.parse_analysis(run.output),
patched_contents=hermes_code_patch.apply_patch(contents, patch),
run_id=run.run_id,
)
return _publish(code_cfg, incident_id, build_number, proposal)
def _validated_patch(
raw_output: str, incident_id: str, path: str, code_cfg: dict, contents: str
) -> tuple[hermes_code_patch.ProposedPatch | None, str]:
"""Parse and gate the patch response against the fetched candidate file."""
outcome = hermes_code_patch.parse_patch_response(raw_output, incident_id)
if not outcome.valid or outcome.patch is None:
return None, f"patch_invalid: {outcome.reject_reason}"
if outcome.patch.path != path:
return None, f"patch_path_mismatch: got {outcome.patch.path!r} expected {path!r}"
ok, gate = hermes_code_patch.validate_patch(outcome.patch, code_cfg, contents)
if not ok:
return None, f"patch_rejected: {gate}"
return outcome.patch, ""
def _publish(
code_cfg: dict, incident_id: str, build_number: int, proposal: _Proposal
) -> tuple[dict[str, Any], dict[str, Any]]:
"""Push the repair branch and open the human-review pull request."""
push = hermes_code_repair.push_branch(
code_cfg, incident_id, build_number, proposal.patch, proposal.patched_contents
)
branch = str(push.get("branch") or "")
if not push.get("committed"):
return _human_required(f"branch_push_failed: {push.get('error')}", proposal.run_id, validated=True)
pull = hermes_code_repair.open_pull_request(
code_cfg, incident_id, build_number, branch, proposal.patch, proposal.analysis
)
if pull.get("error"):
return _human_required(
f"pull_request_failed: {pull.get('error')}", proposal.run_id, validated=True, branch=branch
)
result = {
"status": "pr_opened",
"branch": branch,
"pr_number": pull.get("pr_number"),
"url": pull.get("url"),
"path": proposal.patch.path,
"run_id": proposal.run_id,
}
event = {
"run_id": proposal.run_id,
"validated": True,
"reject_reason": None,
"branch": branch,
"pr_number": pull.get("pr_number"),
"url": pull.get("url"),
}
return result, event
def _human_required(
reason: str, run_id: str | None, validated: bool, branch: str | None = None
) -> tuple[dict[str, Any], dict[str, Any]]:
"""Build the human_required result and its proposal event detail."""
result = {"status": "human_required", "reason": reason}
event = {
"run_id": run_id,
"validated": validated,
"reject_reason": reason,
"branch": branch,
"pr_number": None,
"url": None,
}
return result, event
def _build_patch_prompt(incident_id: str, bundle: dict, code_cfg: dict, contents: str) -> str:
"""Render the frozen patch prompt with incident, bundle, and file context."""
compact = json.dumps(bundle, separators=(",", ":"), ensure_ascii=True)
return (
_PATCH_PROMPT_TEMPLATE.replace("__INCIDENT_ID__", incident_id)
.replace("__OWNER__", str(code_cfg.get("owner") or ""))
.replace("__REPO__", str(code_cfg.get("repo") or ""))
.replace("__BASE_BRANCH__", str(code_cfg.get("base_branch") or ""))
.replace("__PATH__", str(code_cfg.get("candidate_path") or ""))
.replace("__BUNDLE__", compact)
.replace("__FILE_CONTENTS__", contents)
)

View File

@ -0,0 +1,254 @@
from __future__ import annotations
from dataclasses import dataclass
import json
from typing import Any
VALID_REASON = "valid"
_TOP_LEVEL_KEYS = {"incident_id", "analysis", "patch", "human_required", "reason"}
_PATCH_KEYS = {"path", "original", "replacement", "rationale"}
_STRING_FIELDS = ("incident_id", "analysis", "reason")
_NONEMPTY_PATCH_FIELDS = ("path", "original", "replacement")
@dataclass(frozen=True)
class ProposedPatch:
"""Represent one minimal source patch proposed by Hermes.
Inputs: a validated `patch` object from the frozen response schema.
Outputs: the repository-relative path, the exact snippet expected in the
current file, its replacement, and the model's rationale.
"""
path: str
original: str
replacement: str
rationale: str
@dataclass(frozen=True)
class PatchOutcome:
"""Represent the result of parsing a Hermes patch response.
Inputs: raw Hermes output run through `parse_patch_response`.
Outputs: validity, the proposed patch when valid, and a specific reject
reason when invalid.
"""
valid: bool
patch: ProposedPatch | None
reject_reason: str | None
def parse_patch_response(raw_output: str, expected_incident_id: str) -> PatchOutcome:
"""Extract, parse, and validate a Hermes patch response payload.
Inputs: raw model output (JSON, optionally fenced or wrapped in prose)
and the incident id the response must reference.
Outputs: a PatchOutcome; any schema violation, incident mismatch, missing
patch, or human_required escalation yields valid=False with a specific
reject_reason. Never raises.
"""
candidate = _extract_json_object(str(raw_output or ""))
if candidate is None:
return _rejected("no_json_object_found")
try:
payload = json.loads(candidate)
except json.JSONDecodeError as exc:
return _rejected(f"invalid_json: {exc}")
error = _validate_payload(payload, expected_incident_id)
if error:
return _rejected(error)
patch = payload["patch"]
return PatchOutcome(
valid=True,
patch=ProposedPatch(
path=patch["path"],
original=patch["original"],
replacement=patch["replacement"],
rationale=patch["rationale"],
),
reject_reason=None,
)
def parse_analysis(raw_output: str) -> str:
"""Return the analysis string from a patch response, or "" when absent.
Inputs: the same raw Hermes output given to `parse_patch_response`.
Outputs: the top-level analysis string when one can be extracted; used
only for human-facing pull-request context. Never raises.
"""
candidate = _extract_json_object(str(raw_output or ""))
if candidate is None:
return ""
try:
payload = json.loads(candidate)
except json.JSONDecodeError:
return ""
analysis = payload.get("analysis") if isinstance(payload, dict) else None
return analysis if isinstance(analysis, str) else ""
def validate_patch(patch: ProposedPatch, cfg: dict, file_contents: str) -> tuple[bool, str]:
"""Apply every patch gate and name the first failing gate.
Inputs: the proposed patch, cfg with allowed_path_prefixes,
allowed_suffixes, max_patch_bytes, and max_changed_lines, plus the
current contents of the candidate file.
Outputs: (ok, reason); reason is "valid" only when every gate passes,
otherwise it names the first failing gate.
"""
prefixes = [str(item) for item in (cfg.get("allowed_path_prefixes") or [])]
suffixes = [str(item) for item in (cfg.get("allowed_suffixes") or [])]
max_bytes = _int_value(cfg.get("max_patch_bytes"))
max_lines = _int_value(cfg.get("max_changed_lines"))
occurrences = file_contents.count(patch.original)
changed_lines = max(patch.original.count("\n"), patch.replacement.count("\n")) + 1
gates: list[tuple[bool, str]] = [
(_path_is_safe(patch.path), "path_unsafe"),
(any(patch.path.startswith(prefix) for prefix in prefixes), "path_prefix_not_allowed"),
(any(patch.path.endswith(suffix) for suffix in suffixes), "path_suffix_not_allowed"),
(len(patch.original) + len(patch.replacement) <= max_bytes, "patch_too_large"),
(changed_lines <= max_lines, "too_many_changed_lines"),
(occurrences != 0, "original_missing"),
(occurrences <= 1, "original_ambiguous"),
(patch.replacement != patch.original, "replacement_identical"),
]
for passed, reason in gates:
if not passed:
return False, reason
return True, VALID_REASON
def apply_patch(file_contents: str, patch: ProposedPatch) -> str:
"""Replace the patch's original snippet with its replacement exactly once.
Inputs: the current file contents and a validated ProposedPatch.
Outputs: the patched contents. Raises ValueError unless the original
snippet appears exactly once.
"""
occurrences = file_contents.count(patch.original)
if occurrences != 1:
raise ValueError(f"original snippet must appear exactly once, found {occurrences}")
return file_contents.replace(patch.original, patch.replacement)
def _rejected(reason: str) -> PatchOutcome:
"""Build the invalid PatchOutcome for one reject reason."""
return PatchOutcome(valid=False, patch=None, reject_reason=reason)
def _extract_json_object(raw: str) -> str | None:
"""Return the first balanced top-level JSON object in raw text."""
depth = 0
start = -1
in_string = False
escaped = False
for index, char in enumerate(raw):
if in_string:
if escaped:
escaped = False
elif char == "\\":
escaped = True
elif char == '"':
in_string = False
continue
if char == '"' and depth > 0:
in_string = True
elif char == "{":
if depth == 0:
start = index
depth += 1
elif char == "}" and depth > 0:
depth -= 1
if depth == 0:
return raw[start : index + 1]
return None
def _validate_payload(payload: dict[str, Any], expected_incident_id: str) -> str | None:
"""Validate the frozen patch-response schema, returning the first error."""
shape_error = (
_validate_keys(payload)
or _validate_scalar_fields(payload)
or _validate_patch_field(payload["patch"])
)
if shape_error:
return shape_error
if payload["incident_id"] != expected_incident_id:
return f"incident_id_mismatch: got {payload['incident_id']!r} expected {expected_incident_id!r}"
if payload["human_required"]:
return "human_required"
if payload["patch"] is None:
return "patch_missing"
return None
def _validate_keys(payload: dict[str, Any]) -> str | None:
"""Require exactly the frozen top-level keys."""
keys = set(payload)
missing = sorted(_TOP_LEVEL_KEYS - keys)
if missing:
return "missing_keys: " + ", ".join(missing)
extra = sorted(keys - _TOP_LEVEL_KEYS)
if extra:
return "unexpected_keys: " + ", ".join(extra)
return None
def _validate_scalar_fields(payload: dict[str, Any]) -> str | None:
"""Type-check the scalar top-level fields of the response."""
for key in _STRING_FIELDS:
if not isinstance(payload[key], str):
return f"field_type_invalid: {key} must be a string"
if not isinstance(payload["human_required"], bool):
return "field_type_invalid: human_required must be a boolean"
return None
def _validate_patch_field(patch: Any) -> str | None:
"""Validate the patch object shape, tolerating an explicit null."""
if patch is None:
return None
if not isinstance(patch, dict):
return "patch_invalid: must be an object or null"
if set(patch) != _PATCH_KEYS:
return "patch_invalid: must have exactly path, original, replacement, rationale"
if not all(isinstance(patch[key], str) for key in _PATCH_KEYS):
return "patch_invalid: fields must be strings"
for key in _NONEMPTY_PATCH_FIELDS:
if not patch[key]:
return f"patch_field_empty: {key}"
return None
def _path_is_safe(path: str) -> bool:
"""Reject traversal, absolute, backslash, and NUL-bearing paths."""
if not path or "\x00" in path or "\\" in path:
return False
if path.startswith("/"):
return False
return ".." not in path
def _int_value(value: Any) -> int:
"""Coerce a value to int, defaulting to zero."""
try:
return int(value)
except (TypeError, ValueError):
return 0

View File

@ -0,0 +1,247 @@
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"}
_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 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 _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

View File

@ -10,6 +10,7 @@ from .settings_sections import (
_firefly_config, _firefly_config,
_game_stream_config, _game_stream_config,
_hermes_autotriage_config, _hermes_autotriage_config,
_hermes_code_config,
_image_sweeper_config, _image_sweeper_config,
_jenkins_build_weather_config, _jenkins_build_weather_config,
_jenkins_workspace_cleanup_config, _jenkins_workspace_cleanup_config,
@ -192,6 +193,18 @@ class Settings:
hermes_demo_namespace: str hermes_demo_namespace: str
hermes_demo_fixture_configmap: str hermes_demo_fixture_configmap: str
hermes_repair_image: str hermes_repair_image: str
hermes_code_enabled: bool
hermes_code_job: str
hermes_code_owner: str
hermes_code_repo: str
hermes_code_base_branch: str
hermes_code_candidate_path: str
hermes_code_allowed_prefixes: list[str]
hermes_code_allowed_suffixes: list[str]
hermes_code_max_patch_bytes: int
hermes_code_max_changed_lines: int
hermes_gitea_base_url: str
hermes_gitea_token: str
vaultwarden_namespace: str vaultwarden_namespace: str
vaultwarden_pod_label: str vaultwarden_pod_label: str
@ -310,6 +323,7 @@ class Settings:
jenkins_workspace_cleanup_cfg = _jenkins_workspace_cleanup_config() jenkins_workspace_cleanup_cfg = _jenkins_workspace_cleanup_config()
testing_triage_cfg = _testing_triage_config() testing_triage_cfg = _testing_triage_config()
hermes_autotriage_cfg = _hermes_autotriage_config() hermes_autotriage_cfg = _hermes_autotriage_config()
hermes_code_cfg = _hermes_code_config()
vaultwarden_cfg = _vaultwarden_config() vaultwarden_cfg = _vaultwarden_config()
schedule_cfg = _schedule_config() schedule_cfg = _schedule_config()
cluster_cfg = _cluster_state_config() cluster_cfg = _cluster_state_config()
@ -355,6 +369,7 @@ class Settings:
**jenkins_workspace_cleanup_cfg, **jenkins_workspace_cleanup_cfg,
**testing_triage_cfg, **testing_triage_cfg,
**hermes_autotriage_cfg, **hermes_autotriage_cfg,
**hermes_code_cfg,
**vaultwarden_cfg, **vaultwarden_cfg,
**schedule_cfg, **schedule_cfg,
**cluster_cfg, **cluster_cfg,

View File

@ -282,6 +282,31 @@ def _hermes_autotriage_config() -> dict[str, Any]:
} }
def _hermes_code_config() -> dict[str, Any]:
return {
"hermes_code_enabled": _env_bool("ARIADNE_HERMES_CODE_ENABLED", "false"),
"hermes_code_job": _env("ARIADNE_HERMES_CODE_JOB", "hermes-code-demo"),
"hermes_code_owner": _env("ARIADNE_HERMES_CODE_OWNER", "bstein"),
"hermes_code_repo": _env("ARIADNE_HERMES_CODE_REPO", "hermes-code-demo"),
"hermes_code_base_branch": _env("ARIADNE_HERMES_CODE_BASE_BRANCH", "master"),
"hermes_code_candidate_path": _env("ARIADNE_HERMES_CODE_CANDIDATE_PATH", "src/discount.py"),
"hermes_code_allowed_prefixes": [
item.strip()
for item in _env("ARIADNE_HERMES_CODE_ALLOWED_PREFIXES", "src/").split(",")
if item.strip()
],
"hermes_code_allowed_suffixes": [
item.strip()
for item in _env("ARIADNE_HERMES_CODE_ALLOWED_SUFFIXES", ".py").split(",")
if item.strip()
],
"hermes_code_max_patch_bytes": _env_int("ARIADNE_HERMES_CODE_MAX_PATCH_BYTES", 4000),
"hermes_code_max_changed_lines": _env_int("ARIADNE_HERMES_CODE_MAX_CHANGED_LINES", 20),
"hermes_gitea_base_url": _env("ARIADNE_HERMES_GITEA_BASE_URL", "https://scm.bstein.dev").rstrip("/"),
"hermes_gitea_token": _env("ARIADNE_HERMES_GITEA_TOKEN", ""),
}
def _vaultwarden_config() -> dict[str, Any]: def _vaultwarden_config() -> dict[str, Any]:
return { return {
"vaultwarden_namespace": _env("VAULTWARDEN_NAMESPACE", "vaultwarden"), "vaultwarden_namespace": _env("VAULTWARDEN_NAMESPACE", "vaultwarden"),

View File

@ -54,6 +54,18 @@ def _settings(**overrides) -> SimpleNamespace: # type: ignore[no-untyped-def]
"hermes_demo_namespace": "hermes-triage-demo", "hermes_demo_namespace": "hermes-triage-demo",
"hermes_demo_fixture_configmap": "hermes-triage-demo-fixture", "hermes_demo_fixture_configmap": "hermes-triage-demo-fixture",
"hermes_repair_image": "busybox:1.37", "hermes_repair_image": "busybox:1.37",
"hermes_code_enabled": False,
"hermes_code_job": "hermes-code-demo",
"hermes_code_owner": "bstein",
"hermes_code_repo": "hermes-code-demo",
"hermes_code_base_branch": "master",
"hermes_code_candidate_path": "src/discount.py",
"hermes_code_allowed_prefixes": ["src/"],
"hermes_code_allowed_suffixes": [".py"],
"hermes_code_max_patch_bytes": 4000,
"hermes_code_max_changed_lines": 20,
"hermes_gitea_base_url": "https://scm.example",
"hermes_gitea_token": "gitea-token",
"jenkins_base_url": "https://ci.example", "jenkins_base_url": "https://ci.example",
"jenkins_api_user": "user", "jenkins_api_user": "user",
"jenkins_api_token": "token", "jenkins_api_token": "token",

View File

@ -0,0 +1,375 @@
from __future__ import annotations
import json
from types import SimpleNamespace
from ariadne.services import hermes_autotriage as autotriage
from ariadne.services import hermes_code_flow as module
from ariadne.services.hermes_agent_client import HermesRunResult
JOB = "hermes-code-demo"
INCIDENT_ID = f"{JOB}/7"
FILE_CONTENTS = "def discount(price):\n return price * 0.5\n"
BUNDLE = {"incident_id": INCIDENT_ID, "jenkins": {"job": JOB}, "log_evidence": {"records": []}}
class FakeStorage:
def __init__(self) -> None:
self.events: list[dict] = []
def record_event(self, event_type, detail) -> None: # type: ignore[no-untyped-def]
self.events.append({"event_type": event_type, "detail": detail})
def list_events(self, limit=200, event_type=None): # type: ignore[no-untyped-def]
rows = [
dict(row)
for row in reversed(self.events)
if event_type is None or row["event_type"] == event_type
]
return rows[:limit]
def _hermes_cfg() -> dict:
return {"base_url": "http://hermes:8642", "api_key": "key", "total_timeout_seconds": 420.0}
def _code_cfg(**overrides) -> dict: # type: ignore[no-untyped-def]
base = {
"candidate_path": "src/discount.py",
"allowed_path_prefixes": ["src/"],
"allowed_suffixes": [".py"],
"max_patch_bytes": 4000,
"max_changed_lines": 20,
"gitea_base_url": "https://scm.example",
"gitea_token": "secret-token",
"owner": "bstein",
"repo": "hermes-code-demo",
"base_branch": "master",
"timeout_seconds": 15.0,
}
base.update(overrides)
return base
def _model_output(**overrides) -> str: # type: ignore[no-untyped-def]
payload = {
"incident_id": INCIDENT_ID,
"analysis": "The multiplier regressed to 0.5.",
"patch": {
"path": "src/discount.py",
"original": "return price * 0.5",
"replacement": "return price * 0.9",
"rationale": "restore the intended discount",
},
"human_required": False,
"reason": "small localized fix",
}
payload.update(overrides)
return json.dumps(payload)
def _run(status: str = "completed", output=None) -> HermesRunResult: # type: ignore[no-untyped-def]
return HermesRunResult(
status=status,
output=output,
run_id="run-1",
session_id="sess-1",
error=None,
duration_seconds=1.5,
denied_approvals=0,
)
def _install(monkeypatch, *, fetch=None, run=None, push=None, pull=None) -> dict: # type: ignore[no-untyped-def]
calls: dict = {"fetches": [], "runs": [], "pushes": [], "pulls": []}
def fake_fetch(cfg, path): # type: ignore[no-untyped-def]
calls["fetches"].append((cfg, path))
return fetch if fetch is not None else (FILE_CONTENTS, None)
def fake_run(cfg, prompt): # type: ignore[no-untyped-def]
calls["runs"].append((cfg, prompt))
return run if run is not None else _run(output=_model_output())
def fake_push(cfg, incident_id, build_number, patch, contents): # type: ignore[no-untyped-def]
calls["pushes"].append((cfg, incident_id, build_number, patch, contents))
return push if push is not None else {"branch": "hermes-repair/7", "committed": True, "error": None}
def fake_pull(cfg, incident_id, build_number, branch, patch, analysis): # type: ignore[no-untyped-def]
calls["pulls"].append((cfg, incident_id, build_number, branch, patch, analysis))
return pull if pull is not None else {"pr_number": 5, "url": "https://scm.example/pulls/5", "error": None}
monkeypatch.setattr(module.hermes_code_repair, "fetch_file", fake_fetch)
monkeypatch.setattr(module.hermes_agent_client, "run_triage", fake_run)
monkeypatch.setattr(module.hermes_code_repair, "push_branch", fake_push)
monkeypatch.setattr(module.hermes_code_repair, "open_pull_request", fake_pull)
return calls
def _propose(monkeypatch, **kwargs): # type: ignore[no-untyped-def]
storage = FakeStorage()
calls = _install(monkeypatch, **kwargs)
result = module.propose_code_fix(storage, INCIDENT_ID, JOB, 7, BUNDLE, _hermes_cfg(), _code_cfg())
return storage, calls, result
def _event(storage: FakeStorage) -> dict:
rows = [row for row in storage.events if row["event_type"] == module.CODE_PROPOSAL_EVENT_TYPE]
assert len(rows) == 1
return rows[0]["detail"]
def test_happy_path_opens_pull_request(monkeypatch) -> None:
storage, calls, result = _propose(monkeypatch)
assert result == {
"status": "pr_opened",
"branch": "hermes-repair/7",
"pr_number": 5,
"url": "https://scm.example/pulls/5",
"path": "src/discount.py",
"run_id": "run-1",
}
assert calls["fetches"][0] == (_code_cfg(), "src/discount.py")
cfg, incident_id, build_number, patch, contents = calls["pushes"][0]
assert (incident_id, build_number) == (INCIDENT_ID, 7)
assert patch.original == "return price * 0.5"
assert contents == "def discount(price):\n return price * 0.9\n"
_, _, _, branch, _, analysis = calls["pulls"][0]
assert branch == "hermes-repair/7"
assert analysis == "The multiplier regressed to 0.5."
detail = _event(storage)
assert detail == {
"incident_id": INCIDENT_ID,
"job": JOB,
"build_number": 7,
"run_id": "run-1",
"validated": True,
"reject_reason": None,
"branch": "hermes-repair/7",
"pr_number": 5,
"url": "https://scm.example/pulls/5",
}
serialized = json.dumps(detail)
assert "return price" not in serialized
assert "secret-token" not in serialized
def test_prompt_is_frozen_shape(monkeypatch) -> None:
_, calls, _ = _propose(monkeypatch)
cfg, prompt = calls["runs"][0]
assert cfg == _hermes_cfg()
assert prompt.startswith("Use $triage-titan-test-failures.\n")
assert f"MINIMAL source fix for incident {INCIDENT_ID}" in prompt
assert "The repository is bstein/hermes-code-demo branch master." in prompt
assert '"incident_id": "<must equal ' + INCIDENT_ID + '>"' in prompt
assert "appearing exactly once" in prompt
assert "Change as few lines as possible; do not reformat; do not add dependencies." in prompt
assert "Ariadne validates and pushes the change — you do not execute anything." in prompt
assert "Set human_required to true if the fix is not a small localized source change." in prompt
assert json.dumps(BUNDLE, separators=(",", ":")) in prompt
assert "Current content of the candidate file src/discount.py:" in prompt
assert prompt.rstrip().endswith(FILE_CONTENTS.rstrip())
def test_fetch_failure_requires_human(monkeypatch) -> None:
storage, calls, result = _propose(monkeypatch, fetch=(None, "file fetch http 404"))
assert result == {"status": "human_required", "reason": "candidate_fetch_failed: file fetch http 404"}
assert calls["runs"] == []
assert calls["pushes"] == []
detail = _event(storage)
assert detail["run_id"] is None
assert detail["validated"] is False
assert detail["reject_reason"] == "candidate_fetch_failed: file fetch http 404"
def test_unfinished_run_requires_human(monkeypatch) -> None:
storage, calls, result = _propose(monkeypatch, run=_run(status="timeout"))
assert result == {"status": "human_required", "reason": "hermes_run_timeout"}
assert calls["pushes"] == []
assert _event(storage)["run_id"] == "run-1"
def test_completed_run_without_output_requires_human(monkeypatch) -> None:
_, calls, result = _propose(monkeypatch, run=_run(status="completed", output=None))
assert result == {"status": "human_required", "reason": "hermes_run_completed"}
assert calls["pushes"] == []
def test_invalid_patch_response_requires_human(monkeypatch) -> None:
storage, calls, result = _propose(monkeypatch, run=_run(output="no json here"))
assert result == {"status": "human_required", "reason": "patch_invalid: no_json_object_found"}
assert calls["pushes"] == []
assert _event(storage)["validated"] is False
def test_patch_path_mismatch_requires_human(monkeypatch) -> None:
output = _model_output(
patch={
"path": "src/other.py",
"original": "return price * 0.5",
"replacement": "return price * 0.9",
"rationale": "r",
}
)
_, calls, result = _propose(monkeypatch, run=_run(output=output))
assert result["reason"] == "patch_path_mismatch: got 'src/other.py' expected 'src/discount.py'"
assert calls["pushes"] == []
def test_rejected_gate_requires_human(monkeypatch) -> None:
output = _model_output(
patch={
"path": "src/discount.py",
"original": "not in the file",
"replacement": "still not",
"rationale": "r",
}
)
_, calls, result = _propose(monkeypatch, run=_run(output=output))
assert result == {"status": "human_required", "reason": "patch_rejected: original_missing"}
assert calls["pushes"] == []
def test_push_failure_requires_human(monkeypatch) -> None:
storage, calls, result = _propose(
monkeypatch, push={"branch": "hermes-repair/7", "committed": False, "error": "commit http 500"}
)
assert result == {"status": "human_required", "reason": "branch_push_failed: commit http 500"}
assert calls["pulls"] == []
detail = _event(storage)
assert detail["validated"] is True
assert detail["branch"] is None
def test_pull_request_failure_requires_human(monkeypatch) -> None:
storage, _, result = _propose(
monkeypatch, pull={"pr_number": None, "url": None, "error": "pull request http 500"}
)
assert result == {"status": "human_required", "reason": "pull_request_failed: pull request http 500"}
detail = _event(storage)
assert detail["validated"] is True
assert detail["branch"] == "hermes-repair/7"
assert detail["pr_number"] is None
def test_code_config_maps_settings() -> None:
config = SimpleNamespace(
hermes_code_candidate_path="src/discount.py",
hermes_code_allowed_prefixes=["src/"],
hermes_code_allowed_suffixes=[".py"],
hermes_code_max_patch_bytes=4000,
hermes_code_max_changed_lines=20,
hermes_gitea_base_url="https://scm.example",
hermes_gitea_token="secret-token",
hermes_code_owner="bstein",
hermes_code_repo="hermes-code-demo",
hermes_code_base_branch="master",
)
assert module.code_config(config) == _code_cfg()
def _orchestrator_settings(**overrides) -> SimpleNamespace: # type: ignore[no-untyped-def]
values = {
"hermes_autotriage_enabled": True,
"hermes_autotriage_job_allowlist": [JOB],
"hermes_api_url": "http://hermes:8642",
"hermes_api_key": "key",
"hermes_run_timeout_seconds": 420.0,
"hermes_code_enabled": True,
"hermes_code_job": JOB,
"hermes_code_owner": "bstein",
"hermes_code_repo": "hermes-code-demo",
"hermes_code_base_branch": "master",
"hermes_code_candidate_path": "src/discount.py",
"hermes_code_allowed_prefixes": ["src/"],
"hermes_code_allowed_suffixes": [".py"],
"hermes_code_max_patch_bytes": 4000,
"hermes_code_max_changed_lines": 20,
"hermes_gitea_base_url": "https://scm.example",
"hermes_gitea_token": "secret-token",
}
values.update(overrides)
return SimpleNamespace(**values)
def _prepare_orchestrator(monkeypatch, flow_result, **setting_overrides): # type: ignore[no-untyped-def]
storage = FakeStorage()
calls: list = []
monkeypatch.setattr(autotriage, "settings", _orchestrator_settings(**setting_overrides))
monkeypatch.setattr(
autotriage,
"_fetch_last_build",
lambda job: {"number": 7, "result": "FAILURE", "building": False, "url": "https://ci.example/7/"},
)
monkeypatch.setattr(autotriage.hermes_evidence, "collect_evidence", lambda i, j, b: dict(BUNDLE))
def fake_propose(storage_arg, incident_id, job, build_number, bundle, hermes_cfg, code_cfg): # type: ignore[no-untyped-def]
calls.append((incident_id, job, build_number, bundle, hermes_cfg, code_cfg))
return flow_result
monkeypatch.setattr(autotriage.hermes_code_flow, "propose_code_fix", fake_propose)
triage_calls: list = []
def fake_run_triage(cfg, prompt): # type: ignore[no-untyped-def]
triage_calls.append(prompt)
return _run(status="error")
monkeypatch.setattr(autotriage.hermes_agent_client, "run_triage", fake_run_triage)
return storage, calls, triage_calls
def test_orchestrator_records_code_fix_proposed_on_pr(monkeypatch) -> None:
storage, calls, triage_calls = _prepare_orchestrator(
monkeypatch,
{"status": "pr_opened", "branch": "hermes-repair/7", "pr_number": 5, "url": "https://scm.example/pulls/5"},
)
summary = autotriage.run_hermes_autotriage(storage)
assert summary["jobs"][JOB] == {
"status": "human_required",
"incident_id": INCIDENT_ID,
"reason": "code_fix_proposed",
}
incidents = [row["detail"] for row in storage.events if row["event_type"] == autotriage.INCIDENT_EVENT_TYPE]
assert [detail["status"] for detail in incidents] == ["detected", "human_required"]
assert incidents[-1]["phase"] == {
"reason": "code_fix_proposed",
"branch": "hermes-repair/7",
"pr_number": 5,
"url": "https://scm.example/pulls/5",
}
assert [row for row in storage.events if row["event_type"] == autotriage.ACTION_EVENT_TYPE] == []
assert triage_calls == []
incident_id, job, build_number, bundle, hermes_cfg, code_cfg = calls[0]
assert (incident_id, job, build_number) == (INCIDENT_ID, JOB, 7)
assert bundle == BUNDLE
assert hermes_cfg == _hermes_cfg()
assert code_cfg == _code_cfg()
def test_orchestrator_records_flow_failure_reason(monkeypatch) -> None:
storage, _, _ = _prepare_orchestrator(
monkeypatch, {"status": "human_required", "reason": "patch_rejected: path_unsafe"}
)
summary = autotriage.run_hermes_autotriage(storage)
assert summary["jobs"][JOB]["reason"] == "patch_rejected: path_unsafe"
incidents = [row["detail"] for row in storage.events if row["event_type"] == autotriage.INCIDENT_EVENT_TYPE]
assert incidents[-1]["status"] == "human_required"
assert incidents[-1]["phase"] == {"reason": "patch_rejected: path_unsafe"}
def test_orchestrator_defaults_missing_flow_reason(monkeypatch) -> None:
storage, _, _ = _prepare_orchestrator(monkeypatch, {"status": "human_required"})
summary = autotriage.run_hermes_autotriage(storage)
assert summary["jobs"][JOB]["reason"] == "code_fix_not_proposed"
def test_orchestrator_skips_code_path_for_other_jobs(monkeypatch) -> None:
storage, calls, triage_calls = _prepare_orchestrator(
monkeypatch, {"status": "pr_opened"}, hermes_code_job="another-job"
)
summary = autotriage.run_hermes_autotriage(storage)
assert summary["jobs"][JOB]["status"] == "human_required"
assert summary["jobs"][JOB]["reason"] == "hermes_run_error"
assert calls == []
assert len(triage_calls) == 1

View File

@ -0,0 +1,253 @@
from __future__ import annotations
import json
import pytest
from ariadne.services import hermes_code_patch as module
INCIDENT_ID = "hermes-code-demo/7"
FILE_CONTENTS = "def discount(price):\n return price * 0.5\n"
def _patch_dict(**overrides) -> dict: # type: ignore[no-untyped-def]
base = {
"path": "src/discount.py",
"original": "return price * 0.5",
"replacement": "return price * 0.9",
"rationale": "restore the intended 10% discount",
}
base.update(overrides)
return base
def _payload(**overrides) -> dict: # type: ignore[no-untyped-def]
base = {
"incident_id": INCIDENT_ID,
"analysis": "The discount multiplier regressed from 0.9 to 0.5.",
"patch": _patch_dict(),
"human_required": False,
"reason": "small localized fix",
}
base.update(overrides)
return base
def _parse(payload: dict | None = None, raw: str | None = None, incident: str = INCIDENT_ID): # type: ignore[no-untyped-def]
text = raw if raw is not None else json.dumps(payload if payload is not None else _payload())
return module.parse_patch_response(text, incident)
def _proposed(**overrides) -> module.ProposedPatch: # type: ignore[no-untyped-def]
return module.ProposedPatch(**_patch_dict(**overrides))
def _cfg(**overrides) -> dict: # type: ignore[no-untyped-def]
base = {
"allowed_path_prefixes": ["src/"],
"allowed_suffixes": [".py"],
"max_patch_bytes": 4000,
"max_changed_lines": 20,
}
base.update(overrides)
return base
def test_parse_valid_response() -> None:
outcome = _parse()
assert outcome.valid is True
assert outcome.reject_reason is None
assert outcome.patch == module.ProposedPatch(
path="src/discount.py",
original="return price * 0.5",
replacement="return price * 0.9",
rationale="restore the intended 10% discount",
)
def test_parse_accepts_fenced_and_prose_wrapped_json() -> None:
raw = "Here you go.\n```json\n" + json.dumps(_payload()) + "\n```\nDone."
outcome = _parse(raw=raw)
assert outcome.valid is True
assert outcome.patch is not None
def test_parse_accepts_braces_and_escapes_inside_strings() -> None:
payload = _payload(analysis='Uses a {brace}, a "quoted" word, and a \\ escape.')
raw = "Prose with a stray } brace... " + json.dumps(payload) + ' Trailing {"not": "parsed"}'
outcome = _parse(raw=raw)
assert outcome.valid is True
assert outcome.patch is not None
def test_parse_rejects_missing_json() -> None:
assert _parse(raw="no json at all").reject_reason == "no_json_object_found"
assert _parse(raw="").reject_reason == "no_json_object_found"
def test_parse_rejects_invalid_json() -> None:
outcome = _parse(raw="{'incident_id': 'x'}")
assert outcome.valid is False
assert outcome.reject_reason.startswith("invalid_json:")
def test_parse_rejects_missing_keys() -> None:
payload = _payload()
payload.pop("analysis")
payload.pop("reason")
assert _parse(payload).reject_reason == "missing_keys: analysis, reason"
def test_parse_rejects_unexpected_keys() -> None:
assert _parse(_payload(extra=1)).reject_reason == "unexpected_keys: extra"
@pytest.mark.parametrize("field", ["incident_id", "analysis", "reason"])
def test_parse_rejects_non_string_scalars(field) -> None: # type: ignore[no-untyped-def]
outcome = _parse(_payload(**{field: 5}))
assert outcome.reject_reason == f"field_type_invalid: {field} must be a string"
def test_parse_rejects_non_boolean_human_required() -> None:
outcome = _parse(_payload(human_required="no"))
assert outcome.reject_reason == "field_type_invalid: human_required must be a boolean"
def test_parse_rejects_non_object_patch() -> None:
assert _parse(_payload(patch=[1])).reject_reason == "patch_invalid: must be an object or null"
assert _parse(_payload(patch="diff")).reject_reason == "patch_invalid: must be an object or null"
def test_parse_rejects_patch_with_wrong_keys() -> None:
missing = _patch_dict()
missing.pop("rationale")
expected = "patch_invalid: must have exactly path, original, replacement, rationale"
assert _parse(_payload(patch=missing)).reject_reason == expected
assert _parse(_payload(patch=_patch_dict(extra="x"))).reject_reason == expected
def test_parse_rejects_non_string_patch_fields() -> None:
outcome = _parse(_payload(patch=_patch_dict(original=7)))
assert outcome.reject_reason == "patch_invalid: fields must be strings"
@pytest.mark.parametrize("field", ["path", "original", "replacement"])
def test_parse_rejects_empty_patch_fields(field) -> None: # type: ignore[no-untyped-def]
outcome = _parse(_payload(patch=_patch_dict(**{field: ""})))
assert outcome.reject_reason == f"patch_field_empty: {field}"
def test_parse_rejects_incident_mismatch() -> None:
outcome = _parse(_payload(incident_id="other/1"))
assert outcome.reject_reason == f"incident_id_mismatch: got 'other/1' expected {INCIDENT_ID!r}"
def test_parse_rejects_human_required_true() -> None:
assert _parse(_payload(human_required=True)).reject_reason == "human_required"
def test_parse_rejects_null_patch() -> None:
assert _parse(_payload(patch=None)).reject_reason == "patch_missing"
def test_parse_analysis_extracts_string() -> None:
assert module.parse_analysis(json.dumps(_payload())).startswith("The discount multiplier")
assert module.parse_analysis("no json") == ""
assert module.parse_analysis("{'bad': json}") == ""
assert module.parse_analysis('{"analysis": 5}') == ""
assert module.parse_analysis("") == ""
def test_validate_patch_accepts_valid_patch() -> None:
assert module.validate_patch(_proposed(), _cfg(), FILE_CONTENTS) == (True, "valid")
@pytest.mark.parametrize(
"path",
[
"src/../secrets.py",
"/src/discount.py",
"src\\discount.py",
"src/disc\x00ount.py",
"",
],
)
def test_validate_patch_rejects_unsafe_paths(path) -> None: # type: ignore[no-untyped-def]
ok, reason = module.validate_patch(_proposed(path=path), _cfg(), FILE_CONTENTS)
assert (ok, reason) == (False, "path_unsafe")
def test_validate_patch_rejects_disallowed_prefix() -> None:
ok, reason = module.validate_patch(_proposed(path="lib/discount.py"), _cfg(), FILE_CONTENTS)
assert (ok, reason) == (False, "path_prefix_not_allowed")
def test_validate_patch_rejects_disallowed_suffix() -> None:
ok, reason = module.validate_patch(_proposed(path="src/discount.sh"), _cfg(), FILE_CONTENTS)
assert (ok, reason) == (False, "path_suffix_not_allowed")
def test_validate_patch_rejects_empty_allowlists() -> None:
cfg = _cfg(allowed_path_prefixes=[], allowed_suffixes=[])
ok, reason = module.validate_patch(_proposed(), cfg, FILE_CONTENTS)
assert (ok, reason) == (False, "path_prefix_not_allowed")
def test_validate_patch_treats_non_numeric_limits_as_zero() -> None:
cfg = _cfg(max_patch_bytes="lots", max_changed_lines=None)
ok, reason = module.validate_patch(_proposed(), cfg, FILE_CONTENTS)
assert (ok, reason) == (False, "patch_too_large")
def test_validate_patch_rejects_oversized_patch() -> None:
ok, reason = module.validate_patch(_proposed(), _cfg(max_patch_bytes=10), FILE_CONTENTS)
assert (ok, reason) == (False, "patch_too_large")
def test_validate_patch_rejects_too_many_changed_lines() -> None:
patch = _proposed(replacement="return (\n price\n * 0.9\n)")
ok, reason = module.validate_patch(patch, _cfg(max_changed_lines=2), FILE_CONTENTS)
assert (ok, reason) == (False, "too_many_changed_lines")
def test_validate_patch_counts_lines_from_larger_side() -> None:
patch = _proposed(original="return price * 0.5\n", replacement="return price * 0.9")
contents = FILE_CONTENTS
assert module.validate_patch(patch, _cfg(max_changed_lines=2), contents) == (True, "valid")
ok, reason = module.validate_patch(patch, _cfg(max_changed_lines=1), contents)
assert (ok, reason) == (False, "too_many_changed_lines")
def test_validate_patch_rejects_missing_original() -> None:
ok, reason = module.validate_patch(_proposed(original="return price * 0.75"), _cfg(), FILE_CONTENTS)
assert (ok, reason) == (False, "original_missing")
def test_validate_patch_rejects_ambiguous_original() -> None:
contents = FILE_CONTENTS + "\ndef other(price):\n return price * 0.5\n"
ok, reason = module.validate_patch(_proposed(), _cfg(), contents)
assert (ok, reason) == (False, "original_ambiguous")
def test_validate_patch_rejects_identical_replacement() -> None:
patch = _proposed(replacement="return price * 0.5")
ok, reason = module.validate_patch(patch, _cfg(), FILE_CONTENTS)
assert (ok, reason) == (False, "replacement_identical")
def test_apply_patch_replaces_single_occurrence() -> None:
patched = module.apply_patch(FILE_CONTENTS, _proposed())
assert patched == "def discount(price):\n return price * 0.9\n"
def test_apply_patch_raises_on_zero_occurrences() -> None:
with pytest.raises(ValueError, match="exactly once, found 0"):
module.apply_patch("nothing here", _proposed())
def test_apply_patch_raises_on_multiple_occurrences() -> None:
with pytest.raises(ValueError, match="exactly once, found 2"):
module.apply_patch(FILE_CONTENTS * 2, _proposed())

View File

@ -0,0 +1,258 @@
from __future__ import annotations
import base64
import json
from ariadne.services import hermes_code_repair as module
from ariadne.services.hermes_code_patch import ProposedPatch
INCIDENT_ID = "hermes-code-demo/7"
BRANCH = "hermes-repair/7"
class FakeResponse:
def __init__(self, status_code: int, payload=None, text: str = "") -> None: # type: ignore[no-untyped-def]
self.status_code = status_code
self._payload = payload
self.text = text
def json(self): # type: ignore[no-untyped-def]
if self._payload is None:
raise ValueError("no json body")
return self._payload
def _install_http(monkeypatch, responses=None) -> dict: # type: ignore[no-untyped-def]
calls: dict = {"requests": [], "kwargs": None}
queue = list(responses or [])
class FakeClient:
def __init__(self, **kwargs) -> None: # type: ignore[no-untyped-def]
calls["kwargs"] = kwargs
def __enter__(self): # type: ignore[no-untyped-def]
return self
def __exit__(self, *args) -> None: # type: ignore[no-untyped-def]
return None
def _next(self, method, url, kwargs): # type: ignore[no-untyped-def]
calls["requests"].append((method, url, kwargs))
item = queue.pop(0)
if isinstance(item, Exception):
raise item
return item
def get(self, url, **kwargs): # type: ignore[no-untyped-def]
return self._next("GET", url, kwargs)
def put(self, url, **kwargs): # type: ignore[no-untyped-def]
return self._next("PUT", url, kwargs)
def post(self, url, **kwargs): # type: ignore[no-untyped-def]
return self._next("POST", url, kwargs)
monkeypatch.setattr(module.httpx, "Client", FakeClient)
return calls
def _cfg(**overrides) -> dict: # type: ignore[no-untyped-def]
base = {
"gitea_base_url": "https://scm.example",
"gitea_token": "secret-token",
"owner": "bstein",
"repo": "hermes-code-demo",
"base_branch": "master",
"timeout_seconds": 7.5,
}
base.update(overrides)
return base
def _patch(**overrides) -> ProposedPatch: # type: ignore[no-untyped-def]
values = {
"path": "src/discount.py",
"original": "return price * 0.5",
"replacement": "return price * 0.9",
"rationale": "restore the intended discount",
}
values.update(overrides)
return ProposedPatch(**values)
def _push(monkeypatch, responses): # type: ignore[no-untyped-def]
calls = _install_http(monkeypatch, responses)
result = module.push_branch(_cfg(), INCIDENT_ID, 7, _patch(), "patched contents\n")
return calls, result
def test_fetch_file_success(monkeypatch) -> None:
calls = _install_http(monkeypatch, [FakeResponse(200, text="file body")])
contents, error = module.fetch_file(_cfg(), "src/discount.py")
assert (contents, error) == ("file body", None)
method, url, kwargs = calls["requests"][0]
assert method == "GET"
assert url == "https://scm.example/api/v1/repos/bstein/hermes-code-demo/raw/src/discount.py"
assert kwargs["params"] == {"ref": "master"}
assert kwargs["headers"] == {"Authorization": "token secret-token"}
assert calls["kwargs"] == {"timeout": 7.5}
def test_fetch_file_http_error(monkeypatch) -> None:
_install_http(monkeypatch, [FakeResponse(404)])
assert module.fetch_file(_cfg(), "src/discount.py") == (None, "file fetch http 404")
def test_fetch_file_request_exception(monkeypatch) -> None:
_install_http(monkeypatch, [RuntimeError("connect refused")])
contents, error = module.fetch_file(_cfg(), "src/discount.py")
assert contents is None
assert error == "file fetch failed: connect refused"
def test_fetch_file_without_base_url(monkeypatch) -> None:
calls = _install_http(monkeypatch, [])
assert module.fetch_file(_cfg(gitea_base_url=""), "x") == (None, "gitea base url is empty")
assert calls["requests"] == []
def test_fetch_file_uses_default_timeout(monkeypatch) -> None:
calls = _install_http(monkeypatch, [FakeResponse(200, text="ok")])
module.fetch_file(_cfg(timeout_seconds="bad"), "src/discount.py")
assert calls["kwargs"] == {"timeout": 15.0}
def test_push_branch_success_with_new_branch_payload(monkeypatch) -> None:
calls, result = _push(monkeypatch, [FakeResponse(200, {"sha": "abc123"}), FakeResponse(201, {})])
assert result == {"branch": BRANCH, "committed": True, "error": None}
get_method, get_url, get_kwargs = calls["requests"][0]
assert (get_method, get_kwargs["params"]) == ("GET", {"ref": "master"})
assert get_url == "https://scm.example/api/v1/repos/bstein/hermes-code-demo/contents/src/discount.py"
put_method, put_url, put_kwargs = calls["requests"][1]
assert (put_method, put_url) == ("PUT", get_url)
body = put_kwargs["json"]
assert body["branch"] == "master"
assert body["new_branch"] == BRANCH
assert body["sha"] == "abc123"
assert body["message"] == f"fix(hermes): restore the intended discount (incident {INCIDENT_ID})"
assert base64.b64decode(body["content"]).decode() == "patched contents\n"
assert body["author"] == {"name": "Hermes Agent", "email": "hermes@bstein.dev"}
assert body["committer"] == {"name": "Hermes Agent", "email": "hermes@bstein.dev"}
def test_push_branch_falls_back_to_branch_only_payload(monkeypatch) -> None:
calls, result = _push(
monkeypatch,
[FakeResponse(200, {"sha": "abc123"}), FakeResponse(422, {}), FakeResponse(201, {})],
)
assert result == {"branch": BRANCH, "committed": True, "error": None}
fallback_body = calls["requests"][2][2]["json"]
assert fallback_body["branch"] == BRANCH
assert "new_branch" not in fallback_body
def test_push_branch_reports_both_failed_payload_shapes(monkeypatch) -> None:
_, result = _push(
monkeypatch,
[FakeResponse(200, {"sha": "abc123"}), FakeResponse(404, {}), FakeResponse(422, {})],
)
assert result["committed"] is False
assert result["error"] == "commit http 404 then fallback http 422"
def test_push_branch_non_retryable_commit_error(monkeypatch) -> None:
calls, result = _push(monkeypatch, [FakeResponse(200, {"sha": "abc123"}), FakeResponse(500, {})])
assert result["error"] == "commit http 500"
assert len(calls["requests"]) == 2
def test_push_branch_sha_read_error(monkeypatch) -> None:
_, result = _push(monkeypatch, [FakeResponse(404)])
assert result == {"branch": BRANCH, "committed": False, "error": "file sha http 404"}
def test_push_branch_sha_missing(monkeypatch) -> None:
_, result = _push(monkeypatch, [FakeResponse(200, {})])
assert result["error"] == "file sha missing from contents response"
def test_push_branch_request_exception(monkeypatch) -> None:
_, result = _push(monkeypatch, [RuntimeError("boom")])
assert result == {"branch": BRANCH, "committed": False, "error": "branch push failed: boom"}
def test_push_branch_refuses_protected_branch_names(monkeypatch) -> None:
calls = _install_http(monkeypatch, [])
for name in ("master", "main", " "):
monkeypatch.setattr(module, "_branch_name", lambda build_number, name=name: name)
result = module.push_branch(_cfg(), INCIDENT_ID, 7, _patch(), "contents")
assert result["committed"] is False
assert result["error"] == f"refusing branch {name!r}"
assert calls["requests"] == []
def test_push_branch_without_base_url(monkeypatch) -> None:
calls = _install_http(monkeypatch, [])
result = module.push_branch(_cfg(gitea_base_url=""), INCIDENT_ID, 7, _patch(), "contents")
assert result == {"branch": BRANCH, "committed": False, "error": "gitea base url is empty"}
assert calls["requests"] == []
def test_open_pull_request_created(monkeypatch) -> None:
calls = _install_http(
monkeypatch,
[FakeResponse(201, {"number": 5, "html_url": "https://scm.example/pulls/5"})],
)
result = module.open_pull_request(_cfg(), INCIDENT_ID, 7, BRANCH, _patch(), "root cause analysis")
assert result == {"pr_number": 5, "url": "https://scm.example/pulls/5", "error": None}
method, url, kwargs = calls["requests"][0]
assert (method, url) == ("POST", "https://scm.example/api/v1/repos/bstein/hermes-code-demo/pulls")
payload = kwargs["json"]
assert payload["head"] == BRANCH
assert payload["base"] == "master"
assert payload["title"] == f"fix(hermes): repair {INCIDENT_ID}"
body = payload["body"]
assert INCIDENT_ID in body
assert "root cause analysis" in body
assert "restore the intended discount" in body
assert "`src/discount.py`" in body
assert "Proposed by Hermes; validated and pushed by Ariadne; requires human review — no automatic merge." in body
assert "secret-token" not in json.dumps(payload)
def test_open_pull_request_conflict_returns_existing(monkeypatch) -> None:
_install_http(monkeypatch, [FakeResponse(409, {"number": 9, "html_url": "https://scm.example/pulls/9"})])
result = module.open_pull_request(_cfg(), INCIDENT_ID, 7, BRANCH, _patch(), "analysis")
assert result == {"pr_number": 9, "url": "https://scm.example/pulls/9", "error": None}
def test_open_pull_request_conflict_without_payload(monkeypatch) -> None:
_install_http(monkeypatch, [FakeResponse(409)])
result = module.open_pull_request(_cfg(), INCIDENT_ID, 7, BRANCH, _patch(), "analysis")
assert result == {"pr_number": None, "url": None, "error": "pull request already exists"}
def test_open_pull_request_http_error(monkeypatch) -> None:
_install_http(monkeypatch, [FakeResponse(500)])
result = module.open_pull_request(_cfg(), INCIDENT_ID, 7, BRANCH, _patch(), "analysis")
assert result == {"pr_number": None, "url": None, "error": "pull request http 500"}
def test_open_pull_request_created_with_bad_payload(monkeypatch) -> None:
_install_http(monkeypatch, [FakeResponse(201, {"number": True})])
result = module.open_pull_request(_cfg(), INCIDENT_ID, 7, BRANCH, _patch(), "analysis")
assert result == {"pr_number": None, "url": None, "error": None}
def test_open_pull_request_request_exception(monkeypatch) -> None:
_install_http(monkeypatch, [RuntimeError("down")])
result = module.open_pull_request(_cfg(), INCIDENT_ID, 7, BRANCH, _patch(), "analysis")
assert result == {"pr_number": None, "url": None, "error": "pull request failed: down"}
def test_open_pull_request_without_base_url(monkeypatch) -> None:
calls = _install_http(monkeypatch, [])
result = module.open_pull_request(_cfg(gitea_base_url=" "), INCIDENT_ID, 7, BRANCH, _patch(), "a")
assert result == {"pr_number": None, "url": None, "error": "gitea base url is empty"}
assert calls["requests"] == []

View File

@ -93,3 +93,33 @@ def test_from_env_includes_game_stream_settings(monkeypatch) -> None:
assert cfg.wolf_gatekeeper_url == "http://wolf-gatekeeper.game-stream.svc.cluster.local:8087" assert cfg.wolf_gatekeeper_url == "http://wolf-gatekeeper.game-stream.svc.cluster.local:8087"
assert cfg.game_stream_firewall_unlock_ttl_sec == 28800 assert cfg.game_stream_firewall_unlock_ttl_sec == 28800
assert cfg.game_stream_moonlight_host == "moonlight.bstein.dev" assert cfg.game_stream_moonlight_host == "moonlight.bstein.dev"
def test_from_env_includes_hermes_code_settings(monkeypatch) -> None:
monkeypatch.setenv("ARIADNE_HERMES_CODE_ENABLED", "true")
monkeypatch.setenv("ARIADNE_HERMES_CODE_JOB", "hermes-code-demo")
monkeypatch.setenv("ARIADNE_HERMES_CODE_OWNER", "bstein")
monkeypatch.setenv("ARIADNE_HERMES_CODE_REPO", "hermes-code-demo")
monkeypatch.setenv("ARIADNE_HERMES_CODE_BASE_BRANCH", "master")
monkeypatch.setenv("ARIADNE_HERMES_CODE_CANDIDATE_PATH", "src/discount.py")
monkeypatch.setenv("ARIADNE_HERMES_CODE_ALLOWED_PREFIXES", "src/, app/")
monkeypatch.setenv("ARIADNE_HERMES_CODE_ALLOWED_SUFFIXES", ".py")
monkeypatch.setenv("ARIADNE_HERMES_CODE_MAX_PATCH_BYTES", "2000")
monkeypatch.setenv("ARIADNE_HERMES_CODE_MAX_CHANGED_LINES", "10")
monkeypatch.setenv("ARIADNE_HERMES_GITEA_BASE_URL", "https://scm.bstein.dev/")
monkeypatch.setenv("ARIADNE_HERMES_GITEA_TOKEN", "token")
cfg = Settings.from_env()
assert cfg.hermes_code_enabled is True
assert cfg.hermes_code_job == "hermes-code-demo"
assert cfg.hermes_code_owner == "bstein"
assert cfg.hermes_code_repo == "hermes-code-demo"
assert cfg.hermes_code_base_branch == "master"
assert cfg.hermes_code_candidate_path == "src/discount.py"
assert cfg.hermes_code_allowed_prefixes == ["src/", "app/"]
assert cfg.hermes_code_allowed_suffixes == [".py"]
assert cfg.hermes_code_max_patch_bytes == 2000
assert cfg.hermes_code_max_changed_lines == 10
assert cfg.hermes_gitea_base_url == "https://scm.bstein.dev"
assert cfg.hermes_gitea_token == "token"