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:
484
mcp-servers/git-ops/tests/test_providers.py
Normal file
484
mcp-servers/git-ops/tests/test_providers.py
Normal 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
|
||||
Reference in New Issue
Block a user