generated from personal-projects/leo-claude-mktplace
Compare commits
2 Commits
feat/3-iss
...
feat/7-tes
| Author | SHA1 | Date | |
|---|---|---|---|
| 2230bceb51 | |||
| ab8c9069da |
368
README.md
368
README.md
@@ -1,27 +1,35 @@
|
|||||||
# Gitea MCP Remote
|
# Gitea MCP Server
|
||||||
|
|
||||||
MCP server for Gitea API integration.
|
A Model Context Protocol (MCP) server that enables AI assistants like Claude to interact with Gitea repositories through its API. This server provides tools for managing issues, labels, and milestones in your Gitea instance.
|
||||||
|
|
||||||
## Overview
|
## Features
|
||||||
|
|
||||||
This project provides a Model Context Protocol (MCP) server that enables AI assistants to interact with Gitea through its API.
|
- **Issue Operations**: List, get, create, and update issues with full support for labels, milestones, and assignees
|
||||||
|
- **Label Management**: List and create labels with custom colors and descriptions
|
||||||
## Project Status
|
- **Milestone Management**: List and create milestones with due dates and descriptions
|
||||||
|
- **Async API**: Built on modern async Python for efficient operations
|
||||||
Currently in initial development. Project structure has been initialized.
|
- **Type Safety**: Full type hints for better IDE support and code quality
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- Python >= 3.10
|
- Python >= 3.10
|
||||||
- Gitea instance with API access
|
- Gitea instance with API access
|
||||||
|
- Gitea API token with appropriate permissions
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
|
### From Source
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# Clone the repository
|
||||||
|
git clone https://github.com/lmiranda/gitea-mcp-remote.git
|
||||||
|
cd gitea-mcp-remote
|
||||||
|
|
||||||
|
# Install the package
|
||||||
pip install -e .
|
pip install -e .
|
||||||
```
|
```
|
||||||
|
|
||||||
## Development
|
### For Development
|
||||||
|
|
||||||
Install with development dependencies:
|
Install with development dependencies:
|
||||||
|
|
||||||
@@ -29,12 +37,350 @@ Install with development dependencies:
|
|||||||
pip install -e ".[dev]"
|
pip install -e ".[dev]"
|
||||||
```
|
```
|
||||||
|
|
||||||
Run tests:
|
## Configuration
|
||||||
|
|
||||||
|
The server requires two environment variables to connect to your Gitea instance:
|
||||||
|
|
||||||
|
- `GITEA_API_URL`: Base URL of your Gitea instance (e.g., `https://gitea.example.com/api/v1`)
|
||||||
|
- `GITEA_API_TOKEN`: Personal access token for authentication
|
||||||
|
|
||||||
|
### Creating a .env File
|
||||||
|
|
||||||
|
Create a `.env` file in your project directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GITEA_API_URL=https://gitea.example.com/api/v1
|
||||||
|
GITEA_API_TOKEN=your_gitea_token_here
|
||||||
|
```
|
||||||
|
|
||||||
|
### Getting a Gitea API Token
|
||||||
|
|
||||||
|
1. Log into your Gitea instance
|
||||||
|
2. Navigate to Settings > Applications
|
||||||
|
3. Under "Generate New Token", enter a name (e.g., "MCP Server")
|
||||||
|
4. Select appropriate permissions (minimum: read/write for repositories)
|
||||||
|
5. Click "Generate Token" and copy the token
|
||||||
|
6. Add the token to your `.env` file
|
||||||
|
|
||||||
|
## Usage with Claude Desktop
|
||||||
|
|
||||||
|
Add this configuration to your Claude Desktop config file:
|
||||||
|
|
||||||
|
**Location:**
|
||||||
|
- macOS: `~/Library/Application Support/Claude/claude_desktop_config.json`
|
||||||
|
- Windows: `%APPDATA%\Claude\claude_desktop_config.json`
|
||||||
|
- Linux: `~/.config/Claude/claude_desktop_config.json`
|
||||||
|
|
||||||
|
**Configuration:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"gitea": {
|
||||||
|
"command": "python",
|
||||||
|
"args": ["-m", "gitea_mcp.server"],
|
||||||
|
"env": {
|
||||||
|
"GITEA_API_URL": "https://gitea.example.com/api/v1",
|
||||||
|
"GITEA_API_TOKEN": "your_gitea_token_here"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Or, if you prefer using a .env file:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"gitea": {
|
||||||
|
"command": "python",
|
||||||
|
"args": ["-m", "gitea_mcp.server"],
|
||||||
|
"cwd": "/path/to/gitea-mcp-remote"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage with Claude Code
|
||||||
|
|
||||||
|
Add to your MCP settings file:
|
||||||
|
|
||||||
|
**Location:** `~/.config/claude-code/mcp.json` (or your configured MCP settings path)
|
||||||
|
|
||||||
|
**Configuration:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"gitea": {
|
||||||
|
"command": "python",
|
||||||
|
"args": ["-m", "gitea_mcp.server"],
|
||||||
|
"env": {
|
||||||
|
"GITEA_API_URL": "https://gitea.example.com/api/v1",
|
||||||
|
"GITEA_API_TOKEN": "your_gitea_token_here"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Available Tools
|
||||||
|
|
||||||
|
### Issue Tools
|
||||||
|
|
||||||
|
#### gitea_list_issues
|
||||||
|
|
||||||
|
List issues in a repository with optional filters.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- `owner` (required): Repository owner (username or organization)
|
||||||
|
- `repo` (required): Repository name
|
||||||
|
- `state` (optional): Filter by state - `open`, `closed`, or `all` (default: `open`)
|
||||||
|
- `labels` (optional): Comma-separated list of label names to filter by
|
||||||
|
- `milestone` (optional): Milestone name to filter by
|
||||||
|
- `page` (optional): Page number for pagination (default: 1)
|
||||||
|
- `limit` (optional): Number of issues per page (default: 30)
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```
|
||||||
|
List all open issues in myorg/myrepo
|
||||||
|
```
|
||||||
|
|
||||||
|
#### gitea_get_issue
|
||||||
|
|
||||||
|
Get details of a specific issue by number.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- `owner` (required): Repository owner
|
||||||
|
- `repo` (required): Repository name
|
||||||
|
- `index` (required): Issue number
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```
|
||||||
|
Get details of issue #42 in myorg/myrepo
|
||||||
|
```
|
||||||
|
|
||||||
|
#### gitea_create_issue
|
||||||
|
|
||||||
|
Create a new issue in a repository.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- `owner` (required): Repository owner
|
||||||
|
- `repo` (required): Repository name
|
||||||
|
- `title` (required): Issue title
|
||||||
|
- `body` (optional): Issue description/body
|
||||||
|
- `labels` (optional): Array of label IDs to assign
|
||||||
|
- `milestone` (optional): Milestone ID to assign
|
||||||
|
- `assignees` (optional): Array of usernames to assign
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```
|
||||||
|
Create an issue in myorg/myrepo with title "Bug: Login fails" and body "Users cannot log in with special characters in password"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### gitea_update_issue
|
||||||
|
|
||||||
|
Update an existing issue.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- `owner` (required): Repository owner
|
||||||
|
- `repo` (required): Repository name
|
||||||
|
- `index` (required): Issue number
|
||||||
|
- `title` (optional): New issue title
|
||||||
|
- `body` (optional): New issue body
|
||||||
|
- `state` (optional): Issue state - `open` or `closed`
|
||||||
|
- `labels` (optional): Array of label IDs (replaces existing)
|
||||||
|
- `milestone` (optional): Milestone ID to assign
|
||||||
|
- `assignees` (optional): Array of usernames (replaces existing)
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```
|
||||||
|
Close issue #42 in myorg/myrepo
|
||||||
|
```
|
||||||
|
|
||||||
|
### Label Tools
|
||||||
|
|
||||||
|
#### gitea_list_labels
|
||||||
|
|
||||||
|
List all labels in a repository.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- `owner` (required): Repository owner
|
||||||
|
- `repo` (required): Repository name
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```
|
||||||
|
List all labels in myorg/myrepo
|
||||||
|
```
|
||||||
|
|
||||||
|
#### gitea_create_label
|
||||||
|
|
||||||
|
Create a new label in a repository.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- `owner` (required): Repository owner
|
||||||
|
- `repo` (required): Repository name
|
||||||
|
- `name` (required): Label name
|
||||||
|
- `color` (required): Label color (hex without #, e.g., `ff0000` for red)
|
||||||
|
- `description` (optional): Label description
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```
|
||||||
|
Create a label "bug" with red color (ff0000) in myorg/myrepo
|
||||||
|
```
|
||||||
|
|
||||||
|
### Milestone Tools
|
||||||
|
|
||||||
|
#### gitea_list_milestones
|
||||||
|
|
||||||
|
List milestones in a repository.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- `owner` (required): Repository owner
|
||||||
|
- `repo` (required): Repository name
|
||||||
|
- `state` (optional): Filter by state - `open`, `closed`, or `all` (default: `open`)
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```
|
||||||
|
List all milestones in myorg/myrepo
|
||||||
|
```
|
||||||
|
|
||||||
|
#### gitea_create_milestone
|
||||||
|
|
||||||
|
Create a new milestone in a repository.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- `owner` (required): Repository owner
|
||||||
|
- `repo` (required): Repository name
|
||||||
|
- `title` (required): Milestone title
|
||||||
|
- `description` (optional): Milestone description
|
||||||
|
- `due_on` (optional): Due date in ISO 8601 format (e.g., `2024-12-31T23:59:59Z`)
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```
|
||||||
|
Create a milestone "v1.0 Release" with due date 2024-12-31 in myorg/myrepo
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
### Core Components
|
||||||
|
|
||||||
|
#### GiteaClient
|
||||||
|
|
||||||
|
HTTP client for Gitea API interactions.
|
||||||
|
|
||||||
|
**Methods:**
|
||||||
|
- `get(endpoint, params)`: GET request
|
||||||
|
- `post(endpoint, json)`: POST request
|
||||||
|
- `patch(endpoint, json)`: PATCH request
|
||||||
|
|
||||||
|
#### AuthConfig
|
||||||
|
|
||||||
|
Configuration manager for API authentication.
|
||||||
|
|
||||||
|
**Environment Variables:**
|
||||||
|
- `GITEA_API_URL`: Gitea API base URL
|
||||||
|
- `GITEA_API_TOKEN`: Authentication token
|
||||||
|
|
||||||
|
**Methods:**
|
||||||
|
- `get_auth_headers()`: Returns authentication headers
|
||||||
|
|
||||||
|
### Tool Modules
|
||||||
|
|
||||||
|
- `gitea_mcp.tools.issues`: Issue operation tools and handlers
|
||||||
|
- `gitea_mcp.tools.labels`: Label operation tools and handlers
|
||||||
|
- `gitea_mcp.tools.milestones`: Milestone operation tools and handlers
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Setup Development Environment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install with development dependencies
|
||||||
|
pip install -e ".[dev]"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running Tests
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pytest
|
pytest
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
gitea-mcp-remote/
|
||||||
|
├── src/
|
||||||
|
│ └── gitea_mcp/
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── server.py # MCP server implementation
|
||||||
|
│ ├── auth.py # Authentication config
|
||||||
|
│ ├── client.py # Gitea API client
|
||||||
|
│ └── tools/ # Tool implementations
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── issues.py # Issue tools
|
||||||
|
│ ├── labels.py # Label tools
|
||||||
|
│ └── milestones.py # Milestone tools
|
||||||
|
├── tests/ # Test suite
|
||||||
|
├── pyproject.toml # Project configuration
|
||||||
|
└── README.md # This file
|
||||||
|
```
|
||||||
|
|
||||||
|
### Code Quality
|
||||||
|
|
||||||
|
This project uses:
|
||||||
|
- Type hints throughout the codebase
|
||||||
|
- Async/await for all I/O operations
|
||||||
|
- Comprehensive error handling
|
||||||
|
- Structured logging
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Authentication Errors
|
||||||
|
|
||||||
|
If you receive authentication errors:
|
||||||
|
|
||||||
|
1. Verify your `GITEA_API_TOKEN` is correct
|
||||||
|
2. Check that the token has appropriate permissions
|
||||||
|
3. Ensure your `GITEA_API_URL` includes `/api/v1` at the end
|
||||||
|
4. Verify the Gitea instance is accessible from your network
|
||||||
|
|
||||||
|
### Connection Errors
|
||||||
|
|
||||||
|
If you cannot connect to Gitea:
|
||||||
|
|
||||||
|
1. Check that `GITEA_API_URL` is correct and accessible
|
||||||
|
2. Verify network connectivity to the Gitea instance
|
||||||
|
3. Check for firewalls or proxies blocking the connection
|
||||||
|
|
||||||
|
### Tool Not Found
|
||||||
|
|
||||||
|
If Claude cannot find the tools:
|
||||||
|
|
||||||
|
1. Restart Claude Desktop/Code after updating the configuration
|
||||||
|
2. Verify the configuration file syntax is valid JSON
|
||||||
|
3. Check that Python is in your PATH
|
||||||
|
4. Ensure the package is properly installed (`pip list | grep gitea-mcp`)
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
Contributions are welcome! Please feel free to submit issues or pull requests.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT
|
MIT License - see LICENSE file for details
|
||||||
|
|
||||||
|
## Version
|
||||||
|
|
||||||
|
Current version: 0.1.0
|
||||||
|
|
||||||
|
## Author
|
||||||
|
|
||||||
|
Leo Miranda
|
||||||
|
|
||||||
|
## Links
|
||||||
|
|
||||||
|
- Repository: https://github.com/lmiranda/gitea-mcp-remote
|
||||||
|
- Issues: https://github.com/lmiranda/gitea-mcp-remote/issues
|
||||||
|
- MCP Documentation: https://modelcontextprotocol.io
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ dependencies = [
|
|||||||
dev = [
|
dev = [
|
||||||
"pytest>=7.0.0",
|
"pytest>=7.0.0",
|
||||||
"pytest-asyncio>=0.21.0",
|
"pytest-asyncio>=0.21.0",
|
||||||
|
"pytest-cov>=4.0.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
|
|||||||
21
run_tests.sh
Executable file
21
run_tests.sh
Executable file
@@ -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"
|
||||||
103
tests/conftest.py
Normal file
103
tests/conftest.py
Normal file
@@ -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",
|
||||||
|
}
|
||||||
372
tests/test_issues.py
Normal file
372
tests/test_issues.py
Normal file
@@ -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
|
||||||
256
tests/test_labels.py
Normal file
256
tests/test_labels.py
Normal file
@@ -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
|
||||||
334
tests/test_milestones.py
Normal file
334
tests/test_milestones.py
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user