ariadne/tests/test_hermes_sonar_client.py
codex f8a529e196
All checks were successful
Tests / Declarative: Post Actions passed: 1348
feat(hermes): propose fixes for SonarQube findings on a schedule
Triage has only ever entered on a failure. Static analysis is the opposite
shape - a standing backlog that never fails a build and so never asks anyone
for attention. On this instance that backlog is 139 open findings on Ariadne
alone, each already naming its file, its line, its rule and what is wrong.
That is better-located evidence than the console text the code-repair flow
normally mines, and it was being thrown away.

This is a second way into the same flow, not a second flow. A scheduled sweep
picks one finding and hands it to the existing proposal path, which is
unchanged: Hermes returns a patch as data, Ariadne validates it against the
file it names, pushes a branch, opens a pull request nobody merges. A finding
arriving from outside the build is not a reason to relax the gates that make a
proposal worth reading, so it does not.

Three deliberate limits. Security hotspots are never fetched: SonarQube models
them as needing human review, the quality gate here fails on exactly that
condition, and an automation that resolved them would be marking them
reviewed without review - defeating the control rather than satisfying it.
Findings already marked won't-fix carry a judgement someone made, and
reopening it produces pull requests that argue with a person. And the sweep
proposes one fix per run by default, because 139 pull requests nobody reads
would make the review gate theatre.

Selection is by SonarQube's own effort estimate rather than severity: effort
is the closest available proxy for the one-anchor change the patch validator
can actually check, so a trivial CRITICAL beats an involved MINOR. An
unparseable estimate is treated as ineligible, not as free.

Off by default. Triage reacts to a failure someone already cares about; this
opens pull requests nobody asked for, and that is a decision an operator makes
deliberately rather than inherits on upgrade.

Branch naming now sanitizes its token, since it arrives from a finding key as
well as a build number and a ref is one of the few places where an unexpected
character stops being cosmetic.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 22:08:42 -03:00

251 lines
7.0 KiB
Python

