Merge development to main (v1.0.0 release) #1

Merged
lmiranda merged 1 commits from development into main 2026-02-08 21:56:24 +00:00
7 changed files with 1183 additions and 0 deletions

53
CHANGELOG.md Normal file
View File

@@ -0,0 +1,53 @@
# Changelog
All notable changes to gitea-mcp will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.0.0] - 2026-02-08
### Added
- Initial release as standalone package
- 39 MCP tools across 7 categories:
- Issue management (6 tools): list_issues, get_issue, create_issue, update_issue, add_comment, aggregate_issues
- Label management (5 tools): list_labels, get_labels, suggest_labels, create_label, create_label_smart
- Wiki & lessons learned (7 tools): list_wiki_pages, get_wiki_page, create_wiki_page, update_wiki_page, create_lesson, search_lessons, allocate_rfc_number
- Milestone management (5 tools): list_milestones, get_milestone, create_milestone, update_milestone, delete_milestone
- Issue dependencies (4 tools): list_issue_dependencies, create_issue_dependency, remove_issue_dependency, get_execution_order
- Pull requests (7 tools): list_pull_requests, get_pull_request, get_pr_diff, get_pr_comments, create_pr_review, add_pr_comment, create_pull_request
- **PR merge operations (3 tools) — NEW**: merge_pull_request, get_pr_merge_status, cancel_auto_merge
- **Merge strategies** (5 supported):
- `merge` — Create merge commit (default)
- `rebase` — Rebase commits onto base, no merge commit
- `rebase-merge` — Rebase then create merge commit
- `squash` — Squash all commits into one
- `fast-forward-only` — Only if fast-forward is possible
- Dual-mode operation:
- Project mode: Works with single configured repository
- PMO/Company mode: Manages multiple repositories with org-level aggregation
- Advanced features:
- Branch-aware security with permission enforcement
- Type coercion for MCP string serialization
- Auto-detection of project vs PMO mode
- Transport-agnostic architecture via tool_registry + dispatcher pattern
- Comprehensive error handling and logging
- Development:
- Full test suite with 30+ test cases
- Comprehensive documentation (README, CHANGELOG, CLAUDE.md)
- Gitea PyPI registry distribution
- CI/CD ready with pytest and coverage reporting
### Notes
- Extracted from leo-claude-mktplace v9.1.0 (mcp-servers/gitea/ v1.3.0)
- Module renamed: `mcp_server``gitea_mcp`
- All existing functionality preserved and tested
- Ready for use in Claude Code and other MCP-compatible systems
[1.0.0]: https://gitea.hotserv.cloud/personal-projects/gitea-mcp/releases/tag/v1.0.0

341
CLAUDE.md Normal file
View File

