242 lines
8.8 KiB
Python
242 lines
8.8 KiB
Python
"""Connected sources — Gitea & GitHub repositories (phase 2 #92).
|
|
|
|
The assistant can query the source-hosting platforms the project actually
|
|
uses (ObsiGate is hosted on Gitea): repositories, issues/pull requests and
|
|
repository files. Everything is READ-risk, rate-limited through the shared
|
|
registry and audited.
|
|
|
|
Configuration (environment — injected by Infisical in production, never
|
|
hard-coded):
|
|
|
|
* ``OBSIGATE_GITEA_URL`` — base URL of the self-hosted instance (e.g.
|
|
``https://git.example.net``); the ``gitea`` provider is only available when
|
|
this variable is set. Admin-controlled, so the SSRF guard does not apply
|
|
(unlike user-supplied URLs). Both the URL and the tokens can also be set
|
|
from the configuration page (stored in ``data/api_keys.json``, #103) —
|
|
the stored value takes precedence over the environment.
|
|
* ``OBSIGATE_GITEA_TOKEN`` — optional personal access token (private repos).
|
|
* ``OBSIGATE_GITHUB_TOKEN`` — optional token (raises the API rate limits and
|
|
unlocks private repositories).
|
|
|
|
Cloud drives (Google Drive / OneDrive) deliberately stay out of the core:
|
|
per the documented roadmap they are best served by an *external MCP server*
|
|
(#79) so the OAuth surface remains outside ObsiGate.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import binascii
|
|
import logging
|
|
from typing import Any
|
|
|
|
import httpx
|
|
|
|
from backend.tools.context import ToolError, ToolRisk
|
|
from backend.tools.registry import tool
|
|
from backend.tools.schemas import GitGetFileInput, GitProviderInput, GitSearchIssuesInput
|
|
from backend.tools.secrets import get_tool_key
|
|
|
|
logger = logging.getLogger("obsigate.tools.connected")
|
|
|
|
TIMEOUT = 10.0
|
|
USER_AGENT = "ObsiGateAssistant/1.0 (+self-hosted vault AI)"
|
|
MAX_FILE_BYTES = 300_000
|
|
GITHUB_API = "https://api.github.com"
|
|
|
|
|
|
def _provider_base(provider: str) -> tuple[str, str]:
|
|
"""Return (base_url, auth_header_value) for the requested provider."""
|
|
if provider == "gitea":
|
|
base = get_tool_key("OBSIGATE_GITEA_URL").rstrip("/")
|
|
if not base:
|
|
raise ToolError(
|
|
"Source Gitea non configurée (OBSIGATE_GITEA_URL absente).",
|
|
code="provider_not_configured",
|
|
)
|
|
token = get_tool_key("OBSIGATE_GITEA_TOKEN")
|
|
return base, f"token {token}" if token else ""
|
|
if provider == "github":
|
|
token = get_tool_key("OBSIGATE_GITHUB_TOKEN")
|
|
return GITHUB_API, f"Bearer {token}" if token else ""
|
|
raise ToolError(
|
|
f"Fournisseur inconnu : {provider} ('gitea' ou 'github')",
|
|
code="invalid_arguments",
|
|
)
|
|
|
|
|
|
def _headers(auth: str) -> dict[str, str]:
|
|
headers = {"User-Agent": USER_AGENT, "Accept": "application/json"}
|
|
if auth:
|
|
headers["Authorization"] = auth
|
|
return headers
|
|
|
|
|
|
def _request(method: str, url: str, auth: str, **kwargs: Any) -> httpx.Response:
|
|
try:
|
|
resp = httpx.request(
|
|
method, url, headers=_headers(auth), timeout=TIMEOUT, follow_redirects=False,
|
|
**kwargs,
|
|
)
|
|
except httpx.HTTPError as e:
|
|
logger.warning("connected source request failed %s: %s", url, e)
|
|
raise ToolError(
|
|
"Source connectée momentanément indisponible.",
|
|
code="connected_source_unavailable",
|
|
) from e
|
|
if resp.status_code in (401, 403):
|
|
raise ToolError(
|
|
"Accès refusé par la source connectée (jeton manquant ou expiré).",
|
|
code="permission_denied",
|
|
)
|
|
if resp.status_code == 404:
|
|
raise ToolError("Ressource introuvable sur la source connectée.", code="not_found")
|
|
resp.raise_for_status()
|
|
return resp
|
|
|
|
|
|
def _normalize_repo(item: dict[str, Any]) -> dict[str, Any]:
|
|
return {
|
|
"name": item.get("name") or "",
|
|
"full_name": item.get("full_name") or "",
|
|
"url": item.get("html_url") or item.get("clone_url") or "",
|
|
"description": item.get("description") or "",
|
|
"updated": item.get("updated_at") or "",
|
|
"private": bool(item.get("private", False)),
|
|
}
|
|
|
|
|
|
@tool(
|
|
name="git_list_repos",
|
|
description=(
|
|
"List repositories on the connected Gitea instance or GitHub account "
|
|
"(name, url, description, last update). Use when the user asks about "
|
|
"their code projects."
|
|
),
|
|
input_model=GitProviderInput,
|
|
risk=ToolRisk.READ,
|
|
)
|
|
def git_list_repos(ctx, params: GitProviderInput) -> dict[str, Any]:
|
|
"""Query the configured source and return normalized repositories."""
|
|
base, auth = _provider_base(params.provider)
|
|
if params.provider == "gitea":
|
|
url = base + "/api/v1/repos/search"
|
|
query: dict[str, Any] = {"limit": params.limit}
|
|
if params.repo:
|
|
query["q"] = params.repo
|
|
resp = _request("GET", url, auth, params=query)
|
|
items = resp.json().get("data") or []
|
|
else:
|
|
if params.repo:
|
|
url = GITHUB_API + f"/repos/{params.repo.strip('/')}"
|
|
items = [_request("GET", url, auth).json()]
|
|
else:
|
|
resp = _request(
|
|
"GET", GITHUB_API + "/user/repos",
|
|
auth, params={"per_page": params.limit, "sort": "updated"},
|
|
)
|
|
items = resp.json()
|
|
repos = [_normalize_repo(item) for item in items if isinstance(item, dict)]
|
|
return {"provider": params.provider, "count": len(repos), "repos": repos}
|
|
|
|
|
|
@tool(
|
|
name="git_search_issues",
|
|
description=(
|
|
"Search issues and pull requests on the connected Gitea instance or "
|
|
"GitHub (title/body keywords, optional repository scope, open/closed)."
|
|
),
|
|
input_model=GitSearchIssuesInput,
|
|
risk=ToolRisk.READ,
|
|
)
|
|
def git_search_issues(ctx, params: GitSearchIssuesInput) -> dict[str, Any]:
|
|
"""Query issues (and PRs) from the configured source."""
|
|
base, auth = _provider_base(params.provider)
|
|
state = params.state if params.state in ("open", "closed") else "open"
|
|
if params.provider == "gitea":
|
|
if params.repo:
|
|
url = base + f"/api/v1/repos/{params.repo.strip('/')}/issues"
|
|
query: dict[str, Any] = {"state": state, "limit": params.limit, "q": params.query}
|
|
resp = _request("GET", url, auth, params=query)
|
|
items = resp.json()
|
|
else:
|
|
url = base + "/api/v1/repos/issues/search"
|
|
resp = _request("GET", url, auth, params={
|
|
"q": params.query, "state": state, "limit": params.limit,
|
|
})
|
|
items = resp.json()
|
|
else:
|
|
clause = f"{params.query} is:issue is:{state}"
|
|
if params.repo:
|
|
clause += f" repo:{params.repo.strip('/')}"
|
|
resp = _request(
|
|
"GET", GITHUB_API + "/search/issues", auth,
|
|
params={"q": clause, "per_page": params.limit},
|
|
)
|
|
items = (resp.json().get("items") or [])
|
|
issues = [
|
|
{
|
|
"id": item.get("number") or item.get("id") or "",
|
|
"title": (item.get("title") or "")[:300],
|
|
"url": item.get("html_url") or "",
|
|
"state": item.get("state") or "",
|
|
"pull_request": bool(item.get("pull_request")),
|
|
}
|
|
for item in (items if isinstance(items, list) else [])
|
|
if isinstance(item, dict)
|
|
]
|
|
return {
|
|
"provider": params.provider,
|
|
"query": params.query,
|
|
"count": len(issues),
|
|
"issues": issues,
|
|
}
|
|
|
|
|
|
@tool(
|
|
name="git_get_file",
|
|
description=(
|
|
"Read a file's content from a connected Gitea or GitHub repository "
|
|
"(source code, docs, config). Text/JSON only, size-capped."
|
|
),
|
|
input_model=GitGetFileInput,
|
|
risk=ToolRisk.READ,
|
|
)
|
|
def git_get_file(ctx, params: GitGetFileInput) -> dict[str, Any]:
|
|
"""Fetch one repository file and return its decoded text content."""
|
|
base, auth = _provider_base(params.provider)
|
|
repo = params.repo.strip("/")
|
|
path = params.path.strip("/")
|
|
if not repo or not path:
|
|
raise ToolError(
|
|
"'repo' (owner/nom) et 'path' sont obligatoires", code="invalid_arguments"
|
|
)
|
|
if params.provider == "gitea":
|
|
url = base + f"/api/v1/repos/{repo}/contents/{path}"
|
|
else:
|
|
url = GITHUB_API + f"/repos/{repo}/contents/{path}"
|
|
if params.ref:
|
|
url += f"?ref={params.ref}"
|
|
resp = _request("GET", url, auth)
|
|
data = resp.json()
|
|
encoded = data.get("content") or ""
|
|
if (data.get("encoding") or "") == "base64" and encoded:
|
|
try:
|
|
content = base64.b64decode(encoded).decode("utf-8", errors="replace")
|
|
except (ValueError, binascii.Error) as e:
|
|
raise ToolError(
|
|
"Contenu du fichier illisible (encodage inattendu).",
|
|
code="file_decode_error",
|
|
) from e
|
|
else:
|
|
content = encoded
|
|
truncated = len(content) > MAX_FILE_BYTES
|
|
return {
|
|
"provider": params.provider,
|
|
"repo": repo,
|
|
"path": data.get("path") or path,
|
|
"size": data.get("size") or len(content),
|
|
"content": content[:MAX_FILE_BYTES],
|
|
"truncated": truncated,
|
|
}
|