Files
2026-07-13 11:59:58 +08:00

92 lines
3.8 KiB
Python

"""Identity helpers for GitHub webhook dispatch.
Two helpers live here:
* :func:`resolve_thread_id` makes the langgraph thread id deterministic
from ``(repo, number, agent_name)``. Same PR + same agent → same
thread, even across gateway restarts. Different agents on the same PR
(e.g. coder + reviewer) deliberately get different thread ids — see
the function docstring for the rationale.
* :func:`extract_target` extracts the ``(repo, number)`` pair from a
webhook payload, so the dispatcher can route deliveries to the right
thread.
"""
from __future__ import annotations
import uuid
from typing import Any
# UUID5 namespace dedicated to GitHub-driven threads. The bytes themselves
# are arbitrary; what matters is that every gateway in the fleet uses the
# *same* namespace so two replicas produce the same thread id for the same
# (repo, number, agent_name) triple. Don't change this without a migration
# plan.
GITHUB_THREAD_NAMESPACE = uuid.UUID("a3f4b2c1-7e8d-4f6a-b9c0-1234567890ab")
def resolve_thread_id(repo: str, issue_or_pr_number: int, agent_name: str) -> str:
"""Build a deterministic langgraph thread id from a GitHub target + agent.
The agent name is part of the seed so two agents bound to the same
PR/issue (e.g. a coder + a reviewer on ``owner/repo#7``) land on
distinct LangGraph threads. Sharing the thread would force
``multitask_strategy="reject"`` to silently drop one run on every
dual-mention, and would couple the two agents' message histories
and checkpoints. Each agent now owns its own thread; cross-agent
coordination flows through GitHub (PR comments, review threads) —
the source of truth humans see anyway.
Args:
repo: ``"owner/name"``.
issue_or_pr_number: Issue or PR number (they share the namespace on
the GitHub side, so we don't need to distinguish here).
agent_name: The bound custom agent's name. Validated upstream
against ``^[A-Za-z0-9-]+$`` (see
``app/gateway/routers/agents.py::AGENT_NAME_PATTERN``) so it
is safe to embed verbatim in the UUID5 seed.
Returns:
Stringified UUID5 under :data:`GITHUB_THREAD_NAMESPACE`.
"""
if not isinstance(repo, str) or "/" not in repo:
raise ValueError(f"Expected repo as 'owner/name', got {repo!r}")
if not isinstance(issue_or_pr_number, int):
raise ValueError(f"Expected issue_or_pr_number as int, got {type(issue_or_pr_number).__name__}")
if not isinstance(agent_name, str) or not agent_name.strip():
raise ValueError(f"Expected agent_name as non-empty str, got {agent_name!r}")
return str(uuid.uuid5(GITHUB_THREAD_NAMESPACE, f"{repo}#{issue_or_pr_number}:{agent_name}"))
def extract_target(event: str, payload: dict[str, Any]) -> tuple[str, int] | None:
"""Best-effort extraction of (repo, number) from a webhook payload.
Returns ``None`` when the event has no associated issue/PR number
(e.g. ``ping``, ``push``) or when the payload is malformed.
"""
repo = (payload.get("repository") or {}).get("full_name")
if not isinstance(repo, str):
return None
number: int | None = None
if event == "pull_request":
pr = payload.get("pull_request") or {}
number = pr.get("number") or payload.get("number")
elif event == "pull_request_review":
pr = payload.get("pull_request") or {}
number = pr.get("number")
elif event == "pull_request_review_comment":
pr = payload.get("pull_request") or {}
number = pr.get("number")
elif event == "issue_comment":
number = (payload.get("issue") or {}).get("number")
elif event == "issues":
number = (payload.get("issue") or {}).get("number")
else:
return None
if not isinstance(number, int):
return None
return repo, number