All checks were successful
Tests / Declarative: Post Actions passed: 1348
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>
251 lines
7.0 KiB
Python
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
|