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,723 @@
"""
Gitea provider implementation.
Implements the BaseProvider interface for Gitea API operations.
"""
import logging
from datetime import UTC, datetime
from typing import Any
import httpx
from config import Settings, get_settings
from exceptions import (
APIError,
AuthenticationError,
PRNotFoundError,
)
from models import (
CreatePRResult,
GetPRResult,
ListPRsResult,
MergePRResult,
MergeStrategy,
PRInfo,
PRState,
UpdatePRResult,
)
from .base import BaseProvider
logger = logging.getLogger(__name__)
class GiteaProvider(BaseProvider):
"""
Gitea API provider implementation.
Supports all PR operations, branch operations, and repository queries.
"""
def __init__(
self,
base_url: str | None = None,
token: str | None = None,
settings: Settings | None = None,
) -> None:
"""
Initialize Gitea provider.
Args:
base_url: Gitea server URL (e.g., https://gitea.example.com)
token: API token
settings: Optional settings override
"""
self.settings = settings or get_settings()
self.base_url = (base_url or self.settings.gitea_base_url).rstrip("/")
self.token = token or self.settings.gitea_token
self._client: httpx.AsyncClient | None = None
self._user: str | None = None
@property
def name(self) -> str:
"""Return the provider name."""
return "gitea"
async def _get_client(self) -> httpx.AsyncClient:
"""Get or create HTTP client."""
if self._client is None:
headers = {
"Accept": "application/json",
"Content-Type": "application/json",
}
if self.token:
headers["Authorization"] = f"token {self.token}"
self._client = httpx.AsyncClient(
base_url=f"{self.base_url}/api/v1",
headers=headers,
timeout=30.0,
)
return self._client
async def close(self) -> None:
"""Close the HTTP client."""
if self._client:
await self._client.aclose()
self._client = None
async def _request(
self,
method: str,
path: str,
**kwargs: Any,
) -> Any:
"""
Make an API request.
Args:
method: HTTP method
path: API path
**kwargs: Additional request arguments
Returns:
Parsed JSON response
Raises:
APIError: On API errors
AuthenticationError: On auth failures
"""
client = await self._get_client()
try:
response = await client.request(method, path, **kwargs)
if response.status_code == 401:
raise AuthenticationError("gitea", "Invalid or expired token")
if response.status_code == 403:
raise AuthenticationError(
"gitea", "Insufficient permissions for this operation"
)
if response.status_code == 404:
return None
if response.status_code >= 400:
error_msg = response.text
try:
error_data = response.json()
error_msg = error_data.get("message", error_msg)
except Exception:
pass
raise APIError("gitea", response.status_code, error_msg)
if response.status_code == 204:
return None
return response.json()
except httpx.RequestError as e:
raise APIError("gitea", 0, f"Request failed: {e}")
async def is_connected(self) -> bool:
"""Check if connected to Gitea."""
if not self.base_url or not self.token:
return False
try:
result = await self._request("GET", "/user")
return result is not None
except Exception:
return False
async def get_authenticated_user(self) -> str | None:
"""Get the authenticated user's username."""
if self._user:
return self._user
try:
result = await self._request("GET", "/user")
if result:
self._user = result.get("login") or result.get("username")
return self._user
except Exception:
pass
return None
# Repository operations
async def get_repo_info(self, owner: str, repo: str) -> dict[str, Any]:
"""Get repository information."""
result = await self._request("GET", f"/repos/{owner}/{repo}")
if result is None:
raise APIError("gitea", 404, f"Repository not found: {owner}/{repo}")
return result
async def get_default_branch(self, owner: str, repo: str) -> str:
"""Get the default branch for a repository."""
repo_info = await self.get_repo_info(owner, repo)
return repo_info.get("default_branch", "main")
# Pull Request operations
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."""
try:
data: dict[str, Any] = {
"title": title,
"body": body,
"head": source_branch,
"base": target_branch,
}
# Note: Gitea doesn't have draft PR support in all versions
# Draft support was added in Gitea 1.14+
result = await self._request(
"POST",
f"/repos/{owner}/{repo}/pulls",
json=data,
)
if result is None:
return CreatePRResult(
success=False,
error="Failed to create pull request",
)
pr_number = result["number"]
# Add labels if specified
if labels:
await self.add_labels(owner, repo, pr_number, labels)
# Add assignees if specified (via issue update)
if assignees:
await self._request(
"PATCH",
f"/repos/{owner}/{repo}/issues/{pr_number}",
json={"assignees": assignees},
)
# Request reviewers if specified
if reviewers:
await self.request_review(owner, repo, pr_number, reviewers)
return CreatePRResult(
success=True,
pr_number=pr_number,
pr_url=result.get("html_url"),
)
except APIError as e:
return CreatePRResult(
success=False,
error=str(e),
)
async def get_pr(self, owner: str, repo: str, pr_number: int) -> GetPRResult:
"""Get a pull request by number."""
try:
result = await self._request(
"GET",
f"/repos/{owner}/{repo}/pulls/{pr_number}",
)
if result is None:
raise PRNotFoundError(pr_number, f"{owner}/{repo}")
pr_info = self._parse_pr(result)
return GetPRResult(
success=True,
pr=pr_info.to_dict(),
)
except PRNotFoundError:
return GetPRResult(
success=False,
error=f"Pull request #{pr_number} not found",
)
except APIError as e:
return GetPRResult(
success=False,
error=str(e),
)
async def list_prs(
self,
owner: str,
repo: str,
state: PRState | None = None,
author: str | None = None,
limit: int = 20,
) -> ListPRsResult:
"""List pull requests."""
try:
params: dict[str, Any] = {
"limit": limit,
}
if state:
# Gitea uses different state names
if state == PRState.OPEN:
params["state"] = "open"
elif state == PRState.CLOSED or state == PRState.MERGED:
params["state"] = "closed"
else:
params["state"] = "all"
result = await self._request(
"GET",
f"/repos/{owner}/{repo}/pulls",
params=params,
)
if result is None:
return ListPRsResult(
success=True,
pull_requests=[],
total_count=0,
)
prs = []
for pr_data in result:
# Filter by author if specified
if author:
pr_author = pr_data.get("user", {}).get("login", "")
if pr_author.lower() != author.lower():
continue
# Filter merged PRs if looking specifically for merged
if state == PRState.MERGED:
if not pr_data.get("merged"):
continue
pr_info = self._parse_pr(pr_data)
prs.append(pr_info.to_dict())
return ListPRsResult(
success=True,
pull_requests=prs,
total_count=len(prs),
)
except APIError as e:
return ListPRsResult(
success=False,
error=str(e),
)
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."""
try:
# Map merge strategy to Gitea's "Do" values
do_map = {
MergeStrategy.MERGE: "merge",
MergeStrategy.SQUASH: "squash",
MergeStrategy.REBASE: "rebase",
}
data: dict[str, Any] = {
"Do": do_map[merge_strategy],
"delete_branch_after_merge": delete_branch,
}
if commit_message:
data["MergeTitleField"] = commit_message.split("\n")[0]
if "\n" in commit_message:
data["MergeMessageField"] = "\n".join(
commit_message.split("\n")[1:]
)
result = await self._request(
"POST",
f"/repos/{owner}/{repo}/pulls/{pr_number}/merge",
json=data,
)
if result is None:
# Check if PR was actually merged
pr_result = await self.get_pr(owner, repo, pr_number)
if pr_result.success and pr_result.pr:
if pr_result.pr.get("state") == "merged":
return MergePRResult(
success=True,
branch_deleted=delete_branch,
)
return MergePRResult(
success=False,
error="Failed to merge pull request",
)
return MergePRResult(
success=True,
merge_commit_sha=result.get("sha"),
branch_deleted=delete_branch,
)
except APIError as e:
return MergePRResult(
success=False,
error=str(e),
)
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."""
try:
data: dict[str, Any] = {}
if title is not None:
data["title"] = title
if body is not None:
data["body"] = body
if state is not None:
if state == PRState.OPEN:
data["state"] = "open"
elif state == PRState.CLOSED:
data["state"] = "closed"
# Update PR if there's data
if data:
await self._request(
"PATCH",
f"/repos/{owner}/{repo}/pulls/{pr_number}",
json=data,
)
# Update labels via issue endpoint
if labels is not None:
# First clear existing labels
await self._request(
"DELETE",
f"/repos/{owner}/{repo}/issues/{pr_number}/labels",
)
# Then add new labels
if labels:
await self.add_labels(owner, repo, pr_number, labels)
# Update assignees via issue endpoint
if assignees is not None:
await self._request(
"PATCH",
f"/repos/{owner}/{repo}/issues/{pr_number}",
json={"assignees": assignees},
)
# Fetch updated PR
result = await self.get_pr(owner, repo, pr_number)
return UpdatePRResult(
success=result.success,
pr=result.pr,
error=result.error,
)
except APIError as e:
return UpdatePRResult(
success=False,
error=str(e),
)
async def close_pr(
self,
owner: str,
repo: str,
pr_number: int,
) -> UpdatePRResult:
"""Close a pull request without merging."""
return await self.update_pr(
owner,
repo,
pr_number,
state=PRState.CLOSED,
)
# Branch operations
async def delete_remote_branch(
self,
owner: str,
repo: str,
branch: str,
) -> bool:
"""Delete a remote branch."""
try:
await self._request(
"DELETE",
f"/repos/{owner}/{repo}/branches/{branch}",
)
return True
except APIError:
return False
async def get_branch(
self,
owner: str,
repo: str,
branch: str,
) -> dict[str, Any] | None:
"""Get branch information."""
return await self._request(
"GET",
f"/repos/{owner}/{repo}/branches/{branch}",
)
# Comment operations
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."""
result = await self._request(
"POST",
f"/repos/{owner}/{repo}/issues/{pr_number}/comments",
json={"body": body},
)
return result or {}
async def list_pr_comments(
self,
owner: str,
repo: str,
pr_number: int,
) -> list[dict[str, Any]]:
"""List comments on a pull request."""
result = await self._request(
"GET",
f"/repos/{owner}/{repo}/issues/{pr_number}/comments",
)
return result or []
# Label operations
async def add_labels(
self,
owner: str,
repo: str,
pr_number: int,
labels: list[str],
) -> list[str]:
"""Add labels to a pull request."""
# First, get or create label IDs
label_ids = []
for label_name in labels:
label_id = await self._get_or_create_label(owner, repo, label_name)
if label_id:
label_ids.append(label_id)
if label_ids:
await self._request(
"POST",
f"/repos/{owner}/{repo}/issues/{pr_number}/labels",
json={"labels": label_ids},
)
# Return current labels
issue = await self._request(
"GET",
f"/repos/{owner}/{repo}/issues/{pr_number}",
)
if issue:
return [lbl["name"] for lbl in issue.get("labels", [])]
return labels
async def remove_label(
self,
owner: str,
repo: str,
pr_number: int,
label: str,
) -> list[str]:
"""Remove a label from a pull request."""
# Get label ID
label_info = await self._request(
"GET",
f"/repos/{owner}/{repo}/labels?name={label}",
)
if label_info and len(label_info) > 0:
label_id = label_info[0]["id"]
await self._request(
"DELETE",
f"/repos/{owner}/{repo}/issues/{pr_number}/labels/{label_id}",
)
# Return remaining labels
issue = await self._request(
"GET",
f"/repos/{owner}/{repo}/issues/{pr_number}",
)
if issue:
return [lbl["name"] for lbl in issue.get("labels", [])]
return []
async def _get_or_create_label(
self,
owner: str,
repo: str,
label_name: str,
) -> int | None:
"""Get or create a label and return its ID."""
# Try to find existing label
labels = await self._request(
"GET",
f"/repos/{owner}/{repo}/labels",
)
if labels:
for label in labels:
if label["name"].lower() == label_name.lower():
return label["id"]
# Create new label with default color
try:
result = await self._request(
"POST",
f"/repos/{owner}/{repo}/labels",
json={
"name": label_name,
"color": "#3B82F6", # Default blue
},
)
if result:
return result["id"]
except APIError:
pass
return None
# Reviewer operations
async def request_review(
self,
owner: str,
repo: str,
pr_number: int,
reviewers: list[str],
) -> list[str]:
"""Request review from users."""
await self._request(
"POST",
f"/repos/{owner}/{repo}/pulls/{pr_number}/requested_reviewers",
json={"reviewers": reviewers},
)
return reviewers
# Helper methods
def _parse_pr(self, data: dict[str, Any]) -> PRInfo:
"""Parse PR API response into PRInfo."""
# Parse dates
created_at = self._parse_datetime(data.get("created_at"))
updated_at = self._parse_datetime(data.get("updated_at"))
merged_at = self._parse_datetime(data.get("merged_at"))
closed_at = self._parse_datetime(data.get("closed_at"))
# Determine state
if data.get("merged"):
state = PRState.MERGED
elif data.get("state") == "closed":
state = PRState.CLOSED
else:
state = PRState.OPEN
# Extract labels
labels = [lbl["name"] for lbl in data.get("labels", [])]
# Extract assignees
assignees = [a["login"] for a in data.get("assignees", [])]
# Extract reviewers
reviewers = []
if "requested_reviewers" in data:
reviewers = [r["login"] for r in data["requested_reviewers"]]
return PRInfo(
number=data["number"],
title=data["title"],
body=data.get("body", ""),
state=state,
source_branch=data.get("head", {}).get("ref", ""),
target_branch=data.get("base", {}).get("ref", ""),
author=data.get("user", {}).get("login", ""),
created_at=created_at,
updated_at=updated_at,
merged_at=merged_at,
closed_at=closed_at,
url=data.get("html_url"),
labels=labels,
assignees=assignees,
reviewers=reviewers,
mergeable=data.get("mergeable"),
draft=data.get("draft", False),
)
def _parse_datetime(self, value: str | None) -> datetime:
"""Parse datetime string from API."""
if not value:
return datetime.now(UTC)
try:
# Handle Gitea's datetime format
if value.endswith("Z"):
value = value[:-1] + "+00:00"
return datetime.fromisoformat(value)
except ValueError:
return datetime.now(UTC)