This commit is contained in:
2026-01-29 19:53:36 +01:00
parent 1bda2013bb
commit a9708b33e2
27 changed files with 3745 additions and 4 deletions
+381
View File
@@ -0,0 +1,381 @@
"""Gitea API client with bot user authentication."""
from typing import Any, Dict, List, Optional
import httpx
from httpx import AsyncClient, Response
from aegis_gitea_mcp.audit import get_audit_logger
from aegis_gitea_mcp.config import get_settings
class GiteaError(Exception):
"""Base exception for Gitea API errors."""
pass
class GiteaAuthenticationError(GiteaError):
"""Raised when authentication with Gitea fails."""
pass
class GiteaAuthorizationError(GiteaError):
"""Raised when bot user lacks permission for an operation."""
pass
class GiteaNotFoundError(GiteaError):
"""Raised when a requested resource is not found."""
pass
class GiteaClient:
"""Client for interacting with Gitea API as a bot user."""
def __init__(self, base_url: Optional[str] = None, token: Optional[str] = None) -> None:
"""Initialize Gitea client.
Args:
base_url: Base URL of Gitea instance (defaults to config value)
token: Bot user access token (defaults to config value)
"""
self.settings = get_settings()
self.audit = get_audit_logger()
self.base_url = (base_url or self.settings.gitea_base_url).rstrip("/")
self.token = token or self.settings.gitea_token
self.client: Optional[AsyncClient] = None
async def __aenter__(self) -> "GiteaClient":
"""Async context manager entry."""
self.client = AsyncClient(
base_url=self.base_url,
headers={
"Authorization": f"token {self.token}",
"Content-Type": "application/json",
},
timeout=self.settings.request_timeout_seconds,
follow_redirects=True,
)
return self
async def __aexit__(self, *args: Any) -> None:
"""Async context manager exit."""
if self.client:
await self.client.aclose()
def _handle_response(self, response: Response, correlation_id: str) -> Dict[str, Any]:
"""Handle Gitea API response and raise appropriate exceptions.
Args:
response: HTTP response from Gitea
correlation_id: Correlation ID for audit logging
Returns:
Parsed JSON response
Raises:
GiteaAuthenticationError: On 401 responses
GiteaAuthorizationError: On 403 responses
GiteaNotFoundError: On 404 responses
GiteaError: On other error responses
"""
if response.status_code == 401:
self.audit.log_security_event(
event_type="authentication_failure",
description="Gitea API returned 401 Unauthorized",
severity="high",
metadata={"correlation_id": correlation_id},
)
raise GiteaAuthenticationError("Authentication failed - check bot token")
if response.status_code == 403:
self.audit.log_access_denied(
tool_name="gitea_api",
reason="Bot user lacks permission",
correlation_id=correlation_id,
)
raise GiteaAuthorizationError("Bot user lacks permission for this operation")
if response.status_code == 404:
raise GiteaNotFoundError("Resource not found")
if response.status_code >= 400:
error_msg = f"Gitea API error: {response.status_code}"
try:
error_data = response.json()
error_msg = f"{error_msg} - {error_data.get('message', '')}"
except Exception:
pass
raise GiteaError(error_msg)
try:
return response.json()
except Exception:
return {}
async def get_current_user(self) -> Dict[str, Any]:
"""Get information about the current bot user.
Returns:
User information dict
Raises:
GiteaError: On API errors
"""
if not self.client:
raise RuntimeError("Client not initialized - use async context manager")
correlation_id = self.audit.log_tool_invocation(
tool_name="get_current_user",
result_status="pending",
)
try:
response = await self.client.get("/api/v1/user")
user_data = self._handle_response(response, correlation_id)
self.audit.log_tool_invocation(
tool_name="get_current_user",
correlation_id=correlation_id,
result_status="success",
)
return user_data
except Exception as e:
self.audit.log_tool_invocation(
tool_name="get_current_user",
correlation_id=correlation_id,
result_status="error",
error=str(e),
)
raise
async def list_repositories(self) -> List[Dict[str, Any]]:
"""List all repositories visible to the bot user.
Returns:
List of repository information dicts
Raises:
GiteaError: On API errors
"""
if not self.client:
raise RuntimeError("Client not initialized - use async context manager")
correlation_id = self.audit.log_tool_invocation(
tool_name="list_repositories",
result_status="pending",
)
try:
response = await self.client.get("/api/v1/user/repos")
repos_data = self._handle_response(response, correlation_id)
# Ensure we have a list
repos = repos_data if isinstance(repos_data, list) else []
self.audit.log_tool_invocation(
tool_name="list_repositories",
correlation_id=correlation_id,
result_status="success",
params={"count": len(repos)},
)
return repos
except Exception as e:
self.audit.log_tool_invocation(
tool_name="list_repositories",
correlation_id=correlation_id,
result_status="error",
error=str(e),
)
raise
async def get_repository(self, owner: str, repo: str) -> Dict[str, Any]:
"""Get information about a specific repository.
Args:
owner: Repository owner username
repo: Repository name
Returns:
Repository information dict
Raises:
GiteaNotFoundError: If repository doesn't exist or bot lacks access
GiteaError: On other API errors
"""
if not self.client:
raise RuntimeError("Client not initialized - use async context manager")
repo_id = f"{owner}/{repo}"
correlation_id = self.audit.log_tool_invocation(
tool_name="get_repository",
repository=repo_id,
result_status="pending",
)
try:
response = await self.client.get(f"/api/v1/repos/{owner}/{repo}")
repo_data = self._handle_response(response, correlation_id)
self.audit.log_tool_invocation(
tool_name="get_repository",
repository=repo_id,
correlation_id=correlation_id,
result_status="success",
)
return repo_data
except Exception as e:
self.audit.log_tool_invocation(
tool_name="get_repository",
repository=repo_id,
correlation_id=correlation_id,
result_status="error",
error=str(e),
)
raise
async def get_file_contents(
self, owner: str, repo: str, filepath: str, ref: str = "main"
) -> Dict[str, Any]:
"""Get contents of a file in a repository.
Args:
owner: Repository owner username
repo: Repository name
filepath: Path to file within repository
ref: Branch, tag, or commit ref (defaults to 'main')
Returns:
File contents dict with 'content', 'encoding', 'size', etc.
Raises:
GiteaNotFoundError: If file doesn't exist
GiteaError: On other API errors
"""
if not self.client:
raise RuntimeError("Client not initialized - use async context manager")
repo_id = f"{owner}/{repo}"
correlation_id = self.audit.log_tool_invocation(
tool_name="get_file_contents",
repository=repo_id,
target=filepath,
params={"ref": ref},
result_status="pending",
)
try:
response = await self.client.get(
f"/api/v1/repos/{owner}/{repo}/contents/{filepath}",
params={"ref": ref},
)
file_data = self._handle_response(response, correlation_id)
# Check file size against limit
file_size = file_data.get("size", 0)
if file_size > self.settings.max_file_size_bytes:
error_msg = (
f"File size ({file_size} bytes) exceeds "
f"limit ({self.settings.max_file_size_bytes} bytes)"
)
self.audit.log_security_event(
event_type="file_size_limit_exceeded",
description=error_msg,
severity="low",
metadata={
"repository": repo_id,
"filepath": filepath,
"file_size": file_size,
"limit": self.settings.max_file_size_bytes,
},
)
raise GiteaError(error_msg)
self.audit.log_tool_invocation(
tool_name="get_file_contents",
repository=repo_id,
target=filepath,
correlation_id=correlation_id,
result_status="success",
params={"ref": ref, "size": file_size},
)
return file_data
except Exception as e:
self.audit.log_tool_invocation(
tool_name="get_file_contents",
repository=repo_id,
target=filepath,
correlation_id=correlation_id,
result_status="error",
error=str(e),
)
raise
async def get_tree(
self, owner: str, repo: str, ref: str = "main", recursive: bool = False
) -> Dict[str, Any]:
"""Get file tree for a repository.
Args:
owner: Repository owner username
repo: Repository name
ref: Branch, tag, or commit ref (defaults to 'main')
recursive: Whether to recursively fetch tree (default: False for safety)
Returns:
Tree information dict
Raises:
GiteaError: On API errors
"""
if not self.client:
raise RuntimeError("Client not initialized - use async context manager")
repo_id = f"{owner}/{repo}"
correlation_id = self.audit.log_tool_invocation(
tool_name="get_tree",
repository=repo_id,
params={"ref": ref, "recursive": recursive},
result_status="pending",
)
try:
response = await self.client.get(
f"/api/v1/repos/{owner}/{repo}/git/trees/{ref}",
params={"recursive": str(recursive).lower()},
)
tree_data = self._handle_response(response, correlation_id)
self.audit.log_tool_invocation(
tool_name="get_tree",
repository=repo_id,
correlation_id=correlation_id,
result_status="success",
params={"ref": ref, "recursive": recursive, "count": len(tree_data.get("tree", []))},
)
return tree_data
except Exception as e:
self.audit.log_tool_invocation(
tool_name="get_tree",
repository=repo_id,
correlation_id=correlation_id,
result_status="error",
error=str(e),
)
raise