diff --git a/ariadne/services/hermes_autotriage.py b/ariadne/services/hermes_autotriage.py index 513eb87..279fc84 100644 --- a/ariadne/services/hermes_autotriage.py +++ b/ariadne/services/hermes_autotriage.py @@ -145,6 +145,7 @@ def _handle_failure( # noqa: PLR0913 - the tick's issue budget travels with the incident_id = f"{job}/{number}" existing = incidents.get(incident_id) if existing is not None and existing.get("status") != "detected": + _backfill_issue(storage, job, number, incident_id, existing, tick_state) return {"status": "deduped", "incident_id": incident_id} stale = _awaiting_rebuild_incident(incidents, job, incident_id) if stale is not None: @@ -153,6 +154,33 @@ def _handle_failure( # noqa: PLR0913 - the tick's issue budget travels with the return _run_pipeline(storage, incident_id, job, last_build, tick_state) +def _backfill_issue( # noqa: PLR0913 - the tick's issue budget travels with the incident + storage: Any, job: str, number: int, incident_id: str, existing: dict, tick_state: dict[str, Any] +) -> None: + """File the issue an escalated incident never received. + + An incident triaged before issue filing existed, or while its repository + was unmapped, is deduped on every later tick and would otherwise never + reach the service's tracker even though the job is still red. + + Reuses the classification the original diagnosis recorded so the existing + dedupe applies: one open issue per job and classification, refiled only if + it is closed while the job still fails. Skipped when no classification was + recorded, because filing under a guessed label would duplicate rather than + match. Additive and never raises. + """ + + if existing.get("status") != "human_required": + return + classification = hermes_events.recorded_classification(storage, incident_id) + if not classification: + return + base = {"incident_id": incident_id, "job": job, "build_number": number} + bundle = {"jenkins": {"job": job, "build_number": number, "console_failures": [], "console_tail": ""}} + diagnosis = {**_diagnosis(bundle, None, "issue_backfill", None), "classification": classification} + _file_incident_issue(storage, base, diagnosis, tick_state) + + def _awaiting_rebuild_incident( incidents: dict[str, dict[str, Any]], job: str, exclude_id: str ) -> dict[str, Any] | None: diff --git a/ariadne/services/hermes_autotriage_events.py b/ariadne/services/hermes_autotriage_events.py index 517c9cf..594b773 100644 --- a/ariadne/services/hermes_autotriage_events.py +++ b/ariadne/services/hermes_autotriage_events.py @@ -208,3 +208,29 @@ def _int_value(value: Any) -> int: return int(value) except (TypeError, ValueError): return 0 + + +def recorded_classification(storage: Any, incident_id: str) -> str | None: + """Return the classification a past diagnosis recorded for an incident. + + Inputs: the event storage and an incident id. Outputs: the classification + string, or None when no diagnosis event carries one. + + Issue dedupe matches on job and classification, so a later filing must + reuse the label the original diagnosis produced. Filing under a different + one would not match the open issue and would duplicate it, which is + precisely what the dedupe exists to prevent - hence None rather than a + guess when nothing was recorded. + """ + + rows = storage.list_events(limit=_EVENT_SCAN_LIMIT, event_type=DIAGNOSIS_EVENT_TYPE) + for row in rows: + detail = event_detail(row) or {} + if str(detail.get("incident_id") or "") != incident_id: + continue + outcome = detail.get("outcome") + if isinstance(outcome, dict): + classification = str(outcome.get("classification") or "").strip() + if classification: + return classification + return None diff --git a/tests/test_hermes_issue_backfill.py b/tests/test_hermes_issue_backfill.py new file mode 100644 index 0000000..254a330 --- /dev/null +++ b/tests/test_hermes_issue_backfill.py @@ -0,0 +1,68 @@ +"""Tests for filing the issue an escalated incident never received.""" + +from __future__ import annotations + +from ariadne.services import hermes_autotriage as module +from tests.hermes_autotriage_harness import ( + INCIDENT_ID, + JOB, + _build, + _prepare_code, + _seed_incident, +) + + +def _diagnosis_event(storage, incident_id: str, classification: str) -> None: + storage.record_event( + module.DIAGNOSIS_EVENT_TYPE, + {"incident_id": incident_id, "job": JOB, "outcome": {"classification": classification}}, + ) + + +def test_a_deduped_escalation_files_the_issue_it_never_got(monkeypatch) -> None: + """The job is still red; the tracker should not stay empty forever.""" + + env = _prepare_code(monkeypatch, last_build=_build(12, "FAILURE")) + _seed_incident(env.storage, "human_required") + _diagnosis_event(env.storage, INCIDENT_ID, "coverage_quality_gate_failure") + + summary = module.run_hermes_autotriage(env.storage)["jobs"][JOB] + + assert summary == {"status": "deduped", "incident_id": INCIDENT_ID} + filed = env.calls["issues"] + assert len(filed) == 1 + assert filed[0]["classification"] == "coverage_quality_gate_failure" + + +def test_backfill_reuses_the_recorded_classification(monkeypatch) -> None: + """A different label would miss the open issue and duplicate it.""" + + env = _prepare_code(monkeypatch, last_build=_build(12, "FAILURE")) + _seed_incident(env.storage, "human_required") + _diagnosis_event(env.storage, INCIDENT_ID, "pytest_test_failure") + + module.run_hermes_autotriage(env.storage) + + assert env.calls["issues"][0]["classification"] == "pytest_test_failure" + + +def test_no_recorded_classification_means_no_backfill(monkeypatch) -> None: + """Guessing a label would duplicate rather than dedupe.""" + + env = _prepare_code(monkeypatch, last_build=_build(12, "FAILURE")) + _seed_incident(env.storage, "human_required") + + module.run_hermes_autotriage(env.storage) + + assert env.calls["issues"] == [] + + +def test_only_human_required_incidents_are_backfilled(monkeypatch) -> None: + """A resolved or in-flight incident needs no issue.""" + + for status in ("resolved", "awaiting_rebuild", "repairing"): + env = _prepare_code(monkeypatch, last_build=_build(12, "FAILURE")) + _seed_incident(env.storage, status) + _diagnosis_event(env.storage, INCIDENT_ID, "pytest_test_failure") + module.run_hermes_autotriage(env.storage) + assert env.calls["issues"] == [], status