diff --git a/ariadne/services/hermes_incident_body.py b/ariadne/services/hermes_incident_body.py index 05d6c89..163fd0e 100644 --- a/ariadne/services/hermes_incident_body.py +++ b/ariadne/services/hermes_incident_body.py @@ -22,6 +22,17 @@ from ariadne.services import hermes_suggested_remediation as suggestion_field DEFAULT_MAX_BODY_CHARS = 8000 UNDIAGNOSED = "undiagnosed" +# A finding is not a build failure, and rendering one through the other's +# wording produces an issue that is wrong in every heading: a line number +# printed as a build, a "first failed gate" for a build that passed, and a +# SonarQube link labelled "failed build". +SONAR_PREFIX = "sonar/" + + +def is_finding(context: dict) -> bool: + """Report whether this issue describes a finding rather than a failure.""" + + return str(context.get("incident_id") or "").startswith(SONAR_PREFIX) _MAX_TITLE_CHARS = 120 _MAX_FACTS = 10 @@ -94,7 +105,10 @@ def issue_title(context: dict) -> str: `[hermes] {job} #{build}: {classification}` clipped to 120 characters. """ - prefix = f"[hermes] {context.get('job')} #{context.get('build_number')}: " + if is_finding(context): + prefix = f"[hermes] {context.get('job')}: " + else: + prefix = f"[hermes] {context.get('job')} #{context.get('build_number')}: " classification = str(context.get("classification") or UNDIAGNOSED) room = _MAX_TITLE_CHARS - len(prefix) if room <= len(_ELLIPSIS): @@ -149,6 +163,11 @@ def _summary_line(context: dict) -> str: incident_id = context.get("incident_id") classification = context.get("classification") + if is_finding(context): + return ( + f"SonarQube reports **{classification}** in `{context.get('finding_path')}`. " + "The build is green; this is a standing finding, not a failure." + ) run_id = str(context.get("run_id") or "") if not run_id or run_id == "unknown": # The headline is the first thing read, so it must not credit a model @@ -178,14 +197,17 @@ def _footer(context: dict) -> str: def _human_section(context: dict) -> str: """Render the section explaining why the incident needs a person.""" - lines = ["## Why a human is needed", str(context.get("reason") or "no reason recorded")] + heading = "## What is wrong" if is_finding(context) else "## Why a human is needed" + lines = [heading, str(context.get("reason") or "no reason recorded")] observation = str(context.get("observation") or "").strip() if observation: # Escalations that never reached a model carry their explanation here # rather than in facts, which are only populated from a diagnosis. lines.append(observation) authorize_reason = str(context.get("authorize_reason") or "") - if authorize_reason: + if authorize_reason and is_finding(context): + lines.append(f"Ariadne opened no pull request for it: {authorize_reason}.") + elif authorize_reason: lines.append(f"Ariadne did not authorize automated remediation: `{authorize_reason}`.") return "\n\n".join(lines) @@ -220,10 +242,13 @@ def _links_section(context: dict) -> str: build_url = str(context.get("build_url") or "") proposal_url = str(context.get("code_proposal_url") or "") - lines = [ - "## Links", - f"- Failed build: {build_url}" if build_url else "- Failed build: url unavailable", - ] + if is_finding(context): + lines = ["## Links", f"- SonarQube finding: {build_url}" if build_url else "- SonarQube finding: url unavailable"] + else: + lines = [ + "## Links", + f"- Failed build: {build_url}" if build_url else "- Failed build: url unavailable", + ] if proposal_url: lines.append(f"- Proposed fix awaiting review: {proposal_url}") lines.append(f"- {_AUDIT_NOTE}") diff --git a/ariadne/services/hermes_sonar_advice.py b/ariadne/services/hermes_sonar_advice.py index ae44c07..ba5e14e 100644 --- a/ariadne/services/hermes_sonar_advice.py +++ b/ariadne/services/hermes_sonar_advice.py @@ -209,6 +209,7 @@ def _context( # noqa: PLR0913 - the issue body needs every one of these "classification": rule, "confidence": None, "first_failed_gate": "", + "finding_path": issue.get("path"), "reason": reason, # Says plainly why nobody patched it, so the issue does not read as a # failure of the automation. diff --git a/tests/test_hermes_sonar_advice.py b/tests/test_hermes_sonar_advice.py index 93faece..90a4a84 100644 --- a/tests/test_hermes_sonar_advice.py +++ b/tests/test_hermes_sonar_advice.py @@ -232,3 +232,58 @@ def test_the_prompt_bounds_the_file_it_sends() -> None: prompt = module._prompt(INCIDENT, dict(ISSUE), "x" * 90000) assert len(prompt) < 45000 + + +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