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,484 @@
"""
Tests for git provider implementations.
"""
from unittest.mock import MagicMock
import pytest
from exceptions import APIError, AuthenticationError
from models import MergeStrategy, PRState
from providers.gitea import GiteaProvider
class TestBaseProvider:
"""Tests for BaseProvider interface."""
def test_parse_repo_url_https(self, mock_gitea_provider):
"""Test parsing HTTPS repo URL."""
# The mock needs parse_repo_url to work
provider = GiteaProvider(
base_url="https://gitea.test.com",
token="test-token"
)
owner, repo = provider.parse_repo_url("https://gitea.test.com/owner/repo.git")
assert owner == "owner"
assert repo == "repo"
def test_parse_repo_url_https_no_git(self):
"""Test parsing HTTPS URL without .git suffix."""
provider = GiteaProvider(
base_url="https://gitea.test.com",
token="test-token"
)
owner, repo = provider.parse_repo_url("https://gitea.test.com/owner/repo")
assert owner == "owner"
assert repo == "repo"
def test_parse_repo_url_ssh(self):
"""Test parsing SSH repo URL."""
provider = GiteaProvider(
base_url="https://gitea.test.com",
token="test-token"
)
owner, repo = provider.parse_repo_url("git@gitea.test.com:owner/repo.git")
assert owner == "owner"
assert repo == "repo"
def test_parse_repo_url_invalid(self):
"""Test error on invalid URL."""
provider = GiteaProvider(
base_url="https://gitea.test.com",
token="test-token"
)
with pytest.raises(ValueError, match="Unable to parse"):
provider.parse_repo_url("invalid-url")
class TestGiteaProvider:
"""Tests for GiteaProvider."""
@pytest.mark.asyncio
async def test_is_connected(self, gitea_provider, mock_httpx_client):
"""Test connection check."""
mock_httpx_client.request.return_value.json = MagicMock(
return_value={"login": "test-user"}
)
result = await gitea_provider.is_connected()
assert result is True
@pytest.mark.asyncio
async def test_is_connected_no_token(self, test_settings):
"""Test connection fails without token."""
provider = GiteaProvider(
base_url="https://gitea.test.com",
token="",
settings=test_settings,
)
result = await provider.is_connected()
assert result is False
await provider.close()
@pytest.mark.asyncio
async def test_get_authenticated_user(self, gitea_provider, mock_httpx_client):
"""Test getting authenticated user."""
mock_httpx_client.request.return_value.json = MagicMock(
return_value={"login": "test-user"}
)
user = await gitea_provider.get_authenticated_user()
assert user == "test-user"
@pytest.mark.asyncio
async def test_get_repo_info(self, gitea_provider, mock_httpx_client):
"""Test getting repository info."""
mock_httpx_client.request.return_value.json = MagicMock(
return_value={
"name": "repo",
"full_name": "owner/repo",
"default_branch": "main",
}
)
result = await gitea_provider.get_repo_info("owner", "repo")
assert result["name"] == "repo"
assert result["default_branch"] == "main"
@pytest.mark.asyncio
async def test_get_default_branch(self, gitea_provider, mock_httpx_client):
"""Test getting default branch."""
mock_httpx_client.request.return_value.json = MagicMock(
return_value={"default_branch": "develop"}
)
branch = await gitea_provider.get_default_branch("owner", "repo")
assert branch == "develop"
class TestGiteaPROperations:
"""Tests for Gitea PR operations."""
@pytest.mark.asyncio
async def test_create_pr(self, gitea_provider, mock_httpx_client):
"""Test creating a pull request."""
mock_httpx_client.request.return_value.json = MagicMock(
return_value={
"number": 42,
"html_url": "https://gitea.test.com/owner/repo/pull/42",
}
)
result = await gitea_provider.create_pr(
owner="owner",
repo="repo",
title="Test PR",
body="Test body",
source_branch="feature",
target_branch="main",
)
assert result.success is True
assert result.pr_number == 42
assert result.pr_url == "https://gitea.test.com/owner/repo/pull/42"
@pytest.mark.asyncio
async def test_create_pr_with_options(self, gitea_provider, mock_httpx_client):
"""Test creating PR with labels, assignees, reviewers."""
# Use side_effect for multiple API calls:
# 1. POST create PR
# 2. GET labels (for "enhancement") - in add_labels -> _get_or_create_label
# 3. POST add labels to PR - in add_labels
# 4. GET issue to return labels - in add_labels
# 5. PATCH add assignees
# 6. POST request reviewers
mock_responses = [
{"number": 43, "html_url": "https://gitea.test.com/owner/repo/pull/43"}, # Create PR
[{"id": 1, "name": "enhancement"}], # GET labels (found)
{}, # POST add labels to PR
{"labels": [{"name": "enhancement"}]}, # GET issue to return current labels
{}, # PATCH add assignees
{}, # POST request reviewers
]
mock_httpx_client.request.return_value.json = MagicMock(side_effect=mock_responses)
result = await gitea_provider.create_pr(
owner="owner",
repo="repo",
title="Test PR",
body="Test body",
source_branch="feature",
target_branch="main",
labels=["enhancement"],
assignees=["user1"],
reviewers=["reviewer1"],
)
assert result.success is True
@pytest.mark.asyncio
async def test_get_pr(self, gitea_provider, mock_httpx_client, sample_pr_data):
"""Test getting a pull request."""
mock_httpx_client.request.return_value.json = MagicMock(
return_value=sample_pr_data
)
result = await gitea_provider.get_pr("owner", "repo", 42)
assert result.success is True
assert result.pr["number"] == 42
assert result.pr["title"] == "Test PR"
@pytest.mark.asyncio
async def test_get_pr_not_found(self, gitea_provider, mock_httpx_client):
"""Test getting non-existent PR."""
mock_httpx_client.request.return_value.status_code = 404
mock_httpx_client.request.return_value.json = MagicMock(return_value=None)
result = await gitea_provider.get_pr("owner", "repo", 999)
assert result.success is False
@pytest.mark.asyncio
async def test_list_prs(self, gitea_provider, mock_httpx_client, sample_pr_data):
"""Test listing pull requests."""
mock_httpx_client.request.return_value.json = MagicMock(
return_value=[sample_pr_data, sample_pr_data]
)
result = await gitea_provider.list_prs("owner", "repo")
assert result.success is True
assert len(result.pull_requests) == 2
@pytest.mark.asyncio
async def test_list_prs_with_state_filter(self, gitea_provider, mock_httpx_client, sample_pr_data):
"""Test listing PRs with state filter."""
mock_httpx_client.request.return_value.json = MagicMock(
return_value=[sample_pr_data]
)
result = await gitea_provider.list_prs(
"owner", "repo", state=PRState.OPEN
)
assert result.success is True
@pytest.mark.asyncio
async def test_merge_pr(self, gitea_provider, mock_httpx_client):
"""Test merging a pull request."""
# First call returns merge result
mock_httpx_client.request.return_value.json = MagicMock(
return_value={"sha": "merge-commit-sha"}
)
result = await gitea_provider.merge_pr(
"owner", "repo", 42,
merge_strategy=MergeStrategy.SQUASH,
)
assert result.success is True
assert result.merge_commit_sha == "merge-commit-sha"
@pytest.mark.asyncio
async def test_update_pr(self, gitea_provider, mock_httpx_client, sample_pr_data):
"""Test updating a pull request."""
mock_httpx_client.request.return_value.json = MagicMock(
return_value=sample_pr_data
)
result = await gitea_provider.update_pr(
"owner", "repo", 42,
title="Updated Title",
body="Updated body",
)
assert result.success is True
@pytest.mark.asyncio
async def test_close_pr(self, gitea_provider, mock_httpx_client, sample_pr_data):
"""Test closing a pull request."""
sample_pr_data["state"] = "closed"
mock_httpx_client.request.return_value.json = MagicMock(
return_value=sample_pr_data
)
result = await gitea_provider.close_pr("owner", "repo", 42)
assert result.success is True
class TestGiteaBranchOperations:
"""Tests for Gitea branch operations."""
@pytest.mark.asyncio
async def test_get_branch(self, gitea_provider, mock_httpx_client):
"""Test getting branch info."""
mock_httpx_client.request.return_value.json = MagicMock(
return_value={
"name": "main",
"commit": {"sha": "abc123"},
}
)
result = await gitea_provider.get_branch("owner", "repo", "main")
assert result["name"] == "main"
@pytest.mark.asyncio
async def test_delete_remote_branch(self, gitea_provider, mock_httpx_client):
"""Test deleting a remote branch."""
mock_httpx_client.request.return_value.status_code = 204
result = await gitea_provider.delete_remote_branch("owner", "repo", "old-branch")
assert result is True
class TestGiteaCommentOperations:
"""Tests for Gitea comment operations."""
@pytest.mark.asyncio
async def test_add_pr_comment(self, gitea_provider, mock_httpx_client):
"""Test adding a comment to a PR."""
mock_httpx_client.request.return_value.json = MagicMock(
return_value={"id": 1, "body": "Test comment"}
)
result = await gitea_provider.add_pr_comment(
"owner", "repo", 42, "Test comment"
)
assert result["body"] == "Test comment"
@pytest.mark.asyncio
async def test_list_pr_comments(self, gitea_provider, mock_httpx_client):
"""Test listing PR comments."""
mock_httpx_client.request.return_value.json = MagicMock(
return_value=[
{"id": 1, "body": "Comment 1"},
{"id": 2, "body": "Comment 2"},
]
)
result = await gitea_provider.list_pr_comments("owner", "repo", 42)
assert len(result) == 2
class TestGiteaLabelOperations:
"""Tests for Gitea label operations."""
@pytest.mark.asyncio
async def test_add_labels(self, gitea_provider, mock_httpx_client):
"""Test adding labels to a PR."""
# Use side_effect to return different values for different calls
# 1. GET labels (for "bug") - returns existing labels
# 2. POST to create "bug" label
# 3. GET labels (for "urgent")
# 4. POST to create "urgent" label
# 5. POST labels to PR
# 6. GET issue to return final labels
mock_responses = [
[{"id": 1, "name": "existing"}], # GET labels (bug not found)
{"id": 2, "name": "bug"}, # POST create bug
[{"id": 1, "name": "existing"}, {"id": 2, "name": "bug"}], # GET labels (urgent not found)
{"id": 3, "name": "urgent"}, # POST create urgent
{}, # POST add labels to PR
{"labels": [{"name": "bug"}, {"name": "urgent"}]}, # GET issue
]
mock_httpx_client.request.return_value.json = MagicMock(side_effect=mock_responses)
result = await gitea_provider.add_labels(
"owner", "repo", 42, ["bug", "urgent"]
)
# Should return updated label list
assert isinstance(result, list)
@pytest.mark.asyncio
async def test_remove_label(self, gitea_provider, mock_httpx_client):
"""Test removing a label from a PR."""
# Use side_effect for multiple calls
# 1. GET labels to find the label ID
# 2. DELETE the label from the PR
# 3. GET issue to return remaining labels
mock_responses = [
[{"id": 1, "name": "bug"}], # GET labels
{}, # DELETE label
{"labels": []}, # GET issue
]
mock_httpx_client.request.return_value.json = MagicMock(side_effect=mock_responses)
result = await gitea_provider.remove_label(
"owner", "repo", 42, "bug"
)
assert isinstance(result, list)
class TestGiteaReviewerOperations:
"""Tests for Gitea reviewer operations."""
@pytest.mark.asyncio
async def test_request_review(self, gitea_provider, mock_httpx_client):
"""Test requesting review from users."""
mock_httpx_client.request.return_value.json = MagicMock(return_value={})
result = await gitea_provider.request_review(
"owner", "repo", 42, ["reviewer1", "reviewer2"]
)
assert result == ["reviewer1", "reviewer2"]
class TestGiteaErrorHandling:
"""Tests for error handling in Gitea provider."""
@pytest.mark.asyncio
async def test_authentication_error(self, gitea_provider, mock_httpx_client):
"""Test handling authentication errors."""
mock_httpx_client.request.return_value.status_code = 401
with pytest.raises(AuthenticationError):
await gitea_provider._request("GET", "/user")
@pytest.mark.asyncio
async def test_permission_denied(self, gitea_provider, mock_httpx_client):
"""Test handling permission denied errors."""
mock_httpx_client.request.return_value.status_code = 403
with pytest.raises(AuthenticationError, match="Insufficient permissions"):
await gitea_provider._request("GET", "/protected")
@pytest.mark.asyncio
async def test_api_error(self, gitea_provider, mock_httpx_client):
"""Test handling general API errors."""
mock_httpx_client.request.return_value.status_code = 500
mock_httpx_client.request.return_value.text = "Internal Server Error"
mock_httpx_client.request.return_value.json = MagicMock(
return_value={"message": "Server error"}
)
with pytest.raises(APIError):
await gitea_provider._request("GET", "/error")
class TestGiteaPRParsing:
"""Tests for PR data parsing."""
def test_parse_pr_open(self, gitea_provider, sample_pr_data):
"""Test parsing open PR."""
pr_info = gitea_provider._parse_pr(sample_pr_data)
assert pr_info.number == 42
assert pr_info.state == PRState.OPEN
assert pr_info.title == "Test PR"
assert pr_info.source_branch == "feature-branch"
assert pr_info.target_branch == "main"
def test_parse_pr_merged(self, gitea_provider, sample_pr_data):
"""Test parsing merged PR."""
sample_pr_data["merged"] = True
sample_pr_data["merged_at"] = "2024-01-16T10:00:00Z"
pr_info = gitea_provider._parse_pr(sample_pr_data)
assert pr_info.state == PRState.MERGED
def test_parse_pr_closed(self, gitea_provider, sample_pr_data):
"""Test parsing closed PR."""
sample_pr_data["state"] = "closed"
sample_pr_data["closed_at"] = "2024-01-16T10:00:00Z"
pr_info = gitea_provider._parse_pr(sample_pr_data)
assert pr_info.state == PRState.CLOSED
def test_parse_datetime_iso(self, gitea_provider):
"""Test parsing ISO datetime strings."""
dt = gitea_provider._parse_datetime("2024-01-15T10:30:00Z")
assert dt.year == 2024
assert dt.month == 1
assert dt.day == 15
def test_parse_datetime_none(self, gitea_provider):
"""Test parsing None datetime returns now."""
dt = gitea_provider._parse_datetime(None)
assert dt is not None
assert dt.tzinfo is not None