"""Tests for reading open SonarQube findings."""
from __future__ import annotations
import base64
import httpx
import pytest
from ariadne.services import hermes_sonar_client as module
CFG = {
"sonar_base_url": "http://sonarqube.quality.svc.cluster.local:9000/",
"sonar_token": "squ_token",
"timeout_seconds": 5,
}
ISSUE = {
"key": "AZ-1",
"component": "ariadne:ariadne/services/hermes_code_defects.py",
"line": 160,
"rule": "python:S1172",
"severity": "MAJOR",
"type": "CODE_SMELL",
"effort": "5min",
"message": 'Remove the unused function parameter "cfg".',
}
class _Response:
def __init__(self, status_code=200, payload=None, raises=False):
self.status_code = status_code
self._payload = payload if payload is not None else {"issues": [ISSUE]}
self._raises = raises
def json(self):
if self._raises:
raise ValueError("not json")
return self._payload
class _Client:
"""Stands in for httpx.Client, recording the single request made."""
last_url = ""
last_params: dict = {}
last_headers: dict = {}
response = _Response()
error: Exception | None = None
def __init__(self, *_args, **_kwargs):
pass
def __enter__(self):
return self
def __exit__(self, *_args):
return False
def get(self, url, headers=None, params=None):
_Client.last_url = url
_Client.last_params = params or {}
_Client.last_headers = headers or {}
if _Client.error is not None:
raise _Client.error
return _Client.response
@pytest.fixture(autouse=True)
def _stub_client(monkeypatch):
_Client.response = _Response()
_Client.error = None
monkeypatch.setattr(module.httpx, "Client", _Client)
return _Client
def test_a_project_returns_normalized_findings() -> None:
issues, error = module.fetch_issues(CFG, "ariadne")
assert error is None
assert issues == [
{
"key": "AZ-1",
"path": "ariadne/services/hermes_code_defects.py",
"line": 160,
"rule": "python:S1172",
"severity": "MAJOR",
"type": "CODE_SMELL",
"effort": "5min",
"message": 'Remove the unused function parameter "cfg".',
}
]
def test_the_token_is_sent_as_basic_auth_with_an_empty_password() -> None:
"""SonarQube accepts a user token only in that shape."""
module.fetch_issues(CFG, "ariadne")
expected = base64.b64encode(b"squ_token:").decode("ascii")
assert _Client.last_headers["Authorization"] == f"Basic {expected}"
def test_only_unresolved_findings_nobody_has_ruled_on_are_requested() -> None:
"""A won't-fix carries a human judgement; reopening it wastes tokens."""
module.fetch_issues(CFG, "ariadne")
assert _Client.last_params["resolved"] == "false"
assert _Client.last_params["statuses"] == "OPEN,CONFIRMED,REOPENED"
assert _Client.last_params["componentKeys"] == "ariadne"
def test_security_hotspots_are_never_requested() -> None:
"""Resolving a hotspot is a review decision, not a code change."""
module.fetch_issues(CFG, "ariadne")
assert "HOTSPOT" not in _Client.last_params["types"]
assert "SECURITY_HOTSPOT" not in _Client.last_params["types"]
assert set(_Client.last_params["types"].split(",")) == set(module.ALL_ISSUE_TYPES)
def test_the_double_slash_in_the_url_is_avoided() -> None:
module.fetch_issues(CFG, "ariadne")
assert _Client.last_url == (
"http://sonarqube.quality.svc.cluster.local:9000/api/issues/search"
)
def test_configured_types_and_severities_narrow_the_query() -> None:
module.fetch_issues(
{**CFG, "sonar_types": ["code_smell"], "sonar_severities": ["critical", "major"]},
"ariadne",
)
assert _Client.last_params["types"] == "CODE_SMELL"
assert _Client.last_params["severities"] == "CRITICAL,MAJOR"
def test_unrecognised_types_fall_back_to_every_type() -> None:
module.fetch_issues({**CFG, "sonar_types": ["nonsense"]}, "ariadne")
assert set(_Client.last_params["types"].split(",")) == set(module.ALL_ISSUE_TYPES)
@pytest.mark.parametrize(
("size", "expected"),
[(None, "100"), (5, "5"), (9000, "500"), ("junk", "100")],
)
def test_the_page_size_is_clamped(size, expected) -> None:
module.fetch_issues({**CFG, "sonar_page_size": size}, "ariadne")
assert _Client.last_params["ps"] == expected
def test_a_finding_with_no_line_is_dropped() -> None:
"""Nothing can be anchored to a file-level finding, so it is not offered."""
_Client.response = _Response(payload={"issues": [{**ISSUE, "line": None}]})
issues, error = module.fetch_issues(CFG, "ariadne")
assert error is None
assert issues == []
@pytest.mark.parametrize(
"issue",
[
{**ISSUE, "key": ""},
{**ISSUE, "component": "ariadne"},
{**ISSUE, "line": True},
"not-a-dict",
],
)
def test_an_unusable_finding_is_dropped(issue) -> None:
_Client.response = _Response(payload={"issues": [issue]})
assert module.fetch_issues(CFG, "ariadne")[0] == []
def test_a_long_message_is_clipped() -> None:
_Client.response = _Response(payload={"issues": [{**ISSUE, "message": "x " * 500}]})
issues, _ = module.fetch_issues(CFG, "ariadne")
assert len(issues[0]["message"]) <= 400
def test_a_missing_base_url_makes_no_request() -> None:
issues, error = module.fetch_issues({**CFG, "sonar_base_url": ""}, "ariadne")
assert issues == []
assert error == "sonar base url is empty"
def test_a_missing_token_makes_no_request() -> None:
issues, error = module.fetch_issues({**CFG, "sonar_token": ""}, "ariadne")
assert issues == []
assert error == "sonar token is empty"
def test_a_transport_failure_is_reported_not_raised() -> None:
_Client.error = httpx.ConnectError("refused")
issues, error = module.fetch_issues(CFG, "ariadne")
assert issues == []
assert "sonar fetch failed" in error
def test_a_non_200_is_reported() -> None:
_Client.response = _Response(status_code=503)
issues, error = module.fetch_issues(CFG, "ariadne")
assert issues == []
assert error == "sonar fetch http 503"
def test_an_unparseable_body_is_reported() -> None:
_Client.response = _Response(raises=True)
issues, error = module.fetch_issues(CFG, "ariadne")
assert issues == []
assert "sonar response not json" in error
def test_a_payload_that_is_not_a_dict_yields_nothing() -> None:
_Client.response = _Response(payload=["nope"])
assert module.fetch_issues(CFG, "ariadne") == ([], None)
def test_the_timeout_falls_back_when_unparseable() -> None:
assert module._timeout({"timeout_seconds": "soon"}) == module._DEFAULT_TIMEOUT_SECONDS
assert module._timeout({}) == module._DEFAULT_TIMEOUT_SECONDS
@pytest.mark.parametrize(
("component", "expected"),
[("proj:src/a.py", "src/a.py"), ("proj", ""), (None, ""), ("proj: a.py ", "a.py")],
)
def test_the_component_key_yields_the_repository_path(component, expected) -> None:
assert module.component_path(component) == expected