# -*- coding: utf-8 -*- """Gitea REST API client — token per utente MCP.""" import base64 import json import logging import os import re from typing import Any, Optional from urllib.parse import quote import httpx LOGGER = logging.getLogger("loogle_mcp.gitea") GITEA_URL = os.environ.get("GITEA_URL", "https://git.loogle.it").rstrip("/") MCP_USERS = ("daniele", "lucia", "davide", "luca") GITEA_API_TOKEN_SCOPES = "read:repository,write:repository,write:issue,write:user,read:user" TEXT_EXTENSIONS = { ".md", ".txt", ".py", ".sh", ".yml", ".yaml", ".json", ".toml", ".ini", ".conf", ".js", ".ts", ".tsx", ".jsx", ".html", ".css", ".sql", ".go", ".rs", ".env", ".service", ".timer", ".xml", ".csv", } _tokens_cache: Optional[dict[str, str]] = None def _load_tokens() -> dict[str, str]: global _tokens_cache if _tokens_cache is not None: return _tokens_cache tokens: dict[str, str] = {} json_map = os.environ.get("GITEA_API_TOKENS", "").strip() if json_map: try: parsed = json.loads(json_map) if isinstance(parsed, dict): tokens.update({k.lower(): v for k, v in parsed.items() if v}) except json.JSONDecodeError: LOGGER.warning("GITEA_API_TOKENS non è JSON valido") fallback = os.environ.get("GITEA_API_TOKEN", "").strip() for user in MCP_USERS: env_key = f"GITEA_API_TOKEN_{user.upper()}" token = os.environ.get(env_key, "").strip() if token: tokens[user] = token elif user not in tokens and fallback: tokens[user] = fallback if not tokens and fallback: tokens["daniele"] = fallback _tokens_cache = tokens return tokens def list_configured_users() -> list[str]: return list(_load_tokens().keys()) def is_configured(username: Optional[str] = None) -> bool: tokens = _load_tokens() if not tokens: return False if username: user = username.lower() return user in tokens or "daniele" in tokens or bool(tokens) return True def _headers(username: Optional[str] = None) -> dict[str, str]: tokens = _load_tokens() if not tokens: raise RuntimeError( "Nessun token Gitea configurato. " "Imposta GITEA_API_TOKEN o GITEA_API_TOKEN_{USER} in .env — vedi docs/GITEA-TOKEN.md" ) user = (username or "daniele").lower() token = tokens.get(user) or tokens.get("daniele") or next(iter(tokens.values())) return {"Authorization": f"token {token}"} def parse_repo(repo: str) -> tuple[str, str]: cleaned = repo.strip().strip("/") if cleaned.count("/") != 1: raise ValueError("repo deve essere nel formato owner/name (es. daniele/rete)") owner, name = cleaned.split("/", 1) if not owner or not name: raise ValueError("repo deve essere nel formato owner/name (es. daniele/rete)") return owner, name def api_base_url() -> str: return os.environ.get("GITEA_API_URL", GITEA_URL).rstrip("/") def public_base_url() -> str: return os.environ.get("GITEA_URL", "https://git.loogle.it").rstrip("/") def _api_url(path: str) -> str: return f"{api_base_url()}/api/v1{path}" def _request( method: str, path: str, *, username: Optional[str] = None, params: Optional[dict] = None, json_body: Optional[dict] = None, ) -> Any: with httpx.Client(timeout=60.0, verify=True) as client: resp = client.request( method, _api_url(path), headers=_headers(username), params=params, json=json_body, ) if resp.status_code == 404: raise FileNotFoundError(resp.text or "Risorsa Gitea non trovata") resp.raise_for_status() if resp.content: return resp.json() return {} def list_repos( username: Optional[str] = None, page: int = 1, limit: int = 50, ) -> dict: data = _request( "GET", "/user/repos", username=username, params={"page": page, "limit": limit, "sort": "updated"}, ) repos = [] for repo in data if isinstance(data, list) else []: full_name = repo.get("full_name") or "" if not full_name and repo.get("owner"): full_name = f"{repo['owner'].get('login', '')}/{repo.get('name', '')}" repos.append( { "full_name": full_name, "description": repo.get("description") or "", "private": bool(repo.get("private")), "html_url": repo.get("html_url") or f"{public_base_url()}/{full_name}", "default_branch": repo.get("default_branch") or "main", "updated_at": repo.get("updated_at"), } ) return {"repos": repos, "page": page, "count": len(repos)} def _decode_content(entry: dict) -> str: encoding = (entry.get("encoding") or "").lower() raw = entry.get("content") or "" if encoding == "base64": return base64.b64decode(raw).decode("utf-8", errors="replace") return raw def get_file( repo: str, path: str, ref: Optional[str] = None, username: Optional[str] = None, ) -> dict: owner, name = parse_repo(repo) file_path = path.lstrip("/") params = {} if ref: params["ref"] = ref encoded_path = "/".join(quote(part, safe="") for part in file_path.split("/")) data = _request( "GET", f"/repos/{owner}/{name}/contents/{encoded_path}", username=username, params=params or None, ) if isinstance(data, list): entries = [ { "name": item.get("name"), "path": item.get("path"), "type": item.get("type"), "size": item.get("size"), } for item in data ] return { "repo": f"{owner}/{name}", "path": file_path or "/", "type": "dir", "entries": entries, } content = _decode_content(data) return { "repo": f"{owner}/{name}", "path": data.get("path") or file_path, "type": data.get("type") or "file", "size": data.get("size"), "sha": data.get("sha"), "html_url": data.get("html_url") or f"{public_base_url()}/{owner}/{name}/src/branch/{ref or 'main'}/{file_path}", "content": content, } def search_code( query: str, repo: Optional[str] = None, limit: int = 20, username: Optional[str] = None, ) -> dict: q = query.strip() if not q: raise ValueError("query obbligatoria") params: dict[str, Any] = {"q": q, "limit": min(max(limit, 1), 50)} if repo: owner, name = parse_repo(repo) params["repo"] = f"{owner}/{name}" try: data = _request("GET", "/search/code", username=username, params=params) hits = [] for item in data.get("data") or []: repo_name = item.get("repository", {}).get("full_name") or item.get("repository", {}).get("name") hits.append( { "repo": repo_name, "path": item.get("path"), "sha": item.get("sha"), "html_url": item.get("url") or item.get("html_url"), "language": item.get("language"), "snippet": (item.get("content") or item.get("text") or "")[:500], } ) return {"query": q, "repo": repo, "results": hits, "count": len(hits)} except FileNotFoundError: return _search_code_fallback(q, repo, limit, username) except httpx.HTTPStatusError as exc: if exc.response.status_code not in (404, 422): raise return _search_code_fallback(q, repo, limit, username) def _search_code_fallback( query: str, repo: Optional[str], limit: int, username: Optional[str], ) -> dict: """Fallback se /search/code non disponibile: tree + grep su file testo.""" repos: list[str] = [] if repo: owner, name = parse_repo(repo) repos.append(f"{owner}/{name}") else: listed = list_repos(username=username, limit=20) repos = [r["full_name"] for r in listed["repos"] if r.get("full_name")] terms = [t.lower() for t in re.split(r"\s+", query) if t] hits: list[dict] = [] max_files = min(limit * 3, 40) for full_name in repos: owner, name = parse_repo(full_name) try: tree = _request( "GET", f"/repos/{owner}/{name}/git/trees/HEAD", username=username, params={"recursive": "1"}, ) except Exception: continue scanned = 0 for node in tree.get("tree") or []: if node.get("type") != "blob": continue path = node.get("path") or "" ext = os.path.splitext(path)[1].lower() if ext and ext not in TEXT_EXTENSIONS: continue if any(term in path.lower() for term in terms): pass scanned += 1 if scanned > max_files: break try: file_data = get_file(full_name, path, username=username) except Exception: continue content = (file_data.get("content") or "").lower() if not any(term in content or term in path.lower() for term in terms): continue snippet = file_data.get("content") or "" idx = snippet.lower().find(terms[0]) if terms else 0 if idx < 0: idx = 0 hits.append( { "repo": full_name, "path": path, "sha": node.get("sha"), "html_url": file_data.get("html_url"), "snippet": snippet[max(0, idx - 80): idx + 420], } ) if len(hits) >= limit: break if len(hits) >= limit: break return {"query": query, "repo": repo, "results": hits[:limit], "count": len(hits[:limit]), "mode": "fallback"} def list_issues( repo: str, state: str = "open", page: int = 1, limit: int = 20, username: Optional[str] = None, ) -> dict: owner, name = parse_repo(repo) data = _request( "GET", f"/repos/{owner}/{name}/issues", username=username, params={"state": state, "page": page, "limit": limit, "type": "issues"}, ) issues = [] for item in data if isinstance(data, list) else []: issues.append( { "number": item.get("number"), "title": item.get("title"), "state": item.get("state"), "user": (item.get("user") or {}).get("login"), "html_url": item.get("html_url"), "created_at": item.get("created_at"), "updated_at": item.get("updated_at"), "labels": [lbl.get("name") for lbl in (item.get("labels") or [])], } ) return {"repo": f"{owner}/{name}", "state": state, "issues": issues, "count": len(issues)} def get_issue( repo: str, number: int, username: Optional[str] = None, ) -> dict: owner, name = parse_repo(repo) item = _request("GET", f"/repos/{owner}/{name}/issues/{number}", username=username) return { "repo": f"{owner}/{name}", "number": item.get("number"), "title": item.get("title"), "state": item.get("state"), "body": item.get("body") or "", "user": (item.get("user") or {}).get("login"), "html_url": item.get("html_url"), "created_at": item.get("created_at"), "updated_at": item.get("updated_at"), "labels": [lbl.get("name") for lbl in (item.get("labels") or [])], } def create_issue( repo: str, title: str, body: str = "", labels: Optional[list[str]] = None, username: Optional[str] = None, ) -> dict: owner, name = parse_repo(repo) payload: dict[str, Any] = {"title": title.strip(), "body": body or ""} if labels: payload["labels"] = labels item = _request( "POST", f"/repos/{owner}/{name}/issues", username=username, json_body=payload, ) return { "repo": f"{owner}/{name}", "number": item.get("number"), "title": item.get("title"), "state": item.get("state"), "html_url": item.get("html_url"), } def assert_repo_owner(username: str, repo: str, *, is_admin: bool = False) -> tuple[str, str]: owner, name = parse_repo(repo) if not is_admin and owner.lower() != username.lower(): raise PermissionError( f"Puoi scrivere solo su repository di cui sei owner (repo {owner}/{name}, utente {username})" ) return owner, name def create_repo( name: str, username: Optional[str] = None, *, private: bool = True, description: str = "", auto_init: bool = True, ) -> dict: repo_name = name.strip().lower() if not repo_name or not re.match(r"^[a-z0-9][a-z0-9._-]{0,99}$", repo_name): raise ValueError("name repo non valido (usa lettere minuscole, numeri, -, _, .)") payload: dict[str, Any] = { "name": repo_name, "private": private, "auto_init": auto_init, "description": description.strip(), } item = _request("POST", "/user/repos", username=username, json_body=payload) full_name = item.get("full_name") or f"{username}/{repo_name}" return { "full_name": full_name, "private": bool(item.get("private", private)), "html_url": item.get("html_url") or f"{public_base_url()}/{full_name}", "default_branch": item.get("default_branch") or "main", "description": item.get("description") or description, } def create_or_update_file( repo: str, path: str, content: str, message: str, *, branch: Optional[str] = None, username: Optional[str] = None, ) -> dict: owner, repo_name = parse_repo(repo) file_path = path.lstrip("/") if not file_path: raise ValueError("path obbligatorio") if not message.strip(): raise ValueError("message commit obbligatorio") encoded_path = "/".join(quote(part, safe="") for part in file_path.split("/")) params = {} if branch: params["ref"] = branch sha = None action = "create" try: existing = get_file(repo, file_path, ref=branch, username=username) if existing.get("type") == "file": sha = existing.get("sha") action = "update" except FileNotFoundError: pass body: dict[str, Any] = { "content": base64.b64encode(content.encode("utf-8")).decode("ascii"), "message": message.strip(), } if sha: body["sha"] = sha if branch: body["branch"] = branch method = "PUT" if sha else "POST" item = _request( method, f"/repos/{owner}/{repo_name}/contents/{encoded_path}", username=username, params=params or None, json_body=body, ) commit = item.get("commit") or {} content_obj = item.get("content") or {} return { "repo": f"{owner}/{repo_name}", "path": file_path, "action": action, "branch": branch or "default", "sha": content_obj.get("sha"), "commit_sha": commit.get("sha"), "html_url": content_obj.get("html_url") or f"{public_base_url()}/{owner}/{repo_name}/src/branch/{branch or 'main'}/{file_path}", }