2026-08-05 19:18:26 -03:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import base64
|
|
|
|
|
import json
|
|
|
|
|
|
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
|
|
|
import httpx
|
|
|
|
|
|
2026-08-05 19:18:26 -03:00
|
|
|
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}
|
|
|
|
|
|
|
|
|
|
|
2026-08-05 20:16:49 -03:00
|
|
|
def _pull(number: int, head: str = BRANCH, base: str = "master") -> dict:
|
|
|
|
|
return {
|
|
|
|
|
"number": number,
|
|
|
|
|
"html_url": f"https://scm.example/pulls/{number}",
|
|
|
|
|
"head": {"ref": head},
|
|
|
|
|
"base": {"ref": base},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _none_found(error=None) -> dict: # type: ignore[no-untyped-def]
|
feat(hermes): stop the triage system talking about itself, and raise the PR ceiling
Two changes to how this reads and behaves on real service repositories.
The fixture rules were stated to Hermes on every job, so it reasoned about
them out loud and that reasoning was published verbatim into service issue
trackers - ariadne/404 opened with 'The job is ariadne, not
hermes-triage-demo, so the reserved demo fixture classification and repair
action are forbidden'. That reads as though the system exists to serve a
demonstration. Those rules are now appended only for the fixture job, so a
real service is never told about them and cannot repeat them; the demo
classification is unreachable elsewhere by construction rather than by
instruction. The prompt also asks for language aimed at a maintainer who
knows nothing about how triage is configured, and points at the structured
test evidence first now that junit publishes it.
The duplicate guard refused a proposal whenever any repair pull request was
open, which meant one unreviewed fix blocked every later one across the
repository. It now enforces a ceiling instead, ARIADNE_HERMES_CODE_MAX_OPEN_PROPOSALS,
default 64. That is a review-capacity limit, not a correctness one: proposals
are cheap to make and expensive to read.
Auto-triage settings move to their own module; they had grown a section's
worth and pushed settings_sections.py past its size budget.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 13:08:18 -03:00
|
|
|
return {"found": False, "open_count": 0, "pr_number": None, "url": None, "branch": None, "error": error}
|
2026-08-05 20:16:49 -03:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_find_open_proposal_matches_repair_branch(monkeypatch) -> None:
|
|
|
|
|
calls = _install_http(monkeypatch, [FakeResponse(200, [_pull(2)])])
|
|
|
|
|
result = module.find_open_proposal(_cfg())
|
|
|
|
|
assert result == {
|
|
|
|
|
"found": True,
|
feat(hermes): stop the triage system talking about itself, and raise the PR ceiling
Two changes to how this reads and behaves on real service repositories.
The fixture rules were stated to Hermes on every job, so it reasoned about
them out loud and that reasoning was published verbatim into service issue
trackers - ariadne/404 opened with 'The job is ariadne, not
hermes-triage-demo, so the reserved demo fixture classification and repair
action are forbidden'. That reads as though the system exists to serve a
demonstration. Those rules are now appended only for the fixture job, so a
real service is never told about them and cannot repeat them; the demo
classification is unreachable elsewhere by construction rather than by
instruction. The prompt also asks for language aimed at a maintainer who
knows nothing about how triage is configured, and points at the structured
test evidence first now that junit publishes it.
The duplicate guard refused a proposal whenever any repair pull request was
open, which meant one unreviewed fix blocked every later one across the
repository. It now enforces a ceiling instead, ARIADNE_HERMES_CODE_MAX_OPEN_PROPOSALS,
default 64. That is a review-capacity limit, not a correctness one: proposals
are cheap to make and expensive to read.
Auto-triage settings move to their own module; they had grown a section's
worth and pushed settings_sections.py past its size budget.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 13:08:18 -03:00
|
|
|
"open_count": 1,
|
2026-08-05 20:16:49 -03:00
|
|
|
"pr_number": 2,
|
|
|
|
|
"url": "https://scm.example/pulls/2",
|
|
|
|
|
"branch": BRANCH,
|
|
|
|
|
"error": None,
|
|
|
|
|
}
|
|
|
|
|
method, url, kwargs = calls["requests"][0]
|
|
|
|
|
assert (method, url) == ("GET", "https://scm.example/api/v1/repos/bstein/hermes-code-demo/pulls")
|
|
|
|
|
assert kwargs["params"] == {"state": "open", "limit": 50}
|
|
|
|
|
assert kwargs["headers"] == {"Authorization": "token secret-token"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_find_open_proposal_ignores_other_branch_prefixes(monkeypatch) -> None:
|
|
|
|
|
_install_http(monkeypatch, [FakeResponse(200, [_pull(3, head="feature/thing"), _pull(4, head="renovate/x")])])
|
|
|
|
|
assert module.find_open_proposal(_cfg()) == _none_found()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_find_open_proposal_ignores_wrong_base_branch(monkeypatch) -> None:
|
|
|
|
|
_install_http(monkeypatch, [FakeResponse(200, [_pull(3, base="develop")])])
|
|
|
|
|
assert module.find_open_proposal(_cfg()) == _none_found()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_find_open_proposal_returns_lowest_numbered_match(monkeypatch) -> None:
|
|
|
|
|
payload = [
|
|
|
|
|
_pull(9, head="hermes-repair/9"),
|
|
|
|
|
_pull(2, head="hermes-repair/4"),
|
|
|
|
|
_pull(1, head="other/1"),
|
|
|
|
|
_pull(5, head="hermes-repair/5"),
|
|
|
|
|
]
|
|
|
|
|
_install_http(monkeypatch, [FakeResponse(200, payload)])
|
|
|
|
|
result = module.find_open_proposal(_cfg())
|
|
|
|
|
assert (result["found"], result["pr_number"], result["branch"]) == (True, 2, "hermes-repair/4")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_find_open_proposal_skips_malformed_entries(monkeypatch) -> None:
|
|
|
|
|
payload = ["not a dict", {"number": 1}, {"number": True, "head": {"ref": BRANCH}, "base": {"ref": "master"}}]
|
|
|
|
|
_install_http(monkeypatch, [FakeResponse(200, payload)])
|
|
|
|
|
assert module.find_open_proposal(_cfg()) == _none_found()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_find_open_proposal_empty_list(monkeypatch) -> None:
|
|
|
|
|
_install_http(monkeypatch, [FakeResponse(200, [])])
|
|
|
|
|
assert module.find_open_proposal(_cfg()) == _none_found()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_find_open_proposal_http_error_fails_open(monkeypatch) -> None:
|
|
|
|
|
_install_http(monkeypatch, [FakeResponse(503)])
|
|
|
|
|
assert module.find_open_proposal(_cfg()) == _none_found("open proposal lookup http 503")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_find_open_proposal_malformed_json_fails_open(monkeypatch) -> None:
|
|
|
|
|
_install_http(monkeypatch, [FakeResponse(200)])
|
|
|
|
|
result = module.find_open_proposal(_cfg())
|
|
|
|
|
assert result["found"] is False
|
|
|
|
|
assert result["error"] == "open proposal parse failed: no json body"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_find_open_proposal_non_list_payload_fails_open(monkeypatch) -> None:
|
|
|
|
|
_install_http(monkeypatch, [FakeResponse(200, {"message": "nope"})])
|
|
|
|
|
assert module.find_open_proposal(_cfg()) == _none_found("open proposal payload is not a list")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_find_open_proposal_request_exception_fails_open(monkeypatch) -> None:
|
|
|
|
|
_install_http(monkeypatch, [RuntimeError("connection reset")])
|
|
|
|
|
assert module.find_open_proposal(_cfg()) == _none_found("open proposal lookup failed: connection reset")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_find_open_proposal_without_base_url(monkeypatch) -> None:
|
|
|
|
|
calls = _install_http(monkeypatch, [])
|
|
|
|
|
assert module.find_open_proposal(_cfg(gitea_base_url="")) == _none_found("gitea base url is empty")
|
|
|
|
|
assert calls["requests"] == []
|
|
|
|
|
|
|
|
|
|
|
2026-08-05 19:18:26 -03:00
|
|
|
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"})],
|
|
|
|
|
)
|
2026-08-06 23:16:47 -03:00
|
|
|
result = module.open_pull_request(_cfg(), INCIDENT_ID, "run_x", BRANCH, _patch(), "root cause analysis")
|
2026-08-05 19:18:26 -03:00
|
|
|
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"})])
|
2026-08-06 23:16:47 -03:00
|
|
|
result = module.open_pull_request(_cfg(), INCIDENT_ID, "run_x", BRANCH, _patch(), "analysis")
|
2026-08-05 19:18:26 -03:00
|
|
|
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)])
|
2026-08-06 23:16:47 -03:00
|
|
|
result = module.open_pull_request(_cfg(), INCIDENT_ID, "run_x", BRANCH, _patch(), "analysis")
|
2026-08-05 19:18:26 -03:00
|
|
|
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)])
|
2026-08-06 23:16:47 -03:00
|
|
|
result = module.open_pull_request(_cfg(), INCIDENT_ID, "run_x", BRANCH, _patch(), "analysis")
|
2026-08-05 19:18:26 -03:00
|
|
|
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})])
|
2026-08-06 23:16:47 -03:00
|
|
|
result = module.open_pull_request(_cfg(), INCIDENT_ID, "run_x", BRANCH, _patch(), "analysis")
|
2026-08-05 19:18:26 -03:00
|
|
|
assert result == {"pr_number": None, "url": None, "error": None}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_open_pull_request_request_exception(monkeypatch) -> None:
|
|
|
|
|
_install_http(monkeypatch, [RuntimeError("down")])
|
2026-08-06 23:16:47 -03:00
|
|
|
result = module.open_pull_request(_cfg(), INCIDENT_ID, "run_x", BRANCH, _patch(), "analysis")
|
2026-08-05 19:18:26 -03:00
|
|
|
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"] == []
|
2026-08-06 23:16:47 -03:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_the_pull_request_names_the_hermes_run(monkeypatch) -> None:
|
|
|
|
|
""""Proposed by Hermes" is a claim; the run id is the receipt."""
|
|
|
|
|
|
|
|
|
|
calls = _install_http(monkeypatch, [FakeResponse(201, {"number": 5, "html_url": "u"})])
|
|
|
|
|
module.open_pull_request(
|
|
|
|
|
{**_cfg(), "hermes_ui_url": "https://agent.bstein.dev/"},
|
|
|
|
|
INCIDENT_ID,
|
|
|
|
|
"run_a5af87af",
|
|
|
|
|
BRANCH,
|
|
|
|
|
_patch(),
|
|
|
|
|
"analysis",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
body = calls["requests"][0][2]["json"]["body"]
|
2026-08-06 23:46:21 -03:00
|
|
|
assert (
|
|
|
|
|
"**Hermes run:** [run_a5af87af]"
|
|
|
|
|
"(https://agent.bstein.dev/chat?resume=run_a5af87af)"
|
|
|
|
|
) in body
|
|
|
|
|
assert "the tools it called" in body
|
2026-08-06 23:16:47 -03:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_the_pull_request_omits_the_link_when_no_ui_is_configured(monkeypatch) -> None:
|
|
|
|
|
calls = _install_http(monkeypatch, [FakeResponse(201, {"number": 5, "html_url": "u"})])
|
|
|
|
|
module.open_pull_request(_cfg(), INCIDENT_ID, "run_x", BRANCH, _patch(), "analysis")
|
|
|
|
|
|
|
|
|
|
body = calls["requests"][0][2]["json"]["body"]
|
|
|
|
|
assert "**Hermes run:** `run_x`" in body
|
|
|
|
|
assert "http" not in body.split("**Hermes run:**")[1]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_the_pull_request_stays_readable_without_a_run_id(monkeypatch) -> None:
|
|
|
|
|
"""A proposal with no run is still worth opening; it just claims less."""
|
|
|
|
|
|
|
|
|
|
calls = _install_http(monkeypatch, [FakeResponse(201, {"number": 5, "html_url": "u"})])
|
|
|
|
|
module.open_pull_request(_cfg(), INCIDENT_ID, "", BRANCH, _patch(), "analysis")
|
|
|
|
|
|
|
|
|
|
body = calls["requests"][0][2]["json"]["body"]
|
|
|
|
|
assert "Hermes run" not in body
|
|
|
|
|
assert "requires human review" in body
|
2026-08-06 23:46:21 -03:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_the_run_link_reopens_the_run_in_the_hermes_console() -> None:
|
|
|
|
|
"""One helper, so the pull request and the demo monitor cannot diverge."""
|
|
|
|
|
|
|
|
|
|
assert module.run_url("https://agent.bstein.dev/", "run_x") == (
|
|
|
|
|
"https://agent.bstein.dev/chat?resume=run_x"
|
|
|
|
|
)
|
|
|
|
|
assert module.run_url("", "run_x") == ""
|
|
|
|
|
assert module.run_url("https://agent.bstein.dev", " ") == ""
|
2026-08-07 00:05:07 -03:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_a_sweep_proposal_links_to_the_finding_that_caused_it(monkeypatch) -> None:
|
|
|
|
|
"""The reviewer's first question is what the finding said."""
|
|
|
|
|
|
|
|
|
|
calls = _install_http(monkeypatch, [FakeResponse(201, {"number": 6, "html_url": "u"})])
|
|
|
|
|
module.open_pull_request(
|
|
|
|
|
{**_cfg(), "sonar_ui_url": "https://quality.bstein.dev"},
|
|
|
|
|
"sonar/ariadne/AZ2y0FYFKy9i4pkIpNlV",
|
|
|
|
|
"run_x",
|
|
|
|
|
BRANCH,
|
|
|
|
|
_patch(),
|
|
|
|
|
"analysis",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
body = calls["requests"][0][2]["json"]["body"]
|
|
|
|
|
assert "**SonarQube finding:** https://quality.bstein.dev/project/issues" in body
|
|
|
|
|
assert "open=AZ2y0FYFKy9i4pkIpNlV" in body
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_a_build_driven_proposal_has_no_finding_line(monkeypatch) -> None:
|
|
|
|
|
calls = _install_http(monkeypatch, [FakeResponse(201, {"number": 6, "html_url": "u"})])
|
|
|
|
|
module.open_pull_request(
|
|
|
|
|
{**_cfg(), "sonar_ui_url": "https://quality.bstein.dev"},
|
|
|
|
|
INCIDENT_ID,
|
|
|
|
|
"run_x",
|
|
|
|
|
BRANCH,
|
|
|
|
|
_patch(),
|
|
|
|
|
"analysis",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert "SonarQube finding" not in calls["requests"][0][2]["json"]["body"]
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_open_proposal_incidents_reads_them_from_the_titles(monkeypatch) -> None:
|
|
|
|
|
"""The pull requests are the thing that exists; an index could disagree."""
|
|
|
|
|
|
|
|
|
|
calls = _install_http(
|
|
|
|
|
monkeypatch,
|
|
|
|
|
[
|
|
|
|
|
FakeResponse(
|
|
|
|
|
200,
|
|
|
|
|
[
|
|
|
|
|
{"title": "fix(hermes): repair sonar/ariadne/python:S2208/AZ1"},
|
|
|
|
|
{"title": "fix(hermes): repair ariadne/408"},
|
|
|
|
|
{"title": "chore: something a person opened"},
|
|
|
|
|
{"title": ""},
|
|
|
|
|
"not-a-dict",
|
|
|
|
|
],
|
|
|
|
|
)
|
|
|
|
|
],
|
|
|
|
|
)
|
|
|
|
|
incidents, error = module.open_proposal_incidents(_cfg())
|
|
|
|
|
|
|
|
|
|
assert error is None
|
|
|
|
|
assert incidents == ["sonar/ariadne/python:S2208/AZ1", "ariadne/408"]
|
|
|
|
|
assert calls["requests"][0][2]["params"]["state"] == "open"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_open_proposal_incidents_fails_open(monkeypatch) -> None:
|
|
|
|
|
"""A duplicate is noise; a suppressed rule is lost work."""
|
|
|
|
|
|
|
|
|
|
_install_http(monkeypatch, [FakeResponse(500, None)])
|
|
|
|
|
assert module.open_proposal_incidents(_cfg()) == ([], "open proposal lookup http 500")
|
|
|
|
|
|
|
|
|
|
_install_http(monkeypatch, [FakeResponse(200, {"not": "a list"})])
|
|
|
|
|
assert module.open_proposal_incidents(_cfg()) == ([], "open proposal payload is not a list")
|
|
|
|
|
|
|
|
|
|
_install_http(monkeypatch, [httpx.ConnectError("refused")])
|
|
|
|
|
incidents, error = module.open_proposal_incidents(_cfg())
|
|
|
|
|
assert incidents == []
|
|
|
|
|
assert "open proposal lookup failed" in error
|
|
|
|
|
|
|
|
|
|
assert module.open_proposal_incidents({**_cfg(), "gitea_base_url": ""}) == (
|
|
|
|
|
[],
|
|
|
|
|
"gitea base url is empty",
|
|
|
|
|
)
|