feat(hermes): file a suggested fix when a finding cannot become a patch
The sweep opens a pull request only when everything lines up: mapped
repository, file inside the write allowlist, and a change expressible as one
anchored snippet. Most of this instance's backlog fails the last of those -
the bulk of it is cognitive-complexity refactors - so those findings produced
nothing at all. That is backwards. A finding nobody can patch automatically is
precisely the one a maintainer has to do by hand, which is when knowing the
intended fix is worth the most.
Such findings now become an issue carrying the finding, links to both the
SonarQube entry and the Hermes run, and the code Hermes believes would resolve
it. A declined pull request falls through to the same path rather than ending
the attempt.
Not a patch, and the issue says so: nothing here is anchored, validated
against the file, or pushed. That is what lets a suggestion describe work too
large or too diffuse for the patcher, which is the whole point of the path.
Deduped on the rule through the existing issue marker, so one root cause
yields one issue however many files it spans. Off by default: it writes to
real repositories, so an operator turns it on deliberately.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 05:00:08 -03:00
|
|
|
"""Tests for filing a suggested fix when a finding cannot become a patch."""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import json
|
|
|
|
|
from types import SimpleNamespace
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
from ariadne.services import hermes_sonar_advice as module
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
ISSUE = {
|
|
|
|
|
"key": "AZ1",
|
|
|
|
|
"rule": "python:S3776",
|
|
|
|
|
"severity": "CRITICAL",
|
|
|
|
|
"type": "CODE_SMELL",
|
|
|
|
|
"effort": "11min",
|
|
|
|
|
"path": "ariadne/services/thing.py",
|
|
|
|
|
"line": 42,
|
|
|
|
|
"message": "Refactor this function to reduce its Cognitive Complexity from 21 to the 15 allowed.",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
REPO_CFG = {
|
|
|
|
|
"owner": "bstein",
|
|
|
|
|
"repo": "ariadne",
|
|
|
|
|
"gitea_base_url": "https://scm.example",
|
|
|
|
|
"gitea_token": "t",
|
|
|
|
|
"base_branch": "master",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
INCIDENT = "sonar/ariadne/python:S3776/AZ1"
|
|
|
|
|
|
|
|
|
|
SUGGESTION = {
|
|
|
|
|
"path": "ariadne/services/thing.py",
|
|
|
|
|
"explanation": "Extract the validation branch into its own helper.",
|
|
|
|
|
"code": "def _validate(row):\n return bool(row)",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _config(**overrides):
|
|
|
|
|
values = {
|
|
|
|
|
"hermes_ui_url": "https://agent.example",
|
|
|
|
|
"hermes_sonar_ui_url": "https://quality.example",
|
|
|
|
|
"hermes_gitea_base_url": "https://scm.example",
|
|
|
|
|
"hermes_gitea_token": "t",
|
|
|
|
|
}
|
|
|
|
|
values.update(overrides)
|
|
|
|
|
return SimpleNamespace(**values)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class _Storage:
|
|
|
|
|
def __init__(self):
|
|
|
|
|
self.events = []
|
|
|
|
|
|
|
|
|
|
def record_event(self, event_type, detail):
|
|
|
|
|
self.events.append((event_type, detail))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _response(**overrides):
|
|
|
|
|
payload = {
|
|
|
|
|
"incident_id": INCIDENT,
|
|
|
|
|
"analysis": "The function branches five ways over the same row.",
|
|
|
|
|
"code_suggestions": [dict(SUGGESTION)],
|
|
|
|
|
"human_required": True,
|
|
|
|
|
"reason": "needs a maintainer",
|
|
|
|
|
}
|
|
|
|
|
payload.update(overrides)
|
|
|
|
|
return json.dumps(payload)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
def wiring(monkeypatch):
|
|
|
|
|
state = {
|
|
|
|
|
"existing": {"found": False},
|
|
|
|
|
"contents": "def thing():\n pass\n",
|
|
|
|
|
"fetch_error": None,
|
|
|
|
|
"run": SimpleNamespace(status="completed", output=_response(), run_id="run_x"),
|
|
|
|
|
"created": {"issue_number": 9, "url": "https://scm.example/issues/9", "error": None},
|
|
|
|
|
"filed_context": [],
|
|
|
|
|
}
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
module.hermes_incident_issue, "find_open_incident_issue",
|
|
|
|
|
lambda cfg, job, classification, incident: state["existing"],
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
module.hermes_code_repair, "fetch_file",
|
|
|
|
|
lambda cfg, path: (state["contents"], state["fetch_error"]),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(module.hermes_agent_client, "run_triage", lambda cfg, prompt: state["run"])
|
|
|
|
|
|
|
|
|
|
def _create(cfg, context):
|
|
|
|
|
state["filed_context"].append(context)
|
|
|
|
|
return state["created"]
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(module.hermes_incident_issue, "create_incident_issue", _create)
|
|
|
|
|
return state
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_a_finding_becomes_an_issue_carrying_the_suggested_code(wiring) -> None:
|
|
|
|
|
storage = _Storage()
|
|
|
|
|
|
|
|
|
|
result = module.advise(storage, _config(), {}, REPO_CFG, "ariadne", dict(ISSUE))
|
|
|
|
|
|
|
|
|
|
assert result == {"filed": True, "reason": "issue_filed", "url": "https://scm.example/issues/9"}
|
|
|
|
|
context = wiring["filed_context"][0]
|
|
|
|
|
assert context["incident_id"] == INCIDENT
|
|
|
|
|
assert context["classification"] == "python:S3776"
|
|
|
|
|
assert context["code_suggestions"][0].code.startswith("def _validate")
|
|
|
|
|
assert context["reason"].startswith("The function branches")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_the_issue_links_to_both_the_finding_and_the_run(wiring) -> None:
|
|
|
|
|
module.advise(_Storage(), _config(), {}, REPO_CFG, "ariadne", dict(ISSUE))
|
|
|
|
|
|
|
|
|
|
context = wiring["filed_context"][0]
|
|
|
|
|
assert context["build_url"] == (
|
|
|
|
|
"https://quality.example/project/issues?resolved=false&id=ariadne&open=AZ1"
|
|
|
|
|
)
|
|
|
|
|
assert context["run_url"] == "https://agent.example/chat?resume=run_x"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_the_issue_says_why_nothing_was_patched(wiring) -> None:
|
|
|
|
|
"""Otherwise it reads as the automation having failed."""
|
|
|
|
|
|
|
|
|
|
module.advise(_Storage(), _config(), {}, REPO_CFG, "ariadne", dict(ISSUE))
|
|
|
|
|
|
|
|
|
|
assert "no automated patch was possible" in wiring["filed_context"][0]["authorize_reason"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_a_rule_already_filed_is_not_filed_again(wiring) -> None:
|
|
|
|
|
"""One rule is one root cause; an issue per instance buries the repo."""
|
|
|
|
|
|
|
|
|
|
wiring["existing"] = {"found": True, "url": "https://scm.example/issues/3"}
|
|
|
|
|
|
|
|
|
|
result = module.advise(_Storage(), _config(), {}, REPO_CFG, "ariadne", dict(ISSUE))
|
|
|
|
|
|
|
|
|
|
assert result["filed"] is False
|
|
|
|
|
assert result["reason"] == "issue_already_open"
|
|
|
|
|
assert wiring["filed_context"] == []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_an_unreadable_file_files_nothing(wiring) -> None:
|
|
|
|
|
wiring["contents"] = None
|
|
|
|
|
wiring["fetch_error"] = "http 404"
|
|
|
|
|
|
|
|
|
|
result = module.advise(_Storage(), _config(), {}, REPO_CFG, "ariadne", dict(ISSUE))
|
|
|
|
|
|
|
|
|
|
assert result["filed"] is False
|
|
|
|
|
assert "file_fetch_failed" in result["reason"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
|
|
|
("status", "output"),
|
|
|
|
|
[("failed", ""), ("completed", ""), ("timeout", "{}")],
|
|
|
|
|
)
|
|
|
|
|
def test_a_run_that_produced_nothing_files_nothing(wiring, status, output) -> None:
|
|
|
|
|
wiring["run"] = SimpleNamespace(status=status, output=output, run_id="r")
|
|
|
|
|
|
|
|
|
|
assert module.advise(_Storage(), _config(), {}, REPO_CFG, "ariadne", dict(ISSUE))["filed"] is False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_a_response_with_no_suggestions_files_nothing(wiring) -> None:
|
|
|
|
|
wiring["run"] = SimpleNamespace(
|
|
|
|
|
status="completed", output=_response(code_suggestions=[]), run_id="r"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
result = module.advise(_Storage(), _config(), {}, REPO_CFG, "ariadne", dict(ISSUE))
|
|
|
|
|
|
|
|
|
|
assert result["reason"] == "no_suggestions_returned"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_a_failed_creation_is_reported(wiring) -> None:
|
|
|
|
|
wiring["created"] = {"issue_number": None, "url": None, "error": "http 500"}
|
|
|
|
|
|
|
|
|
|
assert module.advise(_Storage(), _config(), {}, REPO_CFG, "ariadne", dict(ISSUE))["filed"] is False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_every_attempt_is_recorded(wiring) -> None:
|
|
|
|
|
storage = _Storage()
|
|
|
|
|
module.advise(storage, _config(), {}, REPO_CFG, "ariadne", dict(ISSUE))
|
|
|
|
|
|
|
|
|
|
assert storage.events[0][0] == module.ADVICE_EVENT_TYPE
|
|
|
|
|
assert storage.events[0][1]["incident_id"] == INCIDENT
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_advice_never_raises(monkeypatch) -> None:
|
|
|
|
|
"""It runs after a pull request was already declined; it must not erase it."""
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
module.hermes_incident_issue, "find_open_incident_issue",
|
|
|
|
|
lambda *a, **k: (_ for _ in ()).throw(RuntimeError("boom")),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
result = module.advise(_Storage(), _config(), {}, REPO_CFG, "ariadne", dict(ISSUE))
|
|
|
|
|
|
|
|
|
|
assert result["filed"] is False
|
|
|
|
|
assert "advice_failed" in result["reason"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
|
|
|
"raw",
|
|
|
|
|
[
|
|
|
|
|
"",
|
|
|
|
|
"not json at all",
|
|
|
|
|
'{"incident_id": "someone/else", "analysis": "a", "code_suggestions": [], "human_required": true, "reason": "r"}',
|
|
|
|
|
'{"incident_id": "' + INCIDENT + '", "code_suggestions": "nope"}',
|
|
|
|
|
],
|
|
|
|
|
)
|
|
|
|
|
def test_an_unusable_response_yields_no_suggestions(raw) -> None:
|
|
|
|
|
assert module.parse_advice(raw, INCIDENT) == ([], "")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_a_good_response_is_parsed() -> None:
|
|
|
|
|
suggestions, analysis = module.parse_advice("noise " + _response() + " trailing", INCIDENT)
|
|
|
|
|
|
|
|
|
|
assert len(suggestions) == 1
|
|
|
|
|
assert suggestions[0].path == "ariadne/services/thing.py"
|
|
|
|
|
assert analysis.startswith("The function branches")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_the_prompt_states_that_nothing_is_applied() -> None:
|
|
|
|
|
prompt = module._prompt(INCIDENT, dict(ISSUE), "def thing(): pass")
|
|
|
|
|
|
|
|
|
|
assert "not a build failure" in prompt
|
|
|
|
|
assert "Nothing you return is applied" in prompt
|
|
|
|
|
assert "python:S3776" in prompt
|
|
|
|
|
assert "def thing(): pass" in prompt
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_the_prompt_bounds_the_file_it_sends() -> None:
|
|
|
|
|
prompt = module._prompt(INCIDENT, dict(ISSUE), "x" * 90000)
|
|
|
|
|
|
|
|
|
|
assert len(prompt) < 45000
|
2026-08-07 05:22:03 -03:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_a_finding_issue_never_uses_build_failure_wording() -> None:
|
|
|
|
|
"""A line number printed as a build number reads as a failure that never happened."""
|
|
|
|
|
|
|
|
|
|
from ariadne.services import hermes_incident_body as body
|
|
|
|
|
from ariadne.services import hermes_code_suggestion as cs
|
|
|
|
|
|
|
|
|
|
context = {
|
|
|
|
|
"incident_id": INCIDENT,
|
|
|
|
|
"job": "ariadne",
|
|
|
|
|
"build_number": 42,
|
|
|
|
|
"classification": "python:S3776",
|
|
|
|
|
"finding_path": "ariadne/services/thing.py",
|
|
|
|
|
"reason": "The function branches five ways.",
|
|
|
|
|
"authorize_reason": "no automated patch was possible for this finding",
|
|
|
|
|
"build_url": "https://quality.example/project/issues?open=AZ1",
|
|
|
|
|
"run_id": "run_x",
|
|
|
|
|
"run_url": "https://agent.example/chat?resume=run_x",
|
|
|
|
|
"code_suggestions": cs.from_payload({"code_suggestions": [dict(SUGGESTION)]}),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
assert body.issue_title(context) == "[hermes] ariadne: python:S3776"
|
|
|
|
|
rendered = body.issue_body(context)
|
|
|
|
|
assert rendered.startswith("SonarQube reports **python:S3776**")
|
|
|
|
|
assert "The build is green" in rendered
|
|
|
|
|
assert "## What is wrong" in rendered
|
|
|
|
|
assert "- SonarQube finding: https://quality.example" in rendered
|
|
|
|
|
assert "Failed build" not in rendered
|
|
|
|
|
assert "first failed gate" not in rendered
|
|
|
|
|
assert "Why a human is needed" not in rendered
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_a_build_failure_issue_is_unchanged() -> None:
|
|
|
|
|
"""The finding wording must not leak into real triage issues."""
|
|
|
|
|
|
|
|
|
|
from ariadne.services import hermes_incident_body as body
|
|
|
|
|
|
|
|
|
|
context = {
|
|
|
|
|
"incident_id": "ariadne/408",
|
|
|
|
|
"job": "ariadne",
|
|
|
|
|
"build_number": 408,
|
|
|
|
|
"classification": "pytest_test_failure",
|
|
|
|
|
"reason": "a repository test failure",
|
|
|
|
|
"authorize_reason": "human_required",
|
|
|
|
|
"build_url": "https://ci.example/job/ariadne/408/",
|
|
|
|
|
"run_id": "run_y",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
assert body.issue_title(context) == "[hermes] ariadne #408: pytest_test_failure"
|
|
|
|
|
rendered = body.issue_body(context)
|
|
|
|
|
assert "## Why a human is needed" in rendered
|
|
|
|
|
assert "- Failed build: https://ci.example" in rendered
|
|
|
|
|
assert "did not authorize automated remediation" in rendered
|
|
|
|
|
assert "SonarQube" not in rendered
|