Files
ObsiGate/backend/tools/connected.py
T
bruno ba0ec3d1fa
CI / lint (push) Successful in 1m46s
CI / security (push) Successful in 1m23s
CI / test (push) Successful in 3m42s
CI / build (push) Successful in 58s
CI / e2e (push) Successful in 10m50s
feat(config): cles des sources connectees et recherche a cle editables depuis la page Configurations (#103)
2026-09-17 13:59:26 -04:00

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,
}