Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions gateway/kanban_transition_emit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
54 changes: 54 additions & 0 deletions tests/gateway/test_kanban_transition_emit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions tests/gateway/test_kanban_transition_emit_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down Expand Up @@ -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__":
Expand Down
Loading