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

404 lines
15 KiB
Python

"""Tests for the GitHub dispatcher's trigger-filter logic."""
from __future__ import annotations
from app.gateway.github.triggers import (
DEFAULT_TRIGGERS,
_resolved_trigger,
event_should_fire,
)
from deerflow.config.agents_config import GitHubTriggerConfig
BOT = "coding-llm-gateway"
def _pr_payload(action: str = "opened", author: str = "zhfeng") -> dict:
return {
"action": action,
"pull_request": {"number": 7, "user": {"login": author}},
"repository": {"full_name": "a/b"},
}
def _comment_payload(body: str, author: str = "zhfeng") -> dict:
return {
"action": "created",
"issue": {"number": 11},
"comment": {"body": body, "user": {"login": author}},
"repository": {"full_name": "a/b"},
}
def _resolve(event: str, override: GitHubTriggerConfig | None = None) -> GitHubTriggerConfig:
"""Mimic what the registry does for one ``(event, override)`` pair.
The dispatcher used to take a full ``binding_triggers`` dict and let
``event_should_fire`` look the event up itself. After the registry
refactor that lookup moved to :func:`_resolved_trigger` at build
time, and ``event_should_fire`` receives a single pre-resolved
:class:`GitHubTriggerConfig`. The tests below still want to assert
behavior given a binding-shaped declaration, so this helper bridges
the two — equivalent to ``_resolved_trigger(event, {event: override})``
with an empty-override default.
"""
override = override if override is not None else GitHubTriggerConfig()
resolved = _resolved_trigger(event, {event: override})
assert resolved is not None # The registry would never call us otherwise.
return resolved
# ---------------------------------------------------------------------------
# Defaults (merged into a binding's explicit-but-empty override)
# ---------------------------------------------------------------------------
#
# Events are opt-in per binding: an empty triggers map (``{}``) registers
# the agent for NO events — that opt-in / opt-out concern now lives in the
# registry (``_resolved_trigger`` returns ``None`` when the binding omits
# the event, and the registry simply does not index the agent for it).
# Once an event IS listed, the per-event defaults in ``DEFAULT_TRIGGERS``
# fill in any field the override didn't explicitly set, exactly as before.
def test_default_pull_request_opened_fires() -> None:
fire, reason = event_should_fire("pull_request", _pr_payload("opened"), _resolve("pull_request"), BOT)
assert fire is True
assert "opened" in reason
def test_default_pull_request_synchronize_does_not_fire() -> None:
fire, reason = event_should_fire("pull_request", _pr_payload("synchronize"), _resolve("pull_request"), BOT)
assert fire is False
assert "synchronize" in reason
def test_default_issue_comment_without_mention_does_not_fire() -> None:
fire, reason = event_should_fire("issue_comment", _comment_payload("just a thought"), _resolve("issue_comment"), BOT)
assert fire is False
assert "mention" in reason.lower()
def test_default_issue_comment_with_mention_fires() -> None:
fire, _ = event_should_fire("issue_comment", _comment_payload(f"hey @{BOT} please look"), _resolve("issue_comment"), BOT)
assert fire is True
def test_default_issue_comment_mention_case_insensitive() -> None:
fire, _ = event_should_fire("issue_comment", _comment_payload(f"hey @{BOT.upper()} look"), _resolve("issue_comment"), BOT)
assert fire is True
def test_event_not_in_binding_is_disabled_at_registry() -> None:
# An event the binding does not list resolves to ``None`` — the
# registry never indexes the agent for it, so ``event_should_fire``
# is never called. This boundary is verified at the resolver layer.
assert _resolved_trigger("pull_request", {}) is None
def test_default_ping_is_disabled_even_when_opted_in() -> None:
# ``ping`` has DEFAULT_TRIGGERS[ping] = None — no field defaults — so
# the opt-in override is used as-is. It has no actions or mention
# requirement, so it fires (which is fine; ping is harmless). What
# we DO care about is that the registry never indexes a ``ping`` event
# the binding didn't list; that's the test above.
assert DEFAULT_TRIGGERS["ping"] is None
def test_default_issues_is_disabled_unless_listed_at_registry() -> None:
# Same shape as the pull_request case above: an issues-less binding
# resolves to ``None`` and the registry drops it.
assert _resolved_trigger("issues", {}) is None
# ---------------------------------------------------------------------------
# Override: action whitelist
# ---------------------------------------------------------------------------
def test_action_whitelist_overrides_default() -> None:
# Default for pull_request is ["opened"]. Widening lets "reopened" fire.
trigger = _resolve("pull_request", GitHubTriggerConfig(actions=["opened", "reopened"]))
fire, _ = event_should_fire("pull_request", _pr_payload("reopened"), trigger, BOT)
assert fire is True
def test_empty_actions_list_blocks_all() -> None:
# Empty list = explicit empty whitelist = nothing matches.
trigger = _resolve("pull_request", GitHubTriggerConfig(actions=[]))
fire, _ = event_should_fire("pull_request", _pr_payload("opened"), trigger, BOT)
assert fire is False
def test_actions_none_allows_any_action() -> None:
trigger = _resolve("pull_request", GitHubTriggerConfig(actions=None))
fire, _ = event_should_fire("pull_request", _pr_payload("labeled"), trigger, BOT)
assert fire is True
# ---------------------------------------------------------------------------
# Override: allow_authors bypasses require_mention
# ---------------------------------------------------------------------------
def test_allow_authors_bypasses_mention_requirement() -> None:
trigger = _resolve("issue_comment", GitHubTriggerConfig(require_mention=True, allow_authors=["zhfeng"]))
fire, reason = event_should_fire(
"issue_comment",
_comment_payload("no handle here", author="zhfeng"),
trigger,
BOT,
)
assert fire is True
assert "zhfeng" in reason
def test_allow_authors_does_not_help_other_users() -> None:
trigger = _resolve("issue_comment", GitHubTriggerConfig(require_mention=True, allow_authors=["alice"]))
fire, _ = event_should_fire(
"issue_comment",
_comment_payload("no handle", author="bob"),
trigger,
BOT,
)
assert fire is False
# ---------------------------------------------------------------------------
# Override: mention_login replaces default login
# ---------------------------------------------------------------------------
def test_mention_login_override() -> None:
trigger = _resolve("issue_comment", GitHubTriggerConfig(require_mention=True, mention_login="other-bot"))
fire, _ = event_should_fire(
"issue_comment",
_comment_payload("hi @other-bot"),
trigger,
BOT,
)
assert fire is True
def test_mention_login_override_default_login_does_not_match() -> None:
trigger = _resolve("issue_comment", GitHubTriggerConfig(require_mention=True, mention_login="other-bot"))
fire, _ = event_should_fire(
"issue_comment",
_comment_payload(f"hi @{BOT}"),
trigger,
BOT,
)
assert fire is False
# ---------------------------------------------------------------------------
# Enabling previously-disabled events
# ---------------------------------------------------------------------------
def test_enabling_issues_via_override() -> None:
# ``issues`` is disabled by default but an operator can opt in.
trigger = _resolve("issues", GitHubTriggerConfig(actions=["opened"]))
fire, _ = event_should_fire(
"issues",
{
"action": "opened",
"issue": {"number": 1, "user": {"login": "x"}},
"repository": {"full_name": "a/b"},
},
trigger,
BOT,
)
assert fire is True
# ---------------------------------------------------------------------------
# issues event: require_mention scans the issue body (no separate comment)
# ---------------------------------------------------------------------------
def test_issues_require_mention_fires_when_body_mentions_bot() -> None:
trigger = _resolve("issues", GitHubTriggerConfig(actions=["opened"], require_mention=True, mention_login=BOT))
fire, reason = event_should_fire(
"issues",
{
"action": "opened",
"issue": {"number": 1, "user": {"login": "alice"}, "body": f"Hey @{BOT} please fix this"},
"repository": {"full_name": "a/b"},
},
trigger,
BOT,
)
assert fire is True
assert "mention" not in reason # mention gate passed
def test_issues_require_mention_skips_without_mention() -> None:
trigger = _resolve("issues", GitHubTriggerConfig(actions=["opened"], require_mention=True, mention_login=BOT))
fire, reason = event_should_fire(
"issues",
{
"action": "opened",
"issue": {"number": 1, "user": {"login": "alice"}, "body": "just a normal bug report"},
"repository": {"full_name": "a/b"},
},
trigger,
BOT,
)
assert fire is False
assert "mention required" in reason
def test_issues_allow_authors_bypasses_mention() -> None:
# zhfeng opening an issue fires even without a mention.
trigger = _resolve("issues", GitHubTriggerConfig(actions=["opened"], require_mention=True, mention_login=BOT, allow_authors=["zhfeng"]))
fire, reason = event_should_fire(
"issues",
{
"action": "opened",
"issue": {"number": 1, "user": {"login": "zhfeng"}, "body": "no mention here"},
"repository": {"full_name": "a/b"},
},
trigger,
BOT,
)
assert fire is True
assert "allow_authors" in reason
def test_pull_request_require_mention_scans_pr_body() -> None:
# A PR-opened trigger with require_mention should scan the PR body.
trigger = _resolve("pull_request", GitHubTriggerConfig(actions=["opened"], require_mention=True, mention_login=BOT))
fire, _ = event_should_fire(
"pull_request",
{
"action": "opened",
"pull_request": {"number": 3, "user": {"login": "bob"}, "body": f"@{BOT} can you finish this?"},
"repository": {"full_name": "a/b"},
},
trigger,
BOT,
)
assert fire is True
# ---------------------------------------------------------------------------
# pull_request_review: require_mention scans the review summary body
# ---------------------------------------------------------------------------
def test_pull_request_review_require_mention_fires_on_review_body_mention() -> None:
# Regression: _comment_body used to fall through to "" for
# pull_request_review, so require_mention could never match even when
# the human explicitly @-mentioned the bot in the review summary.
trigger = _resolve("pull_request_review", GitHubTriggerConfig(require_mention=True, mention_login=BOT))
fire, reason = event_should_fire(
"pull_request_review",
{
"action": "submitted",
"pull_request": {"number": 4},
"review": {"user": {"login": "alice"}, "body": f"@{BOT} please look again", "state": "commented"},
"repository": {"full_name": "a/b"},
},
trigger,
BOT,
)
assert fire is True
assert "mention" not in reason
def test_pull_request_review_require_mention_skips_without_mention() -> None:
trigger = _resolve("pull_request_review", GitHubTriggerConfig(require_mention=True, mention_login=BOT))
fire, reason = event_should_fire(
"pull_request_review",
{
"action": "submitted",
"pull_request": {"number": 5},
"review": {"user": {"login": "alice"}, "body": "looks good", "state": "approved"},
"repository": {"full_name": "a/b"},
},
trigger,
BOT,
)
assert fire is False
assert "mention required" in reason
# ---------------------------------------------------------------------------
# @-mention boundary: ``@deerflow`` must NOT match ``@deerflow-bot``
# ---------------------------------------------------------------------------
def test_mention_prefix_does_not_match_longer_login() -> None:
# Agent with mention_login='deerflow' must NOT fire on a comment that
# addresses a different account, '@deerflow-bot'. Regression for the
# naive substring ``f'@{login}' in body`` check.
trigger = _resolve("issue_comment", GitHubTriggerConfig(require_mention=True, mention_login="deerflow"))
fire, reason = event_should_fire(
"issue_comment",
{
"action": "created",
"issue": {"number": 1, "user": {"login": "alice"}},
"comment": {"body": "Hey @deerflow-bot please review", "user": {"login": "alice"}},
"repository": {"full_name": "a/b"},
},
trigger,
"deerflow",
)
assert fire is False
assert "mention required" in reason
def test_mention_inside_email_does_not_match() -> None:
# Login-class char immediately before ``@`` (an email-like context)
# must not register as a mention.
trigger = _resolve("issue_comment", GitHubTriggerConfig(require_mention=True, mention_login=BOT))
fire, _ = event_should_fire(
"issue_comment",
{
"action": "created",
"issue": {"number": 1, "user": {"login": "alice"}},
"comment": {"body": f"contact noreply@{BOT}.example to retrigger", "user": {"login": "alice"}},
"repository": {"full_name": "a/b"},
},
trigger,
BOT,
)
assert fire is False
def test_mention_at_start_of_body_matches() -> None:
# The boundary regex must allow a mention at position 0 (no char before
# @). Guards against an over-eager fix that requires a preceding
# whitespace.
trigger = _resolve("issue_comment", GitHubTriggerConfig(require_mention=True, mention_login=BOT))
fire, _ = event_should_fire(
"issue_comment",
{
"action": "created",
"issue": {"number": 1, "user": {"login": "alice"}},
"comment": {"body": f"@{BOT} ping", "user": {"login": "alice"}},
"repository": {"full_name": "a/b"},
},
trigger,
BOT,
)
assert fire is True
def test_mention_followed_by_punctuation_matches() -> None:
# ``@bot,`` and ``@bot.`` are still valid mentions — the trailing char
# is not in the login class, so the boundary regex accepts it.
trigger = _resolve("issue_comment", GitHubTriggerConfig(require_mention=True, mention_login=BOT))
for body in (f"hey @{BOT}, please look", f"asked @{BOT}.", f"thanks @{BOT}!"):
fire, _ = event_should_fire(
"issue_comment",
{
"action": "created",
"issue": {"number": 1, "user": {"login": "alice"}},
"comment": {"body": body, "user": {"login": "alice"}},
"repository": {"full_name": "a/b"},
},
trigger,
BOT,
)
assert fire is True, f"expected mention to match in: {body!r}"