feat: generate the tool catalog from the pinned swagger

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.
This commit is contained in:
Latte committed 2026-10-14 15:51:01 +02:00
1 parent a807a70556
commit 041b3ea7ff
9 files changed
+2245 -2

No files matched your search

+199
View File
@@ -0,0 +1,199 @@
"""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])