Files
loogle-scripts/services/loogle-mcp/app/knowledge/gitea.py
T

496 lines
15 KiB
Python

# -*- 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}",
}