ariadne/tests/test_hermes_incident_issue.py
codex 8bc8940d48
All checks were successful
Tests / Declarative: Post Actions passed: 1413
feat(hermes): one open proposal per rule, and link every issue to its run
One SonarQube rule is usually one root cause spread across many files. S2208
appears in three Ariadne modules and the cognitive-complexity rule in dozens,
and a sweep with no memory of what it already proposed would open a
near-identical pull request for every instance. Thirty of those get read as
none, which costs more than proposing nothing.

The sweep now skips any rule that already has an open proposal for that
project. The rules under review are read back from the open pull requests'
own titles rather than from a stored index: the pull requests are the thing
that actually exists, an index could disagree with them, and disagreeing is
the one failure mode that matters here. Once the open one is dealt with, the
next instance of that rule becomes eligible again.

This is not the root-cause collapse - it does not make one pull request fix
every instance of a rule, it just stops proposing the same rule repeatedly.
The collapse needs multi-file patch sets, which the frozen patch contract
cannot express yet.

Fails open like every other duplicate check here: an unreadable list yields no
known rules, so a lookup failure costs one extra proposal rather than
silently dropping a whole rule.

Issues now link their run id into the Hermes console, matching what pull
requests already do. Both artifacts claim a model made the call; both should
let a reader open the page where that call is visible.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 00:39:05 -03:00

497 lines
18 KiB
Python

