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>
216 lines
7.8 KiB
Python
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"
|