.
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user