496 lines
15 KiB
Python
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}",
|
|
}
|