ariadne/ariadne/services/hermes_incident_body.py
codex 950d014707 feat(hermes-triage): real services get both an issue and a patch proposal
The code path was gated on a single job id, so homegrown services could
never produce a pull request. Escalated incidents on mapped repositories
now additionally attempt a bounded patch proposal, and the filed issue
links it.

- hermes_code_flow.propose_for_incident: additive entry point invoked only
  from the escalation branch, so an auto-remediated failure never also
  gets a patch and a failed Hermes run never spends tokens on one
- three gates before any HTTP call: code enabled, not the legacy demo job,
  and the job has a repo mapping; unmapped jobs make zero calls
- never raises: a failed proposal cannot change the incident outcome or
  break the tick
- issue body links the proposal when a pull request was opened
- propose_code_fix added to the bounded action-label set

The legacy demo-job short-circuit is untouched and all of its tests pass
unchanged.

8 new tests; 480 pass in the hermes suite.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 21:29:54 -03:00

216 lines
7.8 KiB
Python

"""Render the title, body, and dedupe marker of a Hermes triage issue.
Pure formatting: nothing here talks to Gitea, reads settings, or raises. The
body is what an operator actually reads, so every list is capped and every
statement clipped — a diagnosis must fit in an issue without burying the one
line that says why a person is needed.
The hidden marker comment is the whole dedupe mechanism. A repeatedly failing
job opens a fresh incident per build; the marker is what lets the next build
recognize the issue already filed for the same job and classification.
"""
from __future__ import annotations
import re
from typing import Any
DEFAULT_MAX_BODY_CHARS = 8000
UNDIAGNOSED = "undiagnosed"
_MAX_TITLE_CHARS = 120
_MAX_FACTS = 10
_MAX_FACT_CHARS = 300
_MAX_INFERENCES = 6
_ELLIPSIS = "..."
_FACT_KEYS = ("statement", "source", "reference")
_TRUNCATION_NOTICE = (
"\n\n_Truncated by Ariadne: the diagnosis exceeded the configured issue body limit. "
"The complete evidence bundle is in the Ariadne audit trail._"
)
_AUDIT_NOTE = (
"Full evidence bundle and audit trail live in Ariadne at `/api/admin/audit/events`, "
"event types `hermes_autotriage_incident` and `hermes_autotriage_diagnosis`."
)
_FOOTER_TEMPLATE = (
"Filed automatically by Ariadne from a Hermes Agent diagnosis (run `{run_id}`). "
"Hermes has no write access to this repository; no files or infrastructure were changed."
)
_MARKER_PATTERN = re.compile(
r"<!--\s*hermes-triage\s+job=(?P<job>.*?)\s+classification=(?P<classification>.*?)"
r"\s+incident=(?P<incident>.*?)\s*-->"
)
def issue_marker(job: str, classification: str, incident_id: str) -> str:
"""Render the hidden marker comment that identifies a filed issue.
Inputs: the Jenkins job, the diagnosis classification, and the incident id.
Outputs: a single-line HTML comment safe to embed in an issue body; values
are flattened so a stray newline or comment terminator cannot break the
marker or the markdown around it.
"""
return (
f"<!-- hermes-triage job={_marker_value(job)}"
f" classification={_marker_value(classification)}"
f" incident={_marker_value(incident_id)} -->"
)
def parse_issue_marker(body: Any) -> dict[str, str] | None:
"""Extract the hermes-triage marker fields from an issue body.
Inputs: an issue body, which may be missing or not a string. Outputs:
{"job", "classification", "incident"} for the first marker found, or None
when the body carries no marker.
"""
if not isinstance(body, str):
return None
match = _MARKER_PATTERN.search(body)
if match is None:
return None
return {name: match.group(name).strip() for name in ("job", "classification", "incident")}
def issue_title(context: dict) -> str:
"""Render the issue title, bounded so issue lists stay readable.
Inputs: the issue context (job, build_number, classification). Outputs:
`[hermes] {job} #{build}: {classification}` clipped to 120 characters.
"""
prefix = f"[hermes] {context.get('job')} #{context.get('build_number')}: "
classification = str(context.get("classification") or UNDIAGNOSED)
room = _MAX_TITLE_CHARS - len(prefix)
if room <= len(_ELLIPSIS):
return f"{prefix}{classification}"[:_MAX_TITLE_CHARS]
return f"{prefix}{_clip(classification, room)}"
def issue_body(context: dict, max_chars: int = DEFAULT_MAX_BODY_CHARS) -> str:
"""Render the markdown issue body with the hidden marker line last.
Inputs: the issue context (incident identity, classification, confidence,
first_failed_gate, reason, authorize_reason, facts, inferences, build_url,
run_id) and the whole-body character cap. Outputs: the markdown body,
truncated with an explicit notice when it would exceed the cap, always
ending in the dedupe marker so truncation can never drop it.
"""
marker = issue_marker(
str(context.get("job") or ""),
str(context.get("classification") or UNDIAGNOSED),
str(context.get("incident_id") or ""),
)
sections = [
_summary_line(context),
_human_section(context),
_facts_section(context),
_inferences_section(context),
_links_section(context),
_FOOTER_TEMPLATE.format(run_id=context.get("run_id") or "unknown"),
]
body = "\n\n".join(section for section in sections if section)
return f"{_bounded_body(body, max_chars - len(marker) - 2)}\n\n{marker}"
def fact_fields(fact: Any) -> dict[str, str]:
"""Normalize one cited fact from a triage dataclass or a plain dict.
Inputs: a TriageFact or a {statement, source, reference} mapping. Outputs:
the three fields as strings, empty when absent.
"""
if isinstance(fact, dict):
return {key: str(fact.get(key) or "") for key in _FACT_KEYS}
return {key: str(getattr(fact, key, "") or "") for key in _FACT_KEYS}
def _summary_line(context: dict) -> str:
"""Render the one-line summary that opens the issue body."""
confidence = context.get("confidence")
return (
f"Hermes auto-triage classified incident `{context.get('incident_id')}` as "
f"**{context.get('classification')}** (confidence {'n/a' if confidence is None else confidence}); "
f"first failed gate: `{context.get('first_failed_gate') or 'unknown'}`."
)
def _human_section(context: dict) -> str:
"""Render the section explaining why the incident needs a person."""
lines = ["## Why a human is needed", str(context.get("reason") or "no reason recorded")]
authorize_reason = str(context.get("authorize_reason") or "")
if authorize_reason:
lines.append(f"Ariadne did not authorize automated remediation: `{authorize_reason}`.")
return "\n\n".join(lines)
def _facts_section(context: dict) -> str:
"""Render the bounded evidence list cited by the diagnosis."""
facts = context.get("facts")
if not isinstance(facts, list) or not facts:
return ""
lines = ["## Facts"]
for fact in facts[:_MAX_FACTS]:
fields = fact_fields(fact)
statement = _clip(fields["statement"], _MAX_FACT_CHARS)
lines.append(f"- **{fields['source'] or 'unknown'}** — {statement} (`{fields['reference']}`)")
return "\n".join(lines)
def _inferences_section(context: dict) -> str:
"""Render the bounded inference list from the diagnosis."""
inferences = context.get("inferences")
if not isinstance(inferences, list) or not inferences:
return ""
lines = ["## Inferences"]
lines.extend(f"- {_clip(str(item), _MAX_FACT_CHARS)}" for item in inferences[:_MAX_INFERENCES])
return "\n".join(lines)
def _links_section(context: dict) -> str:
"""Render the links pointing back at Jenkins, the proposal, and the audit log."""
build_url = str(context.get("build_url") or "")
proposal_url = str(context.get("code_proposal_url") or "")
lines = [
"## Links",
f"- Failed build: {build_url}" if build_url else "- Failed build: url unavailable",
]
if proposal_url:
lines.append(f"- Proposed fix awaiting review: {proposal_url}")
lines.append(f"- {_AUDIT_NOTE}")
return "\n".join(lines)
def _bounded_body(body: str, budget: int) -> str:
"""Clip the rendered body to its budget with an explicit notice."""
if budget <= 0:
return ""
if len(body) <= budget:
return body
room = budget - len(_TRUNCATION_NOTICE)
return body[:room] + _TRUNCATION_NOTICE if room > 0 else body[:budget]
def _clip(value: str, limit: int) -> str:
"""Clip one string to a limit, marking that it was shortened."""
if len(value) <= limit:
return value
return value[: limit - len(_ELLIPSIS)] + _ELLIPSIS
def _marker_value(value: str) -> str:
"""Flatten one marker field so it cannot break the comment or the body."""
return " ".join(str(value).replace("-->", "").split()) or "unknown"