forked from cardosofelipe/fast-next-template
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>
485 lines
16 KiB
Python
485 lines
16 KiB
Python
"""
|
|
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
|