Files
syndarix/mcp-servers/git-ops/providers/base.py
Felipe Cardoso 9dfa76aa41 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>
2026-01-06 20:48:20 +01:00

389 lines
9.2 KiB
Python

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