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