diff --git a/gateway/kanban_transition_emit.py b/gateway/kanban_transition_emit.py index 81d53cf1bb26..939c7fb49a6e 100644 --- a/gateway/kanban_transition_emit.py +++ b/gateway/kanban_transition_emit.py @@ -88,6 +88,15 @@ def build_transition_payload( "task_id": task_id, "board": board, "kind": kind, + # The webhook adapter classifies an incoming event from (in order) + # the X-GitHub-Event / X-GitLab-Event headers, then body ``event_type``, + # then body ``type``. The loopback bridge sends none of those headers + # (its X-Kanban-Event header is not consulted for classification), so + # ``event_type`` in the BODY is what lets the adapter match this POST + # against the route's ``events`` allowlist and spawn the orchestrator + # run. Without it the adapter falls through to "unknown", returns + # {"status": "ignored"} with a 200, and no run fires. Mirror ``kind``. + "event_type": kind, "reason": reason or "", "title": title or "", "event_id": event_id, diff --git a/tests/gateway/test_kanban_transition_emit.py b/tests/gateway/test_kanban_transition_emit.py index d486122f4307..9769f2d073b6 100644 --- a/tests/gateway/test_kanban_transition_emit.py +++ b/tests/gateway/test_kanban_transition_emit.py @@ -91,6 +91,60 @@ def test_missing_reason_yields_empty_string_not_none(): assert payload["reason"] == "" +def test_payload_event_type_is_extractable_by_the_webhook_adapter(): + """Regression: the built payload MUST carry the transition kind in a field + the webhook adapter's event-type extraction reads, or the route ignores it. + + The adapter (gateway/platforms/webhook.py) resolves the incoming event type + from, in precedence order: + + X-GitHub-Event header -> X-GitLab-Event header + -> payload["event_type"] -> payload["type"] -> "unknown" + + The loopback emitter sends no GitHub/GitLab header, so the ONLY way the + adapter can classify a kanban transition is a body field. If the payload + lacks ``event_type``/``type``, extraction falls through to ``"unknown"``, + which is not in the route's ``events`` allowlist ([blocked, completed]) — + so the adapter returns ``{"status": "ignored"}`` with a 200 and NEVER + spawns the orchestrator run. That is the exact production symptom this + guards against (200 received, no agent run). + + This test replicates the adapter's extraction precedence against the built + payload (no HTTP headers, mirroring the loopback POST) and asserts the + transition kind is recovered — i.e. the emitter and the adapter agree on + the wire contract. + """ + kind = "blocked" + payload = build_transition_payload( + task_id="t_evt", board="default", kind=kind, reason="r", event_id=7, + ) + + # Replicate the adapter's exact extraction chain with NO request headers, + # which is how the loopback bridge POSTs (it uses X-Kanban-Event, a header + # the adapter does not consult for event classification). + headers: dict[str, str] = {} + event_type = ( + headers.get("X-GitHub-Event", "") + or headers.get("X-GitLab-Event", "") + or payload.get("event_type", "") + or payload.get("type", "") + or "unknown" + ) + + assert event_type == kind, ( + "adapter would classify the transition as %r, so a route filtering on " + "[blocked, completed] ignores it and no orchestrator run spawns" % event_type + ) + + # And an explicit allowlist check mirroring the adapter's filter: + allowed_events = ["blocked", "completed"] + assert event_type in allowed_events, ( + "extracted event %r must be in the route allowlist so the POST " + "dispatches an agent run instead of returning {'status': 'ignored'}" + % event_type + ) + + # --- Integration: the wired notifier loop actually invokes the bridge ---------- import asyncio # noqa: E402 diff --git a/tests/gateway/test_kanban_transition_emit_http.py b/tests/gateway/test_kanban_transition_emit_http.py index a6b83f4d9c99..ad2dba24a407 100644 --- a/tests/gateway/test_kanban_transition_emit_http.py +++ b/tests/gateway/test_kanban_transition_emit_http.py @@ -30,6 +30,18 @@ async def handler(request): received["sig"] = request.headers.get("X-Hub-Signature-256") received["kind"] = request.headers.get("X-Kanban-Event") received["idem"] = request.headers.get("X-Idempotency-Key") + # Classify the event the way the real webhook adapter does — header + # first, then body ``event_type`` / ``type``. The loopback bridge + # sends no GitHub/GitLab header, so this exercises the body-field + # contract that lets a route actually dispatch (rather than 200-ignore). + parsed = json.loads(body) + received["adapter_event_type"] = ( + request.headers.get("X-GitHub-Event", "") + or request.headers.get("X-GitLab-Event", "") + or parsed.get("event_type", "") + or parsed.get("type", "") + or "unknown" + ) return web.json_response({"ok": True}) async def scenario(): @@ -77,6 +89,15 @@ async def scenario(): assert received["sig"] == expected, "HMAC signature must validate" assert received["kind"] == "blocked" assert received["idem"] == "kanban-transition:default:t_proof:blocked:99" + # The real adapter must be able to classify this as a 'blocked' event from + # the body alone (no GitHub/GitLab header on a loopback POST). If this is + # 'unknown', a route filtering on [blocked, completed] would 200-ignore the + # POST and never spawn the orchestrator run — the production regression. + assert received["adapter_event_type"] == "blocked", ( + "adapter classified the loopback POST as %r; the route would ignore it" + % received["adapter_event_type"] + ) + assert got["event_type"] == "blocked" if __name__ == "__main__":