feat: v3.0.0 architecture overhaul
- Rename marketplace to lm-claude-plugins - Move MCP servers to root with symlinks - Add 6 PR tools to Gitea MCP (list_pull_requests, get_pull_request, get_pr_diff, get_pr_comments, create_pr_review, add_pr_comment) - Add clarity-assist plugin (prompt optimization with ND accommodations) - Add git-flow plugin (workflow automation) - Add pr-review plugin (multi-agent review with confidence scoring) - Centralize configuration docs - Update all documentation for v3.0.0 BREAKING CHANGE: MCP server paths changed, marketplace renamed Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
0
mcp-servers/gitea/mcp_server/__init__.py
Normal file
0
mcp-servers/gitea/mcp_server/__init__.py
Normal file
98
mcp-servers/gitea/mcp_server/config.py
Normal file
98
mcp-servers/gitea/mcp_server/config.py
Normal file
@@ -0,0 +1,98 @@
|
||||
"""
|
||||
Configuration loader for Gitea MCP Server.
|
||||
|
||||
Implements hybrid configuration system:
|
||||
- System-level: ~/.config/claude/gitea.env (credentials)
|
||||
- Project-level: .env (repository specification)
|
||||
"""
|
||||
from pathlib import Path
|
||||
from dotenv import load_dotenv
|
||||
import os
|
||||
import logging
|
||||
from typing import Dict, Optional
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GiteaConfig:
|
||||
"""Hybrid configuration loader with mode detection"""
|
||||
|
||||
def __init__(self):
|
||||
self.api_url: Optional[str] = None
|
||||
self.api_token: Optional[str] = None
|
||||
self.repo: Optional[str] = None
|
||||
self.mode: str = 'project'
|
||||
|
||||
def load(self) -> Dict[str, Optional[str]]:
|
||||
"""
|
||||
Load configuration from system and project levels.
|
||||
Project-level configuration overrides system-level.
|
||||
|
||||
Returns:
|
||||
Dict containing api_url, api_token, repo, mode
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If system config is missing
|
||||
ValueError: If required configuration is missing
|
||||
"""
|
||||
# Load system config
|
||||
system_config = Path.home() / '.config' / 'claude' / 'gitea.env'
|
||||
if system_config.exists():
|
||||
load_dotenv(system_config)
|
||||
logger.info(f"Loaded system configuration from {system_config}")
|
||||
else:
|
||||
raise FileNotFoundError(
|
||||
f"System config not found: {system_config}\n"
|
||||
"Create it with: mkdir -p ~/.config/claude && "
|
||||
"cat > ~/.config/claude/gitea.env"
|
||||
)
|
||||
|
||||
# Load project config (overrides system)
|
||||
project_config = Path.cwd() / '.env'
|
||||
if project_config.exists():
|
||||
load_dotenv(project_config, override=True)
|
||||
logger.info(f"Loaded project configuration from {project_config}")
|
||||
|
||||
# Extract values
|
||||
self.api_url = os.getenv('GITEA_API_URL')
|
||||
self.api_token = os.getenv('GITEA_API_TOKEN')
|
||||
self.repo = os.getenv('GITEA_REPO') # Optional, must be owner/repo format
|
||||
|
||||
# Detect mode
|
||||
if self.repo:
|
||||
self.mode = 'project'
|
||||
logger.info(f"Running in project mode: {self.repo}")
|
||||
else:
|
||||
self.mode = 'company'
|
||||
logger.info("Running in company-wide mode (PMO)")
|
||||
|
||||
# Validate required variables
|
||||
self._validate()
|
||||
|
||||
return {
|
||||
'api_url': self.api_url,
|
||||
'api_token': self.api_token,
|
||||
'repo': self.repo,
|
||||
'mode': self.mode
|
||||
}
|
||||
|
||||
def _validate(self) -> None:
|
||||
"""
|
||||
Validate that required configuration is present.
|
||||
|
||||
Raises:
|
||||
ValueError: If required configuration is missing
|
||||
"""
|
||||
required = {
|
||||
'GITEA_API_URL': self.api_url,
|
||||
'GITEA_API_TOKEN': self.api_token
|
||||
}
|
||||
|
||||
missing = [key for key, value in required.items() if not value]
|
||||
|
||||
if missing:
|
||||
raise ValueError(
|
||||
f"Missing required configuration: {', '.join(missing)}\n"
|
||||
"Check your ~/.config/claude/gitea.env file"
|
||||
)
|
||||
716
mcp-servers/gitea/mcp_server/gitea_client.py
Normal file
716
mcp-servers/gitea/mcp_server/gitea_client.py
Normal file
@@ -0,0 +1,716 @@
|
||||
"""
|
||||
Gitea API client for interacting with Gitea API.
|
||||
|
||||
Provides synchronous methods for:
|
||||
- Issue CRUD operations
|
||||
- Label management
|
||||
- Repository operations
|
||||
- PMO multi-repo aggregation
|
||||
- Wiki operations (lessons learned)
|
||||
- Milestone management
|
||||
- Issue dependencies
|
||||
"""
|
||||
import requests
|
||||
import logging
|
||||
import re
|
||||
from typing import List, Dict, Optional
|
||||
from .config import GiteaConfig
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GiteaClient:
|
||||
"""Client for interacting with Gitea API"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize Gitea client with configuration"""
|
||||
config = GiteaConfig()
|
||||
config_dict = config.load()
|
||||
|
||||
self.base_url = config_dict['api_url']
|
||||
self.token = config_dict['api_token']
|
||||
self.repo = config_dict.get('repo') # Optional default repo in owner/repo format
|
||||
self.mode = config_dict['mode']
|
||||
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update({
|
||||
'Authorization': f'token {self.token}',
|
||||
'Content-Type': 'application/json'
|
||||
})
|
||||
|
||||
logger.info(f"Gitea client initialized in {self.mode} mode")
|
||||
|
||||
def _parse_repo(self, repo: Optional[str] = None) -> tuple:
|
||||
"""Parse owner/repo from input. Always requires 'owner/repo' format."""
|
||||
target = repo or self.repo
|
||||
if not target or '/' not in target:
|
||||
raise ValueError("Use 'owner/repo' format (e.g. 'org/repo-name')")
|
||||
parts = target.split('/', 1)
|
||||
return parts[0], parts[1]
|
||||
|
||||
def list_issues(
|
||||
self,
|
||||
state: str = 'open',
|
||||
labels: Optional[List[str]] = None,
|
||||
repo: Optional[str] = None
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
List issues from Gitea repository.
|
||||
|
||||
Args:
|
||||
state: Issue state (open, closed, all)
|
||||
labels: Filter by labels
|
||||
repo: Repository in 'owner/repo' format
|
||||
|
||||
Returns:
|
||||
List of issue dictionaries
|
||||
"""
|
||||
owner, target_repo = self._parse_repo(repo)
|
||||
url = f"{self.base_url}/repos/{owner}/{target_repo}/issues"
|
||||
params = {'state': state}
|
||||
if labels:
|
||||
params['labels'] = ','.join(labels)
|
||||
logger.info(f"Listing issues from {owner}/{target_repo} with state={state}")
|
||||
response = self.session.get(url, params=params)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
def get_issue(
|
||||
self,
|
||||
issue_number: int,
|
||||
repo: Optional[str] = None
|
||||
) -> Dict:
|
||||
"""Get specific issue details."""
|
||||
owner, target_repo = self._parse_repo(repo)
|
||||
url = f"{self.base_url}/repos/{owner}/{target_repo}/issues/{issue_number}"
|
||||
logger.info(f"Getting issue #{issue_number} from {owner}/{target_repo}")
|
||||
response = self.session.get(url)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
def create_issue(
|
||||
self,
|
||||
title: str,
|
||||
body: str,
|
||||
labels: Optional[List[str]] = None,
|
||||
repo: Optional[str] = None
|
||||
) -> Dict:
|
||||
"""Create a new issue in Gitea."""
|
||||
owner, target_repo = self._parse_repo(repo)
|
||||
url = f"{self.base_url}/repos/{owner}/{target_repo}/issues"
|
||||
data = {'title': title, 'body': body}
|
||||
if labels:
|
||||
label_ids = self._resolve_label_ids(labels, owner, target_repo)
|
||||
data['labels'] = label_ids
|
||||
logger.info(f"Creating issue in {owner}/{target_repo}: {title}")
|
||||
response = self.session.post(url, json=data)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
def _resolve_label_ids(self, label_names: List[str], owner: str, repo: str) -> List[int]:
|
||||
"""Convert label names to label IDs."""
|
||||
org_labels = self.get_org_labels(owner)
|
||||
repo_labels = self.get_labels(f"{owner}/{repo}")
|
||||
all_labels = org_labels + repo_labels
|
||||
label_map = {label['name']: label['id'] for label in all_labels}
|
||||
label_ids = []
|
||||
for name in label_names:
|
||||
if name in label_map:
|
||||
label_ids.append(label_map[name])
|
||||
else:
|
||||
logger.warning(f"Label '{name}' not found, skipping")
|
||||
return label_ids
|
||||
|
||||
def update_issue(
|
||||
self,
|
||||
issue_number: int,
|
||||
title: Optional[str] = None,
|
||||
body: Optional[str] = None,
|
||||
state: Optional[str] = None,
|
||||
labels: Optional[List[str]] = None,
|
||||
repo: Optional[str] = None
|
||||
) -> Dict:
|
||||
"""Update existing issue. Repo must be 'owner/repo' format."""
|
||||
owner, target_repo = self._parse_repo(repo)
|
||||
url = f"{self.base_url}/repos/{owner}/{target_repo}/issues/{issue_number}"
|
||||
data = {}
|
||||
if title is not None:
|
||||
data['title'] = title
|
||||
if body is not None:
|
||||
data['body'] = body
|
||||
if state is not None:
|
||||
data['state'] = state
|
||||
if labels is not None:
|
||||
data['labels'] = labels
|
||||
logger.info(f"Updating issue #{issue_number} in {owner}/{target_repo}")
|
||||
response = self.session.patch(url, json=data)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
def add_comment(
|
||||
self,
|
||||
issue_number: int,
|
||||
comment: str,
|
||||
repo: Optional[str] = None
|
||||
) -> Dict:
|
||||
"""Add comment to issue. Repo must be 'owner/repo' format."""
|
||||
owner, target_repo = self._parse_repo(repo)
|
||||
url = f"{self.base_url}/repos/{owner}/{target_repo}/issues/{issue_number}/comments"
|
||||
data = {'body': comment}
|
||||
logger.info(f"Adding comment to issue #{issue_number} in {owner}/{target_repo}")
|
||||
response = self.session.post(url, json=data)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
def get_labels(self, repo: Optional[str] = None) -> List[Dict]:
|
||||
"""Get all labels from repository. Repo must be 'owner/repo' format."""
|
||||
owner, target_repo = self._parse_repo(repo)
|
||||
url = f"{self.base_url}/repos/{owner}/{target_repo}/labels"
|
||||
logger.info(f"Getting labels from {owner}/{target_repo}")
|
||||
response = self.session.get(url)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
def get_org_labels(self, org: str) -> List[Dict]:
|
||||
"""Get organization-level labels. Org is the organization name."""
|
||||
url = f"{self.base_url}/orgs/{org}/labels"
|
||||
logger.info(f"Getting organization labels for {org}")
|
||||
response = self.session.get(url)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
def list_repos(self, org: str) -> List[Dict]:
|
||||
"""List all repositories in organization. Org is the organization name."""
|
||||
url = f"{self.base_url}/orgs/{org}/repos"
|
||||
logger.info(f"Listing all repositories for organization {org}")
|
||||
response = self.session.get(url)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
def aggregate_issues(
|
||||
self,
|
||||
org: str,
|
||||
state: str = 'open',
|
||||
labels: Optional[List[str]] = None
|
||||
) -> Dict[str, List[Dict]]:
|
||||
"""Fetch issues across all repositories in org."""
|
||||
repos = self.list_repos(org)
|
||||
aggregated = {}
|
||||
logger.info(f"Aggregating issues across {len(repos)} repositories")
|
||||
for repo in repos:
|
||||
repo_name = repo['name']
|
||||
try:
|
||||
issues = self.list_issues(
|
||||
state=state,
|
||||
labels=labels,
|
||||
repo=f"{org}/{repo_name}"
|
||||
)
|
||||
if issues:
|
||||
aggregated[repo_name] = issues
|
||||
logger.info(f"Found {len(issues)} issues in {repo_name}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching issues from {repo_name}: {e}")
|
||||
|
||||
return aggregated
|
||||
|
||||
# ========================================
|
||||
# WIKI OPERATIONS (Lessons Learned)
|
||||
# ========================================
|
||||
|
||||
def list_wiki_pages(self, repo: Optional[str] = None) -> List[Dict]:
|
||||
"""List all wiki pages in repository."""
|
||||
owner, target_repo = self._parse_repo(repo)
|
||||
url = f"{self.base_url}/repos/{owner}/{target_repo}/wiki/pages"
|
||||
logger.info(f"Listing wiki pages from {owner}/{target_repo}")
|
||||
response = self.session.get(url)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
def get_wiki_page(
|
||||
self,
|
||||
page_name: str,
|
||||
repo: Optional[str] = None
|
||||
) -> Dict:
|
||||
"""Get a specific wiki page by name."""
|
||||
owner, target_repo = self._parse_repo(repo)
|
||||
url = f"{self.base_url}/repos/{owner}/{target_repo}/wiki/page/{page_name}"
|
||||
logger.info(f"Getting wiki page '{page_name}' from {owner}/{target_repo}")
|
||||
response = self.session.get(url)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
def create_wiki_page(
|
||||
self,
|
||||
title: str,
|
||||
content: str,
|
||||
repo: Optional[str] = None
|
||||
) -> Dict:
|
||||
"""Create a new wiki page."""
|
||||
owner, target_repo = self._parse_repo(repo)
|
||||
url = f"{self.base_url}/repos/{owner}/{target_repo}/wiki/new"
|
||||
data = {
|
||||
'title': title,
|
||||
'content_base64': self._encode_base64(content)
|
||||
}
|
||||
logger.info(f"Creating wiki page '{title}' in {owner}/{target_repo}")
|
||||
response = self.session.post(url, json=data)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
def update_wiki_page(
|
||||
self,
|
||||
page_name: str,
|
||||
content: str,
|
||||
repo: Optional[str] = None
|
||||
) -> Dict:
|
||||
"""Update an existing wiki page."""
|
||||
owner, target_repo = self._parse_repo(repo)
|
||||
url = f"{self.base_url}/repos/{owner}/{target_repo}/wiki/page/{page_name}"
|
||||
data = {
|
||||
'content_base64': self._encode_base64(content)
|
||||
}
|
||||
logger.info(f"Updating wiki page '{page_name}' in {owner}/{target_repo}")
|
||||
response = self.session.patch(url, json=data)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
def delete_wiki_page(
|
||||
self,
|
||||
page_name: str,
|
||||
repo: Optional[str] = None
|
||||
) -> bool:
|
||||
"""Delete a wiki page."""
|
||||
owner, target_repo = self._parse_repo(repo)
|
||||
url = f"{self.base_url}/repos/{owner}/{target_repo}/wiki/page/{page_name}"
|
||||
logger.info(f"Deleting wiki page '{page_name}' from {owner}/{target_repo}")
|
||||
response = self.session.delete(url)
|
||||
response.raise_for_status()
|
||||
return True
|
||||
|
||||
def _encode_base64(self, content: str) -> str:
|
||||
"""Encode content to base64 for wiki API."""
|
||||
import base64
|
||||
return base64.b64encode(content.encode('utf-8')).decode('utf-8')
|
||||
|
||||
def _decode_base64(self, content: str) -> str:
|
||||
"""Decode base64 content from wiki API."""
|
||||
import base64
|
||||
return base64.b64decode(content.encode('utf-8')).decode('utf-8')
|
||||
|
||||
def search_wiki_pages(
|
||||
self,
|
||||
query: str,
|
||||
repo: Optional[str] = None
|
||||
) -> List[Dict]:
|
||||
"""Search wiki pages by content (client-side filtering)."""
|
||||
pages = self.list_wiki_pages(repo)
|
||||
results = []
|
||||
query_lower = query.lower()
|
||||
for page in pages:
|
||||
if query_lower in page.get('title', '').lower():
|
||||
results.append(page)
|
||||
return results
|
||||
|
||||
def create_lesson(
|
||||
self,
|
||||
title: str,
|
||||
content: str,
|
||||
tags: List[str],
|
||||
category: str = "sprints",
|
||||
repo: Optional[str] = None
|
||||
) -> Dict:
|
||||
"""Create a lessons learned entry in the wiki."""
|
||||
# Sanitize title for wiki page name
|
||||
page_name = f"lessons/{category}/{self._sanitize_page_name(title)}"
|
||||
|
||||
# Add tags as metadata at the end of content
|
||||
full_content = f"{content}\n\n---\n**Tags:** {', '.join(tags)}"
|
||||
|
||||
return self.create_wiki_page(page_name, full_content, repo)
|
||||
|
||||
def search_lessons(
|
||||
self,
|
||||
query: Optional[str] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
repo: Optional[str] = None
|
||||
) -> List[Dict]:
|
||||
"""Search lessons learned by query and/or tags."""
|
||||
pages = self.list_wiki_pages(repo)
|
||||
results = []
|
||||
|
||||
for page in pages:
|
||||
title = page.get('title', '')
|
||||
# Filter to only lessons (pages starting with lessons/)
|
||||
if not title.startswith('lessons/'):
|
||||
continue
|
||||
|
||||
# If query provided, check if it matches title
|
||||
if query:
|
||||
if query.lower() not in title.lower():
|
||||
continue
|
||||
|
||||
# Get full page content for tag matching if tags provided
|
||||
if tags:
|
||||
try:
|
||||
full_page = self.get_wiki_page(title, repo)
|
||||
content = self._decode_base64(full_page.get('content_base64', ''))
|
||||
# Check if any tag is in the content
|
||||
if not any(tag.lower() in content.lower() for tag in tags):
|
||||
continue
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
results.append(page)
|
||||
|
||||
return results
|
||||
|
||||
def _sanitize_page_name(self, title: str) -> str:
|
||||
"""Convert title to valid wiki page name."""
|
||||
# Replace spaces with hyphens, remove special chars
|
||||
name = re.sub(r'[^\w\s-]', '', title)
|
||||
name = re.sub(r'[\s]+', '-', name)
|
||||
return name.lower()
|
||||
|
||||
# ========================================
|
||||
# MILESTONE OPERATIONS
|
||||
# ========================================
|
||||
|
||||
def list_milestones(
|
||||
self,
|
||||
state: str = 'open',
|
||||
repo: Optional[str] = None
|
||||
) -> List[Dict]:
|
||||
"""List all milestones in repository."""
|
||||
owner, target_repo = self._parse_repo(repo)
|
||||
url = f"{self.base_url}/repos/{owner}/{target_repo}/milestones"
|
||||
params = {'state': state}
|
||||
logger.info(f"Listing milestones from {owner}/{target_repo}")
|
||||
response = self.session.get(url, params=params)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
def get_milestone(
|
||||
self,
|
||||
milestone_id: int,
|
||||
repo: Optional[str] = None
|
||||
) -> Dict:
|
||||
"""Get a specific milestone by ID."""
|
||||
owner, target_repo = self._parse_repo(repo)
|
||||
url = f"{self.base_url}/repos/{owner}/{target_repo}/milestones/{milestone_id}"
|
||||
logger.info(f"Getting milestone #{milestone_id} from {owner}/{target_repo}")
|
||||
response = self.session.get(url)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
def create_milestone(
|
||||
self,
|
||||
title: str,
|
||||
description: Optional[str] = None,
|
||||
due_on: Optional[str] = None,
|
||||
repo: Optional[str] = None
|
||||
) -> Dict:
|
||||
"""Create a new milestone."""
|
||||
owner, target_repo = self._parse_repo(repo)
|
||||
url = f"{self.base_url}/repos/{owner}/{target_repo}/milestones"
|
||||
data = {'title': title}
|
||||
if description:
|
||||
data['description'] = description
|
||||
if due_on:
|
||||
data['due_on'] = due_on
|
||||
logger.info(f"Creating milestone '{title}' in {owner}/{target_repo}")
|
||||
response = self.session.post(url, json=data)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
def update_milestone(
|
||||
self,
|
||||
milestone_id: int,
|
||||
title: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
state: Optional[str] = None,
|
||||
due_on: Optional[str] = None,
|
||||
repo: Optional[str] = None
|
||||
) -> Dict:
|
||||
"""Update an existing milestone."""
|
||||
owner, target_repo = self._parse_repo(repo)
|
||||
url = f"{self.base_url}/repos/{owner}/{target_repo}/milestones/{milestone_id}"
|
||||
data = {}
|
||||
if title is not None:
|
||||
data['title'] = title
|
||||
if description is not None:
|
||||
data['description'] = description
|
||||
if state is not None:
|
||||
data['state'] = state
|
||||
if due_on is not None:
|
||||
data['due_on'] = due_on
|
||||
logger.info(f"Updating milestone #{milestone_id} in {owner}/{target_repo}")
|
||||
response = self.session.patch(url, json=data)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
def delete_milestone(
|
||||
self,
|
||||
milestone_id: int,
|
||||
repo: Optional[str] = None
|
||||
) -> bool:
|
||||
"""Delete a milestone."""
|
||||
owner, target_repo = self._parse_repo(repo)
|
||||
url = f"{self.base_url}/repos/{owner}/{target_repo}/milestones/{milestone_id}"
|
||||
logger.info(f"Deleting milestone #{milestone_id} from {owner}/{target_repo}")
|
||||
response = self.session.delete(url)
|
||||
response.raise_for_status()
|
||||
return True
|
||||
|
||||
# ========================================
|
||||
# ISSUE DEPENDENCY OPERATIONS
|
||||
# ========================================
|
||||
|
||||
def list_issue_dependencies(
|
||||
self,
|
||||
issue_number: int,
|
||||
repo: Optional[str] = None
|
||||
) -> List[Dict]:
|
||||
"""List all dependencies for an issue (issues that block this one)."""
|
||||
owner, target_repo = self._parse_repo(repo)
|
||||
url = f"{self.base_url}/repos/{owner}/{target_repo}/issues/{issue_number}/dependencies"
|
||||
logger.info(f"Listing dependencies for issue #{issue_number} in {owner}/{target_repo}")
|
||||
response = self.session.get(url)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
def create_issue_dependency(
|
||||
self,
|
||||
issue_number: int,
|
||||
depends_on: int,
|
||||
repo: Optional[str] = None
|
||||
) -> Dict:
|
||||
"""Create a dependency (issue_number depends on depends_on)."""
|
||||
owner, target_repo = self._parse_repo(repo)
|
||||
url = f"{self.base_url}/repos/{owner}/{target_repo}/issues/{issue_number}/dependencies"
|
||||
data = {
|
||||
'dependentIssue': {
|
||||
'owner': owner,
|
||||
'repo': target_repo,
|
||||
'index': depends_on
|
||||
}
|
||||
}
|
||||
logger.info(f"Creating dependency: #{issue_number} depends on #{depends_on} in {owner}/{target_repo}")
|
||||
response = self.session.post(url, json=data)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
def remove_issue_dependency(
|
||||
self,
|
||||
issue_number: int,
|
||||
depends_on: int,
|
||||
repo: Optional[str] = None
|
||||
) -> bool:
|
||||
"""Remove a dependency between issues."""
|
||||
owner, target_repo = self._parse_repo(repo)
|
||||
url = f"{self.base_url}/repos/{owner}/{target_repo}/issues/{issue_number}/dependencies"
|
||||
data = {
|
||||
'dependentIssue': {
|
||||
'owner': owner,
|
||||
'repo': target_repo,
|
||||
'index': depends_on
|
||||
}
|
||||
}
|
||||
logger.info(f"Removing dependency: #{issue_number} no longer depends on #{depends_on}")
|
||||
response = self.session.delete(url, json=data)
|
||||
response.raise_for_status()
|
||||
return True
|
||||
|
||||
def list_issue_blocks(
|
||||
self,
|
||||
issue_number: int,
|
||||
repo: Optional[str] = None
|
||||
) -> List[Dict]:
|
||||
"""List all issues that this issue blocks."""
|
||||
owner, target_repo = self._parse_repo(repo)
|
||||
url = f"{self.base_url}/repos/{owner}/{target_repo}/issues/{issue_number}/blocks"
|
||||
logger.info(f"Listing issues blocked by #{issue_number} in {owner}/{target_repo}")
|
||||
response = self.session.get(url)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
# ========================================
|
||||
# REPOSITORY VALIDATION
|
||||
# ========================================
|
||||
|
||||
def get_repo_info(self, repo: Optional[str] = None) -> Dict:
|
||||
"""Get repository information including owner type."""
|
||||
owner, target_repo = self._parse_repo(repo)
|
||||
url = f"{self.base_url}/repos/{owner}/{target_repo}"
|
||||
logger.info(f"Getting repo info for {owner}/{target_repo}")
|
||||
response = self.session.get(url)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
def is_org_repo(self, repo: Optional[str] = None) -> bool:
|
||||
"""Check if repository belongs to an organization (not a user)."""
|
||||
info = self.get_repo_info(repo)
|
||||
owner_type = info.get('owner', {}).get('type', '')
|
||||
return owner_type.lower() == 'organization'
|
||||
|
||||
def get_branch_protection(
|
||||
self,
|
||||
branch: str,
|
||||
repo: Optional[str] = None
|
||||
) -> Optional[Dict]:
|
||||
"""Get branch protection rules for a branch."""
|
||||
owner, target_repo = self._parse_repo(repo)
|
||||
url = f"{self.base_url}/repos/{owner}/{target_repo}/branch_protections/{branch}"
|
||||
logger.info(f"Getting branch protection for {branch} in {owner}/{target_repo}")
|
||||
try:
|
||||
response = self.session.get(url)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except requests.exceptions.HTTPError as e:
|
||||
if e.response.status_code == 404:
|
||||
return None # No protection rules
|
||||
raise
|
||||
|
||||
def create_label(
|
||||
self,
|
||||
name: str,
|
||||
color: str,
|
||||
description: Optional[str] = None,
|
||||
repo: Optional[str] = None
|
||||
) -> Dict:
|
||||
"""Create a new label in the repository."""
|
||||
owner, target_repo = self._parse_repo(repo)
|
||||
url = f"{self.base_url}/repos/{owner}/{target_repo}/labels"
|
||||
data = {
|
||||
'name': name,
|
||||
'color': color.lstrip('#') # Remove # if present
|
||||
}
|
||||
if description:
|
||||
data['description'] = description
|
||||
logger.info(f"Creating label '{name}' in {owner}/{target_repo}")
|
||||
response = self.session.post(url, json=data)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
# ========================================
|
||||
# PULL REQUEST OPERATIONS
|
||||
# ========================================
|
||||
|
||||
def list_pull_requests(
|
||||
self,
|
||||
state: str = 'open',
|
||||
sort: str = 'recentupdate',
|
||||
labels: Optional[List[str]] = None,
|
||||
repo: Optional[str] = None
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
List pull requests from Gitea repository.
|
||||
|
||||
Args:
|
||||
state: PR state (open, closed, all)
|
||||
sort: Sort order (oldest, recentupdate, leastupdate, mostcomment, leastcomment, priority)
|
||||
labels: Filter by labels
|
||||
repo: Repository in 'owner/repo' format
|
||||
|
||||
Returns:
|
||||
List of pull request dictionaries
|
||||
"""
|
||||
owner, target_repo = self._parse_repo(repo)
|
||||
url = f"{self.base_url}/repos/{owner}/{target_repo}/pulls"
|
||||
params = {'state': state, 'sort': sort}
|
||||
if labels:
|
||||
params['labels'] = ','.join(labels)
|
||||
logger.info(f"Listing PRs from {owner}/{target_repo} with state={state}")
|
||||
response = self.session.get(url, params=params)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
def get_pull_request(
|
||||
self,
|
||||
pr_number: int,
|
||||
repo: Optional[str] = None
|
||||
) -> Dict:
|
||||
"""Get specific pull request details."""
|
||||
owner, target_repo = self._parse_repo(repo)
|
||||
url = f"{self.base_url}/repos/{owner}/{target_repo}/pulls/{pr_number}"
|
||||
logger.info(f"Getting PR #{pr_number} from {owner}/{target_repo}")
|
||||
response = self.session.get(url)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
def get_pr_diff(
|
||||
self,
|
||||
pr_number: int,
|
||||
repo: Optional[str] = None
|
||||
) -> str:
|
||||
"""Get the diff for a pull request."""
|
||||
owner, target_repo = self._parse_repo(repo)
|
||||
url = f"{self.base_url}/repos/{owner}/{target_repo}/pulls/{pr_number}.diff"
|
||||
logger.info(f"Getting diff for PR #{pr_number} from {owner}/{target_repo}")
|
||||
response = self.session.get(url)
|
||||
response.raise_for_status()
|
||||
return response.text
|
||||
|
||||
def get_pr_comments(
|
||||
self,
|
||||
pr_number: int,
|
||||
repo: Optional[str] = None
|
||||
) -> List[Dict]:
|
||||
"""Get comments on a pull request (uses issue comments endpoint)."""
|
||||
owner, target_repo = self._parse_repo(repo)
|
||||
# PRs share comment endpoint with issues in Gitea
|
||||
url = f"{self.base_url}/repos/{owner}/{target_repo}/issues/{pr_number}/comments"
|
||||
logger.info(f"Getting comments for PR #{pr_number} from {owner}/{target_repo}")
|
||||
response = self.session.get(url)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
def create_pr_review(
|
||||
self,
|
||||
pr_number: int,
|
||||
body: str,
|
||||
event: str = 'COMMENT',
|
||||
comments: Optional[List[Dict]] = None,
|
||||
repo: Optional[str] = None
|
||||
) -> Dict:
|
||||
"""
|
||||
Create a review on a pull request.
|
||||
|
||||
Args:
|
||||
pr_number: Pull request number
|
||||
body: Review body/summary
|
||||
event: Review action (APPROVE, REQUEST_CHANGES, COMMENT)
|
||||
comments: Optional list of inline comments with path, position, body
|
||||
repo: Repository in 'owner/repo' format
|
||||
|
||||
Returns:
|
||||
Created review dictionary
|
||||
"""
|
||||
owner, target_repo = self._parse_repo(repo)
|
||||
url = f"{self.base_url}/repos/{owner}/{target_repo}/pulls/{pr_number}/reviews"
|
||||
data = {
|
||||
'body': body,
|
||||
'event': event
|
||||
}
|
||||
if comments:
|
||||
data['comments'] = comments
|
||||
logger.info(f"Creating review on PR #{pr_number} in {owner}/{target_repo}")
|
||||
response = self.session.post(url, json=data)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
def add_pr_comment(
|
||||
self,
|
||||
pr_number: int,
|
||||
body: str,
|
||||
repo: Optional[str] = None
|
||||
) -> Dict:
|
||||
"""Add a general comment to a pull request (uses issue comment endpoint)."""
|
||||
owner, target_repo = self._parse_repo(repo)
|
||||
# PRs share comment endpoint with issues in Gitea
|
||||
url = f"{self.base_url}/repos/{owner}/{target_repo}/issues/{pr_number}/comments"
|
||||
data = {'body': body}
|
||||
logger.info(f"Adding comment to PR #{pr_number} in {owner}/{target_repo}")
|
||||
response = self.session.post(url, json=data)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
927
mcp-servers/gitea/mcp_server/server.py
Normal file
927
mcp-servers/gitea/mcp_server/server.py
Normal file
@@ -0,0 +1,927 @@
|
||||
"""
|
||||
MCP Server entry point for Gitea integration.
|
||||
|
||||
Provides Gitea tools to Claude Code via JSON-RPC 2.0 over stdio.
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
import json
|
||||
from mcp.server import Server
|
||||
from mcp.server.stdio import stdio_server
|
||||
from mcp.types import Tool, TextContent
|
||||
|
||||
from .config import GiteaConfig
|
||||
from .gitea_client import GiteaClient
|
||||
from .tools.issues import IssueTools
|
||||
from .tools.labels import LabelTools
|
||||
from .tools.wiki import WikiTools
|
||||
from .tools.milestones import MilestoneTools
|
||||
from .tools.dependencies import DependencyTools
|
||||
from .tools.pull_requests import PullRequestTools
|
||||
|
||||
# Suppress noisy MCP validation warnings on stderr
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logging.getLogger("root").setLevel(logging.ERROR)
|
||||
logging.getLogger("mcp").setLevel(logging.ERROR)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GiteaMCPServer:
|
||||
"""MCP Server for Gitea integration"""
|
||||
|
||||
def __init__(self):
|
||||
self.server = Server("gitea-mcp")
|
||||
self.config = None
|
||||
self.client = None
|
||||
self.issue_tools = None
|
||||
self.label_tools = None
|
||||
self.wiki_tools = None
|
||||
self.milestone_tools = None
|
||||
self.dependency_tools = None
|
||||
self.pr_tools = None
|
||||
|
||||
async def initialize(self):
|
||||
"""
|
||||
Initialize server and load configuration.
|
||||
|
||||
Raises:
|
||||
Exception: If initialization fails
|
||||
"""
|
||||
try:
|
||||
config_loader = GiteaConfig()
|
||||
self.config = config_loader.load()
|
||||
|
||||
self.client = GiteaClient()
|
||||
self.issue_tools = IssueTools(self.client)
|
||||
self.label_tools = LabelTools(self.client)
|
||||
self.wiki_tools = WikiTools(self.client)
|
||||
self.milestone_tools = MilestoneTools(self.client)
|
||||
self.dependency_tools = DependencyTools(self.client)
|
||||
self.pr_tools = PullRequestTools(self.client)
|
||||
|
||||
logger.info(f"Gitea MCP Server initialized in {self.config['mode']} mode")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize: {e}")
|
||||
raise
|
||||
|
||||
def setup_tools(self):
|
||||
"""Register all available tools with the MCP server"""
|
||||
|
||||
@self.server.list_tools()
|
||||
async def list_tools() -> list[Tool]:
|
||||
"""Return list of available tools"""
|
||||
return [
|
||||
Tool(
|
||||
name="list_issues",
|
||||
description="List issues from Gitea repository",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"state": {
|
||||
"type": "string",
|
||||
"enum": ["open", "closed", "all"],
|
||||
"default": "open",
|
||||
"description": "Issue state filter"
|
||||
},
|
||||
"labels": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Filter by labels"
|
||||
},
|
||||
"repo": {
|
||||
"type": "string",
|
||||
"description": "Repository name (for PMO mode)"
|
||||
}
|
||||
}
|
||||
}
|
||||
),
|
||||
Tool(
|
||||
name="get_issue",
|
||||
description="Get specific issue details",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"issue_number": {
|
||||
"type": "integer",
|
||||
"description": "Issue number"
|
||||
},
|
||||
"repo": {
|
||||
"type": "string",
|
||||
"description": "Repository name (for PMO mode)"
|
||||
}
|
||||
},
|
||||
"required": ["issue_number"]
|
||||
}
|
||||
),
|
||||
Tool(
|
||||
name="create_issue",
|
||||
description="Create a new issue in Gitea",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Issue title"
|
||||
},
|
||||
"body": {
|
||||
"type": "string",
|
||||
"description": "Issue description"
|
||||
},
|
||||
"labels": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "List of label names"
|
||||
},
|
||||
"repo": {
|
||||
"type": "string",
|
||||
"description": "Repository name (for PMO mode)"
|
||||
}
|
||||
},
|
||||
"required": ["title", "body"]
|
||||
}
|
||||
),
|
||||
Tool(
|
||||
name="update_issue",
|
||||
description="Update existing issue",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"issue_number": {
|
||||
"type": "integer",
|
||||
"description": "Issue number"
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "New title"
|
||||
},
|
||||
"body": {
|
||||
"type": "string",
|
||||
"description": "New body"
|
||||
},
|
||||
"state": {
|
||||
"type": "string",
|
||||
"enum": ["open", "closed"],
|
||||
"description": "New state"
|
||||
},
|
||||
"labels": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "New labels"
|
||||
},
|
||||
"repo": {
|
||||
"type": "string",
|
||||
"description": "Repository name (for PMO mode)"
|
||||
}
|
||||
},
|
||||
"required": ["issue_number"]
|
||||
}
|
||||
),
|
||||
Tool(
|
||||
name="add_comment",
|
||||
description="Add comment to issue",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"issue_number": {
|
||||
"type": "integer",
|
||||
"description": "Issue number"
|
||||
},
|
||||
"comment": {
|
||||
"type": "string",
|
||||
"description": "Comment text"
|
||||
},
|
||||
"repo": {
|
||||
"type": "string",
|
||||
"description": "Repository name (for PMO mode)"
|
||||
}
|
||||
},
|
||||
"required": ["issue_number", "comment"]
|
||||
}
|
||||
),
|
||||
Tool(
|
||||
name="get_labels",
|
||||
description="Get all available labels (org + repo)",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"repo": {
|
||||
"type": "string",
|
||||
"description": "Repository name (for PMO mode)"
|
||||
}
|
||||
}
|
||||
}
|
||||
),
|
||||
Tool(
|
||||
name="suggest_labels",
|
||||
description="Analyze context and suggest appropriate labels",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"context": {
|
||||
"type": "string",
|
||||
"description": "Issue title + description or sprint context"
|
||||
}
|
||||
},
|
||||
"required": ["context"]
|
||||
}
|
||||
),
|
||||
Tool(
|
||||
name="aggregate_issues",
|
||||
description="Fetch issues across all repositories (PMO mode)",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"org": {
|
||||
"type": "string",
|
||||
"description": "Organization name (e.g. 'bandit')"
|
||||
},
|
||||
"state": {
|
||||
"type": "string",
|
||||
"enum": ["open", "closed", "all"],
|
||||
"default": "open",
|
||||
"description": "Issue state filter"
|
||||
},
|
||||
"labels": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Filter by labels"
|
||||
}
|
||||
},
|
||||
"required": ["org"]
|
||||
}
|
||||
),
|
||||
# Wiki Tools (Lessons Learned)
|
||||
Tool(
|
||||
name="list_wiki_pages",
|
||||
description="List all wiki pages in repository",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"repo": {
|
||||
"type": "string",
|
||||
"description": "Repository name (owner/repo format)"
|
||||
}
|
||||
}
|
||||
}
|
||||
),
|
||||
Tool(
|
||||
name="get_wiki_page",
|
||||
description="Get a specific wiki page by name",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"page_name": {
|
||||
"type": "string",
|
||||
"description": "Wiki page name/path"
|
||||
},
|
||||
"repo": {
|
||||
"type": "string",
|
||||
"description": "Repository name (owner/repo format)"
|
||||
}
|
||||
},
|
||||
"required": ["page_name"]
|
||||
}
|
||||
),
|
||||
Tool(
|
||||
name="create_wiki_page",
|
||||
description="Create a new wiki page",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Page title/name"
|
||||
},
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "Page content (markdown)"
|
||||
},
|
||||
"repo": {
|
||||
"type": "string",
|
||||
"description": "Repository name (owner/repo format)"
|
||||
}
|
||||
},
|
||||
"required": ["title", "content"]
|
||||
}
|
||||
),
|
||||
Tool(
|
||||
name="update_wiki_page",
|
||||
description="Update an existing wiki page",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"page_name": {
|
||||
"type": "string",
|
||||
"description": "Wiki page name/path"
|
||||
},
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "New page content (markdown)"
|
||||
},
|
||||
"repo": {
|
||||
"type": "string",
|
||||
"description": "Repository name (owner/repo format)"
|
||||
}
|
||||
},
|
||||
"required": ["page_name", "content"]
|
||||
}
|
||||
),
|
||||
Tool(
|
||||
name="create_lesson",
|
||||
description="Create a lessons learned entry in the wiki",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Lesson title (e.g., 'Sprint 16 - Prevent Infinite Loops')"
|
||||
},
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "Lesson content (markdown with context, problem, solution, prevention)"
|
||||
},
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Tags for categorization"
|
||||
},
|
||||
"category": {
|
||||
"type": "string",
|
||||
"default": "sprints",
|
||||
"description": "Category (sprints, patterns, architecture, etc.)"
|
||||
},
|
||||
"repo": {
|
||||
"type": "string",
|
||||
"description": "Repository name (owner/repo format)"
|
||||
}
|
||||
},
|
||||
"required": ["title", "content", "tags"]
|
||||
}
|
||||
),
|
||||
Tool(
|
||||
name="search_lessons",
|
||||
description="Search lessons learned from previous sprints",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "Search query (optional)"
|
||||
},
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Tags to filter by (optional)"
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"default": 20,
|
||||
"description": "Maximum results"
|
||||
},
|
||||
"repo": {
|
||||
"type": "string",
|
||||
"description": "Repository name (owner/repo format)"
|
||||
}
|
||||
}
|
||||
}
|
||||
),
|
||||
# Milestone Tools
|
||||
Tool(
|
||||
name="list_milestones",
|
||||
description="List all milestones in repository",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"state": {
|
||||
"type": "string",
|
||||
"enum": ["open", "closed", "all"],
|
||||
"default": "open",
|
||||
"description": "Milestone state filter"
|
||||
},
|
||||
"repo": {
|
||||
"type": "string",
|
||||
"description": "Repository name (owner/repo format)"
|
||||
}
|
||||
}
|
||||
}
|
||||
),
|
||||
Tool(
|
||||
name="get_milestone",
|
||||
description="Get a specific milestone by ID",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"milestone_id": {
|
||||
"type": "integer",
|
||||
"description": "Milestone ID"
|
||||
},
|
||||
"repo": {
|
||||
"type": "string",
|
||||
"description": "Repository name (owner/repo format)"
|
||||
}
|
||||
},
|
||||
"required": ["milestone_id"]
|
||||
}
|
||||
),
|
||||
Tool(
|
||||
name="create_milestone",
|
||||
description="Create a new milestone",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Milestone title"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Milestone description"
|
||||
},
|
||||
"due_on": {
|
||||
"type": "string",
|
||||
"description": "Due date (ISO 8601 format)"
|
||||
},
|
||||
"repo": {
|
||||
"type": "string",
|
||||
"description": "Repository name (owner/repo format)"
|
||||
}
|
||||
},
|
||||
"required": ["title"]
|
||||
}
|
||||
),
|
||||
Tool(
|
||||
name="update_milestone",
|
||||
description="Update an existing milestone",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"milestone_id": {
|
||||
"type": "integer",
|
||||
"description": "Milestone ID"
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "New title"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "New description"
|
||||
},
|
||||
"state": {
|
||||
"type": "string",
|
||||
"enum": ["open", "closed"],
|
||||
"description": "New state"
|
||||
},
|
||||
"due_on": {
|
||||
"type": "string",
|
||||
"description": "New due date"
|
||||
},
|
||||
"repo": {
|
||||
"type": "string",
|
||||
"description": "Repository name (owner/repo format)"
|
||||
}
|
||||
},
|
||||
"required": ["milestone_id"]
|
||||
}
|
||||
),
|
||||
Tool(
|
||||
name="delete_milestone",
|
||||
description="Delete a milestone",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"milestone_id": {
|
||||
"type": "integer",
|
||||
"description": "Milestone ID"
|
||||
},
|
||||
"repo": {
|
||||
"type": "string",
|
||||
"description": "Repository name (owner/repo format)"
|
||||
}
|
||||
},
|
||||
"required": ["milestone_id"]
|
||||
}
|
||||
),
|
||||
# Dependency Tools
|
||||
Tool(
|
||||
name="list_issue_dependencies",
|
||||
description="List all dependencies for an issue (issues that block this one)",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"issue_number": {
|
||||
"type": "integer",
|
||||
"description": "Issue number"
|
||||
},
|
||||
"repo": {
|
||||
"type": "string",
|
||||
"description": "Repository name (owner/repo format)"
|
||||
}
|
||||
},
|
||||
"required": ["issue_number"]
|
||||
}
|
||||
),
|
||||
Tool(
|
||||
name="create_issue_dependency",
|
||||
description="Create a dependency (issue depends on another issue)",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"issue_number": {
|
||||
"type": "integer",
|
||||
"description": "Issue that will depend on another"
|
||||
},
|
||||
"depends_on": {
|
||||
"type": "integer",
|
||||
"description": "Issue that blocks issue_number"
|
||||
},
|
||||
"repo": {
|
||||
"type": "string",
|
||||
"description": "Repository name (owner/repo format)"
|
||||
}
|
||||
},
|
||||
"required": ["issue_number", "depends_on"]
|
||||
}
|
||||
),
|
||||
Tool(
|
||||
name="remove_issue_dependency",
|
||||
description="Remove a dependency between issues",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"issue_number": {
|
||||
"type": "integer",
|
||||
"description": "Issue that depends on another"
|
||||
},
|
||||
"depends_on": {
|
||||
"type": "integer",
|
||||
"description": "Issue being depended on"
|
||||
},
|
||||
"repo": {
|
||||
"type": "string",
|
||||
"description": "Repository name (owner/repo format)"
|
||||
}
|
||||
},
|
||||
"required": ["issue_number", "depends_on"]
|
||||
}
|
||||
),
|
||||
Tool(
|
||||
name="get_execution_order",
|
||||
description="Get parallelizable execution order for issues based on dependencies",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"issue_numbers": {
|
||||
"type": "array",
|
||||
"items": {"type": "integer"},
|
||||
"description": "List of issue numbers to analyze"
|
||||
},
|
||||
"repo": {
|
||||
"type": "string",
|
||||
"description": "Repository name (owner/repo format)"
|
||||
}
|
||||
},
|
||||
"required": ["issue_numbers"]
|
||||
}
|
||||
),
|
||||
# Validation Tools
|
||||
Tool(
|
||||
name="validate_repo_org",
|
||||
description="Check if repository belongs to an organization",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"repo": {
|
||||
"type": "string",
|
||||
"description": "Repository name (owner/repo format)"
|
||||
}
|
||||
}
|
||||
}
|
||||
),
|
||||
Tool(
|
||||
name="get_branch_protection",
|
||||
description="Get branch protection rules",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"branch": {
|
||||
"type": "string",
|
||||
"description": "Branch name"
|
||||
},
|
||||
"repo": {
|
||||
"type": "string",
|
||||
"description": "Repository name (owner/repo format)"
|
||||
}
|
||||
},
|
||||
"required": ["branch"]
|
||||
}
|
||||
),
|
||||
Tool(
|
||||
name="create_label",
|
||||
description="Create a new label in the repository",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Label name"
|
||||
},
|
||||
"color": {
|
||||
"type": "string",
|
||||
"description": "Label color (hex code)"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Label description"
|
||||
},
|
||||
"repo": {
|
||||
"type": "string",
|
||||
"description": "Repository name (owner/repo format)"
|
||||
}
|
||||
},
|
||||
"required": ["name", "color"]
|
||||
}
|
||||
),
|
||||
# Pull Request Tools
|
||||
Tool(
|
||||
name="list_pull_requests",
|
||||
description="List pull requests from repository",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"state": {
|
||||
"type": "string",
|
||||
"enum": ["open", "closed", "all"],
|
||||
"default": "open",
|
||||
"description": "PR state filter"
|
||||
},
|
||||
"sort": {
|
||||
"type": "string",
|
||||
"enum": ["oldest", "recentupdate", "leastupdate", "mostcomment", "leastcomment", "priority"],
|
||||
"default": "recentupdate",
|
||||
"description": "Sort order"
|
||||
},
|
||||
"labels": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Filter by labels"
|
||||
},
|
||||
"repo": {
|
||||
"type": "string",
|
||||
"description": "Repository name (owner/repo format)"
|
||||
}
|
||||
}
|
||||
}
|
||||
),
|
||||
Tool(
|
||||
name="get_pull_request",
|
||||
description="Get specific pull request details",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pr_number": {
|
||||
"type": "integer",
|
||||
"description": "Pull request number"
|
||||
},
|
||||
"repo": {
|
||||
"type": "string",
|
||||
"description": "Repository name (owner/repo format)"
|
||||
}
|
||||
},
|
||||
"required": ["pr_number"]
|
||||
}
|
||||
),
|
||||
Tool(
|
||||
name="get_pr_diff",
|
||||
description="Get the diff for a pull request",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pr_number": {
|
||||
"type": "integer",
|
||||
"description": "Pull request number"
|
||||
},
|
||||
"repo": {
|
||||
"type": "string",
|
||||
"description": "Repository name (owner/repo format)"
|
||||
}
|
||||
},
|
||||
"required": ["pr_number"]
|
||||
}
|
||||
),
|
||||
Tool(
|
||||
name="get_pr_comments",
|
||||
description="Get comments on a pull request",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pr_number": {
|
||||
"type": "integer",
|
||||
"description": "Pull request number"
|
||||
},
|
||||
"repo": {
|
||||
"type": "string",
|
||||
"description": "Repository name (owner/repo format)"
|
||||
}
|
||||
},
|
||||
"required": ["pr_number"]
|
||||
}
|
||||
),
|
||||
Tool(
|
||||
name="create_pr_review",
|
||||
description="Create a review on a pull request (approve, request changes, or comment)",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pr_number": {
|
||||
"type": "integer",
|
||||
"description": "Pull request number"
|
||||
},
|
||||
"body": {
|
||||
"type": "string",
|
||||
"description": "Review body/summary"
|
||||
},
|
||||
"event": {
|
||||
"type": "string",
|
||||
"enum": ["APPROVE", "REQUEST_CHANGES", "COMMENT"],
|
||||
"default": "COMMENT",
|
||||
"description": "Review action"
|
||||
},
|
||||
"comments": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {"type": "string"},
|
||||
"position": {"type": "integer"},
|
||||
"body": {"type": "string"}
|
||||
}
|
||||
},
|
||||
"description": "Optional inline comments"
|
||||
},
|
||||
"repo": {
|
||||
"type": "string",
|
||||
"description": "Repository name (owner/repo format)"
|
||||
}
|
||||
},
|
||||
"required": ["pr_number", "body"]
|
||||
}
|
||||
),
|
||||
Tool(
|
||||
name="add_pr_comment",
|
||||
description="Add a general comment to a pull request",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pr_number": {
|
||||
"type": "integer",
|
||||
"description": "Pull request number"
|
||||
},
|
||||
"body": {
|
||||
"type": "string",
|
||||
"description": "Comment text"
|
||||
},
|
||||
"repo": {
|
||||
"type": "string",
|
||||
"description": "Repository name (owner/repo format)"
|
||||
}
|
||||
},
|
||||
"required": ["pr_number", "body"]
|
||||
}
|
||||
)
|
||||
]
|
||||
|
||||
@self.server.call_tool()
|
||||
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
||||
"""
|
||||
Handle tool invocation.
|
||||
|
||||
Args:
|
||||
name: Tool name
|
||||
arguments: Tool arguments
|
||||
|
||||
Returns:
|
||||
List of TextContent with results
|
||||
"""
|
||||
try:
|
||||
# Route to appropriate tool handler
|
||||
if name == "list_issues":
|
||||
result = await self.issue_tools.list_issues(**arguments)
|
||||
elif name == "get_issue":
|
||||
result = await self.issue_tools.get_issue(**arguments)
|
||||
elif name == "create_issue":
|
||||
result = await self.issue_tools.create_issue(**arguments)
|
||||
elif name == "update_issue":
|
||||
result = await self.issue_tools.update_issue(**arguments)
|
||||
elif name == "add_comment":
|
||||
result = await self.issue_tools.add_comment(**arguments)
|
||||
elif name == "get_labels":
|
||||
result = await self.label_tools.get_labels(**arguments)
|
||||
elif name == "suggest_labels":
|
||||
result = await self.label_tools.suggest_labels(**arguments)
|
||||
elif name == "aggregate_issues":
|
||||
result = await self.issue_tools.aggregate_issues(**arguments)
|
||||
# Wiki tools
|
||||
elif name == "list_wiki_pages":
|
||||
result = await self.wiki_tools.list_wiki_pages(**arguments)
|
||||
elif name == "get_wiki_page":
|
||||
result = await self.wiki_tools.get_wiki_page(**arguments)
|
||||
elif name == "create_wiki_page":
|
||||
result = await self.wiki_tools.create_wiki_page(**arguments)
|
||||
elif name == "update_wiki_page":
|
||||
result = await self.wiki_tools.update_wiki_page(**arguments)
|
||||
elif name == "create_lesson":
|
||||
result = await self.wiki_tools.create_lesson(**arguments)
|
||||
elif name == "search_lessons":
|
||||
tags = arguments.get('tags')
|
||||
result = await self.wiki_tools.search_lessons(
|
||||
query=arguments.get('query'),
|
||||
tags=tags,
|
||||
limit=arguments.get('limit', 20),
|
||||
repo=arguments.get('repo')
|
||||
)
|
||||
# Milestone tools
|
||||
elif name == "list_milestones":
|
||||
result = await self.milestone_tools.list_milestones(**arguments)
|
||||
elif name == "get_milestone":
|
||||
result = await self.milestone_tools.get_milestone(**arguments)
|
||||
elif name == "create_milestone":
|
||||
result = await self.milestone_tools.create_milestone(**arguments)
|
||||
elif name == "update_milestone":
|
||||
result = await self.milestone_tools.update_milestone(**arguments)
|
||||
elif name == "delete_milestone":
|
||||
result = await self.milestone_tools.delete_milestone(**arguments)
|
||||
# Dependency tools
|
||||
elif name == "list_issue_dependencies":
|
||||
result = await self.dependency_tools.list_issue_dependencies(**arguments)
|
||||
elif name == "create_issue_dependency":
|
||||
result = await self.dependency_tools.create_issue_dependency(**arguments)
|
||||
elif name == "remove_issue_dependency":
|
||||
result = await self.dependency_tools.remove_issue_dependency(**arguments)
|
||||
elif name == "get_execution_order":
|
||||
result = await self.dependency_tools.get_execution_order(**arguments)
|
||||
# Validation tools
|
||||
elif name == "validate_repo_org":
|
||||
is_org = self.client.is_org_repo(arguments.get('repo'))
|
||||
result = {'is_organization': is_org}
|
||||
elif name == "get_branch_protection":
|
||||
result = self.client.get_branch_protection(
|
||||
arguments['branch'],
|
||||
arguments.get('repo')
|
||||
)
|
||||
elif name == "create_label":
|
||||
result = self.client.create_label(
|
||||
arguments['name'],
|
||||
arguments['color'],
|
||||
arguments.get('description'),
|
||||
arguments.get('repo')
|
||||
)
|
||||
# Pull Request tools
|
||||
elif name == "list_pull_requests":
|
||||
result = await self.pr_tools.list_pull_requests(**arguments)
|
||||
elif name == "get_pull_request":
|
||||
result = await self.pr_tools.get_pull_request(**arguments)
|
||||
elif name == "get_pr_diff":
|
||||
result = await self.pr_tools.get_pr_diff(**arguments)
|
||||
elif name == "get_pr_comments":
|
||||
result = await self.pr_tools.get_pr_comments(**arguments)
|
||||
elif name == "create_pr_review":
|
||||
result = await self.pr_tools.create_pr_review(**arguments)
|
||||
elif name == "add_pr_comment":
|
||||
result = await self.pr_tools.add_pr_comment(**arguments)
|
||||
else:
|
||||
raise ValueError(f"Unknown tool: {name}")
|
||||
|
||||
return [TextContent(
|
||||
type="text",
|
||||
text=json.dumps(result, indent=2)
|
||||
)]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Tool {name} failed: {e}")
|
||||
return [TextContent(
|
||||
type="text",
|
||||
text=f"Error: {str(e)}"
|
||||
)]
|
||||
|
||||
async def run(self):
|
||||
"""Run the MCP server"""
|
||||
await self.initialize()
|
||||
self.setup_tools()
|
||||
|
||||
async with stdio_server() as (read_stream, write_stream):
|
||||
await self.server.run(
|
||||
read_stream,
|
||||
write_stream,
|
||||
self.server.create_initialization_options()
|
||||
)
|
||||
|
||||
|
||||
async def main():
|
||||
"""Main entry point"""
|
||||
server = GiteaMCPServer()
|
||||
await server.run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
11
mcp-servers/gitea/mcp_server/tools/__init__.py
Normal file
11
mcp-servers/gitea/mcp_server/tools/__init__.py
Normal file
@@ -0,0 +1,11 @@
|
||||
"""
|
||||
MCP tools for Gitea integration.
|
||||
|
||||
This package provides MCP tool implementations for:
|
||||
- Issue operations (issues.py)
|
||||
- Label management (labels.py)
|
||||
- Wiki operations (wiki.py)
|
||||
- Milestone management (milestones.py)
|
||||
- Issue dependencies (dependencies.py)
|
||||
- Pull request operations (pull_requests.py)
|
||||
"""
|
||||
216
mcp-servers/gitea/mcp_server/tools/dependencies.py
Normal file
216
mcp-servers/gitea/mcp_server/tools/dependencies.py
Normal file
@@ -0,0 +1,216 @@
|
||||
"""
|
||||
Issue dependency management tools for MCP server.
|
||||
|
||||
Provides async wrappers for issue dependency operations:
|
||||
- List/create/remove dependencies
|
||||
- Build dependency graphs for parallel execution
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import List, Dict, Optional, Set, Tuple
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DependencyTools:
|
||||
"""Async wrappers for Gitea issue dependency operations"""
|
||||
|
||||
def __init__(self, gitea_client):
|
||||
"""
|
||||
Initialize dependency tools.
|
||||
|
||||
Args:
|
||||
gitea_client: GiteaClient instance
|
||||
"""
|
||||
self.gitea = gitea_client
|
||||
|
||||
async def list_issue_dependencies(
|
||||
self,
|
||||
issue_number: int,
|
||||
repo: Optional[str] = None
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
List all dependencies for an issue (issues that block this one).
|
||||
|
||||
Args:
|
||||
issue_number: Issue number
|
||||
repo: Repository in owner/repo format
|
||||
|
||||
Returns:
|
||||
List of issues that this issue depends on
|
||||
"""
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.gitea.list_issue_dependencies(issue_number, repo)
|
||||
)
|
||||
|
||||
async def create_issue_dependency(
|
||||
self,
|
||||
issue_number: int,
|
||||
depends_on: int,
|
||||
repo: Optional[str] = None
|
||||
) -> Dict:
|
||||
"""
|
||||
Create a dependency between issues.
|
||||
|
||||
Args:
|
||||
issue_number: The issue that will depend on another
|
||||
depends_on: The issue that blocks issue_number
|
||||
repo: Repository in owner/repo format
|
||||
|
||||
Returns:
|
||||
Created dependency information
|
||||
"""
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.gitea.create_issue_dependency(issue_number, depends_on, repo)
|
||||
)
|
||||
|
||||
async def remove_issue_dependency(
|
||||
self,
|
||||
issue_number: int,
|
||||
depends_on: int,
|
||||
repo: Optional[str] = None
|
||||
) -> bool:
|
||||
"""
|
||||
Remove a dependency between issues.
|
||||
|
||||
Args:
|
||||
issue_number: The issue that currently depends on another
|
||||
depends_on: The issue being depended on
|
||||
repo: Repository in owner/repo format
|
||||
|
||||
Returns:
|
||||
True if removed successfully
|
||||
"""
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.gitea.remove_issue_dependency(issue_number, depends_on, repo)
|
||||
)
|
||||
|
||||
async def list_issue_blocks(
|
||||
self,
|
||||
issue_number: int,
|
||||
repo: Optional[str] = None
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
List all issues that this issue blocks.
|
||||
|
||||
Args:
|
||||
issue_number: Issue number
|
||||
repo: Repository in owner/repo format
|
||||
|
||||
Returns:
|
||||
List of issues blocked by this issue
|
||||
"""
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.gitea.list_issue_blocks(issue_number, repo)
|
||||
)
|
||||
|
||||
async def build_dependency_graph(
|
||||
self,
|
||||
issue_numbers: List[int],
|
||||
repo: Optional[str] = None
|
||||
) -> Dict[int, List[int]]:
|
||||
"""
|
||||
Build a dependency graph for a list of issues.
|
||||
|
||||
Args:
|
||||
issue_numbers: List of issue numbers to analyze
|
||||
repo: Repository in owner/repo format
|
||||
|
||||
Returns:
|
||||
Dictionary mapping issue_number -> list of issues it depends on
|
||||
"""
|
||||
graph = {}
|
||||
for issue_num in issue_numbers:
|
||||
try:
|
||||
deps = await self.list_issue_dependencies(issue_num, repo)
|
||||
graph[issue_num] = [
|
||||
d.get('number') or d.get('index')
|
||||
for d in deps
|
||||
if (d.get('number') or d.get('index')) in issue_numbers
|
||||
]
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not fetch dependencies for #{issue_num}: {e}")
|
||||
graph[issue_num] = []
|
||||
return graph
|
||||
|
||||
async def get_ready_tasks(
|
||||
self,
|
||||
issue_numbers: List[int],
|
||||
completed: Set[int],
|
||||
repo: Optional[str] = None
|
||||
) -> List[int]:
|
||||
"""
|
||||
Get tasks that are ready to execute (no unresolved dependencies).
|
||||
|
||||
Args:
|
||||
issue_numbers: List of all issue numbers in sprint
|
||||
completed: Set of already completed issue numbers
|
||||
repo: Repository in owner/repo format
|
||||
|
||||
Returns:
|
||||
List of issue numbers that can be executed now
|
||||
"""
|
||||
graph = await self.build_dependency_graph(issue_numbers, repo)
|
||||
ready = []
|
||||
|
||||
for issue_num in issue_numbers:
|
||||
if issue_num in completed:
|
||||
continue
|
||||
|
||||
deps = graph.get(issue_num, [])
|
||||
# Task is ready if all its dependencies are completed
|
||||
if all(dep in completed for dep in deps):
|
||||
ready.append(issue_num)
|
||||
|
||||
return ready
|
||||
|
||||
async def get_execution_order(
|
||||
self,
|
||||
issue_numbers: List[int],
|
||||
repo: Optional[str] = None
|
||||
) -> List[List[int]]:
|
||||
"""
|
||||
Get a parallelizable execution order for issues.
|
||||
|
||||
Returns batches of issues that can be executed in parallel.
|
||||
Each batch contains issues with no unresolved dependencies.
|
||||
|
||||
Args:
|
||||
issue_numbers: List of all issue numbers
|
||||
repo: Repository in owner/repo format
|
||||
|
||||
Returns:
|
||||
List of batches, where each batch can be executed in parallel
|
||||
"""
|
||||
graph = await self.build_dependency_graph(issue_numbers, repo)
|
||||
completed: Set[int] = set()
|
||||
remaining = set(issue_numbers)
|
||||
batches = []
|
||||
|
||||
while remaining:
|
||||
# Find all tasks with no unresolved dependencies
|
||||
batch = []
|
||||
for issue_num in remaining:
|
||||
deps = graph.get(issue_num, [])
|
||||
if all(dep in completed for dep in deps):
|
||||
batch.append(issue_num)
|
||||
|
||||
if not batch:
|
||||
# Circular dependency detected
|
||||
logger.error(f"Circular dependency detected! Remaining: {remaining}")
|
||||
batch = list(remaining) # Force include remaining to avoid infinite loop
|
||||
|
||||
batches.append(batch)
|
||||
completed.update(batch)
|
||||
remaining -= set(batch)
|
||||
|
||||
return batches
|
||||
261
mcp-servers/gitea/mcp_server/tools/issues.py
Normal file
261
mcp-servers/gitea/mcp_server/tools/issues.py
Normal file
@@ -0,0 +1,261 @@
|
||||
"""
|
||||
Issue management tools for MCP server.
|
||||
|
||||
Provides async wrappers for issue CRUD operations with:
|
||||
- Branch-aware security
|
||||
- PMO multi-repo support
|
||||
- Comprehensive error handling
|
||||
"""
|
||||
import asyncio
|
||||
import subprocess
|
||||
import logging
|
||||
from typing import List, Dict, Optional
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class IssueTools:
|
||||
"""Async wrappers for Gitea issue operations with branch detection"""
|
||||
|
||||
def __init__(self, gitea_client):
|
||||
"""
|
||||
Initialize issue tools.
|
||||
|
||||
Args:
|
||||
gitea_client: GiteaClient instance
|
||||
"""
|
||||
self.gitea = gitea_client
|
||||
|
||||
def _get_current_branch(self) -> str:
|
||||
"""
|
||||
Get current git branch.
|
||||
|
||||
Returns:
|
||||
Current branch name or 'unknown' if not in a git repo
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['git', 'rev-parse', '--abbrev-ref', 'HEAD'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True
|
||||
)
|
||||
return result.stdout.strip()
|
||||
except subprocess.CalledProcessError:
|
||||
return "unknown"
|
||||
|
||||
def _check_branch_permissions(self, operation: str) -> bool:
|
||||
"""
|
||||
Check if operation is allowed on current branch.
|
||||
|
||||
Args:
|
||||
operation: Operation name (list_issues, create_issue, etc.)
|
||||
|
||||
Returns:
|
||||
True if operation is allowed, False otherwise
|
||||
"""
|
||||
branch = self._get_current_branch()
|
||||
|
||||
# Production branches (read-only except incidents)
|
||||
if branch in ['main', 'master'] or branch.startswith('prod/'):
|
||||
return operation in ['list_issues', 'get_issue', 'get_labels']
|
||||
|
||||
# Staging branches (read-only for code)
|
||||
if branch == 'staging' or branch.startswith('stage/'):
|
||||
return operation in ['list_issues', 'get_issue', 'get_labels', 'create_issue']
|
||||
|
||||
# Development branches (full access)
|
||||
if branch in ['development', 'develop'] or branch.startswith(('feat/', 'feature/', 'dev/')):
|
||||
return True
|
||||
|
||||
# Unknown branch - be restrictive
|
||||
return False
|
||||
|
||||
async def list_issues(
|
||||
self,
|
||||
state: str = 'open',
|
||||
labels: Optional[List[str]] = None,
|
||||
repo: Optional[str] = None
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
List issues from repository (async wrapper).
|
||||
|
||||
Args:
|
||||
state: Issue state (open, closed, all)
|
||||
labels: Filter by labels
|
||||
repo: Override configured repo (for PMO multi-repo)
|
||||
|
||||
Returns:
|
||||
List of issue dictionaries
|
||||
|
||||
Raises:
|
||||
PermissionError: If operation not allowed on current branch
|
||||
"""
|
||||
if not self._check_branch_permissions('list_issues'):
|
||||
branch = self._get_current_branch()
|
||||
raise PermissionError(
|
||||
f"Cannot list issues on branch '{branch}'. "
|
||||
f"Switch to a development branch."
|
||||
)
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.gitea.list_issues(state, labels, repo)
|
||||
)
|
||||
|
||||
async def get_issue(
|
||||
self,
|
||||
issue_number: int,
|
||||
repo: Optional[str] = None
|
||||
) -> Dict:
|
||||
"""
|
||||
Get specific issue details (async wrapper).
|
||||
|
||||
Args:
|
||||
issue_number: Issue number
|
||||
repo: Override configured repo (for PMO multi-repo)
|
||||
|
||||
Returns:
|
||||
Issue dictionary
|
||||
|
||||
Raises:
|
||||
PermissionError: If operation not allowed on current branch
|
||||
"""
|
||||
if not self._check_branch_permissions('get_issue'):
|
||||
branch = self._get_current_branch()
|
||||
raise PermissionError(
|
||||
f"Cannot get issue on branch '{branch}'. "
|
||||
f"Switch to a development branch."
|
||||
)
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.gitea.get_issue(issue_number, repo)
|
||||
)
|
||||
|
||||
async def create_issue(
|
||||
self,
|
||||
title: str,
|
||||
body: str,
|
||||
labels: Optional[List[str]] = None,
|
||||
repo: Optional[str] = None
|
||||
) -> Dict:
|
||||
"""
|
||||
Create new issue (async wrapper with branch check).
|
||||
|
||||
Args:
|
||||
title: Issue title
|
||||
body: Issue description
|
||||
labels: List of label names
|
||||
repo: Override configured repo (for PMO multi-repo)
|
||||
|
||||
Returns:
|
||||
Created issue dictionary
|
||||
|
||||
Raises:
|
||||
PermissionError: If operation not allowed on current branch
|
||||
"""
|
||||
if not self._check_branch_permissions('create_issue'):
|
||||
branch = self._get_current_branch()
|
||||
raise PermissionError(
|
||||
f"Cannot create issues on branch '{branch}'. "
|
||||
f"Switch to a development branch to create issues."
|
||||
)
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.gitea.create_issue(title, body, labels, repo)
|
||||
)
|
||||
|
||||
async def update_issue(
|
||||
self,
|
||||
issue_number: int,
|
||||
title: Optional[str] = None,
|
||||
body: Optional[str] = None,
|
||||
state: Optional[str] = None,
|
||||
labels: Optional[List[str]] = None,
|
||||
repo: Optional[str] = None
|
||||
) -> Dict:
|
||||
"""
|
||||
Update existing issue (async wrapper with branch check).
|
||||
|
||||
Args:
|
||||
issue_number: Issue number
|
||||
title: New title (optional)
|
||||
body: New body (optional)
|
||||
state: New state - 'open' or 'closed' (optional)
|
||||
labels: New labels (optional)
|
||||
repo: Override configured repo (for PMO multi-repo)
|
||||
|
||||
Returns:
|
||||
Updated issue dictionary
|
||||
|
||||
Raises:
|
||||
PermissionError: If operation not allowed on current branch
|
||||
"""
|
||||
if not self._check_branch_permissions('update_issue'):
|
||||
branch = self._get_current_branch()
|
||||
raise PermissionError(
|
||||
f"Cannot update issues on branch '{branch}'. "
|
||||
f"Switch to a development branch to update issues."
|
||||
)
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.gitea.update_issue(issue_number, title, body, state, labels, repo)
|
||||
)
|
||||
|
||||
async def add_comment(
|
||||
self,
|
||||
issue_number: int,
|
||||
comment: str,
|
||||
repo: Optional[str] = None
|
||||
) -> Dict:
|
||||
"""
|
||||
Add comment to issue (async wrapper with branch check).
|
||||
|
||||
Args:
|
||||
issue_number: Issue number
|
||||
comment: Comment text
|
||||
repo: Override configured repo (for PMO multi-repo)
|
||||
|
||||
Returns:
|
||||
Created comment dictionary
|
||||
|
||||
Raises:
|
||||
PermissionError: If operation not allowed on current branch
|
||||
"""
|
||||
if not self._check_branch_permissions('add_comment'):
|
||||
branch = self._get_current_branch()
|
||||
raise PermissionError(
|
||||
f"Cannot add comments on branch '{branch}'. "
|
||||
f"Switch to a development branch to add comments."
|
||||
)
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.gitea.add_comment(issue_number, comment, repo)
|
||||
)
|
||||
|
||||
async def aggregate_issues(
|
||||
self,
|
||||
org: str,
|
||||
state: str = 'open',
|
||||
labels: Optional[List[str]] = None
|
||||
) -> Dict[str, List[Dict]]:
|
||||
"""Aggregate issues across all repositories in org."""
|
||||
if not self._check_branch_permissions('aggregate_issues'):
|
||||
branch = self._get_current_branch()
|
||||
raise PermissionError(f"Cannot aggregate issues on branch '{branch}'.")
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.gitea.aggregate_issues(org, state, labels)
|
||||
)
|
||||
158
mcp-servers/gitea/mcp_server/tools/labels.py
Normal file
158
mcp-servers/gitea/mcp_server/tools/labels.py
Normal file
@@ -0,0 +1,158 @@
|
||||
"""
|
||||
Label management tools for MCP server.
|
||||
|
||||
Provides async wrappers for label operations with:
|
||||
- Label taxonomy retrieval
|
||||
- Intelligent label suggestion
|
||||
- Dynamic label detection
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import List, Dict, Optional
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LabelTools:
|
||||
"""Async wrappers for Gitea label operations"""
|
||||
|
||||
def __init__(self, gitea_client):
|
||||
"""
|
||||
Initialize label tools.
|
||||
|
||||
Args:
|
||||
gitea_client: GiteaClient instance
|
||||
"""
|
||||
self.gitea = gitea_client
|
||||
|
||||
async def get_labels(self, repo: Optional[str] = None) -> Dict[str, List[Dict]]:
|
||||
"""Get all labels (org + repo). Repo must be 'owner/repo' format."""
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
target_repo = repo or self.gitea.repo
|
||||
if not target_repo or '/' not in target_repo:
|
||||
raise ValueError("Use 'owner/repo' format (e.g. 'org/repo-name')")
|
||||
|
||||
org = target_repo.split('/')[0]
|
||||
|
||||
org_labels = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.gitea.get_org_labels(org)
|
||||
)
|
||||
|
||||
repo_labels = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.gitea.get_labels(target_repo)
|
||||
)
|
||||
|
||||
return {
|
||||
'organization': org_labels,
|
||||
'repository': repo_labels,
|
||||
'total_count': len(org_labels) + len(repo_labels)
|
||||
}
|
||||
|
||||
async def suggest_labels(self, context: str) -> List[str]:
|
||||
"""
|
||||
Analyze context and suggest appropriate labels.
|
||||
|
||||
Args:
|
||||
context: Issue title + description or sprint context
|
||||
|
||||
Returns:
|
||||
List of suggested label names
|
||||
"""
|
||||
suggested = []
|
||||
context_lower = context.lower()
|
||||
|
||||
# Type detection (exclusive - only one)
|
||||
if any(word in context_lower for word in ['bug', 'error', 'fix', 'broken', 'crash', 'fail']):
|
||||
suggested.append('Type/Bug')
|
||||
elif any(word in context_lower for word in ['refactor', 'extract', 'restructure', 'architecture', 'service extraction']):
|
||||
suggested.append('Type/Refactor')
|
||||
elif any(word in context_lower for word in ['feature', 'add', 'implement', 'new', 'create']):
|
||||
suggested.append('Type/Feature')
|
||||
elif any(word in context_lower for word in ['docs', 'documentation', 'readme', 'guide']):
|
||||
suggested.append('Type/Documentation')
|
||||
elif any(word in context_lower for word in ['test', 'testing', 'spec', 'coverage']):
|
||||
suggested.append('Type/Test')
|
||||
elif any(word in context_lower for word in ['chore', 'maintenance', 'update', 'upgrade']):
|
||||
suggested.append('Type/Chore')
|
||||
|
||||
# Priority detection
|
||||
if any(word in context_lower for word in ['critical', 'urgent', 'blocker', 'blocking', 'emergency']):
|
||||
suggested.append('Priority/Critical')
|
||||
elif any(word in context_lower for word in ['high', 'important', 'asap', 'soon']):
|
||||
suggested.append('Priority/High')
|
||||
elif any(word in context_lower for word in ['low', 'nice-to-have', 'optional', 'later']):
|
||||
suggested.append('Priority/Low')
|
||||
else:
|
||||
suggested.append('Priority/Medium')
|
||||
|
||||
# Complexity detection
|
||||
if any(word in context_lower for word in ['simple', 'trivial', 'easy', 'quick']):
|
||||
suggested.append('Complexity/Simple')
|
||||
elif any(word in context_lower for word in ['complex', 'difficult', 'challenging', 'intricate']):
|
||||
suggested.append('Complexity/Complex')
|
||||
else:
|
||||
suggested.append('Complexity/Medium')
|
||||
|
||||
# Efforts detection
|
||||
if any(word in context_lower for word in ['xs', 'tiny', '1 hour', '2 hours']):
|
||||
suggested.append('Efforts/XS')
|
||||
elif any(word in context_lower for word in ['small', 's ', '1 day', 'half day']):
|
||||
suggested.append('Efforts/S')
|
||||
elif any(word in context_lower for word in ['medium', 'm ', '2 days', '3 days']):
|
||||
suggested.append('Efforts/M')
|
||||
elif any(word in context_lower for word in ['large', 'l ', '1 week', '5 days']):
|
||||
suggested.append('Efforts/L')
|
||||
elif any(word in context_lower for word in ['xl', 'extra large', '2 weeks', 'sprint']):
|
||||
suggested.append('Efforts/XL')
|
||||
|
||||
# Component detection (based on keywords)
|
||||
component_keywords = {
|
||||
'Component/Backend': ['backend', 'server', 'api', 'database', 'service'],
|
||||
'Component/Frontend': ['frontend', 'ui', 'interface', 'react', 'vue', 'component'],
|
||||
'Component/API': ['api', 'endpoint', 'rest', 'graphql', 'route'],
|
||||
'Component/Database': ['database', 'db', 'sql', 'migration', 'schema', 'postgres'],
|
||||
'Component/Auth': ['auth', 'authentication', 'login', 'oauth', 'token', 'session'],
|
||||
'Component/Deploy': ['deploy', 'deployment', 'docker', 'kubernetes', 'ci/cd'],
|
||||
'Component/Testing': ['test', 'testing', 'spec', 'jest', 'pytest', 'coverage'],
|
||||
'Component/Docs': ['docs', 'documentation', 'readme', 'guide', 'wiki']
|
||||
}
|
||||
|
||||
for label, keywords in component_keywords.items():
|
||||
if any(keyword in context_lower for keyword in keywords):
|
||||
suggested.append(label)
|
||||
|
||||
# Tech stack detection
|
||||
tech_keywords = {
|
||||
'Tech/Python': ['python', 'fastapi', 'django', 'flask', 'pytest'],
|
||||
'Tech/JavaScript': ['javascript', 'js', 'node', 'npm', 'yarn'],
|
||||
'Tech/Docker': ['docker', 'dockerfile', 'container', 'compose'],
|
||||
'Tech/PostgreSQL': ['postgres', 'postgresql', 'psql', 'sql'],
|
||||
'Tech/Redis': ['redis', 'cache', 'session store'],
|
||||
'Tech/Vue': ['vue', 'vuejs', 'nuxt'],
|
||||
'Tech/FastAPI': ['fastapi', 'pydantic', 'starlette']
|
||||
}
|
||||
|
||||
for label, keywords in tech_keywords.items():
|
||||
if any(keyword in context_lower for keyword in keywords):
|
||||
suggested.append(label)
|
||||
|
||||
# Source detection (based on git branch or context)
|
||||
if 'development' in context_lower or 'dev/' in context_lower:
|
||||
suggested.append('Source/Development')
|
||||
elif 'staging' in context_lower or 'stage/' in context_lower:
|
||||
suggested.append('Source/Staging')
|
||||
elif 'production' in context_lower or 'prod' in context_lower:
|
||||
suggested.append('Source/Production')
|
||||
|
||||
# Risk detection
|
||||
if any(word in context_lower for word in ['breaking', 'breaking change', 'major', 'risky']):
|
||||
suggested.append('Risk/High')
|
||||
elif any(word in context_lower for word in ['safe', 'low risk', 'minor']):
|
||||
suggested.append('Risk/Low')
|
||||
|
||||
logger.info(f"Suggested {len(suggested)} labels based on context")
|
||||
return suggested
|
||||
145
mcp-servers/gitea/mcp_server/tools/milestones.py
Normal file
145
mcp-servers/gitea/mcp_server/tools/milestones.py
Normal file
@@ -0,0 +1,145 @@
|
||||
"""
|
||||
Milestone management tools for MCP server.
|
||||
|
||||
Provides async wrappers for milestone operations:
|
||||
- CRUD operations for milestones
|
||||
- Milestone-sprint relationship tracking
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import List, Dict, Optional
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MilestoneTools:
|
||||
"""Async wrappers for Gitea milestone operations"""
|
||||
|
||||
def __init__(self, gitea_client):
|
||||
"""
|
||||
Initialize milestone tools.
|
||||
|
||||
Args:
|
||||
gitea_client: GiteaClient instance
|
||||
"""
|
||||
self.gitea = gitea_client
|
||||
|
||||
async def list_milestones(
|
||||
self,
|
||||
state: str = 'open',
|
||||
repo: Optional[str] = None
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
List all milestones in repository.
|
||||
|
||||
Args:
|
||||
state: Milestone state (open, closed, all)
|
||||
repo: Repository in owner/repo format
|
||||
|
||||
Returns:
|
||||
List of milestone dictionaries
|
||||
"""
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.gitea.list_milestones(state, repo)
|
||||
)
|
||||
|
||||
async def get_milestone(
|
||||
self,
|
||||
milestone_id: int,
|
||||
repo: Optional[str] = None
|
||||
) -> Dict:
|
||||
"""
|
||||
Get a specific milestone by ID.
|
||||
|
||||
Args:
|
||||
milestone_id: Milestone ID
|
||||
repo: Repository in owner/repo format
|
||||
|
||||
Returns:
|
||||
Milestone dictionary
|
||||
"""
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.gitea.get_milestone(milestone_id, repo)
|
||||
)
|
||||
|
||||
async def create_milestone(
|
||||
self,
|
||||
title: str,
|
||||
description: Optional[str] = None,
|
||||
due_on: Optional[str] = None,
|
||||
repo: Optional[str] = None
|
||||
) -> Dict:
|
||||
"""
|
||||
Create a new milestone.
|
||||
|
||||
Args:
|
||||
title: Milestone title (e.g., "v2.0 Release", "Sprint 17")
|
||||
description: Milestone description
|
||||
due_on: Due date in ISO 8601 format (e.g., "2025-02-01T00:00:00Z")
|
||||
repo: Repository in owner/repo format
|
||||
|
||||
Returns:
|
||||
Created milestone dictionary
|
||||
"""
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.gitea.create_milestone(title, description, due_on, repo)
|
||||
)
|
||||
|
||||
async def update_milestone(
|
||||
self,
|
||||
milestone_id: int,
|
||||
title: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
state: Optional[str] = None,
|
||||
due_on: Optional[str] = None,
|
||||
repo: Optional[str] = None
|
||||
) -> Dict:
|
||||
"""
|
||||
Update an existing milestone.
|
||||
|
||||
Args:
|
||||
milestone_id: Milestone ID
|
||||
title: New title (optional)
|
||||
description: New description (optional)
|
||||
state: New state - 'open' or 'closed' (optional)
|
||||
due_on: New due date (optional)
|
||||
repo: Repository in owner/repo format
|
||||
|
||||
Returns:
|
||||
Updated milestone dictionary
|
||||
"""
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.gitea.update_milestone(
|
||||
milestone_id, title, description, state, due_on, repo
|
||||
)
|
||||
)
|
||||
|
||||
async def delete_milestone(
|
||||
self,
|
||||
milestone_id: int,
|
||||
repo: Optional[str] = None
|
||||
) -> bool:
|
||||
"""
|
||||
Delete a milestone.
|
||||
|
||||
Args:
|
||||
milestone_id: Milestone ID
|
||||
repo: Repository in owner/repo format
|
||||
|
||||
Returns:
|
||||
True if deleted successfully
|
||||
"""
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.gitea.delete_milestone(milestone_id, repo)
|
||||
)
|
||||
274
mcp-servers/gitea/mcp_server/tools/pull_requests.py
Normal file
274
mcp-servers/gitea/mcp_server/tools/pull_requests.py
Normal file
@@ -0,0 +1,274 @@
|
||||
"""
|
||||
Pull request management tools for MCP server.
|
||||
|
||||
Provides async wrappers for PR operations with:
|
||||
- Branch-aware security
|
||||
- PMO multi-repo support
|
||||
- Comprehensive error handling
|
||||
"""
|
||||
import asyncio
|
||||
import subprocess
|
||||
import logging
|
||||
from typing import List, Dict, Optional
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PullRequestTools:
|
||||
"""Async wrappers for Gitea pull request operations with branch detection"""
|
||||
|
||||
def __init__(self, gitea_client):
|
||||
"""
|
||||
Initialize pull request tools.
|
||||
|
||||
Args:
|
||||
gitea_client: GiteaClient instance
|
||||
"""
|
||||
self.gitea = gitea_client
|
||||
|
||||
def _get_current_branch(self) -> str:
|
||||
"""
|
||||
Get current git branch.
|
||||
|
||||
Returns:
|
||||
Current branch name or 'unknown' if not in a git repo
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['git', 'rev-parse', '--abbrev-ref', 'HEAD'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True
|
||||
)
|
||||
return result.stdout.strip()
|
||||
except subprocess.CalledProcessError:
|
||||
return "unknown"
|
||||
|
||||
def _check_branch_permissions(self, operation: str) -> bool:
|
||||
"""
|
||||
Check if operation is allowed on current branch.
|
||||
|
||||
Args:
|
||||
operation: Operation name (list_prs, create_review, etc.)
|
||||
|
||||
Returns:
|
||||
True if operation is allowed, False otherwise
|
||||
"""
|
||||
branch = self._get_current_branch()
|
||||
|
||||
# Read-only operations allowed everywhere
|
||||
read_ops = ['list_pull_requests', 'get_pull_request', 'get_pr_diff', 'get_pr_comments']
|
||||
|
||||
# Production branches (read-only)
|
||||
if branch in ['main', 'master'] or branch.startswith('prod/'):
|
||||
return operation in read_ops
|
||||
|
||||
# Staging branches (read-only for PRs, can comment)
|
||||
if branch == 'staging' or branch.startswith('stage/'):
|
||||
return operation in read_ops + ['add_pr_comment']
|
||||
|
||||
# Development branches (full access)
|
||||
if branch in ['development', 'develop'] or branch.startswith(('feat/', 'feature/', 'dev/')):
|
||||
return True
|
||||
|
||||
# Unknown branch - be restrictive
|
||||
return operation in read_ops
|
||||
|
||||
async def list_pull_requests(
|
||||
self,
|
||||
state: str = 'open',
|
||||
sort: str = 'recentupdate',
|
||||
labels: Optional[List[str]] = None,
|
||||
repo: Optional[str] = None
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
List pull requests from repository (async wrapper).
|
||||
|
||||
Args:
|
||||
state: PR state (open, closed, all)
|
||||
sort: Sort order
|
||||
labels: Filter by labels
|
||||
repo: Override configured repo (for PMO multi-repo)
|
||||
|
||||
Returns:
|
||||
List of pull request dictionaries
|
||||
|
||||
Raises:
|
||||
PermissionError: If operation not allowed on current branch
|
||||
"""
|
||||
if not self._check_branch_permissions('list_pull_requests'):
|
||||
branch = self._get_current_branch()
|
||||
raise PermissionError(
|
||||
f"Cannot list PRs on branch '{branch}'. "
|
||||
f"Switch to a development branch."
|
||||
)
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.gitea.list_pull_requests(state, sort, labels, repo)
|
||||
)
|
||||
|
||||
async def get_pull_request(
|
||||
self,
|
||||
pr_number: int,
|
||||
repo: Optional[str] = None
|
||||
) -> Dict:
|
||||
"""
|
||||
Get specific pull request details (async wrapper).
|
||||
|
||||
Args:
|
||||
pr_number: Pull request number
|
||||
repo: Override configured repo (for PMO multi-repo)
|
||||
|
||||
Returns:
|
||||
Pull request dictionary
|
||||
|
||||
Raises:
|
||||
PermissionError: If operation not allowed on current branch
|
||||
"""
|
||||
if not self._check_branch_permissions('get_pull_request'):
|
||||
branch = self._get_current_branch()
|
||||
raise PermissionError(
|
||||
f"Cannot get PR on branch '{branch}'. "
|
||||
f"Switch to a development branch."
|
||||
)
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.gitea.get_pull_request(pr_number, repo)
|
||||
)
|
||||
|
||||
async def get_pr_diff(
|
||||
self,
|
||||
pr_number: int,
|
||||
repo: Optional[str] = None
|
||||
) -> str:
|
||||
"""
|
||||
Get pull request diff (async wrapper).
|
||||
|
||||
Args:
|
||||
pr_number: Pull request number
|
||||
repo: Override configured repo (for PMO multi-repo)
|
||||
|
||||
Returns:
|
||||
Diff as string
|
||||
|
||||
Raises:
|
||||
PermissionError: If operation not allowed on current branch
|
||||
"""
|
||||
if not self._check_branch_permissions('get_pr_diff'):
|
||||
branch = self._get_current_branch()
|
||||
raise PermissionError(
|
||||
f"Cannot get PR diff on branch '{branch}'. "
|
||||
f"Switch to a development branch."
|
||||
)
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.gitea.get_pr_diff(pr_number, repo)
|
||||
)
|
||||
|
||||
async def get_pr_comments(
|
||||
self,
|
||||
pr_number: int,
|
||||
repo: Optional[str] = None
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
Get comments on a pull request (async wrapper).
|
||||
|
||||
Args:
|
||||
pr_number: Pull request number
|
||||
repo: Override configured repo (for PMO multi-repo)
|
||||
|
||||
Returns:
|
||||
List of comment dictionaries
|
||||
|
||||
Raises:
|
||||
PermissionError: If operation not allowed on current branch
|
||||
"""
|
||||
if not self._check_branch_permissions('get_pr_comments'):
|
||||
branch = self._get_current_branch()
|
||||
raise PermissionError(
|
||||
f"Cannot get PR comments on branch '{branch}'. "
|
||||
f"Switch to a development branch."
|
||||
)
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.gitea.get_pr_comments(pr_number, repo)
|
||||
)
|
||||
|
||||
async def create_pr_review(
|
||||
self,
|
||||
pr_number: int,
|
||||
body: str,
|
||||
event: str = 'COMMENT',
|
||||
comments: Optional[List[Dict]] = None,
|
||||
repo: Optional[str] = None
|
||||
) -> Dict:
|
||||
"""
|
||||
Create a review on a pull request (async wrapper with branch check).
|
||||
|
||||
Args:
|
||||
pr_number: Pull request number
|
||||
body: Review body/summary
|
||||
event: Review action (APPROVE, REQUEST_CHANGES, COMMENT)
|
||||
comments: Optional list of inline comments
|
||||
repo: Override configured repo (for PMO multi-repo)
|
||||
|
||||
Returns:
|
||||
Created review dictionary
|
||||
|
||||
Raises:
|
||||
PermissionError: If operation not allowed on current branch
|
||||
"""
|
||||
if not self._check_branch_permissions('create_pr_review'):
|
||||
branch = self._get_current_branch()
|
||||
raise PermissionError(
|
||||
f"Cannot create PR review on branch '{branch}'. "
|
||||
f"Switch to a development branch to review PRs."
|
||||
)
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.gitea.create_pr_review(pr_number, body, event, comments, repo)
|
||||
)
|
||||
|
||||
async def add_pr_comment(
|
||||
self,
|
||||
pr_number: int,
|
||||
body: str,
|
||||
repo: Optional[str] = None
|
||||
) -> Dict:
|
||||
"""
|
||||
Add a general comment to a pull request (async wrapper with branch check).
|
||||
|
||||
Args:
|
||||
pr_number: Pull request number
|
||||
body: Comment text
|
||||
repo: Override configured repo (for PMO multi-repo)
|
||||
|
||||
Returns:
|
||||
Created comment dictionary
|
||||
|
||||
Raises:
|
||||
PermissionError: If operation not allowed on current branch
|
||||
"""
|
||||
if not self._check_branch_permissions('add_pr_comment'):
|
||||
branch = self._get_current_branch()
|
||||
raise PermissionError(
|
||||
f"Cannot add PR comment on branch '{branch}'. "
|
||||
f"Switch to a development or staging branch to comment on PRs."
|
||||
)
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.gitea.add_pr_comment(pr_number, body, repo)
|
||||
)
|
||||
149
mcp-servers/gitea/mcp_server/tools/wiki.py
Normal file
149
mcp-servers/gitea/mcp_server/tools/wiki.py
Normal file
@@ -0,0 +1,149 @@
|
||||
"""
|
||||
Wiki management tools for MCP server.
|
||||
|
||||
Provides async wrappers for wiki operations to support lessons learned:
|
||||
- Page CRUD operations
|
||||
- Lessons learned creation and search
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import List, Dict, Optional
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WikiTools:
|
||||
"""Async wrappers for Gitea wiki operations"""
|
||||
|
||||
def __init__(self, gitea_client):
|
||||
"""
|
||||
Initialize wiki tools.
|
||||
|
||||
Args:
|
||||
gitea_client: GiteaClient instance
|
||||
"""
|
||||
self.gitea = gitea_client
|
||||
|
||||
async def list_wiki_pages(self, repo: Optional[str] = None) -> List[Dict]:
|
||||
"""List all wiki pages in repository."""
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.gitea.list_wiki_pages(repo)
|
||||
)
|
||||
|
||||
async def get_wiki_page(
|
||||
self,
|
||||
page_name: str,
|
||||
repo: Optional[str] = None
|
||||
) -> Dict:
|
||||
"""Get a specific wiki page by name."""
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.gitea.get_wiki_page(page_name, repo)
|
||||
)
|
||||
|
||||
async def create_wiki_page(
|
||||
self,
|
||||
title: str,
|
||||
content: str,
|
||||
repo: Optional[str] = None
|
||||
) -> Dict:
|
||||
"""Create a new wiki page."""
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.gitea.create_wiki_page(title, content, repo)
|
||||
)
|
||||
|
||||
async def update_wiki_page(
|
||||
self,
|
||||
page_name: str,
|
||||
content: str,
|
||||
repo: Optional[str] = None
|
||||
) -> Dict:
|
||||
"""Update an existing wiki page."""
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.gitea.update_wiki_page(page_name, content, repo)
|
||||
)
|
||||
|
||||
async def delete_wiki_page(
|
||||
self,
|
||||
page_name: str,
|
||||
repo: Optional[str] = None
|
||||
) -> bool:
|
||||
"""Delete a wiki page."""
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.gitea.delete_wiki_page(page_name, repo)
|
||||
)
|
||||
|
||||
async def search_wiki_pages(
|
||||
self,
|
||||
query: str,
|
||||
repo: Optional[str] = None
|
||||
) -> List[Dict]:
|
||||
"""Search wiki pages by title."""
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.gitea.search_wiki_pages(query, repo)
|
||||
)
|
||||
|
||||
async def create_lesson(
|
||||
self,
|
||||
title: str,
|
||||
content: str,
|
||||
tags: List[str],
|
||||
category: str = "sprints",
|
||||
repo: Optional[str] = None
|
||||
) -> Dict:
|
||||
"""
|
||||
Create a lessons learned entry in the wiki.
|
||||
|
||||
Args:
|
||||
title: Lesson title (e.g., "Sprint 16 - Prevent Infinite Loops")
|
||||
content: Lesson content in markdown
|
||||
tags: List of tags for categorization
|
||||
category: Category (sprints, patterns, architecture, etc.)
|
||||
repo: Repository in owner/repo format
|
||||
|
||||
Returns:
|
||||
Created wiki page
|
||||
"""
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.gitea.create_lesson(title, content, tags, category, repo)
|
||||
)
|
||||
|
||||
async def search_lessons(
|
||||
self,
|
||||
query: Optional[str] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
limit: int = 20,
|
||||
repo: Optional[str] = None
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
Search lessons learned from previous sprints.
|
||||
|
||||
Args:
|
||||
query: Search query (optional)
|
||||
tags: Tags to filter by (optional)
|
||||
limit: Maximum results (default 20)
|
||||
repo: Repository in owner/repo format
|
||||
|
||||
Returns:
|
||||
List of matching lessons
|
||||
"""
|
||||
loop = asyncio.get_event_loop()
|
||||
results = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.gitea.search_lessons(query, tags, repo)
|
||||
)
|
||||
return results[:limit]
|
||||
Reference in New Issue
Block a user