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:
2026-01-06 20:48:20 +01:00
parent 4ad3d20cf2
commit 9dfa76aa41
19 changed files with 9544 additions and 0 deletions

View File

@@ -0,0 +1,388 @@
"""
Base provider interface for git hosting platforms.
Defines the abstract interface that all git providers must implement.
"""
from abc import ABC, abstractmethod
from typing import Any
from models import (
CreatePRResult,
GetPRResult,
ListPRsResult,
MergePRResult,
MergeStrategy,
PRState,
UpdatePRResult,
)
class BaseProvider(ABC):
"""
Abstract base class for git hosting providers.
All providers (Gitea, GitHub, GitLab) must implement this interface.
"""
@property
@abstractmethod
def name(self) -> str:
"""Return the provider name (e.g., 'gitea', 'github')."""
...
@abstractmethod
async def is_connected(self) -> bool:
"""Check if the provider is connected and authenticated."""
...
@abstractmethod
async def get_authenticated_user(self) -> str | None:
"""Get the username of the authenticated user."""
...
# Repository operations
@abstractmethod
async def get_repo_info(
self, owner: str, repo: str
) -> dict[str, Any]:
"""
Get repository information.
Args:
owner: Repository owner/organization
repo: Repository name
Returns:
Repository info dict
"""
...
@abstractmethod
async def get_default_branch(
self, owner: str, repo: str
) -> str:
"""
Get the default branch for a repository.
Args:
owner: Repository owner/organization
repo: Repository name
Returns:
Default branch name
"""
...
# Pull Request operations
@abstractmethod
async def create_pr(
self,
owner: str,
repo: str,
title: str,
body: str,
source_branch: str,
target_branch: str,
draft: bool = False,
labels: list[str] | None = None,
assignees: list[str] | None = None,
reviewers: list[str] | None = None,
) -> CreatePRResult:
"""
Create a pull request.
Args:
owner: Repository owner
repo: Repository name
title: PR title
body: PR description
source_branch: Source branch name
target_branch: Target branch name
draft: Whether to create as draft
labels: Labels to add
assignees: Users to assign
reviewers: Users to request review from
Returns:
CreatePRResult with PR number and URL
"""
...
@abstractmethod
async def get_pr(
self, owner: str, repo: str, pr_number: int
) -> GetPRResult:
"""
Get a pull request by number.
Args:
owner: Repository owner
repo: Repository name
pr_number: Pull request number
Returns:
GetPRResult with PR details
"""
...
@abstractmethod
async def list_prs(
self,
owner: str,
repo: str,
state: PRState | None = None,
author: str | None = None,
limit: int = 20,
) -> ListPRsResult:
"""
List pull requests.
Args:
owner: Repository owner
repo: Repository name
state: Filter by state (open, closed, merged)
author: Filter by author
limit: Maximum PRs to return
Returns:
ListPRsResult with list of PRs
"""
...
@abstractmethod
async def merge_pr(
self,
owner: str,
repo: str,
pr_number: int,
merge_strategy: MergeStrategy = MergeStrategy.MERGE,
commit_message: str | None = None,
delete_branch: bool = True,
) -> MergePRResult:
"""
Merge a pull request.
Args:
owner: Repository owner
repo: Repository name
pr_number: Pull request number
merge_strategy: Merge strategy to use
commit_message: Custom merge commit message
delete_branch: Whether to delete source branch
Returns:
MergePRResult with merge status
"""
...
@abstractmethod
async def update_pr(
self,
owner: str,
repo: str,
pr_number: int,
title: str | None = None,
body: str | None = None,
state: PRState | None = None,
labels: list[str] | None = None,
assignees: list[str] | None = None,
) -> UpdatePRResult:
"""
Update a pull request.
Args:
owner: Repository owner
repo: Repository name
pr_number: Pull request number
title: New title
body: New description
state: New state (open, closed)
labels: Replace labels
assignees: Replace assignees
Returns:
UpdatePRResult with updated PR info
"""
...
@abstractmethod
async def close_pr(
self, owner: str, repo: str, pr_number: int
) -> UpdatePRResult:
"""
Close a pull request without merging.
Args:
owner: Repository owner
repo: Repository name
pr_number: Pull request number
Returns:
UpdatePRResult with updated PR info
"""
...
# Branch operations via API (for operations that need to bypass local git)
@abstractmethod
async def delete_remote_branch(
self, owner: str, repo: str, branch: str
) -> bool:
"""
Delete a remote branch via API.
Args:
owner: Repository owner
repo: Repository name
branch: Branch name to delete
Returns:
True if deleted, False otherwise
"""
...
@abstractmethod
async def get_branch(
self, owner: str, repo: str, branch: str
) -> dict[str, Any] | None:
"""
Get branch information via API.
Args:
owner: Repository owner
repo: Repository name
branch: Branch name
Returns:
Branch info dict or None if not found
"""
...
# Comment operations
@abstractmethod
async def add_pr_comment(
self, owner: str, repo: str, pr_number: int, body: str
) -> dict[str, Any]:
"""
Add a comment to a pull request.
Args:
owner: Repository owner
repo: Repository name
pr_number: Pull request number
body: Comment body
Returns:
Created comment info
"""
...
@abstractmethod
async def list_pr_comments(
self, owner: str, repo: str, pr_number: int
) -> list[dict[str, Any]]:
"""
List comments on a pull request.
Args:
owner: Repository owner
repo: Repository name
pr_number: Pull request number
Returns:
List of comments
"""
...
# Label operations
@abstractmethod
async def add_labels(
self, owner: str, repo: str, pr_number: int, labels: list[str]
) -> list[str]:
"""
Add labels to a pull request.
Args:
owner: Repository owner
repo: Repository name
pr_number: Pull request number
labels: Labels to add
Returns:
Updated list of labels
"""
...
@abstractmethod
async def remove_label(
self, owner: str, repo: str, pr_number: int, label: str
) -> list[str]:
"""
Remove a label from a pull request.
Args:
owner: Repository owner
repo: Repository name
pr_number: Pull request number
label: Label to remove
Returns:
Updated list of labels
"""
...
# Reviewer operations
@abstractmethod
async def request_review(
self, owner: str, repo: str, pr_number: int, reviewers: list[str]
) -> list[str]:
"""
Request review from users.
Args:
owner: Repository owner
repo: Repository name
pr_number: Pull request number
reviewers: Usernames to request review from
Returns:
List of reviewers requested
"""
...
# Utility methods
def parse_repo_url(self, repo_url: str) -> tuple[str, str]:
"""
Parse repository URL to extract owner and repo name.
Args:
repo_url: Repository URL (HTTPS or SSH)
Returns:
Tuple of (owner, repo)
Raises:
ValueError: If URL cannot be parsed
"""
import re
# Handle SSH URLs: git@host:owner/repo.git
ssh_match = re.match(r"git@[^:]+:([^/]+)/([^/]+?)(?:\.git)?$", repo_url)
if ssh_match:
return ssh_match.group(1), ssh_match.group(2)
# Handle HTTPS URLs: https://host/owner/repo.git
https_match = re.match(
r"https?://[^/]+/([^/]+)/([^/]+?)(?:\.git)?$", repo_url
)
if https_match:
return https_match.group(1), https_match.group(2)
raise ValueError(f"Unable to parse repository URL: {repo_url}")