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

+193
View File
@@ -0,0 +1,193 @@
"""Tool naming for the generated catalog: ``gitea_<domain>_<action>``.
The domain is derived from the request path, the action from the swagger
operationId (camelCase to snake_case, redundant domain words dropped). A small
override map resolves collisions and awkward results. Everything here is pure
and deterministic.
"""
from __future__ import annotations
import re
from collections.abc import Iterable
DOMAINS: tuple[str, ...] = (
"actions",
"admin",
"hook",
"issue",
"misc",
"notification",
"org",
"package",
"pull",
"release",
"repo",
"team",
"user",
"wiki",
)
TOOL_NAME_PATTERN = re.compile(r"^gitea_[a-z0-9]+(_[a-z0-9]+)+$")
MAX_TOOL_NAME_LENGTH = 64
# Words dropped from the start of the action, per domain. Only the first token is
# considered, and only one token is dropped.
_STRIP_LEADING: dict[str, frozenset[str]] = {
"admin": frozenset({"admin"}),
"repo": frozenset({"repo"}),
"issue": frozenset({"issue", "repo"}),
"pull": frozenset({"repo"}),
"release": frozenset({"repo"}),
"wiki": frozenset({"repo"}),
"org": frozenset({"org", "organization"}),
"team": frozenset({"org", "team"}),
"user": frozenset({"user"}),
"notification": frozenset({"notify", "notification"}),
}
# Words dropped wherever they appear in the action (the domain word is redundant
# there, while scope words such as org/repo/user must stay to avoid collisions).
_STRIP_ANYWHERE: dict[str, frozenset[str]] = {
"admin": frozenset({"admin"}),
"actions": frozenset({"actions"}),
}
# operationId -> full tool name. Used for collisions and awkward generated names.
NAME_OVERRIDES: dict[str, str] = {
# GET /user/applications/oauth2 and GET /user/applications/oauth2/{id} would both
# become get_oauth2_application.
"userGetOauth2Application": "gitea_user_list_oauth2_applications",
"userGetOAuth2Application": "gitea_user_get_oauth2_application",
# Org operations that live under /users/... and /user/... belong to the user domain.
"orgListUserOrgs": "gitea_user_list_orgs",
"orgListCurrentUserOrgs": "gitea_user_current_list_orgs",
"orgGetUserPermissions": "gitea_user_get_org_permissions",
# Repo subscription operations carry a misleading userCurrent prefix.
"userCurrentCheckSubscription": "gitea_repo_check_subscription",
"userCurrentPutSubscription": "gitea_repo_put_subscription",
"userCurrentDeleteSubscription": "gitea_repo_delete_subscription",
"userTrackedTimes": "gitea_repo_list_user_tracked_times",
# Awkward results.
"getUserSettings": "gitea_user_get_settings",
"updateUserSettings": "gitea_user_update_settings",
"userGetStopWatches": "gitea_user_get_stopwatches",
"issueStartStopWatch": "gitea_issue_start_stopwatch",
"issueStopStopWatch": "gitea_issue_stop_stopwatch",
"issueDeleteStopWatch": "gitea_issue_delete_stopwatch",
"createCurrentUserRepo": "gitea_user_current_create_repo",
"renameOrg": "gitea_org_rename",
"repoUnDismissPullReview": "gitea_pull_undismiss_pull_review",
# Upstream typo ("Priories").
"repoUpdateBranchProtectionPriories": "gitea_repo_update_branch_protection_priorities",
}
def classify_domain(path: str) -> str:
"""Return the tool domain for a path relative to ``/api/v1``.
The first matching rule wins:
1. ``/admin...`` is admin, a path containing ``/actions`` is actions,
``/hooks`` is hook, ``/pulls`` is pull;
2. under ``/repos/{owner}/{repo}`` the third segment selects issue (issues,
labels, milestones), release, wiki or notification;
3. ``/teams`` and ``/orgs/{org}/teams`` are team, other ``/orgs`` and ``/org/``
are org, ``/packages`` is package, ``/notifications`` is notification,
``/user`` and ``/users`` are user, ``/repos`` and ``/repositories`` are repo;
4. everything else is misc.
"""
segments = [segment for segment in path.split("/") if segment]
first = segments[0] if segments else ""
if first == "admin":
return "admin"
if "/actions" in path:
return "actions"
if "/hooks" in path:
return "hook"
if "/pulls" in path:
return "pull"
if first == "repos" and len(segments) >= 2 and segments[1] == "issues":
return "issue" # GET /repos/issues/search
if first == "repos" and len(segments) >= 4 and segments[1].startswith("{"):
third = segments[3]
if third in {"issues", "labels", "milestones"}:
return "issue"
if third == "releases":
return "release"
if third == "wiki":
return "wiki"
if third == "notifications":
return "notification"
if first == "teams" or (first == "orgs" and len(segments) >= 3 and segments[2] == "teams"):
return "team"
if first in {"orgs", "org"}:
return "org"
if first == "packages":
return "package"
if first == "notifications":
return "notification"
if first in {"user", "users"}:
return "user"
if first in {"repos", "repositories"}:
return "repo"
return "misc"
def snake_case(operation_id: str) -> str:
"""Convert a camelCase/PascalCase operationId to snake_case.
Acronyms stay together (``getGeneralAPISettings`` -> ``get_general_api_settings``)
and ``OAuth`` is treated as one word.
"""
text = operation_id.replace("OAuth", "Oauth")
text = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1_\2", text)
text = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", text)
return re.sub(r"[^a-z0-9]+", "_", text.lower()).strip("_")
def action_name(operation_id: str, domain: str) -> str:
"""Return the snake_case action for an operationId within a domain."""
tokens = snake_case(operation_id).split("_")
leading = _STRIP_LEADING.get(domain, frozenset())
if len(tokens) > 1 and tokens[0] in leading:
tokens = tokens[1:]
anywhere = _STRIP_ANYWHERE.get(domain, frozenset())
if anywhere:
kept = [token for token in tokens if token not in anywhere]
if kept:
tokens = kept
return "_".join(tokens)
def tool_name_for(operation_id: str, path: str) -> tuple[str, str, bool]:
"""Return ``(tool_name, domain, used_override)`` for one operation.
An override fixes the tool name; the domain still comes from the path and must
agree with the override name.
"""
domain = classify_domain(path)
override = NAME_OVERRIDES.get(operation_id)
if override is not None:
if not override.startswith(f"gitea_{domain}_"):
raise ValueError(
f"override for {operation_id} ({override}) disagrees with domain {domain!r}"
)
return override, domain, True
return f"gitea_{domain}_{action_name(operation_id, domain)}", domain, False
def validate_tool_names(names: Iterable[str]) -> None:
"""Raise ``ValueError`` when a name is malformed, too long or duplicated."""
seen: set[str] = set()
problems: list[str] = []
for name in names:
if not TOOL_NAME_PATTERN.match(name):
problems.append(f"{name}: does not match {TOOL_NAME_PATTERN.pattern}")
if len(name) > MAX_TOOL_NAME_LENGTH:
problems.append(f"{name}: longer than {MAX_TOOL_NAME_LENGTH} characters")
if name in seen:
problems.append(f"{name}: duplicate tool name")
seen.add(name)
if problems:
raise ValueError("invalid tool names:\n" + "\n".join(sorted(problems)))