feat(mcp): implement Git Operations MCP server with Gitea provider
Implements the Git Operations MCP server (Issue #58) providing: Core features: - GitPython wrapper for local repository operations (clone, commit, push, pull, diff, log) - Branch management (create, delete, list, checkout) - Workspace isolation per project with file-based locking - Gitea provider for remote PR operations MCP Tools (17 registered): - clone_repository, git_status, create_branch, list_branches - checkout, commit, push, pull, diff, log - create_pull_request, get_pull_request, list_pull_requests - merge_pull_request, get_workspace, lock_workspace, unlock_workspace Technical details: - FastMCP + FastAPI with JSON-RPC 2.0 protocol - pydantic-settings for configuration (env prefix: GIT_OPS_) - Comprehensive error hierarchy with structured codes - 131 tests passing with 67% coverage - Async operations via ThreadPoolExecutor Closes: #105, #106, #107, #108, #109 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
361
mcp-servers/git-ops/exceptions.py
Normal file
361
mcp-servers/git-ops/exceptions.py
Normal file
@@ -0,0 +1,361 @@
|
||||
"""
|
||||
Exception hierarchy for Git Operations MCP Server.
|
||||
|
||||
Provides structured error handling with error codes for MCP responses.
|
||||
"""
|
||||
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
|
||||
class ErrorCode(str, Enum):
|
||||
"""Error codes for Git Operations errors."""
|
||||
|
||||
# General errors (1xxx)
|
||||
INTERNAL_ERROR = "GIT_1000"
|
||||
INVALID_REQUEST = "GIT_1001"
|
||||
NOT_FOUND = "GIT_1002"
|
||||
PERMISSION_DENIED = "GIT_1003"
|
||||
TIMEOUT = "GIT_1004"
|
||||
RATE_LIMITED = "GIT_1005"
|
||||
|
||||
# Workspace errors (2xxx)
|
||||
WORKSPACE_NOT_FOUND = "GIT_2000"
|
||||
WORKSPACE_LOCKED = "GIT_2001"
|
||||
WORKSPACE_SIZE_EXCEEDED = "GIT_2002"
|
||||
WORKSPACE_CREATE_FAILED = "GIT_2003"
|
||||
WORKSPACE_DELETE_FAILED = "GIT_2004"
|
||||
|
||||
# Git operation errors (3xxx)
|
||||
CLONE_FAILED = "GIT_3000"
|
||||
CHECKOUT_FAILED = "GIT_3001"
|
||||
COMMIT_FAILED = "GIT_3002"
|
||||
PUSH_FAILED = "GIT_3003"
|
||||
PULL_FAILED = "GIT_3004"
|
||||
MERGE_CONFLICT = "GIT_3005"
|
||||
BRANCH_EXISTS = "GIT_3006"
|
||||
BRANCH_NOT_FOUND = "GIT_3007"
|
||||
INVALID_REF = "GIT_3008"
|
||||
DIRTY_WORKSPACE = "GIT_3009"
|
||||
UNCOMMITTED_CHANGES = "GIT_3010"
|
||||
FETCH_FAILED = "GIT_3011"
|
||||
RESET_FAILED = "GIT_3012"
|
||||
|
||||
# Provider errors (4xxx)
|
||||
PROVIDER_ERROR = "GIT_4000"
|
||||
PROVIDER_AUTH_FAILED = "GIT_4001"
|
||||
PROVIDER_NOT_FOUND = "GIT_4002"
|
||||
PR_CREATE_FAILED = "GIT_4003"
|
||||
PR_MERGE_FAILED = "GIT_4004"
|
||||
PR_NOT_FOUND = "GIT_4005"
|
||||
API_ERROR = "GIT_4006"
|
||||
|
||||
# Credential errors (5xxx)
|
||||
CREDENTIAL_ERROR = "GIT_5000"
|
||||
CREDENTIAL_NOT_FOUND = "GIT_5001"
|
||||
CREDENTIAL_INVALID = "GIT_5002"
|
||||
SSH_KEY_ERROR = "GIT_5003"
|
||||
|
||||
|
||||
class GitOpsError(Exception):
|
||||
"""Base exception for Git Operations errors."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
code: ErrorCode = ErrorCode.INTERNAL_ERROR,
|
||||
details: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.code = code
|
||||
self.details = details or {}
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Convert to dictionary for MCP response."""
|
||||
result = {
|
||||
"error": self.message,
|
||||
"code": self.code.value,
|
||||
}
|
||||
if self.details:
|
||||
result["details"] = self.details
|
||||
return result
|
||||
|
||||
|
||||
# Workspace Errors
|
||||
|
||||
|
||||
class WorkspaceError(GitOpsError):
|
||||
"""Base exception for workspace-related errors."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
code: ErrorCode = ErrorCode.WORKSPACE_NOT_FOUND,
|
||||
details: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
super().__init__(message, code, details)
|
||||
|
||||
|
||||
class WorkspaceNotFoundError(WorkspaceError):
|
||||
"""Workspace does not exist."""
|
||||
|
||||
def __init__(self, project_id: str) -> None:
|
||||
super().__init__(
|
||||
f"Workspace not found for project: {project_id}",
|
||||
ErrorCode.WORKSPACE_NOT_FOUND,
|
||||
{"project_id": project_id},
|
||||
)
|
||||
|
||||
|
||||
class WorkspaceLockedError(WorkspaceError):
|
||||
"""Workspace is locked by another operation."""
|
||||
|
||||
def __init__(self, project_id: str, holder: str | None = None) -> None:
|
||||
details: dict[str, Any] = {"project_id": project_id}
|
||||
if holder:
|
||||
details["locked_by"] = holder
|
||||
super().__init__(
|
||||
f"Workspace is locked for project: {project_id}",
|
||||
ErrorCode.WORKSPACE_LOCKED,
|
||||
details,
|
||||
)
|
||||
|
||||
|
||||
class WorkspaceSizeExceededError(WorkspaceError):
|
||||
"""Workspace size limit exceeded."""
|
||||
|
||||
def __init__(self, project_id: str, current_size: float, max_size: float) -> None:
|
||||
super().__init__(
|
||||
f"Workspace size limit exceeded for project: {project_id}",
|
||||
ErrorCode.WORKSPACE_SIZE_EXCEEDED,
|
||||
{
|
||||
"project_id": project_id,
|
||||
"current_size_gb": current_size,
|
||||
"max_size_gb": max_size,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# Git Operation Errors
|
||||
|
||||
|
||||
class GitError(GitOpsError):
|
||||
"""Base exception for git operation errors."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
code: ErrorCode = ErrorCode.INTERNAL_ERROR,
|
||||
details: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
super().__init__(message, code, details)
|
||||
|
||||
|
||||
class CloneError(GitError):
|
||||
"""Failed to clone repository."""
|
||||
|
||||
def __init__(self, repo_url: str, reason: str) -> None:
|
||||
super().__init__(
|
||||
f"Failed to clone repository: {reason}",
|
||||
ErrorCode.CLONE_FAILED,
|
||||
{"repo_url": repo_url, "reason": reason},
|
||||
)
|
||||
|
||||
|
||||
class CheckoutError(GitError):
|
||||
"""Failed to checkout branch or ref."""
|
||||
|
||||
def __init__(self, ref: str, reason: str) -> None:
|
||||
super().__init__(
|
||||
f"Failed to checkout '{ref}': {reason}",
|
||||
ErrorCode.CHECKOUT_FAILED,
|
||||
{"ref": ref, "reason": reason},
|
||||
)
|
||||
|
||||
|
||||
class CommitError(GitError):
|
||||
"""Failed to commit changes."""
|
||||
|
||||
def __init__(self, reason: str) -> None:
|
||||
super().__init__(
|
||||
f"Failed to commit: {reason}",
|
||||
ErrorCode.COMMIT_FAILED,
|
||||
{"reason": reason},
|
||||
)
|
||||
|
||||
|
||||
class PushError(GitError):
|
||||
"""Failed to push to remote."""
|
||||
|
||||
def __init__(self, branch: str, reason: str) -> None:
|
||||
super().__init__(
|
||||
f"Failed to push branch '{branch}': {reason}",
|
||||
ErrorCode.PUSH_FAILED,
|
||||
{"branch": branch, "reason": reason},
|
||||
)
|
||||
|
||||
|
||||
class PullError(GitError):
|
||||
"""Failed to pull from remote."""
|
||||
|
||||
def __init__(self, branch: str, reason: str) -> None:
|
||||
super().__init__(
|
||||
f"Failed to pull branch '{branch}': {reason}",
|
||||
ErrorCode.PULL_FAILED,
|
||||
{"branch": branch, "reason": reason},
|
||||
)
|
||||
|
||||
|
||||
class MergeConflictError(GitError):
|
||||
"""Merge conflict detected."""
|
||||
|
||||
def __init__(self, conflicting_files: list[str]) -> None:
|
||||
super().__init__(
|
||||
f"Merge conflict detected in {len(conflicting_files)} files",
|
||||
ErrorCode.MERGE_CONFLICT,
|
||||
{"conflicting_files": conflicting_files},
|
||||
)
|
||||
|
||||
|
||||
class BranchExistsError(GitError):
|
||||
"""Branch already exists."""
|
||||
|
||||
def __init__(self, branch_name: str) -> None:
|
||||
super().__init__(
|
||||
f"Branch already exists: {branch_name}",
|
||||
ErrorCode.BRANCH_EXISTS,
|
||||
{"branch": branch_name},
|
||||
)
|
||||
|
||||
|
||||
class BranchNotFoundError(GitError):
|
||||
"""Branch does not exist."""
|
||||
|
||||
def __init__(self, branch_name: str) -> None:
|
||||
super().__init__(
|
||||
f"Branch not found: {branch_name}",
|
||||
ErrorCode.BRANCH_NOT_FOUND,
|
||||
{"branch": branch_name},
|
||||
)
|
||||
|
||||
|
||||
class InvalidRefError(GitError):
|
||||
"""Invalid git reference."""
|
||||
|
||||
def __init__(self, ref: str) -> None:
|
||||
super().__init__(
|
||||
f"Invalid git reference: {ref}",
|
||||
ErrorCode.INVALID_REF,
|
||||
{"ref": ref},
|
||||
)
|
||||
|
||||
|
||||
class DirtyWorkspaceError(GitError):
|
||||
"""Workspace has uncommitted changes."""
|
||||
|
||||
def __init__(self, modified_files: list[str]) -> None:
|
||||
super().__init__(
|
||||
f"Workspace has {len(modified_files)} uncommitted changes",
|
||||
ErrorCode.DIRTY_WORKSPACE,
|
||||
{"modified_files": modified_files[:10]}, # Limit to first 10
|
||||
)
|
||||
|
||||
|
||||
# Provider Errors
|
||||
|
||||
|
||||
class ProviderError(GitOpsError):
|
||||
"""Base exception for provider-related errors."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
code: ErrorCode = ErrorCode.PROVIDER_ERROR,
|
||||
details: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
super().__init__(message, code, details)
|
||||
|
||||
|
||||
class AuthenticationError(ProviderError):
|
||||
"""Authentication with provider failed."""
|
||||
|
||||
def __init__(self, provider: str, reason: str) -> None:
|
||||
super().__init__(
|
||||
f"Authentication failed with {provider}: {reason}",
|
||||
ErrorCode.PROVIDER_AUTH_FAILED,
|
||||
{"provider": provider, "reason": reason},
|
||||
)
|
||||
|
||||
|
||||
class ProviderNotFoundError(ProviderError):
|
||||
"""Provider not configured or recognized."""
|
||||
|
||||
def __init__(self, provider: str) -> None:
|
||||
super().__init__(
|
||||
f"Provider not found or not configured: {provider}",
|
||||
ErrorCode.PROVIDER_NOT_FOUND,
|
||||
{"provider": provider},
|
||||
)
|
||||
|
||||
|
||||
class PRError(ProviderError):
|
||||
"""Pull request operation failed."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
code: ErrorCode = ErrorCode.PR_CREATE_FAILED,
|
||||
details: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
super().__init__(message, code, details)
|
||||
|
||||
|
||||
class PRNotFoundError(PRError):
|
||||
"""Pull request not found."""
|
||||
|
||||
def __init__(self, pr_number: int, repo: str) -> None:
|
||||
super().__init__(
|
||||
f"Pull request #{pr_number} not found in {repo}",
|
||||
ErrorCode.PR_NOT_FOUND,
|
||||
{"pr_number": pr_number, "repo": repo},
|
||||
)
|
||||
|
||||
|
||||
class APIError(ProviderError):
|
||||
"""Provider API error."""
|
||||
|
||||
def __init__(
|
||||
self, provider: str, status_code: int, message: str
|
||||
) -> None:
|
||||
super().__init__(
|
||||
f"{provider} API error ({status_code}): {message}",
|
||||
ErrorCode.API_ERROR,
|
||||
{"provider": provider, "status_code": status_code, "message": message},
|
||||
)
|
||||
|
||||
|
||||
# Credential Errors
|
||||
|
||||
|
||||
class CredentialError(GitOpsError):
|
||||
"""Base exception for credential-related errors."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
code: ErrorCode = ErrorCode.CREDENTIAL_ERROR,
|
||||
details: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
super().__init__(message, code, details)
|
||||
|
||||
|
||||
class CredentialNotFoundError(CredentialError):
|
||||
"""Credential not found."""
|
||||
|
||||
def __init__(self, credential_type: str, identifier: str) -> None:
|
||||
super().__init__(
|
||||
f"{credential_type} credential not found: {identifier}",
|
||||
ErrorCode.CREDENTIAL_NOT_FOUND,
|
||||
{"type": credential_type, "identifier": identifier},
|
||||
)
|
||||
Reference in New Issue
Block a user