from __future__ import annotations
from types import SimpleNamespace
from ariadne.services import hermes_incident_body as body
from ariadne.services import hermes_incident_issue as module
JOB = "metis"
INCIDENT_ID = f"{JOB}/12"
CLASSIFICATION = "dependency_resolution_failure"
TOKEN = "super-secret-token"
PROPOSAL_URL = "https://scm.example/pulls/8"
class FakeResponse:
def __init__(self, status_code: int, payload=None) -> None: # type: ignore[no-untyped-def]
self.status_code = status_code
self._payload = payload
def json(self): # type: ignore[no-untyped-def]
if isinstance(self._payload, Exception):
raise self._payload
return self._payload
class FakeStorage:
def __init__(self, explode: bool = False) -> None:
self.events: list[tuple[str, dict]] = []
self.explode = explode
def record_event(self, event_type, detail) -> None: # type: ignore[no-untyped-def]
if self.explode:
raise RuntimeError("storage is down")
self.events.append((event_type, detail))
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 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]
values = {
"gitea_base_url": "https://scm.example",
"gitea_token": TOKEN,
"owner": "bstein",
"repo": JOB,
"timeout_seconds": 7.5,
}
values.update(overrides)
return values
def _context(**overrides) -> dict: # type: ignore[no-untyped-def]
values = {
"incident_id": INCIDENT_ID,
"job": JOB,
"build_number": 12,
"build_url": "https://ci.example/job/metis/12/",
"classification": CLASSIFICATION,
"confidence": 0.91,
"first_failed_gate": "dependencies",
"reason": "no allowlisted action fits this failure",
"facts": [{"statement": "pip could not resolve urllib3", "source": "jenkins", "reference": "console"}],
"inferences": ["an upstream index published a broken constraint"],
"authorize_reason": "classification_not_actionable",
"run_id": "run-9",
}
values.update(overrides)
return values
def _issue(number: int, job: str = JOB, classification: str = CLASSIFICATION, incident: str = INCIDENT_ID) -> dict:
return {
"number": number,
"html_url": f"https://scm.example/bstein/{job}/issues/{number}",
"body": f"diagnosis\n\n{body.issue_marker(job, classification, incident)}",
}
def _settings(**overrides) -> SimpleNamespace: # type: ignore[no-untyped-def]
values = {
"hermes_issues_enabled": True,
"hermes_issue_repos": {JOB: ("bstein", JOB)},
"hermes_issue_dedupe_scope": "classification",
"hermes_issue_max_per_tick": 2,
"hermes_gitea_base_url": "https://scm.example",
"hermes_gitea_token": TOKEN,
}
values.update(overrides)
return SimpleNamespace(**values)
def _base(build_number: int = 12) -> dict:
return {"incident_id": f"{JOB}/{build_number}", "job": JOB, "build_number": build_number}
def _diagnosis(**overrides) -> dict: # type: ignore[no-untyped-def]
decision = SimpleNamespace(
classification=CLASSIFICATION,
confidence=0.91,
first_failed_gate="dependencies",
reason="the failure has no known signature",
facts=[SimpleNamespace(statement="pip failed", source="jenkins", reference="console")],
inferences=["upstream index broke"],
)
values = {
"bundle": {"jenkins": {"url": "https://ci.example/job/metis/12/"}},
"outcome": SimpleNamespace(decision=decision),
"authorize_reason": "classification_not_actionable",
"run_id": "run-9",
}
values.update(overrides)
return values
def test_marker_round_trips_through_the_body() -> None:
marker = body.issue_marker(JOB, CLASSIFICATION, INCIDENT_ID)
assert body.parse_issue_marker(f"text\n{marker}\nmore") == {
"job": JOB,
"classification": CLASSIFICATION,
"incident": INCIDENT_ID,
}
def test_marker_flattens_values_that_would_break_the_comment() -> None:
marker = body.issue_marker("job\nname", "a --> b", INCIDENT_ID)
assert "\n" not in marker
assert body.parse_issue_marker(marker) == {
"job": "job name",
"classification": "a b",
"incident": INCIDENT_ID,
}
def test_marker_parsing_tolerates_missing_and_non_string_bodies() -> None:
assert body.parse_issue_marker(None) is None
assert body.parse_issue_marker(12) is None
assert body.parse_issue_marker("a plain human issue") is None
def test_classification_scope_matches_a_different_incident_of_the_same_job(monkeypatch) -> None:
calls = _install_http(monkeypatch, [FakeResponse(200, [_issue(7, incident=f"{JOB}/9")])])
found = module.find_open_incident_issue(_cfg(), JOB, CLASSIFICATION, INCIDENT_ID)
assert found == {
"found": True,
"issue_number": 7,
"url": "https://scm.example/bstein/metis/issues/7",
"error": None,
}
method, url, kwargs = calls["requests"][0]
assert (method, url) == ("GET", "https://scm.example/api/v1/repos/bstein/metis/issues")
assert kwargs["params"] == {"state": "open", "limit": 50}
assert kwargs["headers"] == {"Authorization": f"token {TOKEN}"}
def test_classification_scope_ignores_other_jobs_and_classifications(monkeypatch) -> None:
_install_http(
monkeypatch,
[FakeResponse(200, [_issue(7, job="lesavka"), _issue(8, classification="flaky_test")])],
)
found = module.find_open_incident_issue(_cfg(), JOB, CLASSIFICATION, INCIDENT_ID)
assert found["found"] is False
assert found["error"] is None
def test_incident_scope_matches_only_the_same_incident(monkeypatch) -> None:
_install_http(
monkeypatch,
[FakeResponse(200, [_issue(7, incident=f"{JOB}/9"), _issue(9, incident=INCIDENT_ID)])],
)
found = module.find_open_incident_issue(
_cfg(dedupe_scope="incident"), JOB, CLASSIFICATION, INCIDENT_ID
)
assert found["found"] is True
assert found["issue_number"] == 9
def test_lookup_returns_the_lowest_numbered_match(monkeypatch) -> None:
_install_http(monkeypatch, [FakeResponse(200, [_issue(31), _issue(12), _issue(20)])])
found = module.find_open_incident_issue(_cfg(), JOB, CLASSIFICATION, INCIDENT_ID)
assert found["issue_number"] == 12
def test_lookup_ignores_unmarked_and_malformed_issues(monkeypatch) -> None:
payload = [
{"number": 3, "body": "a human filed this"},
{"number": True, "body": _issue(4)["body"]},
{"number": "5", "body": _issue(5)["body"]},
"not an issue",
]
_install_http(monkeypatch, [FakeResponse(200, payload)])
assert module.find_open_incident_issue(_cfg(), JOB, CLASSIFICATION, INCIDENT_ID)["found"] is False
def test_lookup_fails_open_on_http_parse_and_transport_errors(monkeypatch) -> None:
_install_http(
monkeypatch,
[
FakeResponse(503, None),
FakeResponse(200, ValueError("no json")),
FakeResponse(200, {"issues": []}),
RuntimeError("connection reset"),
],
)
statuses = [
module.find_open_incident_issue(_cfg(), JOB, CLASSIFICATION, INCIDENT_ID) for _ in range(4)
]
assert [result["found"] for result in statuses] == [False, False, False, False]
assert statuses[0]["error"] == "open issue lookup http 503"
assert "parse failed" in statuses[1]["error"]
assert statuses[2]["error"] == "open issue payload is not a list"
assert "connection reset" in statuses[3]["error"]
def test_lookup_without_a_base_url_never_calls_gitea(monkeypatch) -> None:
calls = _install_http(monkeypatch, [])
found = module.find_open_incident_issue(_cfg(gitea_base_url=" "), JOB, CLASSIFICATION, INCIDENT_ID)
assert found == {"found": False, "issue_number": None, "url": None, "error": "gitea base url is empty"}
assert calls["requests"] == []
def test_create_posts_the_issue_and_returns_its_identity(monkeypatch) -> None:
payload = {"number": 41, "html_url": "https://scm.example/bstein/metis/issues/41"}
calls = _install_http(monkeypatch, [FakeResponse(201, payload)])
created = module.create_incident_issue(_cfg(), _context())
assert created == {"issue_number": 41, "url": payload["html_url"], "error": None}
method, url, kwargs = calls["requests"][0]
assert (method, url) == ("POST", "https://scm.example/api/v1/repos/bstein/metis/issues")
assert kwargs["headers"] == {"Authorization": f"token {TOKEN}"}
assert kwargs["json"]["title"] == f"[hermes] {JOB} #12: {CLASSIFICATION}"
def test_create_reports_http_errors_exceptions_and_missing_numbers(monkeypatch) -> None:
_install_http(
monkeypatch,
[FakeResponse(422, None), RuntimeError("tls handshake failed"), FakeResponse(201, {"html_url": "u"})],
)
results = [module.create_incident_issue(_cfg(), _context()) for _ in range(3)]
assert results[0] == {"issue_number": None, "url": None, "error": "issue create http 422"}
assert "tls handshake failed" in results[1]["error"]
assert results[2] == {"issue_number": None, "url": "u", "error": "issue create response had no number"}
def test_create_without_a_base_url_never_calls_gitea(monkeypatch) -> None:
calls = _install_http(monkeypatch, [])
created = module.create_incident_issue(_cfg(gitea_base_url=""), _context())
assert created["error"] == "gitea base url is empty"
assert calls["requests"] == []
def test_title_truncates_a_long_classification() -> None:
title = body.issue_title(_context(classification="x" * 400))
assert len(title) == 120
assert title.startswith(f"[hermes] {JOB} #12: ")
assert title.endswith("...")
def test_body_carries_the_marker_the_build_url_and_the_no_write_footer() -> None:
rendered = body.issue_body(_context())
assert rendered.rstrip().endswith(body.issue_marker(JOB, CLASSIFICATION, INCIDENT_ID))
assert "https://ci.example/job/metis/12/" in rendered
assert "Hermes has no write access to this repository" in rendered
assert "no files or infrastructure were changed" in rendered
assert "run `run-9`" in rendered
assert "/api/admin/audit/events" in rendered
assert "## Why a human is needed" in rendered
assert "classification_not_actionable" in rendered
assert "- **jenkins** — pip could not resolve urllib3 (`console`)" in rendered
def test_body_never_contains_the_gitea_token() -> None:
rendered = body.issue_body(_context(reason=f"failure while using {TOKEN[:4]}"))
assert TOKEN not in rendered
def test_body_caps_facts_inferences_and_statement_length() -> None:
rendered = body.issue_body(
_context(
facts=[{"statement": "s" * 500, "source": "jenkins", "reference": f"r{i}"} for i in range(20)],
inferences=[f"inference {i}" for i in range(12)],
)
)
assert rendered.count("- **jenkins**") == 10
assert rendered.count("- inference ") == 6
assert "s" * 500 not in rendered
assert "s" * 297 + "..." in rendered
def test_body_truncates_to_the_configured_cap_and_keeps_the_marker() -> None:
rendered = body.issue_body(_context(reason="r" * 20000), max_chars=1200)
assert len(rendered) <= 1200
assert "Truncated by Ariadne" in rendered
assert rendered.rstrip().endswith(body.issue_marker(JOB, CLASSIFICATION, INCIDENT_ID))
def test_a_proposed_fix_flows_from_the_diagnosis_into_the_issue_body() -> None:
proposal = {"branch": "hermes-repair/12", "pr_number": 8, "url": PROPOSAL_URL}
context = module.issue_context(_base(), _diagnosis(code_proposal=proposal))
assert context["code_proposal_url"] == PROPOSAL_URL
assert f"- Proposed fix awaiting review: {PROPOSAL_URL}" in body.issue_body(context)
assert module.issue_context(_base(), _diagnosis())["code_proposal_url"] == ""
assert module.issue_context(_base(), _diagnosis(code_proposal={}))["code_proposal_url"] == ""
assert "Proposed fix awaiting review" not in body.issue_body(_context())
def test_context_defaults_to_undiagnosed_when_no_decision_was_parsed() -> None:
context = module.issue_context(_base(), _diagnosis(outcome=None, run_id=None))
assert context["classification"] == "undiagnosed"
assert context["reason"] == "classification_not_actionable"
assert context["facts"] == []
assert context["build_url"] == "https://ci.example/job/metis/12/"
def test_maybe_file_issue_files_and_records_one_event(monkeypatch) -> None:
payload = {"number": 5, "html_url": "https://scm.example/bstein/metis/issues/5"}
calls = _install_http(monkeypatch, [FakeResponse(200, []), FakeResponse(201, payload)])
storage, tick = FakeStorage(), {}
detail = module.maybe_file_issue(storage, _settings(), _base(), _diagnosis(), tick)
assert detail == {
"incident_id": INCIDENT_ID,
"job": JOB,
"build_number": 12,
"classification": CLASSIFICATION,
"issue_number": 5,
"url": payload["html_url"],
"skipped": False,
"error": None,
}
assert storage.events == [(module.ISSUE_EVENT_TYPE, detail)]
assert tick == {"issues_filed": 1}
assert [call[0] for call in calls["requests"]] == ["GET", "POST"]
def test_maybe_file_issue_skips_when_an_open_issue_already_exists(monkeypatch) -> None:
calls = _install_http(monkeypatch, [FakeResponse(200, [_issue(7, incident=f"{JOB}/9")])])
storage, tick = FakeStorage(), {}
detail = module.maybe_file_issue(storage, _settings(), _base(), _diagnosis(), tick)
assert detail["skipped"] is True
assert detail["issue_number"] == 7
assert detail["error"] is None
assert [call[0] for call in calls["requests"]] == ["GET"]
assert tick == {}
def test_disabled_flag_files_nothing_and_calls_nothing(monkeypatch) -> None:
calls = _install_http(monkeypatch, [])
storage = FakeStorage()
assert module.maybe_file_issue(storage, _settings(hermes_issues_enabled=False), _base(), _diagnosis(), {}) is None
assert calls["requests"] == []
assert storage.events == []
def test_unmapped_job_files_nothing_and_calls_nothing(monkeypatch) -> None:
calls = _install_http(monkeypatch, [])
storage = FakeStorage()
result = module.maybe_file_issue(
storage, _settings(hermes_issue_repos={"other-job": ("bstein", "other")}), _base(), _diagnosis(), {}
)
assert result is None
assert calls["requests"] == []
assert storage.events == []
def test_max_per_tick_stops_a_burst_of_failures(monkeypatch) -> None:
payload = {"number": 5, "html_url": "u"}
calls = _install_http(
monkeypatch,
[FakeResponse(200, []), FakeResponse(201, payload), FakeResponse(200, []), FakeResponse(201, payload)],
)
storage, tick = FakeStorage(), {}
config = _settings(hermes_issue_max_per_tick=2)
results = [
module.maybe_file_issue(storage, config, _base(build), _diagnosis(), tick) for build in (12, 13, 14)
]
assert [result is None for result in results] == [False, False, True]
assert tick == {"issues_filed": 2}
assert len(calls["requests"]) == 4
def test_lookup_failure_fails_open_and_still_files(monkeypatch) -> None:
calls = _install_http(monkeypatch, [FakeResponse(500, None), FakeResponse(201, {"number": 6})])
storage, tick = FakeStorage(), {}
detail = module.maybe_file_issue(storage, _settings(), _base(), _diagnosis(), tick)
assert detail["skipped"] is False
assert detail["issue_number"] == 6
assert [call[0] for call in calls["requests"]] == ["GET", "POST"]
def test_create_failure_is_recorded_on_the_event(monkeypatch) -> None:
_install_http(monkeypatch, [FakeResponse(200, []), FakeResponse(403, None)])
storage, tick = FakeStorage(), {}
detail = module.maybe_file_issue(storage, _settings(), _base(), _diagnosis(), tick)
assert detail["issue_number"] is None
assert detail["error"] == "issue create http 403"
assert detail["skipped"] is False
assert tick == {"issues_filed": 1}
def test_maybe_file_issue_never_raises(monkeypatch) -> None:
_install_http(monkeypatch, [FakeResponse(200, []), FakeResponse(201, {"number": 5})])
assert module.maybe_file_issue(FakeStorage(explode=True), _settings(), _base(), _diagnosis(), {}) is None
def test_repo_map_entries_accept_pairs_dicts_and_strings(monkeypatch) -> None:
calls = _install_http(monkeypatch, [FakeResponse(200, []), FakeResponse(201, {"number": 5})] * 2)
for repos in ({JOB: {"owner": "bstein", "repo": "metis"}}, {JOB: "bstein/metis"}):
module.maybe_file_issue(
FakeStorage(), _settings(hermes_issue_repos=repos), _base(), _diagnosis(), {}
)
assert [call[1] for call in calls["requests"]] == [
"https://scm.example/api/v1/repos/bstein/metis/issues"
] * 4
def test_broken_repo_map_entries_file_nothing(monkeypatch) -> None:
calls = _install_http(monkeypatch, [])
for repos in ({JOB: "bstein"}, {JOB: ("bstein", "")}, {JOB: 12}, {JOB: None}):
assert (
module.maybe_file_issue(
FakeStorage(), _settings(hermes_issue_repos=repos), _base(), _diagnosis(), {}
)
is None
)
assert calls["requests"] == []