"""Tool naming for the generated catalog: ``gitea__``. 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)))