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

411 lines
15 KiB
Python

"""Tests for the GitHub binding registry."""
from __future__ import annotations
from pathlib import Path
import pytest
import yaml
from app.gateway.github.registry import (
_invalidate_cache,
build_github_agent_registry,
lookup_agents,
)
def _write_agent(base: Path, user_id: str, name: str, body: dict) -> Path:
agent_dir = base / "users" / user_id / "agents" / name
agent_dir.mkdir(parents=True, exist_ok=True)
(agent_dir / "config.yaml").write_text(yaml.safe_dump(body), encoding="utf-8")
return agent_dir
def _write_legacy_agent(base: Path, name: str, body: dict) -> Path:
"""Write an agent under the pre-user-isolation shared layout at ``{base}/agents/{name}/``."""
agent_dir = base / "agents" / name
agent_dir.mkdir(parents=True, exist_ok=True)
(agent_dir / "config.yaml").write_text(yaml.safe_dump(body), encoding="utf-8")
return agent_dir
@pytest.fixture()
def base_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
"""Isolate the agents store for this test.
Also drops the registry's module-level mtime cache: tmp_path is
freshly minted per test and the previous test's cache signature
would otherwise survive into this one and short-circuit the scan.
"""
monkeypatch.setenv("DEER_FLOW_HOME", str(tmp_path))
from deerflow.config import paths as paths_module
monkeypatch.setattr(paths_module, "_paths", None)
_invalidate_cache()
return tmp_path
def test_registry_empty_when_no_agents(base_dir: Path) -> None:
registry = build_github_agent_registry()
assert registry == {}
def test_registry_skips_agents_without_github_block(base_dir: Path) -> None:
_write_agent(base_dir, "default", "plain", {"name": "plain"})
registry = build_github_agent_registry()
assert registry == {}
def test_registry_indexes_by_repo_and_event(base_dir: Path) -> None:
_write_agent(
base_dir,
"default",
"coding-llm-gateway",
{
"name": "coding-llm-gateway",
"github": {
"bindings": [
{
"repo": "zhfeng/llm-gateway",
"triggers": {"pull_request": {"actions": ["opened"]}},
}
],
},
},
)
registry = build_github_agent_registry()
matched = lookup_agents(registry, "zhfeng/llm-gateway", "pull_request")
assert len(matched) == 1
assert matched[0].agent.name == "coding-llm-gateway"
assert matched[0].user_id == "default"
assert matched[0].agent.github.installation_id is None # default
def test_registry_does_not_register_events_the_binding_omits(base_dir: Path) -> None:
# Events are opt-in per binding. A binding with an empty trigger map
# registers for nothing — the dispatcher will never fan a webhook out
# to this agent. (Tightened from the old behavior, which auto-included
# all default-enabled events.)
_write_agent(
base_dir,
"default",
"silent",
{
"name": "silent",
"github": {"bindings": [{"repo": "a/b"}]},
},
)
registry = build_github_agent_registry()
for event in (
"pull_request",
"issue_comment",
"pull_request_review_comment",
"pull_request_review",
"issues",
"ping",
):
assert lookup_agents(registry, "a/b", event) == [], event
def test_registry_indexes_only_explicitly_listed_events(base_dir: Path) -> None:
# An explicit ``pull_request: {}`` opts the agent in for PR events ONLY —
# not the other default-enabled events.
_write_agent(
base_dir,
"default",
"pr-only",
{
"name": "pr-only",
"github": {"bindings": [{"repo": "a/b", "triggers": {"pull_request": {}}}]},
},
)
registry = build_github_agent_registry()
assert len(lookup_agents(registry, "a/b", "pull_request")) == 1
assert lookup_agents(registry, "a/b", "issue_comment") == []
assert lookup_agents(registry, "a/b", "pull_request_review_comment") == []
def test_registry_picks_up_event_only_via_explicit_trigger_override(base_dir: Path) -> None:
# ``issues`` is disabled by default, but an explicit trigger entry opts in.
_write_agent(
base_dir,
"default",
"issue-bot",
{
"name": "issue-bot",
"github": {"bindings": [{"repo": "a/b", "triggers": {"issues": {}}}]},
},
)
registry = build_github_agent_registry()
matched = lookup_agents(registry, "a/b", "issues")
assert len(matched) == 1
assert matched[0].agent.name == "issue-bot"
def test_registry_supports_multiple_agents_on_same_repo_event(base_dir: Path) -> None:
for n in ("alpha", "beta"):
_write_agent(
base_dir,
"default",
n,
{
"name": n,
"github": {
"bindings": [
{"repo": "a/b", "triggers": {"pull_request": {}}},
],
},
},
)
registry = build_github_agent_registry()
matched = lookup_agents(registry, "a/b", "pull_request")
names = sorted(m.agent.name for m in matched)
assert names == ["alpha", "beta"]
def test_registry_supports_one_agent_on_multiple_repos(base_dir: Path) -> None:
_write_agent(
base_dir,
"default",
"multi",
{
"name": "multi",
"github": {
"bindings": [
{"repo": "a/one", "triggers": {"pull_request": {}}},
{"repo": "a/two", "triggers": {"pull_request": {}}},
],
},
},
)
registry = build_github_agent_registry()
assert len(lookup_agents(registry, "a/one", "pull_request")) == 1
assert len(lookup_agents(registry, "a/two", "pull_request")) == 1
assert lookup_agents(registry, "a/three", "pull_request") == []
def test_registry_scans_multiple_users(base_dir: Path) -> None:
_write_agent(
base_dir,
"default",
"agent-a",
{"name": "agent-a", "github": {"bindings": [{"repo": "x/y", "triggers": {"pull_request": {}}}]}},
)
_write_agent(
base_dir,
"alice",
"agent-b",
{"name": "agent-b", "github": {"bindings": [{"repo": "x/y", "triggers": {"pull_request": {}}}]}},
)
registry = build_github_agent_registry()
matched = lookup_agents(registry, "x/y", "pull_request")
user_ids = sorted(m.user_id for m in matched)
assert user_ids == ["alice", "default"]
def test_registry_skips_broken_agent_config(base_dir: Path, caplog: pytest.LogCaptureFixture) -> None:
# One good, one with malformed YAML.
_write_agent(
base_dir,
"default",
"good",
{"name": "good", "github": {"bindings": [{"repo": "a/b", "triggers": {"pull_request": {}}}]}},
)
bad_dir = base_dir / "users" / "default" / "agents" / "broken"
bad_dir.mkdir(parents=True)
(bad_dir / "config.yaml").write_text("this: : not : valid\n", encoding="utf-8")
with caplog.at_level("WARNING", logger="app.gateway.github.registry"):
registry = build_github_agent_registry()
assert any("broken" in rec.message for rec in caplog.records)
matched = lookup_agents(registry, "a/b", "pull_request")
assert [m.agent.name for m in matched] == ["good"]
# ---------------------------------------------------------------------------
# mtime-keyed cache
# ---------------------------------------------------------------------------
def test_registry_cache_returns_same_object_on_warm_call(base_dir: Path) -> None:
"""Identical (user_id, agent, mtime) signature → no YAML reparse.
The warm path only does iterdir + stat per agent — the dominant
YAML-parse cost is skipped. We verify by reference identity: the
cache returns the same dict object across calls so any caller that
holds a reference sees a coherent snapshot.
"""
_write_agent(
base_dir,
"default",
"alpha",
{"name": "alpha", "github": {"bindings": [{"repo": "a/b", "triggers": {"pull_request": {}}}]}},
)
first = build_github_agent_registry()
second = build_github_agent_registry()
assert first is second # same object, no rebuild
assert lookup_agents(first, "a/b", "pull_request")[0].agent.name == "alpha"
def test_registry_cache_invalidates_on_new_agent(base_dir: Path) -> None:
"""Adding a new agent on disk invalidates the cache.
Operator edits between webhook deliveries must be visible without a
process restart — the mtime signature changes when a new entry is
added, so the next call rebuilds.
"""
_write_agent(
base_dir,
"default",
"alpha",
{"name": "alpha", "github": {"bindings": [{"repo": "a/b", "triggers": {"pull_request": {}}}]}},
)
first = build_github_agent_registry()
assert {m.agent.name for m in lookup_agents(first, "a/b", "pull_request")} == {"alpha"}
_write_agent(
base_dir,
"default",
"beta",
{"name": "beta", "github": {"bindings": [{"repo": "a/b", "triggers": {"pull_request": {}}}]}},
)
second = build_github_agent_registry()
assert second is not first
assert {m.agent.name for m in lookup_agents(second, "a/b", "pull_request")} == {"alpha", "beta"}
def test_registry_cache_invalidates_on_config_edit(base_dir: Path) -> None:
"""Editing config.yaml advances mtime and triggers a rebuild.
Pure mtime-bump suffices: an operator who edits the file with the
same byte content (touch) still gets a fresh parse — which is
desirable; no harm done.
"""
agent_dir = _write_agent(
base_dir,
"default",
"edited",
{"name": "edited", "github": {"bindings": [{"repo": "a/b", "triggers": {"pull_request": {}}}]}},
)
first = build_github_agent_registry()
assert {m.agent.name for m in lookup_agents(first, "a/b", "pull_request")} == {"edited"}
# Rewrite with a different repo binding and bump the mtime well past
# filesystem granularity so the change is observable.
import os
import time
(agent_dir / "config.yaml").write_text(
yaml.safe_dump({"name": "edited", "github": {"bindings": [{"repo": "c/d", "triggers": {"pull_request": {}}}]}}),
encoding="utf-8",
)
future = time.time() + 5
os.utime(agent_dir / "config.yaml", (future, future))
second = build_github_agent_registry()
assert second is not first
assert lookup_agents(second, "a/b", "pull_request") == []
assert {m.agent.name for m in lookup_agents(second, "c/d", "pull_request")} == {"edited"}
# ---------------------------------------------------------------------------
# Legacy shared layout ({base_dir}/agents/) for unmigrated installations
# ---------------------------------------------------------------------------
def test_registry_indexes_legacy_shared_agent(base_dir: Path) -> None:
"""Legacy ``{base_dir}/agents/{name}/`` still indexes for unmigrated installs.
CLAUDE.md commits to the legacy layout as a read-only fallback, and
``load_agent_config(name)`` resolves it under DEFAULT_USER_ID. The
GitHub registry must agree, or an unmigrated install with a
``github:`` block silently fans out to nothing.
"""
_write_legacy_agent(
base_dir,
"shared-bot",
{"name": "shared-bot", "github": {"bindings": [{"repo": "a/b", "triggers": {"pull_request": {}}}]}},
)
registry = build_github_agent_registry()
matched = lookup_agents(registry, "a/b", "pull_request")
assert len(matched) == 1
assert matched[0].agent.name == "shared-bot"
# Legacy agents are bucketed under DEFAULT_USER_ID — same as load_agent_config().
assert matched[0].user_id == "default"
def test_registry_per_user_entry_shadows_legacy_with_same_name(base_dir: Path) -> None:
"""A ``users/default/agents/{name}/`` entry hides the legacy ``agents/{name}/`` entry.
Mirrors ``list_custom_agents``' precedence so migration is a no-op for
the registry — the legacy row stops appearing the moment the per-user
copy lands, and no duplicate trigger sets bleed through.
"""
# Different trigger set on each so we can tell which one won.
_write_legacy_agent(
base_dir,
"shadowed",
{"name": "shadowed", "github": {"bindings": [{"repo": "legacy/repo", "triggers": {"pull_request": {}}}]}},
)
_write_agent(
base_dir,
"default",
"shadowed",
{"name": "shadowed", "github": {"bindings": [{"repo": "user/repo", "triggers": {"pull_request": {}}}]}},
)
registry = build_github_agent_registry()
# Per-user binding wins.
assert len(lookup_agents(registry, "user/repo", "pull_request")) == 1
# Legacy binding does not appear.
assert lookup_agents(registry, "legacy/repo", "pull_request") == []
def test_registry_legacy_cache_invalidates_on_legacy_config_edit(base_dir: Path) -> None:
"""Editing a legacy ``{base_dir}/agents/{name}/config.yaml`` invalidates the cache."""
agent_dir = _write_legacy_agent(
base_dir,
"legacy-edited",
{"name": "legacy-edited", "github": {"bindings": [{"repo": "a/b", "triggers": {"pull_request": {}}}]}},
)
first = build_github_agent_registry()
assert {m.agent.name for m in lookup_agents(first, "a/b", "pull_request")} == {"legacy-edited"}
import os
import time
(agent_dir / "config.yaml").write_text(
yaml.safe_dump({"name": "legacy-edited", "github": {"bindings": [{"repo": "c/d", "triggers": {"pull_request": {}}}]}}),
encoding="utf-8",
)
future = time.time() + 5
os.utime(agent_dir / "config.yaml", (future, future))
second = build_github_agent_registry()
assert second is not first
assert lookup_agents(second, "a/b", "pull_request") == []
assert {m.agent.name for m in lookup_agents(second, "c/d", "pull_request")} == {"legacy-edited"}
def test_registry_cache_invalidates_on_agent_deletion(base_dir: Path) -> None:
"""Removing an agent dir drops it from the next registry."""
_write_agent(
base_dir,
"default",
"alpha",
{"name": "alpha", "github": {"bindings": [{"repo": "a/b", "triggers": {"pull_request": {}}}]}},
)
agent_dir = _write_agent(
base_dir,
"default",
"doomed",
{"name": "doomed", "github": {"bindings": [{"repo": "a/b", "triggers": {"pull_request": {}}}]}},
)
first = build_github_agent_registry()
assert {m.agent.name for m in lookup_agents(first, "a/b", "pull_request")} == {"alpha", "doomed"}
import shutil
shutil.rmtree(agent_dir)
second = build_github_agent_registry()
assert second is not first
assert {m.agent.name for m in lookup_agents(second, "a/b", "pull_request")} == {"alpha"}