404 lines
15 KiB
Python
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}"
|