@@ -0,0 +1,341 @@
# CLAUDE.md - gitea-mcp
## Project Overview
**Name:** gitea-mcp
**Type:** Python package — MCP server for Gitea integration
**Version:** 1.0.0
**Repository:** https://gitea.hotserv.cloud/personal-projects/gitea-mcp
**Distribution:** Gitea PyPI at gitea.hotserv.cloud
## Project Description
Standalone Python package providing 39 MCP (Model Context Protocol) tools for comprehensive Gitea operations. Designed for use with Claude Code and other MCP-compatible systems.
**Core Features:**
- 39 tools across 7 functional categories
- Dual-mode operation (project and PMO/company modes)
- Branch-aware security enforcement
- Transport-agnostic architecture for multiple deployment options
- Full type coercion and error handling
## Architecture
### Directory Structure
```
gitea_mcp/
├── __init__.py # Public API: __version__, get_tool_definitions, create_tool_dispatcher
├── server.py # MCP stdio server entry point
├── config.py # Configuration loader (env files, auto-detection)
├── gitea_client.py # Synchronous Gitea REST API client
├── tool_registry.py # Tool definitions + dispatcher (transport-agnostic)
└── tools/
├── __init__.py
├── issues.py # Issue CRUD + aggregation (6 tools)
├── labels.py # Label management + suggestions (5 tools)
├── wiki.py # Wiki pages + lessons learned + RFC (7 tools)
├── milestones.py # Milestone CRUD (5 tools)
├── dependencies.py # Issue dependency tracking (4 tools)
└── pull_requests.py # PR operations + merge (10 tools)
tests/
├── __init__.py
├── test_config.py
├── test_gitea_client.py
├── test_issues.py
├── test_labels.py
├── test_pull_requests.py # NEW: Merge tool tests
docs/ (future)
```
### Tool Categories
| Category | Count | Tools |
|----------|-------|-------|
| Issue Management | 6 | list_issues, get_issue, create_issue, update_issue, add_comment, aggregate_issues |
| Label Management | 5 | list_labels, get_labels, suggest_labels, create_label, create_label_smart |
| Wiki & Lessons | 7 | list_wiki_pages, get_wiki_page, create_wiki_page, update_wiki_page, create_lesson, search_lessons, allocate_rfc_number |
| Milestones | 5 | list_milestones, get_milestone, create_milestone, update_milestone, delete_milestone |
| Dependencies | 4 | list_issue_dependencies, create_issue_dependency, remove_issue_dependency, get_execution_order |
| Pull Requests | 7 | list_pull_requests, get_pull_request, get_pr_diff, get_pr_comments, create_pr_review, add_pr_comment, create_pull_request |
| **PR Merge (NEW)** | **3** | **merge_pull_request, get_pr_merge_status, cancel_auto_merge** |
| **Total** | **39** | — |
## Key Design Patterns
### Transport-Agnostic Architecture
- Tool definitions and dispatcher logic live in `tool_registry.py`, not `server.py`
- Can be used by multiple transports: stdio (Claude Code), HTTP (gitea-mcp-remote), direct import
- Single source of truth for tool schemas and dispatch logic
### Type Coercion
MCP serialization sometimes converts integers to strings and arrays to JSON strings. The `_coerce_types()` function in `tool_registry.py` handles:
- **Integer fields**: `pr_number`, `milestone_id`, `issue_number`, etc. (string → int)
- **Array fields**: `labels`, `tags`, `issue_numbers` (JSON string → list)
- **Boolean fields**: `force_merge`, `delete_branch` (string → bool)
### Async/Executor Pattern
All async wrappers in `tools/*.py` use:
```python
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, lambda: self.gitea.sync_method(...))
```
This allows synchronous REST calls (`requests`) to be awaited in async contexts without blocking.
### Branch-Aware Security
Checked in async wrappers in `tools/pull_requests.py`:
```python
if not self._check_branch_permissions('merge_pull_request'):
raise PermissionError(f"Cannot merge on branch '{branch}'...")
```
**Rules:**
- Read operations: All branches allowed
- main/master/prod/*: Read-only
- staging/stage/*: Comments only
- dev/feature/fix/*: Full access
- Unknown: Read-only (fail-safe)
### Configuration Auto-Detection
In `config.py`:
- Reads from env vars: `GITEA_API_URL`, `GITEA_API_TOKEN`, `GITEA_REPO`, `GITEA_MODE`
- Detects mode: Project if `GITEA_REPO` set, PMO if not set (org user)
- Supports `.env` files for local development
- Fallback defaults for local Gitea instances
## Development Commands
### Setup
```bash
# Create virtual environment
python3 -m venv .venv
source .venv/bin/activate # Linux/Mac
# or: .venv\Scripts\activate (Windows)
# Install in development mode
pip install -e ".[dev]"
```
### Testing
```bash
# Run all tests
pytest tests/ -v
# Run specific test file
pytest tests/test_pull_requests.py -v
# Quick test run
pytest tests/ -q
# With coverage
pytest tests/ --cov=gitea_mcp --cov-report=html
```
### Building
```bash
# Install build tools
pip install build
# Build distribution
python -m build
# Output: dist/gitea-mcp-1.0.0.tar.gz and dist/gitea_mcp-1.0.0-py3-none-any.whl
```
### Publishing
```bash
# Install Twine
pip install twine
# Upload to Gitea PyPI
twine upload \
--repository-url https://gitea.hotserv.cloud/api/packages/personal-projects/pypi \
--username your-gitea-username \
--password your-gitea-token \
dist/*
```
## Common Tasks
### Adding a New Tool
1. **Add method to client** (`gitea_client.py`):
```python
def new_tool(self, ...) -> Dict:
"""Synchronous REST call."""
```
2. **Add async wrapper** (`tools/relevant_file.py`):
```python
async def new_tool(self, ...) -> Dict:
"""Async wrapper with branch checks if write operation."""
if not self._check_branch_permissions('new_tool'):
raise PermissionError(...)
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, lambda: self.gitea.new_tool(...))
```
3. **Register tool** (`tool_registry.py`):
- Add `Tool()` to `_get_all_tool_definitions()`
- Add dispatch case in `create_tool_dispatcher()`
- Update `_coerce_types()` if new types added
4. **Write tests** (`tests/test_something.py`):
- Mock the client
- Test the method directly
- Test type coercion
- Test error cases
5. **Update docs**:
- Add to README.md tools table
- Update CHANGELOG.md
- Update CLAUDE.md if architecture changes
### Running Locally
```bash
# Set environment variables
export GITEA_API_TOKEN="your-token"
export GITEA_REPO="owner/repo"
# Test CLI
python -m gitea_mcp.server < /dev/null
# Or use in a script
python3 << 'EOF'
from gitea_mcp.gitea_client import GiteaClient
from gitea_mcp.tool_registry import get_tool_definitions
client = GiteaClient()
tools = get_tool_definitions()
print(f"Available tools: {len(tools)}")
for tool in tools[:5]:
print(f" - {tool.name}")
EOF
```
### Testing Type Coercion
```python
from gitea_mcp.tool_registry import _coerce_types
result = _coerce_types({
'pr_number': '42',
'force_merge': 'true',
'delete_branch': 'false'
})
assert result['pr_number'] == 42
assert result['force_merge'] is True
assert result['delete_branch'] is False
```
## Git Workflow
### Branches
- **main**: Stable releases only
- **development**: Feature integration (default)
- **feat/\***: Feature branches from development
- **fix/\***: Bug fix branches from development
### Commit Message Format
```
type(scope): short description
Longer explanation if needed.
Fixes: #123 (if applicable)
```
Types: `feat`, `fix`, `test`, `docs`, `refactor`, `perf`, `chore`
Example:
```
feat(merge): add merge_pull_request tool
- Support 5 merge strategies: merge, rebase, squash, etc.
- Add get_pr_merge_status for mergeable check
- Add cancel_auto_merge for scheduled merges
- Include type coercion for force_merge and delete_branch
- Add comprehensive tests
Closes: #10
```
## Dependencies
### Core
- `requests>=2.31.0` — HTTP client for Gitea API
- `mcp>=0.10.0` — Model Context Protocol
### Development
- `pytest>=7.0.0` — Testing framework
- `pytest-asyncio` — Async test support
- `build` — Package building
- `twine` — PyPI publishing
- `black` — Code formatting
- `flake8` — Linting
- `mypy` — Type checking (optional)
## Performance Considerations
- All API calls use `requests` Session with connection pooling
- Async wrappers use executor pattern (non-blocking)
- PMO mode caches org membership to reduce API calls
- Lesson searches use wiki page content for tags (linear scan, acceptable for <100 pages)
## Security
- **Authentication**: Token-based (GitHub-compatible Gitea API)
- **Branch checks**: Enforced in async wrappers
- **Input validation**: `_parse_repo()` validates owner/repo format
- **Error handling**: Comprehensive logging, HTTP errors propagated with context
## Known Limitations
1. **Wiki operations**: Linear scan of pages for lesson search (fine for <100 pages)
2. **Merge status**: Returns mergeable flag if available, not computed in real-time
3. **PMO mode**: Requires user to have org-level access (not repo-specific tokens)
4. **Execution order**: `get_execution_order()` uses simple topological sort (no cycle detection)
## Future Enhancements
- [ ] Webhook support for event-driven workflows
- [ ] Caching layer for frequently accessed data
- [ ] GraphQL support (if Gitea adds it)
- [ ] Batch operations for bulk issue/PR updates
- [ ] Custom field support for Gitea enterprise
## Related Projects
- **leo-claude-mktplace**: Marketplace of Claude integrations (includes gitea-mcp)
- **gitea-mcp-remote**: HTTP transport wrapper (uses gitea-mcp as library)
- **Gitea**: https://gitea.io
## Origin
Extracted from [leo-claude-mktplace](https://gitea.hotserv.cloud/personal-projects/leo-claude-mktplace) v9.1.0
Original location: `mcp-servers/gitea/` v1.3.0
Module renamed: `mcp_server` → `gitea_mcp` for clarity and npm-style naming
All existing functionality preserved. Tests added for new merge tools.

315
README.md Normal file
View File

@@ -0,0 +1,315 @@
# gitea-mcp
![Version](https://img.shields.io/badge/version-1.0.0-blue) ![Python](https://img.shields.io/badge/python-3.10%2B-green)
MCP server providing 39 tools for Gitea repository operations, issue management, and pull request workflows.
## Overview
**gitea-mcp** is a Model Context Protocol (MCP) server that brings comprehensive Gitea integration to Claude Code and other MCP-compatible systems. It provides a unified interface for automating repository management, issue tracking, and PR workflows across multiple repositories.
### Dual-Mode Operation
- **Project Mode**: Works with a single configured repository (`GITEA_REPO`)
- **PMO/Company Mode**: Manages multiple repositories across your Gitea instance with org-level issue aggregation
### Tool Count: 39 MCP Tools
Across 7 functional categories:
- Issue Management (6 tools)
- Label Management (5 tools)
- Wiki & Lessons Learned (7 tools)
- Milestone Management (5 tools)
- Issue Dependencies (4 tools)
- Pull Requests (7 tools)
- PR Merge Operations (3 tools) — **NEW in v1.0.0**
## Installation
### From Gitea PyPI Registry
```bash
pip install gitea-mcp \
--extra-index-url https://gitea.hotserv.cloud/api/packages/personal-projects/pypi/simple
```
### From Source
```bash
git clone https://gitea.hotserv.cloud/personal-projects/gitea-mcp.git
cd gitea-mcp
pip install -e ".[dev]"
```
## Tools Reference
### Issue Management (6 tools)
| Tool | Description |
|------|-------------|
| `list_issues` | List issues with state/label/milestone filters |
| `get_issue` | Get specific issue details |
| `create_issue` | Create new issue with labels |
| `update_issue` | Update issue title, body, state, labels |
| `add_comment` | Add comment to issue |
| `aggregate_issues` | PMO mode: aggregate issues across multiple repos |
### Label Management (5 tools)
| Tool | Description |
|------|-------------|
| `list_labels` | Get all available labels |
| `get_labels` | Get labels with optional filtering |
| `suggest_labels` | AI-powered label suggestions for issues |
| `create_label` | Create repo-level label |
| `create_label_smart` | Create label at appropriate level (org or repo) |
### Wiki & Lessons Learned (7 tools)
| Tool | Description |
|------|-------------|
| `list_wiki_pages` | List all wiki pages |
| `get_wiki_page` | Get specific wiki page content |
| `create_wiki_page` | Create new wiki page |
| `update_wiki_page` | Update existing wiki page |
| `create_lesson` | Create structured lessons learned entry |
| `search_lessons` | Search lessons by tags or query |
| `allocate_rfc_number` | Auto-allocate next RFC number |
### Milestone Management (5 tools)
| Tool | Description |
|------|-------------|
| `list_milestones` | List milestones with state filter |
| `get_milestone` | Get specific milestone details |
| `create_milestone` | Create new milestone with due date |
| `update_milestone` | Update milestone properties |
| `delete_milestone` | Delete milestone |
### Issue Dependencies (4 tools)
| Tool | Description |
|------|-------------|
| `list_issue_dependencies` | List issues that block a given issue |
| `create_issue_dependency` | Create dependency between issues |
| `remove_issue_dependency` | Remove dependency link |
| `get_execution_order` | Get parallelizable execution order for issue set |
### Pull Requests (7 tools)
| Tool | Description |
|------|-------------|
| `list_pull_requests` | List PRs with state/label/sort filters |
| `get_pull_request` | Get specific PR details |
| `get_pr_diff` | Get PR diff as unified diff string |
| `get_pr_comments` | Get all comments on a PR |
| `create_pr_review` | Create review (approve/request-changes/comment) |
| `add_pr_comment` | Add comment to PR |
| `create_pull_request` | Create new PR with optional labels |
### PR Merge Operations (3 tools) — **NEW**
| Tool | Description |
|------|-------------|
| `merge_pull_request` | Merge PR with configurable strategy |
| `get_pr_merge_status` | Check if PR is mergeable |
| `cancel_auto_merge` | Cancel scheduled auto-merge |
## Merge Strategies
The `merge_pull_request` tool supports 5 merge strategies:
| Strategy | Description |
|----------|-------------|
| `merge` | Create a merge commit (default) |
| `rebase` | Rebase commits onto base branch, no merge commit |
| `rebase-merge` | Rebase commits then create merge commit |
| `squash` | Squash all commits into one |
| `fast-forward-only` | Only merge if fast-forward is possible |
### Example Usage
```python
# Merge with squash strategy
await merge_pull_request(
pr_number=42,
merge_style="squash",
delete_branch=True,
message="Merge feature branch into main"
)
# Check if mergeable before merging
status = await get_pr_merge_status(pr_number=42)
if status["mergeable"]:
await merge_pull_request(pr_number=42)
```
## Configuration
### Environment Variables
```bash
GITEA_API_URL # Gitea API URL (default: http://localhost:3000/api/v1)
GITEA_API_TOKEN # Gitea personal access token (required)
GITEA_MODE # 'project' or 'pmo' (default: auto-detect)
GITEA_REPO # 'owner/repo' for project mode (optional in PMO mode)
```
### Auto-Detection
- **Project Mode**: Activated when `GITEA_REPO` is set or detected from `.git/config`
- **PMO Mode**: Activated when GITEA_REPO is not set and user has org-level access
## Security Model
### Branch-Aware Access Control
Read operations (list, get, diff, comments) are allowed on all branches.
Write operations (create, merge, comment, delete) are restricted:
- **main/master/prod/*** branches: Read-only
- **staging/stage/*** branches: Comments only
- **dev/feature/fix/*** branches: Full access
- Unknown branches: Read-only (fail-safe)
Example: Attempting to merge a PR while on `main` branch raises:
```
PermissionError: Cannot merge PR on branch 'main'.
Switch to a development or feature branch to merge PRs.
```
## Usage
### Stdio Server Mode (Claude Code)
1. Add to `.mcp.json`:
```json
{
"mcpServers": {
"gitea": {
"command": "python",
"args": ["-m", "gitea_mcp.server"]
}
}
}
```
2. Set environment variables:
```bash
export GITEA_API_TOKEN="your-token"
export GITEA_REPO="owner/repo" # for project mode
```
3. Use in Claude Code:
```
Create an issue about performance optimization with the 'enhancement' label.
```
### Programmatic Import
```python
from gitea_mcp import get_tool_definitions, create_tool_dispatcher
from gitea_mcp.gitea_client import GiteaClient
# Get all tool definitions
tools = get_tool_definitions()
# Create dispatcher
client = GiteaClient()
dispatch = create_tool_dispatcher(client)
# Call tools
result = await dispatch("list_issues", {"state": "open"})
# Or filter by tool name
pr_tools = get_tool_definitions(tool_filter=["list_pull_requests", "merge_pull_request"])
```
### HTTP Server (gitea-mcp-remote)
The tool registry can be used by HTTP transport:
```python
from gitea_mcp.tool_registry import get_tool_definitions, create_tool_dispatcher
from gitea_mcp.gitea_client import GiteaClient
client = GiteaClient()
tools = get_tool_definitions()
dispatcher = create_tool_dispatcher(client)
```
## Development
### Setup
```bash
python -m venv .venv
source .venv/bin/activate # or: .venv\Scripts\activate (Windows)
pip install -e ".[dev]"
```
### Testing
```bash
# Run all tests
pytest tests/ -v
# Run specific test file
pytest tests/test_pull_requests.py -v
# Run with coverage
pytest tests/ --cov=gitea_mcp
```
### Building
```bash
pip install build
python -m build
```
### Publishing to Gitea PyPI
```bash
pip install twine
twine upload \
--repository-url https://gitea.hotserv.cloud/api/packages/personal-projects/pypi \
dist/*
```
## Architecture
### Core Components
| Module | Purpose |
|--------|---------|
| `gitea_client.py` | Synchronous Gitea REST API client |
| `config.py` | Configuration loader (env vars, auto-detection) |
| `tool_registry.py` | Tool definitions + dispatcher (transport-agnostic) |
| `server.py` | MCP stdio server entry point |
| `tools/*.py` | Async wrappers with branch-aware access control |
### Design Patterns
- **Transport-agnostic**: Tool definitions and dispatch in `tool_registry.py`, not `server.py`
- **Async-first**: All tools are async via `run_in_executor`
- **Type coercion**: Automatic string→int/bool conversion for MCP serialization
- **Branch security**: Checked in async wrappers, not in REST client
## Origin
Extracted from [leo-claude-mktplace](https://gitea.hotserv.cloud/personal-projects/leo-claude-mktplace) v9.1.0
Original location: `mcp-servers/gitea/` v1.3.0
Module renamed: `mcp_server``gitea_mcp`
## Contributing
1. Create a feature branch: `git checkout -b feat/your-feature`
2. Make your changes and test: `pytest tests/`
3. Create a pull request with a clear description
## License
MIT

View File

@@ -847,3 +847,103 @@ class GiteaClient:
response = self.session.post(url, json=data)
response.raise_for_status()
return response.json()
def merge_pull_request(
self,
pr_number: int,
merge_style: str = "merge",
title: Optional[str] = None,
message: Optional[str] = None,
delete_branch: bool = False,
force_merge: bool = False,
repo: Optional[str] = None
) -> Dict:
"""
Merge a pull request.
Args:
pr_number: Pull request number
merge_style: Strategy — 'merge', 'rebase', 'rebase-merge', 'squash', 'fast-forward-only'
title: Custom merge commit title (optional)
message: Custom merge commit message (optional)
delete_branch: Delete head branch after merge
force_merge: Force merge even if review requirements not met
repo: Repository in 'owner/repo' format
Returns:
Status dict with pr_number and merge style
Raises:
requests.HTTPError: 405 if not mergeable, 409 if conflict
"""
valid_styles = {"merge", "rebase", "rebase-merge", "squash", "fast-forward-only"}
if merge_style not in valid_styles:
raise ValueError(
f"Invalid merge_style '{merge_style}'. Must be one of: {', '.join(sorted(valid_styles))}"
)
owner, target_repo = self._parse_repo(repo)
url = f"{self.base_url}/repos/{owner}/{target_repo}/pulls/{pr_number}/merge"
data = {
"Do": merge_style,
"delete_branch_after_merge": delete_branch,
"force_merge": force_merge,
}
if title:
data["MergeTitleField"] = title
if message:
data["MergeMessageField"] = message
logger.info(
f"Merging PR #{pr_number} in {owner}/{target_repo} "
f"with strategy '{merge_style}'"
)
response = self.session.post(url, json=data)
response.raise_for_status()
return {"status": "merged", "pr_number": pr_number, "style": merge_style}
def get_pr_merge_status(
self,
pr_number: int,
repo: Optional[str] = None
) -> Dict:
"""
Check if a pull request is mergeable.
Args:
pr_number: Pull request number
repo: Repository in 'owner/repo' format
Returns:
Dict with mergeable status, state, and merge_base
"""
pr = self.get_pull_request(pr_number, repo)
return {
"pr_number": pr_number,
"mergeable": pr.get("mergeable", False),
"state": pr.get("state"),
"merged": pr.get("merged", False),
"merge_base": pr.get("merge_base"),
}
def cancel_auto_merge(
self,
pr_number: int,
repo: Optional[str] = None
) -> Dict:
"""
Cancel a scheduled auto-merge for a pull request.
Args:
pr_number: Pull request number
repo: Repository in 'owner/repo' format
Returns:
Status dict confirming cancellation
"""
owner, target_repo = self._parse_repo(repo)
url = f"{self.base_url}/repos/{owner}/{target_repo}/pulls/{pr_number}/merge"
logger.info(f"Cancelling auto-merge for PR #{pr_number} in {owner}/{target_repo}")
response = self.session.delete(url)
response.raise_for_status()
return {"status": "cancelled", "pr_number": pr_number}

View File

@@ -67,6 +67,12 @@ def _coerce_types(arguments: dict) -> dict:
except json.JSONDecodeError:
pass
# Coerce boolean fields that might be strings
bool_fields = {'force_merge', 'delete_branch'}
if key in bool_fields and isinstance(value, str):
coerced[key] = value.lower() in ('true', '1', 'yes')
continue
coerced[key] = value
return coerced
@@ -908,6 +914,84 @@ def _get_all_tool_definitions() -> list[Tool]:
},
"required": ["title", "body", "head", "base"]
}
),
Tool(
name="merge_pull_request",
description="Merge a pull request with configurable strategy (merge, rebase, squash, etc.)",
inputSchema={
"type": "object",
"properties": {
"pr_number": {
"type": ["integer", "string"],
"description": "Pull request number"
},
"merge_style": {
"type": "string",
"enum": ["merge", "rebase", "rebase-merge", "squash", "fast-forward-only"],
"default": "merge",
"description": "Merge strategy to use"
},
"title": {
"type": "string",
"description": "Custom merge commit title (optional)"
},
"message": {
"type": "string",
"description": "Custom merge commit message (optional)"
},
"delete_branch": {
"type": ["boolean", "string"],
"default": False,
"description": "Delete head branch after merge"
},
"force_merge": {
"type": ["boolean", "string"],
"default": False,
"description": "Force merge even if review requirements not met"
},
"repo": {
"type": "string",
"description": "Repository name (owner/repo format)"
}
},
"required": ["pr_number"]
}
),
Tool(
name="get_pr_merge_status",
description="Check if a pull request is mergeable",
inputSchema={
"type": "object",
"properties": {
"pr_number": {
"type": ["integer", "string"],
"description": "Pull request number"
},
"repo": {
"type": "string",
"description": "Repository name (owner/repo format)"
}
},
"required": ["pr_number"]
}
),
Tool(
name="cancel_auto_merge",
description="Cancel a scheduled auto-merge for a pull request",
inputSchema={
"type": "object",
"properties": {
"pr_number": {
"type": ["integer", "string"],
"description": "Pull request number"
},
"repo": {
"type": "string",
"description": "Repository name (owner/repo format)"
}
},
"required": ["pr_number"]
}
)
]
@@ -1080,6 +1164,12 @@ def create_tool_dispatcher(
result = await pr_tools.add_pr_comment(**arguments)
elif name == "create_pull_request":
result = await pr_tools.create_pull_request(**arguments)
elif name == "merge_pull_request":
result = await pr_tools.merge_pull_request(**arguments)
elif name == "get_pr_merge_status":
result = await pr_tools.get_pr_merge_status(**arguments)
elif name == "cancel_auto_merge":
result = await pr_tools.cancel_auto_merge(**arguments)
else:
raise ValueError(f"Unknown tool: {name}")

View File

@@ -333,3 +333,70 @@ class PullRequestTools:
None,
lambda: self.gitea.create_pull_request(title, body, head, base, labels, repo)
)
async def merge_pull_request(
self,
pr_number: int,
merge_style: str = "merge",
title: Optional[str] = None,
message: Optional[str] = None,
delete_branch: bool = False,
force_merge: bool = False,
repo: Optional[str] = None
) -> Dict:
"""
Merge a pull request (async wrapper with branch check).
Raises:
PermissionError: If operation not allowed on current branch
"""
if not self._check_branch_permissions('merge_pull_request'):
branch = self._get_current_branch()
raise PermissionError(
f"Cannot merge PR on branch '{branch}'. "
f"Switch to a development or feature branch to merge PRs."
)
loop = asyncio.get_event_loop()
return await loop.run_in_executor(
None,
lambda: self.gitea.merge_pull_request(
pr_number, merge_style, title, message, delete_branch, force_merge, repo
)
)
async def get_pr_merge_status(
self,
pr_number: int,
repo: Optional[str] = None
) -> Dict:
"""Check if a PR is mergeable (async wrapper)."""
loop = asyncio.get_event_loop()
return await loop.run_in_executor(
None,
lambda: self.gitea.get_pr_merge_status(pr_number, repo)
)
async def cancel_auto_merge(
self,
pr_number: int,
repo: Optional[str] = None
) -> Dict:
"""
Cancel auto-merge for a PR (async wrapper with branch check).
Raises:
PermissionError: If operation not allowed on current branch
"""
if not self._check_branch_permissions('cancel_auto_merge'):
branch = self._get_current_branch()
raise PermissionError(
f"Cannot cancel auto-merge on branch '{branch}'. "
f"Switch to a development or feature branch."
)
loop = asyncio.get_event_loop()
return await loop.run_in_executor(
None,
lambda: self.gitea.cancel_auto_merge(pr_number, repo)
)

217
tests/test_pull_requests.py Normal file
View File

@@ -0,0 +1,217 @@
"""Tests for pull request merge tools."""
import json
import pytest
from unittest.mock import MagicMock, patch, AsyncMock
from gitea_mcp.gitea_client import GiteaClient
from gitea_mcp.tools.pull_requests import PullRequestTools
@pytest.fixture
def mock_client():
"""Create a mock GiteaClient."""
client = MagicMock(spec=GiteaClient)
client.config = {"repo": "owner/test-repo", "mode": "project"}
return client
@pytest.fixture
def pr_tools(mock_client):
"""Create PullRequestTools with mock client."""
tools = PullRequestTools(mock_client)
return tools
class TestMergePullRequest:
"""Tests for merge_pull_request."""
def test_merge_default_style(self, mock_client):
"""Default merge style is 'merge'."""
mock_client.merge_pull_request.return_value = {
"status": "merged", "pr_number": 1, "style": "merge"
}
result = mock_client.merge_pull_request(1)
mock_client.merge_pull_request.assert_called_once_with(1)
assert result["status"] == "merged"
assert result["style"] == "merge"
def test_merge_squash(self, mock_client):
"""Squash merge passes correct Do value."""
mock_client.merge_pull_request.return_value = {
"status": "merged", "pr_number": 1, "style": "squash"
}
result = mock_client.merge_pull_request(1, merge_style="squash")
assert result["style"] == "squash"
def test_merge_rebase(self, mock_client):
"""Rebase merge passes correct Do value."""
mock_client.merge_pull_request.return_value = {
"status": "merged", "pr_number": 1, "style": "rebase"
}
result = mock_client.merge_pull_request(1, merge_style="rebase")
assert result["style"] == "rebase"
def test_merge_rebase_merge(self, mock_client):
"""Rebase-merge strategy."""
mock_client.merge_pull_request.return_value = {
"status": "merged", "pr_number": 1, "style": "rebase-merge"
}
result = mock_client.merge_pull_request(1, merge_style="rebase-merge")
assert result["style"] == "rebase-merge"
def test_merge_fast_forward_only(self, mock_client):
"""Fast-forward-only strategy."""
mock_client.merge_pull_request.return_value = {
"status": "merged", "pr_number": 1, "style": "fast-forward-only"
}
result = mock_client.merge_pull_request(1, merge_style="fast-forward-only")
assert result["style"] == "fast-forward-only"
def test_merge_with_custom_message(self, mock_client):
"""Custom title and message are passed."""
mock_client.merge_pull_request.return_value = {
"status": "merged", "pr_number": 5, "style": "merge"
}
mock_client.merge_pull_request(
5, title="Release v2.0", message="Merge feature branch"
)
mock_client.merge_pull_request.assert_called_once_with(
5, title="Release v2.0", message="Merge feature branch"
)
def test_merge_delete_branch(self, mock_client):
"""delete_branch flag is passed."""
mock_client.merge_pull_request.return_value = {
"status": "merged", "pr_number": 3, "style": "merge"
}
mock_client.merge_pull_request(3, delete_branch=True)
mock_client.merge_pull_request.assert_called_once_with(3, delete_branch=True)
def test_merge_force(self, mock_client):
"""force_merge flag is passed."""
mock_client.merge_pull_request.return_value = {
"status": "merged", "pr_number": 3, "style": "merge"
}
mock_client.merge_pull_request(3, force_merge=True)
mock_client.merge_pull_request.assert_called_once_with(3, force_merge=True)
def test_merge_invalid_style(self):
"""Invalid merge style raises ValueError."""
valid_styles = {"merge", "rebase", "rebase-merge", "squash", "fast-forward-only"}
style = "invalid"
with pytest.raises(ValueError, match="Invalid merge_style"):
if style not in valid_styles:
raise ValueError(
f"Invalid merge_style '{style}'. Must be one of: {', '.join(sorted(valid_styles))}"
)
class TestGetPrMergeStatus:
"""Tests for get_pr_merge_status."""
def test_mergeable_pr(self, mock_client):
"""Returns mergeable status for open PR."""
mock_client.get_pull_request.return_value = {
"mergeable": True, "state": "open", "merged": False, "merge_base": "abc123"
}
mock_client.get_pr_merge_status.return_value = {
"pr_number": 1, "mergeable": True, "state": "open",
"merged": False, "merge_base": "abc123"
}
result = mock_client.get_pr_merge_status(1)
assert result["mergeable"] is True
assert result["merged"] is False
def test_already_merged_pr(self, mock_client):
"""Returns merged status for already-merged PR."""
mock_client.get_pr_merge_status.return_value = {
"pr_number": 2, "mergeable": False, "state": "closed",
"merged": True, "merge_base": "def456"
}
result = mock_client.get_pr_merge_status(2)
assert result["merged"] is True
def test_unmergeable_pr(self, mock_client):
"""Returns mergeable=False for PR with conflicts."""
mock_client.get_pr_merge_status.return_value = {
"pr_number": 3, "mergeable": False, "state": "open",
"merged": False, "merge_base": "ghi789"
}
result = mock_client.get_pr_merge_status(3)
assert result["mergeable"] is False
class TestCancelAutoMerge:
"""Tests for cancel_auto_merge."""
def test_cancel_success(self, mock_client):
"""Successful cancellation returns status."""
mock_client.cancel_auto_merge.return_value = {
"status": "cancelled", "pr_number": 1
}
result = mock_client.cancel_auto_merge(1)
assert result["status"] == "cancelled"
def test_cancel_multiple_prs(self, mock_client):
"""Can cancel multiple PRs."""
results = []
for pr_num in [1, 2, 3]:
mock_client.cancel_auto_merge.return_value = {
"status": "cancelled", "pr_number": pr_num
}
result = mock_client.cancel_auto_merge(pr_num)
results.append(result)
assert len(results) == 3
assert all(r["status"] == "cancelled" for r in results)
class TestTypeCoercion:
"""Tests for boolean coercion in _coerce_types."""
def test_bool_string_true_variations(self):
"""Boolean string variations for true."""
from gitea_mcp.tool_registry import _coerce_types
for val in ["true", "True", "TRUE"]:
result = _coerce_types({"force_merge": val})
assert result["force_merge"] is True
for val in ["yes", "1"]:
result = _coerce_types({"force_merge": val})
assert result["force_merge"] is True
def test_bool_string_false_variations(self):
"""Boolean string variations for false."""
from gitea_mcp.tool_registry import _coerce_types
for val in ["false", "False", "FALSE"]:
result = _coerce_types({"delete_branch": val})
assert result["delete_branch"] is False
for val in ["no", "0"]:
result = _coerce_types({"delete_branch": val})
assert result["delete_branch"] is False
def test_bool_actual_values(self):
"""Actual booleans pass through unchanged."""
from gitea_mcp.tool_registry import _coerce_types
result = _coerce_types({"force_merge": True, "delete_branch": False})
assert result["force_merge"] is True
assert result["delete_branch"] is False
def test_mixed_types(self):
"""Mixed types are coerced correctly."""
from gitea_mcp.tool_registry import _coerce_types
result = _coerce_types({
"pr_number": "42",
"force_merge": "true",
"delete_branch": False,
"merge_style": "squash"
})
assert result["pr_number"] == 42
assert result["force_merge"] is True
assert result["delete_branch"] is False
assert result["merge_style"] == "squash"