feat: major improvements to projman plugin v1.0.0

- Remove Wiki.js MCP server entirely
- Add wiki, milestone, and dependency tools to Gitea MCP server
- Add parallel execution support based on dependency graph
- Add mandatory pre-planning validations (org check, labels, docs/changes)
- Add CLI blocking rules to all agents (API-only)
- Add standardized task naming: [Sprint XX] <type>: <description>
- Add branch naming convention: feat/, fix/, debug/ prefixes
- Add MR body template without subtasks
- Add auto-close issues via commit keywords
- Create claude-config-maintainer plugin for CLAUDE.md optimization
- Update all sprint commands with new tools and workflows
- Update documentation to remove Wiki.js references

New MCP tools:
- Wiki: list_wiki_pages, get_wiki_page, create_wiki_page, create_lesson, search_lessons
- Milestones: list_milestones, get_milestone, create_milestone, update_milestone
- Dependencies: list_issue_dependencies, create_issue_dependency, get_execution_order
- Validation: validate_repo_org, get_branch_protection, create_label

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-19 17:10:22 -05:00
parent d84425cbb0
commit 74b28170fa
39 changed files with 3410 additions and 4023 deletions

View File

@@ -6,9 +6,13 @@ Provides synchronous methods for:
- 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
@@ -209,3 +213,381 @@ class GiteaClient:
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()

View File

@@ -14,6 +14,9 @@ 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
# Suppress noisy MCP validation warnings on stderr
logging.basicConfig(level=logging.INFO)
@@ -31,6 +34,9 @@ class GiteaMCPServer:
self.client = None
self.issue_tools = None
self.label_tools = None
self.wiki_tools = None
self.milestone_tools = None
self.dependency_tools = None
async def initialize(self):
"""
@@ -46,6 +52,9 @@ class GiteaMCPServer:
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)
logger.info(f"Gitea MCP Server initialized in {self.config['mode']} mode")
except Exception as e:
@@ -237,6 +246,398 @@ class GiteaMCPServer:
},
"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"]
}
)
]
@@ -270,6 +671,61 @@ class GiteaMCPServer:
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')
)
else:
raise ValueError(f"Unknown tool: {name}")

View 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

View 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)
)

View 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]