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