Adds scripts/generate_catalog.py (with --check), the naming rules, schema conversion, the committed tools.json for all 537 operations and the load_catalog() loader.
200 lines
7.1 KiB
Python
200 lines
7.1 KiB
Python
"""Tests for tool naming and domain classification."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from collections import Counter
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from aegis_gitea_mcp.catalog import DOMAINS, load_catalog
|
|
from aegis_gitea_mcp.catalog.naming import (
|
|
MAX_TOOL_NAME_LENGTH,
|
|
NAME_OVERRIDES,
|
|
TOOL_NAME_PATTERN,
|
|
action_name,
|
|
classify_domain,
|
|
snake_case,
|
|
tool_name_for,
|
|
validate_tool_names,
|
|
)
|
|
|
|
SPEC_PATH = Path(__file__).resolve().parents[1] / "spec" / "gitea-swagger.v1.json"
|
|
|
|
|
|
def spec_operations() -> list[tuple[str, str, str]]:
|
|
spec = json.loads(SPEC_PATH.read_text("utf-8"))
|
|
found = []
|
|
for path, item in spec["paths"].items():
|
|
for method, operation in item.items():
|
|
if method == "parameters":
|
|
continue
|
|
found.append((path, method, operation["operationId"]))
|
|
return found
|
|
|
|
|
|
def test_domains_are_the_fourteen_known_names() -> None:
|
|
assert len(DOMAINS) == 14
|
|
assert set(DOMAINS) == {
|
|
"admin",
|
|
"repo",
|
|
"issue",
|
|
"pull",
|
|
"release",
|
|
"wiki",
|
|
"org",
|
|
"team",
|
|
"user",
|
|
"actions",
|
|
"hook",
|
|
"package",
|
|
"notification",
|
|
"misc",
|
|
}
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("path", "domain"),
|
|
[
|
|
("/admin/users", "admin"),
|
|
("/admin/hooks", "admin"),
|
|
("/admin/actions/runners", "admin"),
|
|
("/repos/{owner}/{repo}/actions/secrets/{secretname}", "actions"),
|
|
("/orgs/{org}/actions/variables", "actions"),
|
|
("/user/actions/runners", "actions"),
|
|
("/repos/{owner}/{repo}/hooks", "hook"),
|
|
("/repos/{owner}/{repo}/hooks/git/{id}", "hook"),
|
|
("/orgs/{org}/hooks", "hook"),
|
|
("/user/hooks", "hook"),
|
|
("/repos/{owner}/{repo}/pulls/{index}/reviews", "pull"),
|
|
("/repos/{owner}/{repo}/issues/{index}/labels", "issue"),
|
|
("/repos/{owner}/{repo}/labels", "issue"),
|
|
("/repos/{owner}/{repo}/milestones", "issue"),
|
|
("/repos/issues/search", "issue"),
|
|
("/repos/{owner}/{repo}/releases/tags/{tag}", "release"),
|
|
("/repos/{owner}/{repo}/wiki/pages", "wiki"),
|
|
("/repos/{owner}/{repo}/notifications", "notification"),
|
|
("/notifications/threads/{id}", "notification"),
|
|
("/teams/{id}/members", "team"),
|
|
("/orgs/{org}/teams", "team"),
|
|
("/orgs/{org}/members", "org"),
|
|
("/org/{org}/repos", "org"),
|
|
("/packages/{owner}", "package"),
|
|
("/user", "user"),
|
|
("/user/repos", "user"),
|
|
("/users/{username}/repos", "user"),
|
|
("/repos/{owner}/{repo}/branches", "repo"),
|
|
("/repos/search", "repo"),
|
|
("/repositories/{id}", "repo"),
|
|
("/version", "misc"),
|
|
("/markdown", "misc"),
|
|
("/settings/api", "misc"),
|
|
],
|
|
)
|
|
def test_classify_domain(path: str, domain: str) -> None:
|
|
assert classify_domain(path) == domain
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("operation_id", "expected"),
|
|
[
|
|
("repoListBranches", "repo_list_branches"),
|
|
("getGeneralAPISettings", "get_general_api_settings"),
|
|
("userCreateOAuth2Application", "user_create_oauth2_application"),
|
|
("GetWorkflowRun", "get_workflow_run"),
|
|
("repoGetByID", "repo_get_by_id"),
|
|
],
|
|
)
|
|
def test_snake_case(operation_id: str, expected: str) -> None:
|
|
assert snake_case(operation_id) == expected
|
|
|
|
|
|
def test_action_name_strips_domain_words() -> None:
|
|
assert action_name("repoListBranches", "repo") == "list_branches"
|
|
assert action_name("issueGetComments", "issue") == "get_comments"
|
|
assert action_name("adminCreateUser", "admin") == "create_user"
|
|
assert action_name("orgListMembers", "org") == "list_members"
|
|
assert action_name("userGetCurrent", "user") == "get_current"
|
|
assert action_name("notifyGetList", "notification") == "get_list"
|
|
# Scope words stay where stripping them would collide.
|
|
assert action_name("orgListActionsSecrets", "actions") == "org_list_secrets"
|
|
assert action_name("repoListHooks", "hook") == "repo_list_hooks"
|
|
|
|
|
|
def test_action_name_never_returns_empty() -> None:
|
|
assert action_name("repo", "repo") == "repo"
|
|
assert action_name("actions", "actions") == "actions"
|
|
|
|
|
|
def test_tool_name_examples() -> None:
|
|
assert tool_name_for("repoListBranches", "/repos/{owner}/{repo}/branches")[0] == (
|
|
"gitea_repo_list_branches"
|
|
)
|
|
assert tool_name_for("adminCreateUser", "/admin/users")[0] == "gitea_admin_create_user"
|
|
name, domain, overridden = tool_name_for(
|
|
"repoCreateRunnerRegistrationToken",
|
|
"/repos/{owner}/{repo}/actions/runners/registration-token",
|
|
)
|
|
assert domain == "actions"
|
|
assert name.startswith("gitea_actions_")
|
|
assert not overridden
|
|
|
|
|
|
def test_override_must_agree_with_domain(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setitem(NAME_OVERRIDES, "repoListBranches", "gitea_issue_list_branches")
|
|
with pytest.raises(ValueError, match="disagrees"):
|
|
tool_name_for("repoListBranches", "/repos/{owner}/{repo}/branches")
|
|
|
|
|
|
def test_every_override_is_used_and_valid() -> None:
|
|
used = {operation_id for _, _, operation_id in spec_operations()}
|
|
assert set(NAME_OVERRIDES) <= used
|
|
for name in NAME_OVERRIDES.values():
|
|
assert TOOL_NAME_PATTERN.match(name)
|
|
|
|
|
|
def test_naming_is_deterministic() -> None:
|
|
first = [tool_name_for(op_id, path)[0] for path, _, op_id in spec_operations()]
|
|
second = [tool_name_for(op_id, path)[0] for path, _, op_id in spec_operations()]
|
|
assert first == second
|
|
|
|
|
|
def test_real_catalog_names_are_valid_and_unique() -> None:
|
|
names = [operation.tool_name for operation in load_catalog().operations]
|
|
assert len(names) == 537
|
|
assert len(set(names)) == len(names)
|
|
for name in names:
|
|
assert TOOL_NAME_PATTERN.match(name), name
|
|
assert len(name) <= MAX_TOOL_NAME_LENGTH, name
|
|
validate_tool_names(names)
|
|
|
|
|
|
def test_real_catalog_domain_counts_sum_to_537() -> None:
|
|
counts = Counter(operation.domain for operation in load_catalog().operations)
|
|
assert sum(counts.values()) == 537
|
|
assert set(counts) == set(DOMAINS)
|
|
for operation in load_catalog().operations:
|
|
assert operation.tool_name.startswith(f"gitea_{operation.domain}_")
|
|
|
|
|
|
def test_real_catalog_specific_examples() -> None:
|
|
catalog = load_catalog()
|
|
by_id = {operation.operation_id: operation for operation in catalog.operations}
|
|
assert by_id["repoListBranches"].tool_name == "gitea_repo_list_branches"
|
|
assert by_id["adminCreateUser"].tool_name == "gitea_admin_create_user"
|
|
assert by_id["updateRepoSecret"].domain == "actions"
|
|
assert by_id["adminListHooks"].domain == "admin"
|
|
assert by_id["orgListHooks"].domain == "hook"
|
|
assert by_id["repoMergePullRequest"].domain == "pull"
|
|
|
|
|
|
def test_validate_tool_names_reports_problems() -> None:
|
|
with pytest.raises(ValueError, match="duplicate") as dup:
|
|
validate_tool_names(["gitea_repo_get", "gitea_repo_get"])
|
|
assert "gitea_repo_get" in str(dup.value)
|
|
with pytest.raises(ValueError, match="does not match"):
|
|
validate_tool_names(["gitea_Repo_get"])
|
|
with pytest.raises(ValueError, match="longer than"):
|
|
validate_tool_names(["gitea_repo_" + "a" * 60])
|