diff --git a/pyproject.toml b/pyproject.toml index 599a1cb..050a1e9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,7 @@ dependencies = [ dev = [ "pytest>=7.0.0", "pytest-asyncio>=0.21.0", + "pytest-cov>=4.0.0", ] [project.scripts] diff --git a/run_tests.sh b/run_tests.sh new file mode 100755 index 0000000..dda1083 --- /dev/null +++ b/run_tests.sh @@ -0,0 +1,21 @@ +#!/bin/bash +# Test runner script for gitea-mcp-remote + +set -e + +echo "Running gitea-mcp-remote test suite..." +echo + +# Install dev dependencies if not already installed +if ! command -v pytest &> /dev/null; then + echo "Installing dev dependencies..." + pip install -e ".[dev]" + echo +fi + +# Run tests with coverage +echo "Running tests with coverage..." +python -m pytest tests/ -v --cov=src/gitea_mcp --cov-report=term-missing --cov-report=html + +echo +echo "Coverage report saved to htmlcov/index.html" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..024709b --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,103 @@ +"""Shared pytest fixtures for Gitea MCP tests.""" + +import pytest +from unittest.mock import AsyncMock, MagicMock +from gitea_mcp.auth import AuthConfig +from gitea_mcp.client import GiteaClient + + +@pytest.fixture +def mock_config(monkeypatch): + """Create mock authentication config. + + This fixture sets up test environment variables and returns + a configured AuthConfig instance for testing. + """ + monkeypatch.setenv("GITEA_API_URL", "http://gitea.example.com/api/v1") + monkeypatch.setenv("GITEA_API_TOKEN", "test_token_123") + return AuthConfig() + + +@pytest.fixture +def mock_client(mock_config): + """Create a mock GiteaClient instance. + + Returns a GiteaClient with mocked HTTP methods that don't make + real API calls. Use this for testing tool handlers. + """ + client = GiteaClient(mock_config, timeout=10.0) + + # Mock the internal HTTP client methods + client.get = AsyncMock() + client.post = AsyncMock() + client.patch = AsyncMock() + client.delete = AsyncMock() + + # Mock context manager + client.__aenter__ = AsyncMock(return_value=client) + client.__aexit__ = AsyncMock(return_value=None) + + return client + + +@pytest.fixture +def sample_issue(): + """Sample issue data for testing. + + Returns a dict representing a typical Gitea issue response. + """ + return { + "id": 1, + "number": 42, + "title": "Test Issue", + "body": "This is a test issue", + "state": "open", + "created_at": "2024-01-15T10:00:00Z", + "updated_at": "2024-01-15T12:00:00Z", + "html_url": "http://gitea.example.com/test/repo/issues/42", + "labels": [ + {"id": 1, "name": "bug", "color": "ff0000"}, + {"id": 2, "name": "priority-high", "color": "ff9900"}, + ], + "milestone": { + "id": 10, + "title": "v1.0", + "state": "open", + }, + "assignees": [ + {"id": 100, "login": "testuser"}, + ], + } + + +@pytest.fixture +def sample_label(): + """Sample label data for testing. + + Returns a dict representing a typical Gitea label response. + """ + return { + "id": 1, + "name": "bug", + "color": "ff0000", + "description": "Something isn't working", + } + + +@pytest.fixture +def sample_milestone(): + """Sample milestone data for testing. + + Returns a dict representing a typical Gitea milestone response. + """ + return { + "id": 10, + "title": "v1.0", + "description": "First major release", + "state": "open", + "open_issues": 5, + "closed_issues": 15, + "created_at": "2024-01-01T00:00:00Z", + "updated_at": "2024-01-15T12:00:00Z", + "due_on": "2024-12-31T23:59:59Z", + } diff --git a/tests/test_issues.py b/tests/test_issues.py new file mode 100644 index 0000000..521dddc --- /dev/null +++ b/tests/test_issues.py @@ -0,0 +1,372 @@ +"""Tests for issue operations tools.""" + +import pytest +from mcp.types import Tool, TextContent +from gitea_mcp.tools.issues import ( + get_issue_tools, + handle_issue_tool, + _list_issues, + _get_issue, + _create_issue, + _update_issue, +) +from gitea_mcp.client import GiteaClientError + + +class TestIssueToolDefinitions: + """Test issue tool schema definitions.""" + + def test_get_issue_tools_returns_list(self): + """Test that get_issue_tools returns a list of Tool objects.""" + tools = get_issue_tools() + assert isinstance(tools, list) + assert len(tools) == 4 + assert all(isinstance(tool, Tool) for tool in tools) + + def test_list_issues_tool_schema(self): + """Test gitea_list_issues tool has correct schema.""" + tools = get_issue_tools() + list_tool = next(t for t in tools if t.name == "gitea_list_issues") + + assert list_tool.name == "gitea_list_issues" + assert "list issues" in list_tool.description.lower() + + schema = list_tool.inputSchema + assert schema["type"] == "object" + assert set(schema["required"]) == {"owner", "repo"} + assert "owner" in schema["properties"] + assert "repo" in schema["properties"] + assert "state" in schema["properties"] + assert "labels" in schema["properties"] + assert "milestone" in schema["properties"] + + def test_get_issue_tool_schema(self): + """Test gitea_get_issue tool has correct schema.""" + tools = get_issue_tools() + get_tool = next(t for t in tools if t.name == "gitea_get_issue") + + assert get_tool.name == "gitea_get_issue" + assert "get details" in get_tool.description.lower() + + schema = get_tool.inputSchema + assert set(schema["required"]) == {"owner", "repo", "index"} + assert "index" in schema["properties"] + + def test_create_issue_tool_schema(self): + """Test gitea_create_issue tool has correct schema.""" + tools = get_issue_tools() + create_tool = next(t for t in tools if t.name == "gitea_create_issue") + + assert create_tool.name == "gitea_create_issue" + assert "create" in create_tool.description.lower() + + schema = create_tool.inputSchema + assert set(schema["required"]) == {"owner", "repo", "title"} + assert "body" in schema["properties"] + assert "labels" in schema["properties"] + assert "assignees" in schema["properties"] + + def test_update_issue_tool_schema(self): + """Test gitea_update_issue tool has correct schema.""" + tools = get_issue_tools() + update_tool = next(t for t in tools if t.name == "gitea_update_issue") + + assert update_tool.name == "gitea_update_issue" + assert "update" in update_tool.description.lower() + + schema = update_tool.inputSchema + assert set(schema["required"]) == {"owner", "repo", "index"} + assert "title" in schema["properties"] + assert "state" in schema["properties"] + + +class TestListIssues: + """Test _list_issues function.""" + + @pytest.mark.asyncio + async def test_list_issues_success(self, mock_client, sample_issue): + """Test successful issue listing.""" + mock_client.get.return_value = [sample_issue] + + arguments = { + "owner": "testowner", + "repo": "testrepo", + "state": "open", + } + + result = await _list_issues(arguments, mock_client) + + assert isinstance(result, list) + assert len(result) == 1 + assert isinstance(result[0], TextContent) + assert "Test Issue" in result[0].text + assert "#42" in result[0].text + assert "bug" in result[0].text + + mock_client.get.assert_called_once() + call_args = mock_client.get.call_args + assert "/repos/testowner/testrepo/issues" in call_args[0][0] + + @pytest.mark.asyncio + async def test_list_issues_empty(self, mock_client): + """Test listing when no issues found.""" + mock_client.get.return_value = [] + + arguments = { + "owner": "testowner", + "repo": "testrepo", + "state": "closed", + } + + result = await _list_issues(arguments, mock_client) + + assert len(result) == 1 + assert "No closed issues found" in result[0].text + + @pytest.mark.asyncio + async def test_list_issues_with_filters(self, mock_client, sample_issue): + """Test listing with label and milestone filters.""" + mock_client.get.return_value = [sample_issue] + + arguments = { + "owner": "testowner", + "repo": "testrepo", + "state": "all", + "labels": "bug,priority-high", + "milestone": "v1.0", + "page": 2, + "limit": 50, + } + + result = await _list_issues(arguments, mock_client) + + mock_client.get.assert_called_once() + call_args = mock_client.get.call_args + params = call_args[1]["params"] + assert params["labels"] == "bug,priority-high" + assert params["milestone"] == "v1.0" + assert params["page"] == 2 + assert params["limit"] == 50 + + +class TestGetIssue: + """Test _get_issue function.""" + + @pytest.mark.asyncio + async def test_get_issue_success(self, mock_client, sample_issue): + """Test successful issue retrieval.""" + mock_client.get.return_value = sample_issue + + arguments = { + "owner": "testowner", + "repo": "testrepo", + "index": 42, + } + + result = await _get_issue(arguments, mock_client) + + assert len(result) == 1 + assert "Issue #42" in result[0].text + assert "Test Issue" in result[0].text + assert "This is a test issue" in result[0].text + assert "bug" in result[0].text + assert "v1.0" in result[0].text + assert "testuser" in result[0].text + + mock_client.get.assert_called_once() + assert "/repos/testowner/testrepo/issues/42" in mock_client.get.call_args[0][0] + + +class TestCreateIssue: + """Test _create_issue function.""" + + @pytest.mark.asyncio + async def test_create_issue_minimal(self, mock_client, sample_issue): + """Test creating issue with minimal required fields.""" + mock_client.post.return_value = sample_issue + + arguments = { + "owner": "testowner", + "repo": "testrepo", + "title": "New Issue", + } + + result = await _create_issue(arguments, mock_client) + + assert len(result) == 1 + assert "Created issue #42" in result[0].text + assert "Test Issue" in result[0].text + + mock_client.post.assert_called_once() + call_args = mock_client.post.call_args + assert "/repos/testowner/testrepo/issues" in call_args[0][0] + assert call_args[1]["json"]["title"] == "New Issue" + + @pytest.mark.asyncio + async def test_create_issue_full(self, mock_client, sample_issue): + """Test creating issue with all optional fields.""" + mock_client.post.return_value = sample_issue + + arguments = { + "owner": "testowner", + "repo": "testrepo", + "title": "New Issue", + "body": "Detailed description", + "labels": [1, 2], + "milestone": 10, + "assignees": ["user1", "user2"], + } + + result = await _create_issue(arguments, mock_client) + + assert len(result) == 1 + + call_args = mock_client.post.call_args + json_data = call_args[1]["json"] + assert json_data["title"] == "New Issue" + assert json_data["body"] == "Detailed description" + assert json_data["labels"] == [1, 2] + assert json_data["milestone"] == 10 + assert json_data["assignees"] == ["user1", "user2"] + + +class TestUpdateIssue: + """Test _update_issue function.""" + + @pytest.mark.asyncio + async def test_update_issue_title(self, mock_client, sample_issue): + """Test updating issue title.""" + updated_issue = sample_issue.copy() + updated_issue["title"] = "Updated Title" + mock_client.patch.return_value = updated_issue + + arguments = { + "owner": "testowner", + "repo": "testrepo", + "index": 42, + "title": "Updated Title", + } + + result = await _update_issue(arguments, mock_client) + + assert len(result) == 1 + assert "Updated issue #42" in result[0].text + + mock_client.patch.assert_called_once() + call_args = mock_client.patch.call_args + assert "/repos/testowner/testrepo/issues/42" in call_args[0][0] + assert call_args[1]["json"]["title"] == "Updated Title" + + @pytest.mark.asyncio + async def test_update_issue_state(self, mock_client, sample_issue): + """Test closing an issue.""" + closed_issue = sample_issue.copy() + closed_issue["state"] = "closed" + mock_client.patch.return_value = closed_issue + + arguments = { + "owner": "testowner", + "repo": "testrepo", + "index": 42, + "state": "closed", + } + + result = await _update_issue(arguments, mock_client) + + assert "State: closed" in result[0].text + + call_args = mock_client.patch.call_args + assert call_args[1]["json"]["state"] == "closed" + + +class TestIssueToolHandler: + """Test handle_issue_tool function.""" + + @pytest.mark.asyncio + async def test_handle_list_issues(self, mock_client, sample_issue): + """Test handler routes to _list_issues.""" + mock_client.get.return_value = [sample_issue] + + arguments = { + "owner": "testowner", + "repo": "testrepo", + } + + result = await handle_issue_tool("gitea_list_issues", arguments, mock_client) + + assert len(result) == 1 + assert isinstance(result[0], TextContent) + mock_client.get.assert_called_once() + + @pytest.mark.asyncio + async def test_handle_get_issue(self, mock_client, sample_issue): + """Test handler routes to _get_issue.""" + mock_client.get.return_value = sample_issue + + arguments = { + "owner": "testowner", + "repo": "testrepo", + "index": 42, + } + + result = await handle_issue_tool("gitea_get_issue", arguments, mock_client) + + assert len(result) == 1 + assert "Issue #42" in result[0].text + + @pytest.mark.asyncio + async def test_handle_create_issue(self, mock_client, sample_issue): + """Test handler routes to _create_issue.""" + mock_client.post.return_value = sample_issue + + arguments = { + "owner": "testowner", + "repo": "testrepo", + "title": "New Issue", + } + + result = await handle_issue_tool("gitea_create_issue", arguments, mock_client) + + assert len(result) == 1 + assert "Created issue" in result[0].text + + @pytest.mark.asyncio + async def test_handle_update_issue(self, mock_client, sample_issue): + """Test handler routes to _update_issue.""" + mock_client.patch.return_value = sample_issue + + arguments = { + "owner": "testowner", + "repo": "testrepo", + "index": 42, + "title": "Updated", + } + + result = await handle_issue_tool("gitea_update_issue", arguments, mock_client) + + assert len(result) == 1 + assert "Updated issue" in result[0].text + + @pytest.mark.asyncio + async def test_handle_unknown_tool(self, mock_client): + """Test handler with unknown tool name.""" + result = await handle_issue_tool("gitea_unknown_tool", {}, mock_client) + + assert len(result) == 1 + assert "Unknown issue tool" in result[0].text + + @pytest.mark.asyncio + async def test_handle_client_error(self, mock_client): + """Test handler gracefully handles GiteaClientError.""" + mock_client.get.side_effect = GiteaClientError("API Error") + + arguments = { + "owner": "testowner", + "repo": "testrepo", + } + + result = await handle_issue_tool("gitea_list_issues", arguments, mock_client) + + assert len(result) == 1 + assert "Error:" in result[0].text + assert "API Error" in result[0].text diff --git a/tests/test_labels.py b/tests/test_labels.py new file mode 100644 index 0000000..5dbbd99 --- /dev/null +++ b/tests/test_labels.py @@ -0,0 +1,256 @@ +"""Tests for label operations tools.""" + +import pytest +from mcp.types import Tool, TextContent +from gitea_mcp.tools.labels import ( + get_label_tools, + handle_label_tool, + _list_labels, + _create_label, +) +from gitea_mcp.client import GiteaClientError + + +class TestLabelToolDefinitions: + """Test label tool schema definitions.""" + + def test_get_label_tools_returns_list(self): + """Test that get_label_tools returns a list of Tool objects.""" + tools = get_label_tools() + assert isinstance(tools, list) + assert len(tools) == 2 + assert all(isinstance(tool, Tool) for tool in tools) + + def test_list_labels_tool_schema(self): + """Test gitea_list_labels tool has correct schema.""" + tools = get_label_tools() + list_tool = next(t for t in tools if t.name == "gitea_list_labels") + + assert list_tool.name == "gitea_list_labels" + assert "list" in list_tool.description.lower() + assert "label" in list_tool.description.lower() + + schema = list_tool.inputSchema + assert schema["type"] == "object" + assert set(schema["required"]) == {"owner", "repo"} + assert "owner" in schema["properties"] + assert "repo" in schema["properties"] + + def test_create_label_tool_schema(self): + """Test gitea_create_label tool has correct schema.""" + tools = get_label_tools() + create_tool = next(t for t in tools if t.name == "gitea_create_label") + + assert create_tool.name == "gitea_create_label" + assert "create" in create_tool.description.lower() + + schema = create_tool.inputSchema + assert set(schema["required"]) == {"owner", "repo", "name", "color"} + assert "name" in schema["properties"] + assert "color" in schema["properties"] + assert "description" in schema["properties"] + assert "hex" in schema["properties"]["color"]["description"].lower() + + +class TestListLabels: + """Test _list_labels function.""" + + @pytest.mark.asyncio + async def test_list_labels_success(self, mock_client, sample_label): + """Test successful label listing.""" + additional_label = { + "id": 2, + "name": "enhancement", + "color": "00ff00", + "description": "New feature or request", + } + mock_client.get.return_value = [sample_label, additional_label] + + arguments = { + "owner": "testowner", + "repo": "testrepo", + } + + result = await _list_labels(mock_client, arguments) + + assert isinstance(result, list) + assert len(result) == 1 + assert isinstance(result[0], TextContent) + assert "bug" in result[0].text + assert "enhancement" in result[0].text + assert "#ff0000" in result[0].text + assert "#00ff00" in result[0].text + assert "Something isn't working" in result[0].text + assert "New feature" in result[0].text + + mock_client.get.assert_called_once_with("/repos/testowner/testrepo/labels") + + @pytest.mark.asyncio + async def test_list_labels_empty(self, mock_client): + """Test listing when no labels found.""" + mock_client.get.return_value = [] + + arguments = { + "owner": "testowner", + "repo": "testrepo", + } + + result = await _list_labels(mock_client, arguments) + + assert len(result) == 1 + assert "No labels found" in result[0].text + assert "testowner/testrepo" in result[0].text + + @pytest.mark.asyncio + async def test_list_labels_without_description(self, mock_client): + """Test listing labels without descriptions.""" + label_no_desc = { + "id": 1, + "name": "bug", + "color": "ff0000", + } + mock_client.get.return_value = [label_no_desc] + + arguments = { + "owner": "testowner", + "repo": "testrepo", + } + + result = await _list_labels(mock_client, arguments) + + assert len(result) == 1 + assert "bug" in result[0].text + assert "#ff0000" in result[0].text + + +class TestCreateLabel: + """Test _create_label function.""" + + @pytest.mark.asyncio + async def test_create_label_minimal(self, mock_client, sample_label): + """Test creating label with minimal required fields.""" + mock_client.post.return_value = sample_label + + arguments = { + "owner": "testowner", + "repo": "testrepo", + "name": "bug", + "color": "ff0000", + } + + result = await _create_label(mock_client, arguments) + + assert len(result) == 1 + assert "Created label 'bug'" in result[0].text + assert "#ff0000" in result[0].text + assert "testowner/testrepo" in result[0].text + + mock_client.post.assert_called_once() + call_args = mock_client.post.call_args + assert call_args[0][0] == "/repos/testowner/testrepo/labels" + payload = call_args[0][1] + assert payload["name"] == "bug" + assert payload["color"] == "ff0000" + + @pytest.mark.asyncio + async def test_create_label_with_description(self, mock_client, sample_label): + """Test creating label with description.""" + mock_client.post.return_value = sample_label + + arguments = { + "owner": "testowner", + "repo": "testrepo", + "name": "bug", + "color": "ff0000", + "description": "Something isn't working", + } + + result = await _create_label(mock_client, arguments) + + assert len(result) == 1 + + call_args = mock_client.post.call_args + payload = call_args[0][1] + assert payload["description"] == "Something isn't working" + + @pytest.mark.asyncio + async def test_create_label_strips_hash(self, mock_client, sample_label): + """Test creating label with # prefix is handled correctly.""" + mock_client.post.return_value = sample_label + + arguments = { + "owner": "testowner", + "repo": "testrepo", + "name": "bug", + "color": "#ff0000", + } + + result = await _create_label(mock_client, arguments) + + call_args = mock_client.post.call_args + payload = call_args[0][1] + assert payload["color"] == "ff0000" + assert not payload["color"].startswith("#") + + +class TestLabelToolHandler: + """Test handle_label_tool function.""" + + @pytest.mark.asyncio + async def test_handle_list_labels(self, mock_client, sample_label): + """Test handler routes to _list_labels.""" + mock_client.get.return_value = [sample_label] + + arguments = { + "owner": "testowner", + "repo": "testrepo", + } + + result = await handle_label_tool(mock_client, "gitea_list_labels", arguments) + + assert len(result) == 1 + assert isinstance(result[0], TextContent) + assert "bug" in result[0].text + mock_client.get.assert_called_once() + + @pytest.mark.asyncio + async def test_handle_create_label(self, mock_client, sample_label): + """Test handler routes to _create_label.""" + mock_client.post.return_value = sample_label + + arguments = { + "owner": "testowner", + "repo": "testrepo", + "name": "bug", + "color": "ff0000", + } + + result = await handle_label_tool(mock_client, "gitea_create_label", arguments) + + assert len(result) == 1 + assert "Created label" in result[0].text + mock_client.post.assert_called_once() + + @pytest.mark.asyncio + async def test_handle_unknown_tool(self, mock_client): + """Test handler with unknown tool name.""" + result = await handle_label_tool(mock_client, "gitea_unknown_tool", {}) + + assert len(result) == 1 + assert "Unknown label tool" in result[0].text + + @pytest.mark.asyncio + async def test_handle_client_error(self, mock_client): + """Test handler gracefully handles GiteaClientError.""" + mock_client.get.side_effect = GiteaClientError("API Error") + + arguments = { + "owner": "testowner", + "repo": "testrepo", + } + + result = await handle_label_tool(mock_client, "gitea_list_labels", arguments) + + assert len(result) == 1 + assert "Gitea API error:" in result[0].text + assert "API Error" in result[0].text diff --git a/tests/test_milestones.py b/tests/test_milestones.py new file mode 100644 index 0000000..ba2ea40 --- /dev/null +++ b/tests/test_milestones.py @@ -0,0 +1,334 @@ +"""Tests for milestone operations tools.""" + +import pytest +from mcp.types import Tool, TextContent +from gitea_mcp.tools.milestones import ( + get_milestone_tools, + handle_milestone_tool, + _list_milestones, + _create_milestone, +) +from gitea_mcp.client import GiteaClientError + + +class TestMilestoneToolDefinitions: + """Test milestone tool schema definitions.""" + + def test_get_milestone_tools_returns_list(self): + """Test that get_milestone_tools returns a list of Tool objects.""" + tools = get_milestone_tools() + assert isinstance(tools, list) + assert len(tools) == 2 + assert all(isinstance(tool, Tool) for tool in tools) + + def test_list_milestones_tool_schema(self): + """Test gitea_list_milestones tool has correct schema.""" + tools = get_milestone_tools() + list_tool = next(t for t in tools if t.name == "gitea_list_milestones") + + assert list_tool.name == "gitea_list_milestones" + assert "list" in list_tool.description.lower() + assert "milestone" in list_tool.description.lower() + + schema = list_tool.inputSchema + assert schema["type"] == "object" + assert set(schema["required"]) == {"owner", "repo"} + assert "owner" in schema["properties"] + assert "repo" in schema["properties"] + assert "state" in schema["properties"] + assert schema["properties"]["state"]["enum"] == ["open", "closed", "all"] + + def test_create_milestone_tool_schema(self): + """Test gitea_create_milestone tool has correct schema.""" + tools = get_milestone_tools() + create_tool = next(t for t in tools if t.name == "gitea_create_milestone") + + assert create_tool.name == "gitea_create_milestone" + assert "create" in create_tool.description.lower() + + schema = create_tool.inputSchema + assert set(schema["required"]) == {"owner", "repo", "title"} + assert "title" in schema["properties"] + assert "description" in schema["properties"] + assert "due_on" in schema["properties"] + assert "iso 8601" in schema["properties"]["due_on"]["description"].lower() + + +class TestListMilestones: + """Test _list_milestones function.""" + + @pytest.mark.asyncio + async def test_list_milestones_success(self, mock_client, sample_milestone): + """Test successful milestone listing.""" + additional_milestone = { + "id": 11, + "title": "v2.0", + "description": "Second major release", + "state": "open", + "open_issues": 10, + "closed_issues": 0, + "created_at": "2024-02-01T00:00:00Z", + "updated_at": "2024-02-01T00:00:00Z", + } + mock_client.get.return_value = [sample_milestone, additional_milestone] + + arguments = { + "owner": "testowner", + "repo": "testrepo", + "state": "open", + } + + result = await _list_milestones(arguments, mock_client) + + assert isinstance(result, list) + assert len(result) == 1 + assert isinstance(result[0], TextContent) + assert "v1.0" in result[0].text + assert "v2.0" in result[0].text + assert "First major release" in result[0].text + assert "Open Issues: 5" in result[0].text + assert "Closed Issues: 15" in result[0].text + + mock_client.get.assert_called_once() + call_args = mock_client.get.call_args + assert "/repos/testowner/testrepo/milestones" in call_args[0][0] + assert call_args[1]["params"]["state"] == "open" + + @pytest.mark.asyncio + async def test_list_milestones_empty(self, mock_client): + """Test listing when no milestones found.""" + mock_client.get.return_value = [] + + arguments = { + "owner": "testowner", + "repo": "testrepo", + "state": "closed", + } + + result = await _list_milestones(arguments, mock_client) + + assert len(result) == 1 + assert "No closed milestones found" in result[0].text + assert "testowner/testrepo" in result[0].text + + @pytest.mark.asyncio + async def test_list_milestones_default_state(self, mock_client, sample_milestone): + """Test listing milestones with default state.""" + mock_client.get.return_value = [sample_milestone] + + arguments = { + "owner": "testowner", + "repo": "testrepo", + } + + result = await _list_milestones(arguments, mock_client) + + call_args = mock_client.get.call_args + assert call_args[1]["params"]["state"] == "open" + + @pytest.mark.asyncio + async def test_list_milestones_all_states(self, mock_client, sample_milestone): + """Test listing milestones with state=all.""" + mock_client.get.return_value = [sample_milestone] + + arguments = { + "owner": "testowner", + "repo": "testrepo", + "state": "all", + } + + result = await _list_milestones(arguments, mock_client) + + assert "Found 1 all milestone(s)" in result[0].text + + @pytest.mark.asyncio + async def test_list_milestones_with_due_date(self, mock_client, sample_milestone): + """Test milestone listing includes due date.""" + mock_client.get.return_value = [sample_milestone] + + arguments = { + "owner": "testowner", + "repo": "testrepo", + } + + result = await _list_milestones(arguments, mock_client) + + assert "Due: 2024-12-31T23:59:59Z" in result[0].text + + @pytest.mark.asyncio + async def test_list_milestones_without_optional_fields(self, mock_client): + """Test milestone listing without description and due date.""" + minimal_milestone = { + "id": 10, + "title": "v1.0", + "state": "open", + "open_issues": 5, + "closed_issues": 15, + "created_at": "2024-01-01T00:00:00Z", + } + mock_client.get.return_value = [minimal_milestone] + + arguments = { + "owner": "testowner", + "repo": "testrepo", + } + + result = await _list_milestones(arguments, mock_client) + + assert "v1.0" in result[0].text + assert "State: open" in result[0].text + assert "Created: 2024-01-01T00:00:00Z" in result[0].text + + +class TestCreateMilestone: + """Test _create_milestone function.""" + + @pytest.mark.asyncio + async def test_create_milestone_minimal(self, mock_client, sample_milestone): + """Test creating milestone with minimal required fields.""" + mock_client.post.return_value = sample_milestone + + arguments = { + "owner": "testowner", + "repo": "testrepo", + "title": "v1.0", + } + + result = await _create_milestone(arguments, mock_client) + + assert len(result) == 1 + assert "Created milestone: v1.0" in result[0].text + + mock_client.post.assert_called_once() + call_args = mock_client.post.call_args + assert "/repos/testowner/testrepo/milestones" in call_args[0][0] + json_data = call_args[1]["json"] + assert json_data["title"] == "v1.0" + + @pytest.mark.asyncio + async def test_create_milestone_with_description(self, mock_client, sample_milestone): + """Test creating milestone with description.""" + mock_client.post.return_value = sample_milestone + + arguments = { + "owner": "testowner", + "repo": "testrepo", + "title": "v1.0", + "description": "First major release", + } + + result = await _create_milestone(arguments, mock_client) + + assert "Description: First major release" in result[0].text + + call_args = mock_client.post.call_args + json_data = call_args[1]["json"] + assert json_data["description"] == "First major release" + + @pytest.mark.asyncio + async def test_create_milestone_with_due_date(self, mock_client, sample_milestone): + """Test creating milestone with due date.""" + mock_client.post.return_value = sample_milestone + + arguments = { + "owner": "testowner", + "repo": "testrepo", + "title": "v1.0", + "due_on": "2024-12-31T23:59:59Z", + } + + result = await _create_milestone(arguments, mock_client) + + assert "Due: 2024-12-31T23:59:59Z" in result[0].text + + call_args = mock_client.post.call_args + json_data = call_args[1]["json"] + assert json_data["due_on"] == "2024-12-31T23:59:59Z" + + @pytest.mark.asyncio + async def test_create_milestone_full(self, mock_client, sample_milestone): + """Test creating milestone with all optional fields.""" + mock_client.post.return_value = sample_milestone + + arguments = { + "owner": "testowner", + "repo": "testrepo", + "title": "v1.0", + "description": "First major release", + "due_on": "2024-12-31T23:59:59Z", + } + + result = await _create_milestone(arguments, mock_client) + + assert len(result) == 1 + assert "Created milestone: v1.0" in result[0].text + assert "Description: First major release" in result[0].text + assert "Due: 2024-12-31T23:59:59Z" in result[0].text + + call_args = mock_client.post.call_args + json_data = call_args[1]["json"] + assert json_data["title"] == "v1.0" + assert json_data["description"] == "First major release" + assert json_data["due_on"] == "2024-12-31T23:59:59Z" + + +class TestMilestoneToolHandler: + """Test handle_milestone_tool function.""" + + @pytest.mark.asyncio + async def test_handle_list_milestones(self, mock_client, sample_milestone): + """Test handler routes to _list_milestones.""" + mock_client.get.return_value = [sample_milestone] + + arguments = { + "owner": "testowner", + "repo": "testrepo", + } + + result = await handle_milestone_tool("gitea_list_milestones", arguments, mock_client) + + assert len(result) == 1 + assert isinstance(result[0], TextContent) + assert "v1.0" in result[0].text + mock_client.get.assert_called_once() + + @pytest.mark.asyncio + async def test_handle_create_milestone(self, mock_client, sample_milestone): + """Test handler routes to _create_milestone.""" + mock_client.post.return_value = sample_milestone + + arguments = { + "owner": "testowner", + "repo": "testrepo", + "title": "v1.0", + } + + result = await handle_milestone_tool("gitea_create_milestone", arguments, mock_client) + + assert len(result) == 1 + assert "Created milestone" in result[0].text + mock_client.post.assert_called_once() + + @pytest.mark.asyncio + async def test_handle_unknown_tool(self, mock_client): + """Test handler with unknown tool name.""" + result = await handle_milestone_tool("gitea_unknown_tool", {}, mock_client) + + assert len(result) == 1 + assert "Unknown milestone tool" in result[0].text + + @pytest.mark.asyncio + async def test_handle_client_error(self, mock_client): + """Test handler gracefully handles GiteaClientError.""" + mock_client.get.side_effect = GiteaClientError("API Error") + + arguments = { + "owner": "testowner", + "repo": "testrepo", + } + + result = await handle_milestone_tool("gitea_list_milestones", arguments, mock_client) + + assert len(result) == 1 + assert "Error:" in result[0].text + assert "API Error" in result[0].text