Merge pull request 'development' (#454) from development into main

Reviewed-on: #454
This commit was merged in pull request #454.
This commit is contained in:
2026-02-09 00:57:27 +00:00
57 changed files with 561 additions and 6368 deletions

View File

@@ -6,6 +6,24 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased]
### Added
- **All plugins:** Dispatch files now active command handlers — bare `/noun` shows available sub-commands and prompts for selection instead of doing nothing
- **git-flow:** New `/gitflow setup` command — auto-detects Gitea system config, configures workflow settings, injects CLAUDE.md git-flow section automatically
- **git-flow:** `claude-md-integration.md` moved to `skills/` for programmatic use by setup command
- **project-hygiene:** New `hygiene.md` dispatch file for bare `/hygiene` invocation
### Changed
- **gitea MCP:** Switched from local source (`mcp_server/`) to published `gitea-mcp>=1.0.0` package from Gitea PyPI registry
- **gitea MCP:** Module namespace changed: `mcp_server.server``gitea_mcp.server`
- **mcp-servers/gitea/:** Thinned to venv wrapper — source code removed, package installed from registry
- **All dispatch files:** Renamed `## Sub-commands` to `## Available Commands`, added `## Workflow` section
### Fixed
- **All plugins:** Bare `/noun` commands no longer dead-end — they display sub-command menus and prompt for selection
## [9.1.2] - 2026-02-07
### Fixed

View File

@@ -21,7 +21,7 @@ Plugin marketplace for Claude Code. 20 plugins across 5 domains, 5 shared MCP se
| Server | Plugins Using It | External System |
|--------|-------------------|-----------------|
| gitea | projman, pr-review | Gitea (issues, PRs, wiki) |
| gitea | projman, pr-review | Gitea (issues, PRs, wiki) — uses published `gitea-mcp` package |
| netbox | cmdb-assistant | NetBox (DCIM, IPAM) |
| data-platform | data-platform | PostgreSQL, dbt |
| viz-platform | viz-platform | DMC registry |

View File

@@ -261,9 +261,11 @@ MCP servers are **shared at repository root** and configured in `.mcp.json`.
|---------|---------|---------|
| MCP configuration | `.mcp.json` | `.mcp.json` (at repo root) |
| Shared MCP server | `mcp-servers/{server}/` | `mcp-servers/gitea/` |
| MCP server code | `mcp-servers/{server}/mcp_server/` | `mcp-servers/gitea/mcp_server/` |
| MCP server code | `mcp-servers/{server}/mcp_server/` | `mcp-servers/netbox/mcp_server/` |
| MCP venv (local) | `mcp-servers/{server}/.venv/` | `mcp-servers/gitea/.venv/` |
**Note:** `mcp-servers/gitea/` is a thin wrapper — source code is in the published `gitea-mcp` package (Gitea PyPI). Other MCP servers still have local source code.
**Note:** Plugins do NOT have their own `mcp-servers/` directories. All MCP servers are shared at root and configured via `.mcp.json`.
### MCP Venv Paths - CRITICAL

View File

@@ -1,6 +0,0 @@
2026-02-03T14:09:25 | mcp-servers | /home/lmiranda/claude-plugins-work/mcp-servers/gitea/tests/test_config.py | docs/COMMANDS-CHEATSHEET.md CLAUDE.md
2026-02-03T14:09:33 | mcp-servers | /home/lmiranda/claude-plugins-work/mcp-servers/gitea/tests/test_gitea_client.py | docs/COMMANDS-CHEATSHEET.md CLAUDE.md
2026-02-03T14:10:22 | mcp-servers | /home/lmiranda/claude-plugins-work/mcp-servers/gitea/tests/test_issues.py | docs/COMMANDS-CHEATSHEET.md CLAUDE.md
2026-02-03T14:17:12 | mcp-servers | /home/lmiranda/claude-plugins-work/mcp-servers/gitea/README.md | docs/COMMANDS-CHEATSHEET.md CLAUDE.md
2026-02-03T14:18:27 | mcp-servers | /home/lmiranda/claude-plugins-work/mcp-servers/gitea/CHANGELOG.md | docs/COMMANDS-CHEATSHEET.md CLAUDE.md
2026-02-03T14:18:41 | mcp-servers | /home/lmiranda/claude-plugins-work/mcp-servers/gitea/TESTING.md | docs/COMMANDS-CHEATSHEET.md CLAUDE.md

View File

@@ -1,92 +0,0 @@
# Changelog
All notable changes to the Gitea MCP Server will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [1.3.0] - 2026-02-03
### Added
- Pull request tools (7 tools):
- `list_pull_requests` - List PRs from repository
- `get_pull_request` - Get specific PR details
- `get_pr_diff` - Get PR diff content
- `get_pr_comments` - Get comments on a PR
- `create_pr_review` - Create PR review (approve/request changes/comment)
- `add_pr_comment` - Add comment to PR
- `create_pull_request` - Create new pull request
- Label creation tools (3 tools):
- `create_label` - Create repo-level label
- `create_org_label` - Create organization-level label
- `create_label_smart` - Auto-detect org vs repo for label creation
- Validation tools (2 tools):
- `validate_repo_org` - Check if repo belongs to organization
- `get_branch_protection` - Get branch protection rules
### Changed
- Total tools increased from 20 to 36
- Updated test suite to 64 tests (was 42)
### Fixed
- Test fixtures updated to use `owner/repo` format
- Fixed aggregate_issues tests to pass required `org` argument
## [1.2.0] - 2026-01-28
### Added
- Milestone management tools (5 tools):
- `list_milestones` - List all milestones
- `get_milestone` - Get specific milestone
- `create_milestone` - Create new milestone
- `update_milestone` - Update existing milestone
- `delete_milestone` - Delete a milestone
- Issue dependency tools (4 tools):
- `list_issue_dependencies` - List blocking issues
- `create_issue_dependency` - Create dependency between issues
- `remove_issue_dependency` - Remove dependency
- `get_execution_order` - Calculate parallelizable execution order
## [1.1.0] - 2026-01-21
### Added
- Wiki and lessons learned tools (7 tools):
- `list_wiki_pages` - List all wiki pages
- `get_wiki_page` - Get specific wiki page content
- `create_wiki_page` - Create new wiki page
- `update_wiki_page` - Update existing wiki page
- `create_lesson` - Create lessons learned entry
- `search_lessons` - Search lessons by query/tags
- `allocate_rfc_number` - Get next available RFC number
- Automatic git remote URL detection for repository configuration
- Support for SSH, HTTPS, and HTTP git URL formats
### Changed
- Configuration now uses `owner/repo` format exclusively
- Removed separate `GITEA_OWNER` configuration (now derived from repo path)
## [1.0.0] - 2025-01-06
### Added
- Initial release with 8 core tools:
- `list_issues` - List issues from repository
- `get_issue` - Get specific issue details
- `create_issue` - Create new issue with labels
- `update_issue` - Update existing issue
- `add_comment` - Add comment to issue
- `get_labels` - Get all labels (org + repo)
- `suggest_labels` - Intelligent label suggestion
- `aggregate_issues` - Cross-repository issue aggregation (PMO mode)
- Hybrid configuration system (system + project level)
- Branch-aware security model
- Mode detection (project vs company/PMO)
- 42 unit tests with mocks
- Comprehensive documentation
[Unreleased]: https://github.com/owner/repo/compare/v1.3.0...HEAD
[1.3.0]: https://github.com/owner/repo/compare/v1.2.0...v1.3.0
[1.2.0]: https://github.com/owner/repo/compare/v1.1.0...v1.2.0
[1.1.0]: https://github.com/owner/repo/compare/v1.0.0...v1.1.0
[1.0.0]: https://github.com/owner/repo/releases/tag/v1.0.0

View File

@@ -1,490 +1,47 @@
# Gitea MCP Server
# Gitea MCP Server (Marketplace Wrapper)
Model Context Protocol (MCP) server for Gitea integration with Claude Code.
This directory provides the virtual environment for the `gitea-mcp` package.
## Overview
## Package
The Gitea MCP Server provides Claude Code with direct access to Gitea for issue management, label operations, and repository tracking. It supports both single-repository (project mode) and multi-repository (company/PMO mode) operations.
**Source:** [gitea-mcp](https://gitea.hotserv.cloud/personal-projects/gitea-mcp)
**Registry:** Gitea PyPI at gitea.hotserv.cloud
**Version:** >=1.0.0
**Status**: ✅ Phase 1 Complete - Fully functional and tested
## Features
### Core Functionality
- **Issue Management**: CRUD operations for Gitea issues
- **Label Taxonomy**: Dynamic 44-label system with intelligent suggestions
- **Mode Detection**: Automatic project vs company-wide mode detection
- **Branch-Aware Security**: Prevents accidental changes on production branches
- **Hybrid Configuration**: System-level credentials + project-level paths
- **PMO Support**: Multi-repository aggregation for organization-wide views
### Tools Provided (36 total)
#### Issue Management (6 tools)
| Tool | Description | Mode |
|------|-------------|------|
| `list_issues` | List issues from repository | Both |
| `get_issue` | Get specific issue details | Both |
| `create_issue` | Create new issue with labels | Both |
| `update_issue` | Update existing issue | Both |
| `add_comment` | Add comment to issue | Both |
| `aggregate_issues` | Cross-repository issue aggregation | PMO Only |
#### Label Management (5 tools)
| Tool | Description | Mode |
|------|-------------|------|
| `get_labels` | Get all labels (org + repo) | Both |
| `suggest_labels` | Intelligent label suggestion | Both |
| `create_label` | Create repo-level label | Both |
| `create_org_label` | Create organization-level label | Both |
| `create_label_smart` | Auto-detect org vs repo for label creation | Both |
#### Wiki & Lessons Learned (7 tools)
| Tool | Description | Mode |
|------|-------------|------|
| `list_wiki_pages` | List all wiki pages | Both |
| `get_wiki_page` | Get specific wiki page content | Both |
| `create_wiki_page` | Create new wiki page | Both |
| `update_wiki_page` | Update existing wiki page | Both |
| `create_lesson` | Create lessons learned entry | Both |
| `search_lessons` | Search lessons by query/tags | Both |
| `allocate_rfc_number` | Get next available RFC number | Both |
#### Milestone Management (5 tools)
| Tool | Description | Mode |
|------|-------------|------|
| `list_milestones` | List all milestones | Both |
| `get_milestone` | Get specific milestone | Both |
| `create_milestone` | Create new milestone | Both |
| `update_milestone` | Update existing milestone | Both |
| `delete_milestone` | Delete a milestone | Both |
#### Issue Dependencies (4 tools)
| Tool | Description | Mode |
|------|-------------|------|
| `list_issue_dependencies` | List blocking issues | Both |
| `create_issue_dependency` | Create dependency between issues | Both |
| `remove_issue_dependency` | Remove dependency | Both |
| `get_execution_order` | Calculate parallelizable execution order | Both |
#### Pull Request Tools (7 tools)
| Tool | Description | Mode |
|------|-------------|------|
| `list_pull_requests` | List PRs from repository | Both |
| `get_pull_request` | Get specific PR details | Both |
| `get_pr_diff` | Get PR diff content | Both |
| `get_pr_comments` | Get comments on a PR | Both |
| `create_pr_review` | Create PR review (approve/request changes) | Both |
| `add_pr_comment` | Add comment to PR | Both |
| `create_pull_request` | Create new pull request | Both |
#### Validation Tools (2 tools)
| Tool | Description | Mode |
|------|-------------|------|
| `validate_repo_org` | Check if repo belongs to organization | Both |
| `get_branch_protection` | Get branch protection rules | Both |
## Architecture
### Directory Structure
```
mcp-servers/gitea/
├── .venv/ # Python virtual environment
├── requirements.txt # Python dependencies
├── run.sh # Entry point script
├── mcp_server/
│ ├── __init__.py
│ ├── server.py # MCP server entry point (36 tools)
│ ├── config.py # Configuration loader with auto-detection
│ ├── gitea_client.py # Gitea API client
│ └── tools/
│ ├── __init__.py
│ ├── issues.py # Issue management tools
│ ├── labels.py # Label management tools
│ ├── wiki.py # Wiki & lessons learned tools
│ ├── milestones.py # Milestone management tools
│ ├── dependencies.py # Issue dependency tools
│ └── pull_requests.py # Pull request tools
├── tests/
│ ├── __init__.py
│ ├── test_config.py
│ ├── test_gitea_client.py
│ ├── test_issues.py
│ └── test_labels.py
├── README.md # This file
├── TESTING.md # Testing instructions
└── CHANGELOG.md # Version history
```
### Mode Detection
The server operates in two modes based on environment variables:
**Project Mode** (Single Repository):
- When `GITEA_REPO` is set
- Operates on single repository
- Used by `projman` plugin
**Company Mode** (Multi-Repository / PMO):
- When `GITEA_REPO` is NOT set
- Operates on all repositories in organization
- Used by `projman-pmo` plugin
### Branch-Aware Security
Operations are restricted based on the current Git branch:
| Branch | Read | Create Issue | Update/Comment |
|--------|------|--------------|----------------|
| `main`, `master`, `prod/*` | ✅ | ❌ | ❌ |
| `staging`, `stage/*` | ✅ | ✅ | ❌ |
| `development`, `develop`, `feat/*`, `dev/*` | ✅ | ✅ | ✅ |
## Installation
### Prerequisites
- Python 3.10 or higher
- Git repository (for branch detection)
- Access to Gitea instance with API token
### Step 1: Install Dependencies
## Setup
```bash
cd mcp-servers/gitea
python3 -m venv .venv
source .venv/bin/activate # Linux/Mac
# or .venv\Scripts\activate # Windows
source .venv/bin/activate
pip install -r requirements.txt
```
### Step 2: Configure System-Level Settings
Create `~/.config/claude/gitea.env`:
Or use the marketplace setup script:
```bash
mkdir -p ~/.config/claude
cat > ~/.config/claude/gitea.env << EOF
GITEA_API_URL=https://gitea.example.com/api/v1
GITEA_API_TOKEN=your_gitea_token_here
EOF
chmod 600 ~/.config/claude/gitea.env
./scripts/setup-venvs.sh gitea
```
### Step 3: Configure Project-Level Settings (Optional)
For project mode, create `.env` in your project root:
```bash
echo "GITEA_REPO=your-repo-name" > .env
echo ".env" >> .gitignore
```
For company/PMO mode, omit the `.env` file or don't set `GITEA_REPO`.
## Configuration
### System-Level Configuration
See `~/.config/claude/gitea.env` for system-level config (API URL, token).
See project `.env` for project-level config (GITEA_ORG, GITEA_REPO).
**File**: `~/.config/claude/gitea.env`
**Required Variables**:
- `GITEA_API_URL` - Gitea API endpoint (e.g., `https://gitea.example.com/api/v1`)
- `GITEA_API_TOKEN` - Personal access token with repo permissions
### Project-Level Configuration
**File**: `<project-root>/.env`
**Optional Variables**:
- `GITEA_REPO` - Repository in `owner/repo` format (enables project mode)
### Automatic Repository Detection
If `GITEA_REPO` is not set, the server auto-detects the repository from your git remote:
**Supported URL Formats**:
- SSH: `ssh://git@gitea.example.com:22/owner/repo.git`
- SSH short: `git@gitea.example.com:owner/repo.git`
- HTTPS: `https://gitea.example.com/owner/repo.git`
- HTTP: `http://gitea.example.com/owner/repo.git`
The repository is extracted as `owner/repo` format automatically.
### Project Directory Detection
The server finds your project directory using these strategies (in order):
1. `CLAUDE_PROJECT_DIR` environment variable (highest priority)
2. `PWD` environment variable (if `.git` or `.env` present)
3. Current working directory (if `.git` or `.env` present)
4. Falls back to company/PMO mode if no project found
### Generating Gitea API Token
1. Log into Gitea: https://gitea.example.com
2. Navigate to: **Settings****Applications****Manage Access Tokens**
3. Click **Generate New Token**
4. Configure token:
- **Token Name**: `claude-code-mcp`
- **Permissions**:
-`repo` (all) - Read/write repositories, issues, labels
-`read:org` - Read organization information and labels
-`read:user` - Read user information
5. Click **Generate Token**
6. Copy token immediately (shown only once)
7. Add to `~/.config/claude/gitea.env`
## Usage
### Running the MCP Server
## Updating
```bash
cd mcp-servers/gitea
source .venv/bin/activate
python -m mcp_server.server
pip install --upgrade gitea-mcp \
--extra-index-url https://gitea.hotserv.cloud/api/packages/personal-projects/pypi/simple
```
The server communicates via JSON-RPC 2.0 over stdio.
## Features
### Integration with Claude Code Plugins
The `gitea-mcp` package provides MCP tools for:
The MCP server is designed to be used by Claude Code plugins via `.mcp.json` configuration:
```json
{
"mcpServers": {
"gitea": {
"command": "python",
"args": ["-m", "mcp_server.server"],
"cwd": "${CLAUDE_PLUGIN_ROOT}/../mcp-servers/gitea",
"env": {
"PYTHONPATH": "${CLAUDE_PLUGIN_ROOT}/../mcp-servers/gitea"
}
}
}
}
```
### Example Tool Calls
**List Issues**:
```python
from mcp_server.tools.issues import IssueTools
from mcp_server.gitea_client import GiteaClient
client = GiteaClient()
issue_tools = IssueTools(client)
issues = await issue_tools.list_issues(state='open', labels=['Type/Bug'])
```
**Suggest Labels**:
```python
from mcp_server.tools.labels import LabelTools
label_tools = LabelTools(client)
context = "Fix critical authentication bug in production API"
suggestions = await label_tools.suggest_labels(context)
# Returns: ['Type/Bug', 'Priority/Critical', 'Component/Auth', 'Component/API', ...]
```
## Testing
### Unit Tests
Run all 64 unit tests with mocks:
```bash
pytest tests/ -v
```
Expected: `64 passed`
### Integration Tests
Test with real Gitea instance:
```bash
python -c "
from mcp_server.gitea_client import GiteaClient
client = GiteaClient()
issues = client.list_issues(state='open')
print(f'Found {len(issues)} open issues')
"
```
### Full Testing Guide
See [TESTING.md](./TESTING.md) for comprehensive testing instructions.
## Label Taxonomy System
The system supports a dynamic 44-label taxonomy (28 org + 16 repo):
**Organization Labels (28)**:
- `Agent/*` (2) - Agent/Human, Agent/Claude
- `Complexity/*` (3) - Simple, Medium, Complex
- `Efforts/*` (5) - XS, S, M, L, XL
- `Priority/*` (4) - Low, Medium, High, Critical
- `Risk/*` (3) - Low, Medium, High
- `Source/*` (4) - Development, Staging, Production, Customer
- `Type/*` (6) - Bug, Feature, Refactor, Documentation, Test, Chore
**Repository Labels (16)**:
- `Component/*` (9) - Backend, Frontend, API, Database, Auth, Deploy, Testing, Docs, Infra
- `Tech/*` (7) - Python, JavaScript, Docker, PostgreSQL, Redis, Vue, FastAPI
Labels are fetched dynamically from Gitea and suggestions adapt to the current taxonomy.
## Security
### Token Storage
- Store tokens in `~/.config/claude/gitea.env`
- Set file permissions to `600` (read/write owner only)
- Never commit tokens to Git
- Use separate tokens for development and production
### Branch Detection
The MCP server implements defense-in-depth branch detection:
1. **MCP Tools**: Check branch before operations
2. **Agent Prompts**: Warn users about branch restrictions
3. **CLAUDE.md**: Provides additional context
### Input Validation
- All user input is validated before API calls
- Issue titles and descriptions are sanitized
- Label names are checked against taxonomy
- Repository names are validated
## Troubleshooting
### Common Issues
**Module not found**:
```bash
cd mcp-servers/gitea
source .venv/bin/activate
```
**Configuration not found**:
```bash
ls -la ~/.config/claude/gitea.env
# If missing, create it following installation steps
```
**Authentication failed**:
```bash
# Test token manually
curl -H "Authorization: token YOUR_TOKEN" \
https://gitea.example.com/api/v1/user
```
**Permission denied on branch**:
```bash
# Check current branch
git branch --show-current
# Switch to development branch
git checkout development
```
See [TESTING.md](./TESTING.md#troubleshooting) for more details.
## Development
### Project Structure
- `config.py` - Hybrid configuration loader with auto-detection
- `gitea_client.py` - Synchronous Gitea API client using requests
- `tools/issues.py` - Issue management with branch detection
- `tools/labels.py` - Label management and intelligent suggestions
- `tools/wiki.py` - Wiki pages and lessons learned
- `tools/milestones.py` - Milestone CRUD operations
- `tools/dependencies.py` - Issue dependency tracking
- `tools/pull_requests.py` - PR review and management
- `server.py` - MCP server with 36 tools over JSON-RPC 2.0 stdio
### Adding New Tools
1. Add method to `GiteaClient` (sync)
2. Add async wrapper to appropriate tool class
3. Register tool in `server.py` `setup_tools()`
4. Add unit tests
5. Update documentation
### Testing Philosophy
- **Unit tests**: Use mocks for fast feedback
- **Integration tests**: Use real Gitea API for validation
- **Branch detection**: Test all branch types
- **Mode detection**: Test both project and company modes
## Performance
### Caching
Labels are cached to reduce API calls:
```python
from functools import lru_cache
@lru_cache(maxsize=128)
def get_labels_cached(self, repo: str):
return self.get_labels(repo)
```
### Retry Logic
API calls include automatic retry with exponential backoff:
```python
@retry_on_failure(max_retries=3, delay=1)
def list_issues(self, state='open', labels=None, repo=None):
# Implementation
```
## Changelog
See [CHANGELOG.md](./CHANGELOG.md) for full version history.
### Recent Updates
- **v1.3.0** - Pull request tools (7 tools), label creation tools (3)
- **v1.2.0** - Milestone management (5 tools), issue dependencies (4 tools)
- **v1.1.0** - Wiki & lessons learned system (7 tools)
- **v1.0.0** - Initial release with core issue/label tools (8 tools)
## License
MIT License - Part of the Leo Claude Marketplace project.
## Related Documentation
- **Projman Documentation**: `plugins/projman/README.md`
- **Configuration Guide**: `plugins/projman/CONFIGURATION.md`
- **Testing Guide**: `TESTING.md`
## Support
For issues or questions:
1. Check [TESTING.md](./TESTING.md) troubleshooting section
2. Review [plugins/projman/README.md](../../README.md) for plugin documentation
3. Create an issue in the project repository
---
**Built for**: Leo Claude Marketplace - Project Management Plugins
**Tools**: 36
**Status**: ✅ Production Ready
**Last Updated**: 2026-02-03
- **Issues**: CRUD, comments, dependencies, execution order
- **Labels**: Get, suggest, create (org + repo level)
- **Milestones**: CRUD operations
- **Pull Requests**: List, get, diff, comments, reviews, create
- **Wiki**: Pages, lessons learned, RFC allocation
- **Validation**: Repository org check, branch protection

View File

@@ -1,582 +0,0 @@
# Gitea MCP Server - Testing Guide
This document provides comprehensive testing instructions for the Gitea MCP Server implementation.
## Table of Contents
1. [Unit Tests](#unit-tests)
2. [Manual MCP Server Testing](#manual-mcp-server-testing)
3. [Integration Testing](#integration-testing)
4. [Configuration Setup for Testing](#configuration-setup-for-testing)
5. [Troubleshooting](#troubleshooting)
---
## Unit Tests
Unit tests use mocks to test all modules without requiring a real Gitea instance.
### Prerequisites
Ensure the virtual environment is activated and dependencies are installed:
```bash
cd mcp-servers/gitea
source .venv/bin/activate # Linux/Mac
# or .venv\Scripts\activate # Windows
```
### Running All Tests
Run all 64 unit tests:
```bash
pytest tests/ -v
```
Expected output:
```
============================== 64 passed ==============================
```
### Running Specific Test Files
Run tests for a specific module:
```bash
# Configuration tests
pytest tests/test_config.py -v
# Gitea client tests
pytest tests/test_gitea_client.py -v
# Issue tools tests
pytest tests/test_issues.py -v
# Label tools tests
pytest tests/test_labels.py -v
```
### Running Specific Tests
Run a single test:
```bash
pytest tests/test_config.py::test_load_system_config -v
```
### Test Coverage
Generate coverage report:
```bash
pytest --cov=mcp_server --cov-report=html tests/
# View coverage report
# Open htmlcov/index.html in your browser
```
Expected coverage: >80% for all modules
### Test Organization
**Configuration Tests** (`test_config.py`):
- System-level configuration loading
- Project-level configuration override
- Mode detection (project vs company)
- Missing configuration handling
**Gitea Client Tests** (`test_gitea_client.py`):
- API client initialization
- Issue CRUD operations
- Label retrieval
- PMO multi-repo operations
**Issue Tools Tests** (`test_issues.py`):
- Branch-aware security checks
- Async wrappers for sync client
- Permission enforcement
- PMO aggregation mode
**Label Tools Tests** (`test_labels.py`):
- Label retrieval (org + repo)
- Intelligent label suggestion
- Multi-category detection
---
## Manual MCP Server Testing
Test the MCP server manually using stdio communication.
### Step 1: Start the MCP Server
```bash
cd mcp-servers/gitea
source .venv/bin/activate
python -m mcp_server.server
```
The server will start and wait for JSON-RPC 2.0 messages on stdin.
### Step 2: Test Tool Listing
In another terminal, send a tool listing request:
```bash
echo '{"jsonrpc": "2.0", "id": 1, "method": "tools/list"}' | python -m mcp_server.server
```
Expected response:
```json
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"tools": [
{"name": "list_issues", "description": "List issues from Gitea repository", ...},
{"name": "get_issue", "description": "Get specific issue details", ...},
{"name": "create_issue", "description": "Create a new issue in Gitea", ...},
...
]
}
}
```
### Step 3: Test Tool Invocation
**Note:** Manual tool invocation requires proper configuration. See [Configuration Setup](#configuration-setup-for-testing).
Example: List issues
```bash
echo '{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "list_issues",
"arguments": {
"state": "open"
}
}
}' | python -m mcp_server.server
```
---
## Integration Testing
Test the MCP server with a real Gitea instance.
### Prerequisites
1. **Gitea Instance**: Access to https://gitea.example.com (or your Gitea instance)
2. **API Token**: Personal access token with required permissions
3. **Configuration**: Properly configured system and project configs
### Step 1: Configuration Setup
Create system-level configuration:
```bash
mkdir -p ~/.config/claude
cat > ~/.config/claude/gitea.env << EOF
GITEA_API_URL=https://gitea.example.com/api/v1
GITEA_API_TOKEN=your_gitea_token_here
GITEA_OWNER=bandit
EOF
chmod 600 ~/.config/claude/gitea.env
```
Create project-level configuration (for project mode testing):
```bash
cd /path/to/test/project
cat > .env << EOF
GITEA_REPO=test-repo
EOF
# Add to .gitignore
echo ".env" >> .gitignore
```
### Step 2: Generate Gitea API Token
1. Log into Gitea: https://gitea.example.com
2. Navigate to: **Settings****Applications****Manage Access Tokens**
3. Click **Generate New Token**
4. Token configuration:
- **Token Name:** `mcp-integration-test`
- **Required Permissions:**
-`repo` (all) - Read/write access to repositories, issues, labels
-`read:org` - Read organization information and labels
-`read:user` - Read user information
5. Click **Generate Token**
6. Copy the token immediately (shown only once)
7. Add to `~/.config/claude/gitea.env`
### Step 3: Verify Configuration
Test configuration loading:
```bash
cd mcp-servers/gitea
source .venv/bin/activate
python -c "
from mcp_server.config import GiteaConfig
config = GiteaConfig()
result = config.load()
print(f'API URL: {result[\"api_url\"]}')
print(f'Owner: {result[\"owner\"]}')
print(f'Repo: {result[\"repo\"]}')
print(f'Mode: {result[\"mode\"]}')
"
```
Expected output:
```
API URL: https://gitea.example.com/api/v1
Owner: bandit
Repo: test-repo (or None for company mode)
Mode: project (or company)
```
### Step 4: Test Gitea Client
Test basic Gitea API operations:
```bash
python -c "
from mcp_server.gitea_client import GiteaClient
client = GiteaClient()
# Test listing issues
print('Testing list_issues...')
issues = client.list_issues(state='open')
print(f'Found {len(issues)} open issues')
# Test getting labels
print('\\nTesting get_labels...')
labels = client.get_labels()
print(f'Found {len(labels)} repository labels')
# Test getting org labels
print('\\nTesting get_org_labels...')
org_labels = client.get_org_labels()
print(f'Found {len(org_labels)} organization labels')
print('\\n✅ All integration tests passed!')
"
```
### Step 5: Test Issue Creation (Optional)
**Warning:** This creates a real issue in Gitea. Use a test repository.
```bash
python -c "
from mcp_server.gitea_client import GiteaClient
client = GiteaClient()
# Create test issue
print('Creating test issue...')
issue = client.create_issue(
title='[TEST] MCP Server Integration Test',
body='This is a test issue created by the Gitea MCP Server integration tests.',
labels=['Type/Test']
)
print(f'Created issue #{issue[\"number\"]}: {issue[\"title\"]}')
# Clean up: Close the issue
print('\\nClosing test issue...')
client.update_issue(issue['number'], state='closed')
print('✅ Test issue closed')
"
```
### Step 6: Test MCP Server with Real API
Start the MCP server and test with real Gitea API:
```bash
cd mcp-servers/gitea
source .venv/bin/activate
# Run server with test script
python << 'EOF'
import asyncio
import json
from mcp_server.server import GiteaMCPServer
async def test_server():
server = GiteaMCPServer()
await server.initialize()
# Test list_issues
result = await server.issue_tools.list_issues(state='open')
print(f'Found {len(result)} open issues')
# Test get_labels
labels = await server.label_tools.get_labels()
print(f'Found {labels["total_count"]} total labels')
# Test suggest_labels
suggestions = await server.label_tools.suggest_labels(
"Fix critical bug in authentication"
)
print(f'Suggested labels: {", ".join(suggestions)}')
print('✅ All MCP server integration tests passed!')
asyncio.run(test_server())
EOF
```
### Step 7: Test PMO Mode (Optional)
Test company-wide mode (no GITEA_REPO):
```bash
# Temporarily remove GITEA_REPO
unset GITEA_REPO
python -c "
from mcp_server.gitea_client import GiteaClient
client = GiteaClient()
print(f'Running in {client.mode} mode')
# Test list_repos
print('\\nTesting list_repos...')
repos = client.list_repos()
print(f'Found {len(repos)} repositories')
# Test aggregate_issues
print('\\nTesting aggregate_issues...')
aggregated = client.aggregate_issues(state='open')
for repo_name, issues in aggregated.items():
print(f' {repo_name}: {len(issues)} open issues')
print('\\n✅ PMO mode tests passed!')
"
```
---
## Configuration Setup for Testing
### Minimal Configuration
**System-level** (`~/.config/claude/gitea.env`):
```bash
GITEA_API_URL=https://gitea.example.com/api/v1
GITEA_API_TOKEN=your_token_here
GITEA_OWNER=bandit
```
**Project-level** (`.env` in project root):
```bash
# For project mode
GITEA_REPO=test-repo
# For company mode (PMO), omit GITEA_REPO
```
### Verification
Verify configuration is correct:
```bash
# Check system config exists
ls -la ~/.config/claude/gitea.env
# Check permissions (should be 600)
stat -c "%a %n" ~/.config/claude/gitea.env
# Check content (without exposing token)
grep -v TOKEN ~/.config/claude/gitea.env
# Check project config (if using project mode)
cat .env
```
---
## Troubleshooting
### Common Issues
#### 1. Import Errors
**Error:**
```
ModuleNotFoundError: No module named 'mcp_server'
```
**Solution:**
```bash
# Ensure you're in the correct directory
cd mcp-servers/gitea
# Activate virtual environment
source .venv/bin/activate
# Verify installation
pip list | grep mcp
```
#### 2. Configuration Not Found
**Error:**
```
FileNotFoundError: System config not found: /home/user/.config/claude/gitea.env
```
**Solution:**
```bash
# Create system config
mkdir -p ~/.config/claude
cat > ~/.config/claude/gitea.env << EOF
GITEA_API_URL=https://gitea.example.com/api/v1
GITEA_API_TOKEN=your_token_here
GITEA_OWNER=bandit
EOF
chmod 600 ~/.config/claude/gitea.env
```
#### 3. Missing Required Configuration
**Error:**
```
ValueError: Missing required configuration: GITEA_API_TOKEN, GITEA_OWNER
```
**Solution:**
```bash
# Check configuration file
cat ~/.config/claude/gitea.env
# Ensure all required variables are present:
# - GITEA_API_URL
# - GITEA_API_TOKEN
# - GITEA_OWNER
```
#### 4. API Authentication Failed
**Error:**
```
requests.exceptions.HTTPError: 401 Client Error: Unauthorized
```
**Solution:**
```bash
# Test token manually
curl -H "Authorization: token YOUR_TOKEN" \
https://gitea.example.com/api/v1/user
# If fails, regenerate token in Gitea settings
```
#### 5. Permission Errors (Branch Detection)
**Error:**
```
PermissionError: Cannot create issues on branch 'main'
```
**Solution:**
```bash
# Check current branch
git branch --show-current
# Switch to development branch
git checkout development
# or
git checkout -b feat/test-feature
```
#### 6. Repository Not Specified
**Error:**
```
ValueError: Repository not specified
```
**Solution:**
```bash
# Add GITEA_REPO to project config
echo "GITEA_REPO=your-repo-name" >> .env
# Or specify repo in tool call
# (for PMO mode multi-repo operations)
```
### Debug Mode
Enable debug logging:
```bash
export LOG_LEVEL=DEBUG
python -m mcp_server.server
```
### Test Summary
After completing all tests, verify:
- ✅ All 64 unit tests pass
- ✅ MCP server starts without errors
- ✅ Configuration loads correctly
- ✅ Gitea API client connects successfully
- ✅ Issues can be listed from Gitea
- ✅ Labels can be retrieved
- ✅ Label suggestions work correctly
- ✅ Branch detection blocks writes on main/staging
- ✅ Mode detection works (project vs company)
---
## Success Criteria
Phase 1 is complete when:
1. **All unit tests pass** (64/64)
2. **MCP server starts without errors**
3. **Can list issues from Gitea**
4. **Can create issues with labels** (in development mode)
5. **Mode detection works** (project vs company)
6. **Branch detection prevents writes on main/staging**
7. **Configuration properly merges** system + project levels
---
## Next Steps
After completing testing:
1. **Document any issues** found during testing
2. **Create integration with projman plugin** (Phase 2)
3. **Test in real project workflow** (Phase 5)
4. **Performance optimization** (if needed)
5. **Production hardening** (Phase 8)
---
## Additional Resources
- **MCP Documentation**: https://docs.anthropic.com/claude/docs/mcp
- **Gitea API Documentation**: https://docs.gitea.io/en-us/api-usage/
- **Projman Documentation**: `plugins/projman/README.md`
- **Configuration Guide**: `plugins/projman/CONFIGURATION.md`
---
**Last Updated**: 2025-01-06 (Phase 1 Implementation)

View File

@@ -1,30 +0,0 @@
"""
Gitea MCP Server package.
Provides MCP tools for Gitea integration via JSON-RPC 2.0.
For external consumers (e.g., HTTP transport), use:
from mcp_server import get_tool_definitions, create_tool_dispatcher, GiteaClient
# Get tool schemas
tools = get_tool_definitions()
# Create dispatcher bound to a client
client = GiteaClient()
dispatch = create_tool_dispatcher(client)
result = await dispatch("list_issues", {"state": "open"})
"""
__version__ = "1.0.0"
from .tool_registry import get_tool_definitions, create_tool_dispatcher
from .gitea_client import GiteaClient
from .config import GiteaConfig
__all__ = [
"__version__",
"get_tool_definitions",
"create_tool_dispatcher",
"GiteaClient",
"GiteaConfig",
]

View File

@@ -1,227 +0,0 @@
"""
Configuration loader for Gitea MCP Server.
Implements hybrid configuration system:
- System-level: ~/.config/claude/gitea.env (credentials)
- Project-level: .env (repository specification)
- Auto-detection: Falls back to git remote URL parsing
"""
from pathlib import Path
from dotenv import load_dotenv
import os
import re
import subprocess
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"
)
# Find project directory (MCP server cwd is plugin dir, not project dir)
project_dir = self._find_project_directory()
# Load project config (overrides system)
if project_dir:
project_config = project_dir / '.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
# Auto-detect repo from git remote if not specified
if not self.repo and project_dir:
self.repo = self._detect_repo_from_git(project_dir)
if self.repo:
logger.info(f"Auto-detected repository from git remote: {self.repo}")
# 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"
)
def _find_project_directory(self) -> Optional[Path]:
"""
Find the user's project directory.
The MCP server runs with cwd set to the plugin directory, not the
user's project. We need to find the actual project directory using
various heuristics.
Returns:
Path to project directory, or None if not found
"""
# Strategy 1: Check CLAUDE_PROJECT_DIR environment variable
project_dir = os.getenv('CLAUDE_PROJECT_DIR')
if project_dir:
path = Path(project_dir)
if path.exists():
logger.info(f"Found project directory from CLAUDE_PROJECT_DIR: {path}")
return path
# Strategy 2: Check PWD (original working directory before cwd override)
pwd = os.getenv('PWD')
if pwd:
path = Path(pwd)
# Verify it has .git or .env (indicates a project)
if path.exists() and ((path / '.git').exists() or (path / '.env').exists()):
logger.info(f"Found project directory from PWD: {path}")
return path
# Strategy 3: Check current working directory
# This handles test scenarios and cases where cwd is actually the project
cwd = Path.cwd()
if (cwd / '.git').exists() or (cwd / '.env').exists():
logger.info(f"Found project directory from cwd: {cwd}")
return cwd
# Strategy 4: Check if GITEA_REPO is already set (user configured it)
# If so, we don't need to find the project directory for git detection
if os.getenv('GITEA_REPO'):
logger.debug("GITEA_REPO already set, skipping project directory detection")
return None
logger.debug("Could not determine project directory")
return None
def _detect_repo_from_git(self, project_dir: Optional[Path] = None) -> Optional[str]:
"""
Auto-detect repository from git remote origin URL.
Args:
project_dir: Directory to run git command from (defaults to cwd)
Supports URL formats:
- SSH: ssh://git@host:port/owner/repo.git
- SSH short: git@host:owner/repo.git
- HTTPS: https://host/owner/repo.git
- HTTP: http://host/owner/repo.git
Returns:
Repository in 'owner/repo' format, or None if detection fails
"""
try:
result = subprocess.run(
['git', 'remote', 'get-url', 'origin'],
capture_output=True,
text=True,
timeout=5,
cwd=str(project_dir) if project_dir else None
)
if result.returncode != 0:
logger.debug("No git remote 'origin' found")
return None
url = result.stdout.strip()
return self._parse_git_url(url)
except subprocess.TimeoutExpired:
logger.warning("Git command timed out")
return None
except FileNotFoundError:
logger.debug("Git not available")
return None
except Exception as e:
logger.debug(f"Failed to detect repo from git: {e}")
return None
def _parse_git_url(self, url: str) -> Optional[str]:
"""
Parse git URL to extract owner/repo.
Args:
url: Git remote URL
Returns:
Repository in 'owner/repo' format, or None if parsing fails
"""
# Remove .git suffix if present
url = re.sub(r'\.git$', '', url)
# SSH format: ssh://git@host:port/owner/repo
ssh_match = re.match(r'ssh://[^/]+/(.+/.+)$', url)
if ssh_match:
return ssh_match.group(1)
# SSH short format: git@host:owner/repo
ssh_short_match = re.match(r'git@[^:]+:(.+/.+)$', url)
if ssh_short_match:
return ssh_short_match.group(1)
# HTTPS/HTTP format: https://host/owner/repo
http_match = re.match(r'https?://[^/]+/(.+/.+)$', url)
if http_match:
return http_match.group(1)
logger.warning(f"Could not parse git URL: {url}")
return None

View File

@@ -1,849 +0,0 @@
"""
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,
milestone: Optional[str] = None,
repo: Optional[str] = None
) -> List[Dict]:
"""
List issues from Gitea repository.
Args:
state: Issue state (open, closed, all)
labels: Filter by labels
milestone: Filter by milestone title (exact match)
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)
if milestone:
params['milestones'] = milestone
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."""
full_repo = f"{owner}/{repo}"
# Only fetch org labels if repo belongs to an organization
org_labels = []
if self.is_org_repo(full_repo):
org_labels = self.get_org_labels(owner)
repo_labels = self.get_labels(full_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,
milestone: Optional[int] = None,
repo: Optional[str] = None
) -> Dict:
"""
Update existing issue.
Args:
issue_number: Issue number to update
title: New title (optional)
body: New body (optional)
state: New state - 'open' or 'closed' (optional)
labels: New labels (optional)
milestone: Milestone ID to assign (optional)
repo: Repository in 'owner/repo' format
Returns:
Updated issue dictionary
"""
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
if milestone is not None:
data['milestone'] = milestone
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."""
from urllib.parse import quote
owner, target_repo = self._parse_repo(repo)
# URL-encode the page_name to handle special characters like ':'
encoded_page_name = quote(page_name, safe='')
url = f"{self.base_url}/repos/{owner}/{target_repo}/wiki/page/{encoded_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."""
from urllib.parse import quote
owner, target_repo = self._parse_repo(repo)
# URL-encode the page_name to handle special characters like ':'
encoded_page_name = quote(page_name, safe='')
url = f"{self.base_url}/repos/{owner}/{target_repo}/wiki/page/{encoded_page_name}"
data = {
'title': page_name, # CRITICAL: include title to preserve page name
'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."""
from urllib.parse import quote
owner, target_repo = self._parse_repo(repo)
# URL-encode the page_name to handle special characters like ':'
encoded_page_name = quote(page_name, safe='')
url = f"{self.base_url}/repos/{owner}/{target_repo}/wiki/page/{encoded_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).
Uses the /orgs/{owner} endpoint to reliably detect organizations,
as the owner.type field in repo info may be null in some Gitea versions.
"""
owner, _ = self._parse_repo(repo)
return self._is_organization(owner)
def _is_organization(self, owner: str) -> bool:
"""
Check if an owner is an organization by querying the orgs endpoint.
Args:
owner: The owner name to check
Returns:
True if owner is an organization, False if user or unknown
"""
url = f"{self.base_url}/orgs/{owner}"
try:
response = self.session.get(url)
# 200 = organization exists, 404 = not an organization (user account)
return response.status_code == 200
except Exception as e:
logger.warning(f"Failed to check if {owner} is organization: {e}")
return False
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()
def create_org_label(
self,
org: str,
name: str,
color: str,
description: Optional[str] = None
) -> Dict:
"""
Create a new label at the organization level.
Organization labels are shared across all repositories in the org.
Use this for workflow labels (Type, Priority, Complexity, Effort, etc.)
Args:
org: Organization name
name: Label name (e.g., 'Type/Bug', 'Priority/High')
color: Hex color code (with or without #)
description: Optional label description
Returns:
Created label dictionary
"""
url = f"{self.base_url}/orgs/{org}/labels"
data = {
'name': name,
'color': color.lstrip('#') # Remove # if present
}
if description:
data['description'] = description
logger.info(f"Creating organization label '{name}' in {org}")
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()
def create_pull_request(
self,
title: str,
body: str,
head: str,
base: str,
labels: Optional[List[str]] = None,
repo: Optional[str] = None
) -> Dict:
"""
Create a new pull request.
Args:
title: PR title
body: PR description/body
head: Source branch name (the branch with changes)
base: Target branch name (the branch to merge into)
labels: Optional list of label names
repo: Repository in 'owner/repo' format
Returns:
Created pull request dictionary
"""
owner, target_repo = self._parse_repo(repo)
url = f"{self.base_url}/repos/{owner}/{target_repo}/pulls"
data = {
'title': title,
'body': body,
'head': head,
'base': base
}
if labels:
label_ids = self._resolve_label_ids(labels, owner, target_repo)
data['labels'] = label_ids
logger.info(f"Creating PR '{title}' in {owner}/{target_repo}: {head} -> {base}")
response = self.session.post(url, json=data)
response.raise_for_status()
return response.json()

View File

@@ -1,93 +0,0 @@
"""
MCP Server entry point for Gitea integration.
Provides Gitea tools to Claude Code via JSON-RPC 2.0 over stdio.
"""
import asyncio
import logging
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 .tool_registry import get_tool_definitions, create_tool_dispatcher
# 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._dispatcher = 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._dispatcher = create_tool_dispatcher(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 get_tool_definitions()
@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
"""
return await self._dispatcher(name, arguments)
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())

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +0,0 @@
"""
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)
"""

View File

@@ -1,216 +0,0 @@
"""
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

@@ -1,287 +0,0 @@
"""
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 os
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_project_directory(self) -> Optional[str]:
"""
Get the user's project directory from environment.
Returns:
Project directory path or None if not set
"""
return os.environ.get('CLAUDE_PROJECT_DIR')
def _get_current_branch(self) -> str:
"""
Get current git branch from user's project directory.
Uses CLAUDE_PROJECT_DIR environment variable to determine the correct
directory for git operations, avoiding the bug where git runs from
the installed plugin directory instead of the user's project.
Returns:
Current branch name or 'unknown' if not in a git repo
"""
try:
project_dir = self._get_project_directory()
result = subprocess.run(
['git', 'rev-parse', '--abbrev-ref', 'HEAD'],
capture_output=True,
text=True,
check=True,
cwd=project_dir # Run git in project directory, not plugin directory
)
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)
# Include all common feature/fix branch patterns
dev_prefixes = (
'feat/', 'feature/', 'dev/',
'fix/', 'bugfix/', 'hotfix/',
'chore/', 'refactor/', 'docs/', 'test/'
)
if branch in ['development', 'develop'] or branch.startswith(dev_prefixes):
return True
# Unknown branch - be restrictive
return False
async def list_issues(
self,
state: str = 'open',
labels: Optional[List[str]] = None,
milestone: Optional[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
milestone: Filter by milestone title (exact match)
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, milestone, 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,
milestone: Optional[int] = 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)
milestone: Milestone ID to assign (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, milestone, 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)
)

View File

@@ -1,377 +0,0 @@
"""
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
import re
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 if org-owned, repo-only if user-owned)."""
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')")
# Check if repo belongs to an organization or user
is_org = await loop.run_in_executor(
None,
lambda: self.gitea.is_org_repo(target_repo)
)
org_labels = []
if is_org:
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, repo: Optional[str] = None) -> List[str]:
"""
Analyze context and suggest appropriate labels from repository's actual labels.
This method fetches actual labels from the repository and matches them
dynamically, supporting any label naming convention (slash, colon-space, etc.).
Args:
context: Issue title + description or sprint context
repo: Repository in 'owner/repo' format (optional, uses default if not provided)
Returns:
List of suggested label names that exist in the repository
"""
# Fetch actual labels from repository
target_repo = repo or self.gitea.repo
if not target_repo:
logger.warning("No repository specified, returning empty suggestions")
return []
try:
labels_data = await self.get_labels(target_repo)
all_labels = labels_data.get('organization', []) + labels_data.get('repository', [])
label_names = [label['name'] for label in all_labels]
except Exception as e:
logger.warning(f"Failed to fetch labels: {e}. Using fallback suggestions.")
label_names = []
# Build label lookup for dynamic matching
label_lookup = self._build_label_lookup(label_names)
suggested = []
context_lower = context.lower()
# Type detection (exclusive - only one)
type_label = None
if any(word in context_lower for word in ['bug', 'error', 'fix', 'broken', 'crash', 'fail']):
type_label = self._find_label(label_lookup, 'type', 'bug')
elif any(word in context_lower for word in ['refactor', 'extract', 'restructure', 'architecture', 'service extraction']):
type_label = self._find_label(label_lookup, 'type', 'refactor')
elif any(word in context_lower for word in ['feature', 'add', 'implement', 'new', 'create']):
type_label = self._find_label(label_lookup, 'type', 'feature')
elif any(word in context_lower for word in ['docs', 'documentation', 'readme', 'guide']):
type_label = self._find_label(label_lookup, 'type', 'documentation')
elif any(word in context_lower for word in ['test', 'testing', 'spec', 'coverage']):
type_label = self._find_label(label_lookup, 'type', 'test')
elif any(word in context_lower for word in ['chore', 'maintenance', 'update', 'upgrade']):
type_label = self._find_label(label_lookup, 'type', 'chore')
if type_label:
suggested.append(type_label)
# Priority detection
priority_label = None
if any(word in context_lower for word in ['critical', 'urgent', 'blocker', 'blocking', 'emergency']):
priority_label = self._find_label(label_lookup, 'priority', 'critical')
elif any(word in context_lower for word in ['high', 'important', 'asap', 'soon']):
priority_label = self._find_label(label_lookup, 'priority', 'high')
elif any(word in context_lower for word in ['low', 'nice-to-have', 'optional', 'later']):
priority_label = self._find_label(label_lookup, 'priority', 'low')
else:
priority_label = self._find_label(label_lookup, 'priority', 'medium')
if priority_label:
suggested.append(priority_label)
# Complexity detection
complexity_label = None
if any(word in context_lower for word in ['simple', 'trivial', 'easy', 'quick']):
complexity_label = self._find_label(label_lookup, 'complexity', 'simple')
elif any(word in context_lower for word in ['complex', 'difficult', 'challenging', 'intricate']):
complexity_label = self._find_label(label_lookup, 'complexity', 'complex')
else:
complexity_label = self._find_label(label_lookup, 'complexity', 'medium')
if complexity_label:
suggested.append(complexity_label)
# Effort detection (supports both "Effort" and "Efforts" naming)
effort_label = None
if any(word in context_lower for word in ['xs', 'tiny', '1 hour', '2 hours']):
effort_label = self._find_label(label_lookup, 'effort', 'xs')
elif any(word in context_lower for word in ['small', 's ', '1 day', 'half day']):
effort_label = self._find_label(label_lookup, 'effort', 's')
elif any(word in context_lower for word in ['medium', 'm ', '2 days', '3 days']):
effort_label = self._find_label(label_lookup, 'effort', 'm')
elif any(word in context_lower for word in ['large', 'l ', '1 week', '5 days']):
effort_label = self._find_label(label_lookup, 'effort', 'l')
elif any(word in context_lower for word in ['xl', 'extra large', '2 weeks', 'sprint']):
effort_label = self._find_label(label_lookup, 'effort', 'xl')
if effort_label:
suggested.append(effort_label)
# Component detection (based on keywords)
component_mappings = {
'backend': ['backend', 'server', 'api', 'database', 'service'],
'frontend': ['frontend', 'ui', 'interface', 'react', 'vue', 'component'],
'api': ['api', 'endpoint', 'rest', 'graphql', 'route'],
'database': ['database', 'db', 'sql', 'migration', 'schema', 'postgres'],
'auth': ['auth', 'authentication', 'login', 'oauth', 'token', 'session'],
'deploy': ['deploy', 'deployment', 'docker', 'kubernetes', 'ci/cd'],
'testing': ['test', 'testing', 'spec', 'jest', 'pytest', 'coverage'],
'docs': ['docs', 'documentation', 'readme', 'guide', 'wiki']
}
for component, keywords in component_mappings.items():
if any(keyword in context_lower for keyword in keywords):
label = self._find_label(label_lookup, 'component', component)
if label and label not in suggested:
suggested.append(label)
# Tech stack detection
tech_mappings = {
'python': ['python', 'fastapi', 'django', 'flask', 'pytest'],
'javascript': ['javascript', 'js', 'node', 'npm', 'yarn'],
'docker': ['docker', 'dockerfile', 'container', 'compose'],
'postgresql': ['postgres', 'postgresql', 'psql', 'sql'],
'redis': ['redis', 'cache', 'session store'],
'vue': ['vue', 'vuejs', 'nuxt'],
'fastapi': ['fastapi', 'pydantic', 'starlette']
}
for tech, keywords in tech_mappings.items():
if any(keyword in context_lower for keyword in keywords):
label = self._find_label(label_lookup, 'tech', tech)
if label and label not in suggested:
suggested.append(label)
# Source detection (based on git branch or context)
source_label = None
if 'development' in context_lower or 'dev/' in context_lower:
source_label = self._find_label(label_lookup, 'source', 'development')
elif 'staging' in context_lower or 'stage/' in context_lower:
source_label = self._find_label(label_lookup, 'source', 'staging')
elif 'production' in context_lower or 'prod' in context_lower:
source_label = self._find_label(label_lookup, 'source', 'production')
if source_label:
suggested.append(source_label)
# Risk detection
risk_label = None
if any(word in context_lower for word in ['breaking', 'breaking change', 'major', 'risky']):
risk_label = self._find_label(label_lookup, 'risk', 'high')
elif any(word in context_lower for word in ['safe', 'low risk', 'minor']):
risk_label = self._find_label(label_lookup, 'risk', 'low')
if risk_label:
suggested.append(risk_label)
logger.info(f"Suggested {len(suggested)} labels based on context and {len(label_names)} available labels")
return suggested
def _build_label_lookup(self, label_names: List[str]) -> Dict[str, Dict[str, str]]:
"""
Build a lookup dictionary for label matching.
Supports various label formats:
- Slash format: Type/Bug, Priority/High
- Colon-space format: Type: Bug, Priority: High
- Colon format: Type:Bug
Args:
label_names: List of actual label names from repository
Returns:
Nested dict: {category: {value: actual_label_name}}
"""
lookup: Dict[str, Dict[str, str]] = {}
for label in label_names:
# Try different separator patterns
# Pattern: Category<separator>Value
# Separators: /, : , :
match = re.match(r'^([^/:]+)(?:/|:\s*|:)(.+)$', label)
if match:
category = match.group(1).lower().rstrip('s') # Normalize: "Efforts" -> "effort"
value = match.group(2).lower()
if category not in lookup:
lookup[category] = {}
lookup[category][value] = label
return lookup
def _find_label(self, lookup: Dict[str, Dict[str, str]], category: str, value: str) -> Optional[str]:
"""
Find actual label name from lookup.
Args:
lookup: Label lookup dictionary
category: Category to search (e.g., 'type', 'priority')
value: Value to find (e.g., 'bug', 'high')
Returns:
Actual label name if found, None otherwise
"""
category_lower = category.lower().rstrip('s') # Normalize
value_lower = value.lower()
if category_lower in lookup and value_lower in lookup[category_lower]:
return lookup[category_lower][value_lower]
return None
# Organization-level label categories (workflow labels shared across repos)
ORG_LABEL_CATEGORIES = {'agent', 'complexity', 'effort', 'efforts', 'priority', 'risk', 'source', 'type'}
# Repository-level label categories (project-specific labels)
REPO_LABEL_CATEGORIES = {'component', 'tech'}
async def create_label_smart(
self,
name: str,
color: str,
description: Optional[str] = None,
repo: Optional[str] = None
) -> Dict:
"""
Create a label at the appropriate level (org or repo) based on category.
Skips if label already exists (checks both org and repo levels).
Organization labels: Agent, Complexity, Effort, Priority, Risk, Source, Type
Repository labels: Component, Tech
Args:
name: Label name (e.g., 'Type/Bug', 'Component/Backend')
color: Hex color code
description: Optional label description
repo: Repository in 'owner/repo' format
Returns:
Created label dictionary with 'level' key, or 'skipped' if already exists
"""
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')")
owner = target_repo.split('/')[0]
is_org = await loop.run_in_executor(
None,
lambda: self.gitea.is_org_repo(target_repo)
)
# Fetch existing labels to check for duplicates
existing_labels = await self.get_labels(target_repo)
all_existing = existing_labels.get('organization', []) + existing_labels.get('repository', [])
existing_names = [label['name'].lower() for label in all_existing]
# Normalize the new label name for comparison
name_normalized = name.lower()
# Also check for format variations (Type/Bug vs Type: Bug)
name_variations = [name_normalized]
if '/' in name:
name_variations.append(name.replace('/', ': ').lower())
name_variations.append(name.replace('/', ':').lower())
elif ': ' in name:
name_variations.append(name.replace(': ', '/').lower())
elif ':' in name:
name_variations.append(name.replace(':', '/').lower())
# Check if label already exists in any format
for variation in name_variations:
if variation in existing_names:
logger.info(f"Label '{name}' already exists (found as '{variation}'), skipping")
return {
'name': name,
'skipped': True,
'reason': f"Label already exists",
'level': 'existing'
}
# Parse category from label name
category = None
if '/' in name:
category = name.split('/')[0].lower().rstrip('s')
elif ':' in name:
category = name.split(':')[0].strip().lower().rstrip('s')
# If it's an org repo and the category is an org-level category, create at org level
if is_org and category in self.ORG_LABEL_CATEGORIES:
result = await loop.run_in_executor(
None,
lambda: self.gitea.create_org_label(owner, name, color, description)
)
# Handle unexpected response types (API may return list or non-dict)
if not isinstance(result, dict):
logger.error(f"Unexpected API response type for org label: {type(result)} - {result}")
return {
'name': name,
'error': True,
'reason': f"API returned {type(result).__name__} instead of dict: {result}",
'level': 'organization'
}
result['level'] = 'organization'
result['skipped'] = False
logger.info(f"Created organization label '{name}' in {owner}")
else:
# Create at repo level
result = await loop.run_in_executor(
None,
lambda: self.gitea.create_label(name, color, description, target_repo)
)
# Handle unexpected response types (API may return list or non-dict)
if not isinstance(result, dict):
logger.error(f"Unexpected API response type for repo label: {type(result)} - {result}")
return {
'name': name,
'error': True,
'reason': f"API returned {type(result).__name__} instead of dict: {result}",
'level': 'repository'
}
result['level'] = 'repository'
result['skipped'] = False
logger.info(f"Created repository label '{name}' in {target_repo}")
return result

View File

@@ -1,145 +0,0 @@
"""
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

@@ -1,335 +0,0 @@
"""
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 os
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_project_directory(self) -> Optional[str]:
"""
Get the user's project directory from environment.
Returns:
Project directory path or None if not set
"""
return os.environ.get('CLAUDE_PROJECT_DIR')
def _get_current_branch(self) -> str:
"""
Get current git branch from user's project directory.
Uses CLAUDE_PROJECT_DIR environment variable to determine the correct
directory for git operations, avoiding the bug where git runs from
the installed plugin directory instead of the user's project.
Returns:
Current branch name or 'unknown' if not in a git repo
"""
try:
project_dir = self._get_project_directory()
result = subprocess.run(
['git', 'rev-parse', '--abbrev-ref', 'HEAD'],
capture_output=True,
text=True,
check=True,
cwd=project_dir # Run git in project directory, not plugin directory
)
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)
# Include all common feature/fix branch patterns
dev_prefixes = (
'feat/', 'feature/', 'dev/',
'fix/', 'bugfix/', 'hotfix/',
'chore/', 'refactor/', 'docs/', 'test/'
)
if branch in ['development', 'develop'] or branch.startswith(dev_prefixes):
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)
)
async def create_pull_request(
self,
title: str,
body: str,
head: str,
base: str,
labels: Optional[List[str]] = None,
repo: Optional[str] = None
) -> Dict:
"""
Create a new pull request (async wrapper with branch check).
Args:
title: PR title
body: PR description/body
head: Source branch name (the branch with changes)
base: Target branch name (the branch to merge into)
labels: Optional list of label names
repo: Override configured repo (for PMO multi-repo)
Returns:
Created pull request dictionary
Raises:
PermissionError: If operation not allowed on current branch
"""
if not self._check_branch_permissions('create_pull_request'):
branch = self._get_current_branch()
raise PermissionError(
f"Cannot create PR on branch '{branch}'. "
f"Switch to a development or feature branch to create PRs."
)
loop = asyncio.get_event_loop()
return await loop.run_in_executor(
None,
lambda: self.gitea.create_pull_request(title, body, head, base, labels, repo)
)

View File

@@ -1,187 +0,0 @@
"""
Wiki management tools for MCP server.
Provides async wrappers for wiki operations to support lessons learned:
- Page CRUD operations
- Lessons learned creation and search
- RFC number allocation
"""
import asyncio
import logging
import re
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]
async def allocate_rfc_number(self, repo: Optional[str] = None) -> Dict:
"""
Allocate the next available RFC number.
Scans existing wiki pages for RFC-NNNN pattern and returns
the next sequential number.
Args:
repo: Repository in owner/repo format
Returns:
Dict with 'next_number' (int) and 'formatted' (str like 'RFC-0001')
"""
pages = await self.list_wiki_pages(repo)
# Extract RFC numbers from page titles
rfc_numbers = []
rfc_pattern = re.compile(r'^RFC-(\d{4})')
for page in pages:
title = page.get('title', '')
match = rfc_pattern.match(title)
if match:
rfc_numbers.append(int(match.group(1)))
# Calculate next number
if rfc_numbers:
next_num = max(rfc_numbers) + 1
else:
next_num = 1
return {
'next_number': next_num,
'formatted': f'RFC-{next_num:04d}'
}

View File

@@ -1,43 +0,0 @@
[build-system]
requires = ["setuptools>=61.0", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "gitea-mcp-server"
version = "1.0.0"
description = "MCP Server for Gitea integration - provides issue, label, wiki, milestone, dependency, and PR tools"
readme = "README.md"
requires-python = ">=3.10"
license = {text = "MIT"}
authors = [
{ name = "Leo Miranda" }
]
keywords = ["mcp", "gitea", "claude", "tools"]
classifiers = [
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
]
dependencies = [
"mcp>=0.9.0",
"python-dotenv>=1.0.0",
"requests>=2.31.0",
"pydantic>=2.5.0",
]
[project.optional-dependencies]
test = [
"pytest>=7.4.3",
"pytest-asyncio>=0.23.0",
]
[tool.setuptools.packages.find]
where = ["."]
include = ["mcp_server*"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]

View File

@@ -1,6 +1,2 @@
mcp>=0.9.0 # MCP SDK from Anthropic
python-dotenv>=1.0.0 # Environment variable loading
requests>=2.31.0 # HTTP client for Gitea API
pydantic>=2.5.0 # Data validation
pytest>=7.4.3 # Testing framework
pytest-asyncio>=0.23.0 # Async testing support
--extra-index-url https://gitea.hotserv.cloud/api/packages/personal-projects/pypi/simple
gitea-mcp>=1.0.0

View File

@@ -17,5 +17,4 @@ else
fi
cd "$SCRIPT_DIR"
export PYTHONPATH="$SCRIPT_DIR"
exec "$PYTHON" -m mcp_server.server "$@"
exec "$PYTHON" -m gitea_mcp.server "$@"

View File

@@ -1,259 +0,0 @@
"""
Unit tests for configuration loader.
"""
import pytest
from pathlib import Path
import os
from mcp_server.config import GiteaConfig
def test_load_system_config(tmp_path, monkeypatch):
"""Test loading system-level configuration"""
# Mock home directory
config_dir = tmp_path / '.config' / 'claude'
config_dir.mkdir(parents=True)
config_file = config_dir / 'gitea.env'
config_file.write_text(
"GITEA_API_URL=https://test.com/api/v1\n"
"GITEA_API_TOKEN=test_token\n"
"GITEA_OWNER=test_owner\n"
)
monkeypatch.setenv('HOME', str(tmp_path))
monkeypatch.chdir(tmp_path)
config = GiteaConfig()
result = config.load()
assert result['api_url'] == 'https://test.com/api/v1'
assert result['api_token'] == 'test_token'
assert result['mode'] == 'company' # No repo specified
assert result['repo'] is None
def test_project_config_override(tmp_path, monkeypatch):
"""Test that project config overrides system config"""
# Set up system config
system_config_dir = tmp_path / '.config' / 'claude'
system_config_dir.mkdir(parents=True)
system_config = system_config_dir / 'gitea.env'
system_config.write_text(
"GITEA_API_URL=https://test.com/api/v1\n"
"GITEA_API_TOKEN=test_token\n"
"GITEA_OWNER=test_owner\n"
)
# Set up project config
project_dir = tmp_path / 'project'
project_dir.mkdir()
project_config = project_dir / '.env'
project_config.write_text("GITEA_REPO=test_repo\n")
monkeypatch.setenv('HOME', str(tmp_path))
monkeypatch.chdir(project_dir)
config = GiteaConfig()
result = config.load()
assert result['repo'] == 'test_repo'
assert result['mode'] == 'project'
def test_missing_system_config(tmp_path, monkeypatch):
"""Test error handling for missing system configuration"""
monkeypatch.setenv('HOME', str(tmp_path))
monkeypatch.chdir(tmp_path)
with pytest.raises(FileNotFoundError) as exc_info:
config = GiteaConfig()
config.load()
assert "System config not found" in str(exc_info.value)
def test_missing_required_config(tmp_path, monkeypatch):
"""Test error handling for missing required variables"""
# Clear environment variables
for var in ['GITEA_API_URL', 'GITEA_API_TOKEN', 'GITEA_OWNER', 'GITEA_REPO']:
monkeypatch.delenv(var, raising=False)
# Create incomplete config
config_dir = tmp_path / '.config' / 'claude'
config_dir.mkdir(parents=True)
config_file = config_dir / 'gitea.env'
config_file.write_text(
"GITEA_API_URL=https://test.com/api/v1\n"
# Missing GITEA_API_TOKEN and GITEA_OWNER
)
monkeypatch.setenv('HOME', str(tmp_path))
monkeypatch.chdir(tmp_path)
with pytest.raises(ValueError) as exc_info:
config = GiteaConfig()
config.load()
assert "Missing required configuration" in str(exc_info.value)
def test_mode_detection_project(tmp_path, monkeypatch):
"""Test mode detection for project mode"""
config_dir = tmp_path / '.config' / 'claude'
config_dir.mkdir(parents=True)
config_file = config_dir / 'gitea.env'
config_file.write_text(
"GITEA_API_URL=https://test.com/api/v1\n"
"GITEA_API_TOKEN=test_token\n"
"GITEA_OWNER=test_owner\n"
"GITEA_REPO=test_repo\n"
)
monkeypatch.setenv('HOME', str(tmp_path))
monkeypatch.chdir(tmp_path)
config = GiteaConfig()
result = config.load()
assert result['mode'] == 'project'
assert result['repo'] == 'test_repo'
def test_mode_detection_company(tmp_path, monkeypatch):
"""Test mode detection for company mode (PMO)"""
# Clear environment variables, especially GITEA_REPO
for var in ['GITEA_API_URL', 'GITEA_API_TOKEN', 'GITEA_OWNER', 'GITEA_REPO']:
monkeypatch.delenv(var, raising=False)
config_dir = tmp_path / '.config' / 'claude'
config_dir.mkdir(parents=True)
config_file = config_dir / 'gitea.env'
config_file.write_text(
"GITEA_API_URL=https://test.com/api/v1\n"
"GITEA_API_TOKEN=test_token\n"
"GITEA_OWNER=test_owner\n"
# No GITEA_REPO
)
monkeypatch.setenv('HOME', str(tmp_path))
monkeypatch.chdir(tmp_path)
config = GiteaConfig()
result = config.load()
assert result['mode'] == 'company'
assert result['repo'] is None
# ========================================
# GIT URL PARSING TESTS
# ========================================
def test_parse_git_url_ssh_format():
"""Test parsing SSH format git URL"""
config = GiteaConfig()
# SSH with port: ssh://git@host:port/owner/repo.git
url = "ssh://git@hotserv.tailc9b278.ts.net:2222/personal-projects/personal-portfolio.git"
result = config._parse_git_url(url)
assert result == "personal-projects/personal-portfolio"
def test_parse_git_url_ssh_short_format():
"""Test parsing SSH short format git URL"""
config = GiteaConfig()
# SSH short: git@host:owner/repo.git
url = "git@github.com:owner/repo.git"
result = config._parse_git_url(url)
assert result == "owner/repo"
def test_parse_git_url_https_format():
"""Test parsing HTTPS format git URL"""
config = GiteaConfig()
# HTTPS: https://host/owner/repo.git
url = "https://gitea.hotserv.cloud/personal-projects/leo-claude-mktplace.git"
result = config._parse_git_url(url)
assert result == "personal-projects/leo-claude-mktplace"
def test_parse_git_url_http_format():
"""Test parsing HTTP format git URL"""
config = GiteaConfig()
# HTTP: http://host/owner/repo.git
url = "http://gitea.hotserv.cloud/personal-projects/repo.git"
result = config._parse_git_url(url)
assert result == "personal-projects/repo"
def test_parse_git_url_without_git_suffix():
"""Test parsing git URL without .git suffix"""
config = GiteaConfig()
url = "https://github.com/owner/repo"
result = config._parse_git_url(url)
assert result == "owner/repo"
def test_parse_git_url_invalid_format():
"""Test parsing invalid git URL returns None"""
config = GiteaConfig()
url = "not-a-valid-url"
result = config._parse_git_url(url)
assert result is None
def test_find_project_directory_from_env(tmp_path, monkeypatch):
"""Test finding project directory from CLAUDE_PROJECT_DIR env var"""
project_dir = tmp_path / 'my-project'
project_dir.mkdir()
(project_dir / '.git').mkdir()
monkeypatch.setenv('CLAUDE_PROJECT_DIR', str(project_dir))
config = GiteaConfig()
result = config._find_project_directory()
assert result == project_dir
def test_find_project_directory_from_cwd(tmp_path, monkeypatch):
"""Test finding project directory from cwd with .env file"""
project_dir = tmp_path / 'project'
project_dir.mkdir()
(project_dir / '.env').write_text("GITEA_REPO=test/repo")
monkeypatch.chdir(project_dir)
# Clear env vars that might interfere
monkeypatch.delenv('CLAUDE_PROJECT_DIR', raising=False)
monkeypatch.delenv('PWD', raising=False)
config = GiteaConfig()
result = config._find_project_directory()
assert result == project_dir
def test_find_project_directory_none_when_no_markers(tmp_path, monkeypatch):
"""Test returns None when no project markers found"""
empty_dir = tmp_path / 'empty'
empty_dir.mkdir()
monkeypatch.chdir(empty_dir)
monkeypatch.delenv('CLAUDE_PROJECT_DIR', raising=False)
monkeypatch.delenv('PWD', raising=False)
monkeypatch.delenv('GITEA_REPO', raising=False)
config = GiteaConfig()
result = config._find_project_directory()
assert result is None

View File

@@ -1,270 +0,0 @@
"""
Unit tests for Gitea API client.
"""
import pytest
from unittest.mock import Mock, patch, MagicMock
from mcp_server.gitea_client import GiteaClient
@pytest.fixture
def mock_config():
"""Fixture providing mocked configuration"""
with patch('mcp_server.gitea_client.GiteaConfig') as mock_cfg:
mock_instance = mock_cfg.return_value
mock_instance.load.return_value = {
'api_url': 'https://test.com/api/v1',
'api_token': 'test_token',
'repo': 'test_owner/test_repo', # Combined owner/repo format
'mode': 'project'
}
yield mock_cfg
@pytest.fixture
def gitea_client(mock_config):
"""Fixture providing GiteaClient instance with mocked config"""
return GiteaClient()
def test_client_initialization(gitea_client):
"""Test client initializes with correct configuration"""
assert gitea_client.base_url == 'https://test.com/api/v1'
assert gitea_client.token == 'test_token'
assert gitea_client.repo == 'test_owner/test_repo' # Combined format
assert gitea_client.mode == 'project'
assert 'Authorization' in gitea_client.session.headers
assert gitea_client.session.headers['Authorization'] == 'token test_token'
def test_list_issues(gitea_client):
"""Test listing issues"""
mock_response = Mock()
mock_response.json.return_value = [
{'number': 1, 'title': 'Test Issue 1'},
{'number': 2, 'title': 'Test Issue 2'}
]
mock_response.raise_for_status = Mock()
with patch.object(gitea_client.session, 'get', return_value=mock_response):
issues = gitea_client.list_issues(state='open')
assert len(issues) == 2
assert issues[0]['title'] == 'Test Issue 1'
gitea_client.session.get.assert_called_once()
def test_list_issues_with_labels(gitea_client):
"""Test listing issues with label filter"""
mock_response = Mock()
mock_response.json.return_value = [{'number': 1, 'title': 'Bug Issue'}]
mock_response.raise_for_status = Mock()
with patch.object(gitea_client.session, 'get', return_value=mock_response):
issues = gitea_client.list_issues(state='open', labels=['Type/Bug'])
gitea_client.session.get.assert_called_once()
call_args = gitea_client.session.get.call_args
assert call_args[1]['params']['labels'] == 'Type/Bug'
def test_get_issue(gitea_client):
"""Test getting specific issue"""
mock_response = Mock()
mock_response.json.return_value = {'number': 1, 'title': 'Test Issue'}
mock_response.raise_for_status = Mock()
with patch.object(gitea_client.session, 'get', return_value=mock_response):
issue = gitea_client.get_issue(1)
assert issue['number'] == 1
assert issue['title'] == 'Test Issue'
def test_create_issue(gitea_client):
"""Test creating new issue"""
mock_response = Mock()
mock_response.json.return_value = {
'number': 1,
'title': 'New Issue',
'body': 'Issue body'
}
mock_response.raise_for_status = Mock()
# Mock is_org_repo to avoid network call during label resolution
with patch.object(gitea_client, 'is_org_repo', return_value=True):
# Mock get_org_labels and get_labels for label resolution
with patch.object(gitea_client, 'get_org_labels', return_value=[{'name': 'Type/Bug', 'id': 1}]):
with patch.object(gitea_client, 'get_labels', return_value=[]):
with patch.object(gitea_client.session, 'post', return_value=mock_response):
issue = gitea_client.create_issue(
title='New Issue',
body='Issue body',
labels=['Type/Bug']
)
assert issue['title'] == 'New Issue'
gitea_client.session.post.assert_called_once()
def test_update_issue(gitea_client):
"""Test updating existing issue"""
mock_response = Mock()
mock_response.json.return_value = {
'number': 1,
'title': 'Updated Issue'
}
mock_response.raise_for_status = Mock()
with patch.object(gitea_client.session, 'patch', return_value=mock_response):
issue = gitea_client.update_issue(
issue_number=1,
title='Updated Issue'
)
assert issue['title'] == 'Updated Issue'
gitea_client.session.patch.assert_called_once()
def test_add_comment(gitea_client):
"""Test adding comment to issue"""
mock_response = Mock()
mock_response.json.return_value = {'body': 'Test comment'}
mock_response.raise_for_status = Mock()
with patch.object(gitea_client.session, 'post', return_value=mock_response):
comment = gitea_client.add_comment(1, 'Test comment')
assert comment['body'] == 'Test comment'
gitea_client.session.post.assert_called_once()
def test_get_labels(gitea_client):
"""Test getting repository labels"""
mock_response = Mock()
mock_response.json.return_value = [
{'name': 'Type/Bug'},
{'name': 'Priority/High'}
]
mock_response.raise_for_status = Mock()
with patch.object(gitea_client.session, 'get', return_value=mock_response):
labels = gitea_client.get_labels()
assert len(labels) == 2
assert labels[0]['name'] == 'Type/Bug'
def test_get_org_labels(gitea_client):
"""Test getting organization labels"""
mock_response = Mock()
mock_response.json.return_value = [
{'name': 'Type/Bug'},
{'name': 'Type/Feature'}
]
mock_response.raise_for_status = Mock()
with patch.object(gitea_client.session, 'get', return_value=mock_response):
labels = gitea_client.get_org_labels(org='test_owner')
assert len(labels) == 2
def test_list_repos(gitea_client):
"""Test listing organization repositories (PMO mode)"""
mock_response = Mock()
mock_response.json.return_value = [
{'name': 'repo1'},
{'name': 'repo2'}
]
mock_response.raise_for_status = Mock()
with patch.object(gitea_client.session, 'get', return_value=mock_response):
repos = gitea_client.list_repos(org='test_owner')
assert len(repos) == 2
assert repos[0]['name'] == 'repo1'
def test_aggregate_issues(gitea_client):
"""Test aggregating issues across repositories (PMO mode)"""
# Mock list_repos
gitea_client.list_repos = Mock(return_value=[
{'name': 'repo1'},
{'name': 'repo2'}
])
# Mock list_issues
gitea_client.list_issues = Mock(side_effect=[
[{'number': 1, 'title': 'Issue 1'}], # repo1
[{'number': 2, 'title': 'Issue 2'}] # repo2
])
aggregated = gitea_client.aggregate_issues(org='test_owner', state='open')
assert 'repo1' in aggregated
assert 'repo2' in aggregated
assert len(aggregated['repo1']) == 1
assert len(aggregated['repo2']) == 1
def test_no_repo_specified_error(gitea_client):
"""Test error when repository not specified or invalid format"""
# Create client without repo
with patch('mcp_server.gitea_client.GiteaConfig') as mock_cfg:
mock_instance = mock_cfg.return_value
mock_instance.load.return_value = {
'api_url': 'https://test.com/api/v1',
'api_token': 'test_token',
'repo': None, # No repo
'mode': 'company'
}
client = GiteaClient()
with pytest.raises(ValueError) as exc_info:
client.list_issues()
assert "Use 'owner/repo' format" in str(exc_info.value)
# ========================================
# ORGANIZATION DETECTION TESTS
# ========================================
def test_is_organization_true(gitea_client):
"""Test _is_organization returns True for valid organization"""
mock_response = Mock()
mock_response.status_code = 200
with patch.object(gitea_client.session, 'get', return_value=mock_response):
result = gitea_client._is_organization('personal-projects')
assert result is True
gitea_client.session.get.assert_called_once_with(
'https://test.com/api/v1/orgs/personal-projects'
)
def test_is_organization_false(gitea_client):
"""Test _is_organization returns False for user account"""
mock_response = Mock()
mock_response.status_code = 404
with patch.object(gitea_client.session, 'get', return_value=mock_response):
result = gitea_client._is_organization('lmiranda')
assert result is False
def test_is_org_repo_uses_orgs_endpoint(gitea_client):
"""Test is_org_repo uses /orgs endpoint instead of owner.type"""
mock_response = Mock()
mock_response.status_code = 200
with patch.object(gitea_client.session, 'get', return_value=mock_response):
result = gitea_client.is_org_repo('personal-projects/repo')
assert result is True
# Should call /orgs/personal-projects, not /repos/.../
gitea_client.session.get.assert_called_once_with(
'https://test.com/api/v1/orgs/personal-projects'
)

View File

@@ -1,163 +0,0 @@
"""
Unit tests for issue tools with branch detection.
"""
import pytest
from unittest.mock import Mock, patch, AsyncMock
from mcp_server.tools.issues import IssueTools
@pytest.fixture
def mock_gitea_client():
"""Fixture providing mocked Gitea client"""
client = Mock()
client.mode = 'project'
return client
@pytest.fixture
def issue_tools(mock_gitea_client):
"""Fixture providing IssueTools instance"""
return IssueTools(mock_gitea_client)
@pytest.mark.asyncio
async def test_list_issues_development_branch(issue_tools):
"""Test listing issues on development branch (allowed)"""
with patch.object(issue_tools, '_get_current_branch', return_value='feat/test-feature'):
issue_tools.gitea.list_issues = Mock(return_value=[{'number': 1}])
issues = await issue_tools.list_issues(state='open')
assert len(issues) == 1
issue_tools.gitea.list_issues.assert_called_once()
@pytest.mark.asyncio
async def test_create_issue_development_branch(issue_tools):
"""Test creating issue on development branch (allowed)"""
with patch.object(issue_tools, '_get_current_branch', return_value='development'):
issue_tools.gitea.create_issue = Mock(return_value={'number': 1})
issue = await issue_tools.create_issue('Test', 'Body')
assert issue['number'] == 1
issue_tools.gitea.create_issue.assert_called_once()
@pytest.mark.asyncio
async def test_create_issue_main_branch_blocked(issue_tools):
"""Test creating issue on main branch (blocked)"""
with patch.object(issue_tools, '_get_current_branch', return_value='main'):
with pytest.raises(PermissionError) as exc_info:
await issue_tools.create_issue('Test', 'Body')
assert "Cannot create issues on branch 'main'" in str(exc_info.value)
@pytest.mark.asyncio
async def test_create_issue_staging_branch_allowed(issue_tools):
"""Test creating issue on staging branch (allowed for documentation)"""
with patch.object(issue_tools, '_get_current_branch', return_value='staging'):
issue_tools.gitea.create_issue = Mock(return_value={'number': 1})
issue = await issue_tools.create_issue('Test', 'Body')
assert issue['number'] == 1
@pytest.mark.asyncio
async def test_update_issue_main_branch_blocked(issue_tools):
"""Test updating issue on main branch (blocked)"""
with patch.object(issue_tools, '_get_current_branch', return_value='main'):
with pytest.raises(PermissionError) as exc_info:
await issue_tools.update_issue(1, title='Updated')
assert "Cannot update issues on branch 'main'" in str(exc_info.value)
@pytest.mark.asyncio
async def test_list_issues_main_branch_allowed(issue_tools):
"""Test listing issues on main branch (allowed - read-only)"""
with patch.object(issue_tools, '_get_current_branch', return_value='main'):
issue_tools.gitea.list_issues = Mock(return_value=[{'number': 1}])
issues = await issue_tools.list_issues(state='open')
assert len(issues) == 1
@pytest.mark.asyncio
async def test_get_issue(issue_tools):
"""Test getting specific issue"""
with patch.object(issue_tools, '_get_current_branch', return_value='development'):
issue_tools.gitea.get_issue = Mock(return_value={'number': 1, 'title': 'Test'})
issue = await issue_tools.get_issue(1)
assert issue['number'] == 1
@pytest.mark.asyncio
async def test_add_comment(issue_tools):
"""Test adding comment to issue"""
with patch.object(issue_tools, '_get_current_branch', return_value='development'):
issue_tools.gitea.add_comment = Mock(return_value={'body': 'Test comment'})
comment = await issue_tools.add_comment(1, 'Test comment')
assert comment['body'] == 'Test comment'
@pytest.mark.asyncio
async def test_aggregate_issues_company_mode(issue_tools):
"""Test aggregating issues in company mode"""
issue_tools.gitea.mode = 'company'
with patch.object(issue_tools, '_get_current_branch', return_value='development'):
issue_tools.gitea.aggregate_issues = Mock(return_value={
'repo1': [{'number': 1}],
'repo2': [{'number': 2}]
})
aggregated = await issue_tools.aggregate_issues(org='test_owner')
assert 'repo1' in aggregated
assert 'repo2' in aggregated
@pytest.mark.asyncio
async def test_aggregate_issues_project_mode(issue_tools):
"""Test that aggregate_issues works in project mode with org argument"""
issue_tools.gitea.mode = 'project'
with patch.object(issue_tools, '_get_current_branch', return_value='development'):
issue_tools.gitea.aggregate_issues = Mock(return_value={
'repo1': [{'number': 1}]
})
# aggregate_issues now works in any mode when org is provided
aggregated = await issue_tools.aggregate_issues(org='test_owner')
assert 'repo1' in aggregated
def test_branch_detection():
"""Test branch detection logic"""
tools = IssueTools(Mock())
# Test development branches
with patch.object(tools, '_get_current_branch', return_value='development'):
assert tools._check_branch_permissions('create_issue') is True
with patch.object(tools, '_get_current_branch', return_value='feat/new-feature'):
assert tools._check_branch_permissions('create_issue') is True
# Test production branches
with patch.object(tools, '_get_current_branch', return_value='main'):
assert tools._check_branch_permissions('create_issue') is False
assert tools._check_branch_permissions('list_issues') is True
# Test staging branches
with patch.object(tools, '_get_current_branch', return_value='staging'):
assert tools._check_branch_permissions('create_issue') is True
assert tools._check_branch_permissions('update_issue') is False

View File

@@ -1,478 +0,0 @@
"""
Unit tests for label tools with suggestion logic.
"""
import pytest
from unittest.mock import Mock, patch
from mcp_server.tools.labels import LabelTools
@pytest.fixture
def mock_gitea_client():
"""Fixture providing mocked Gitea client"""
client = Mock()
client.repo = 'test_org/test_repo'
client.is_org_repo = Mock(return_value=True)
return client
@pytest.fixture
def label_tools(mock_gitea_client):
"""Fixture providing LabelTools instance"""
return LabelTools(mock_gitea_client)
@pytest.mark.asyncio
async def test_get_labels(label_tools):
"""Test getting all labels (org + repo)"""
label_tools.gitea.get_org_labels = Mock(return_value=[
{'name': 'Type/Bug'},
{'name': 'Type/Feature'}
])
label_tools.gitea.get_labels = Mock(return_value=[
{'name': 'Component/Backend'},
{'name': 'Component/Frontend'}
])
result = await label_tools.get_labels()
assert len(result['organization']) == 2
assert len(result['repository']) == 2
assert result['total_count'] == 4
# ========================================
# LABEL LOOKUP TESTS (NEW)
# ========================================
def test_build_label_lookup_slash_format():
"""Test building label lookup with slash format labels"""
mock_client = Mock()
mock_client.repo = 'test/repo'
tools = LabelTools(mock_client)
labels = ['Type/Bug', 'Type/Feature', 'Priority/High', 'Priority/Low']
lookup = tools._build_label_lookup(labels)
assert 'type' in lookup
assert 'bug' in lookup['type']
assert lookup['type']['bug'] == 'Type/Bug'
assert lookup['type']['feature'] == 'Type/Feature'
assert 'priority' in lookup
assert lookup['priority']['high'] == 'Priority/High'
def test_build_label_lookup_colon_space_format():
"""Test building label lookup with colon-space format labels"""
mock_client = Mock()
mock_client.repo = 'test/repo'
tools = LabelTools(mock_client)
labels = ['Type: Bug', 'Type: Feature', 'Priority: High', 'Effort: M']
lookup = tools._build_label_lookup(labels)
assert 'type' in lookup
assert 'bug' in lookup['type']
assert lookup['type']['bug'] == 'Type: Bug'
assert lookup['type']['feature'] == 'Type: Feature'
assert 'priority' in lookup
assert lookup['priority']['high'] == 'Priority: High'
# Test singular "Effort" (not "Efforts")
assert 'effort' in lookup
assert lookup['effort']['m'] == 'Effort: M'
def test_build_label_lookup_efforts_normalization():
"""Test that 'Efforts' is normalized to 'effort' for matching"""
mock_client = Mock()
mock_client.repo = 'test/repo'
tools = LabelTools(mock_client)
labels = ['Efforts/XS', 'Efforts/S', 'Efforts/M']
lookup = tools._build_label_lookup(labels)
# 'Efforts' should be normalized to 'effort'
assert 'effort' in lookup
assert lookup['effort']['xs'] == 'Efforts/XS'
def test_find_label():
"""Test finding labels from lookup"""
mock_client = Mock()
mock_client.repo = 'test/repo'
tools = LabelTools(mock_client)
lookup = {
'type': {'bug': 'Type: Bug', 'feature': 'Type: Feature'},
'priority': {'high': 'Priority: High', 'low': 'Priority: Low'}
}
assert tools._find_label(lookup, 'type', 'bug') == 'Type: Bug'
assert tools._find_label(lookup, 'priority', 'high') == 'Priority: High'
assert tools._find_label(lookup, 'type', 'nonexistent') is None
assert tools._find_label(lookup, 'nonexistent', 'bug') is None
# ========================================
# SUGGEST LABELS WITH DYNAMIC FORMAT TESTS
# ========================================
def _create_tools_with_labels(labels):
"""Helper to create LabelTools with mocked labels"""
import asyncio
mock_client = Mock()
mock_client.repo = 'test/repo'
mock_client.is_org_repo = Mock(return_value=False)
mock_client.get_labels = Mock(return_value=[{'name': l} for l in labels])
return LabelTools(mock_client)
@pytest.mark.asyncio
async def test_suggest_labels_with_slash_format():
"""Test label suggestion with slash format labels"""
labels = [
'Type/Bug', 'Type/Feature', 'Type/Refactor',
'Priority/Critical', 'Priority/High', 'Priority/Medium', 'Priority/Low',
'Complexity/Simple', 'Complexity/Medium', 'Complexity/Complex',
'Component/Auth'
]
tools = _create_tools_with_labels(labels)
context = "Fix critical bug in login authentication"
suggestions = await tools.suggest_labels(context)
assert 'Type/Bug' in suggestions
assert 'Priority/Critical' in suggestions
assert 'Component/Auth' in suggestions
@pytest.mark.asyncio
async def test_suggest_labels_with_colon_space_format():
"""Test label suggestion with colon-space format labels"""
labels = [
'Type: Bug', 'Type: Feature', 'Type: Refactor',
'Priority: Critical', 'Priority: High', 'Priority: Medium', 'Priority: Low',
'Complexity: Simple', 'Complexity: Medium', 'Complexity: Complex',
'Effort: XS', 'Effort: S', 'Effort: M', 'Effort: L', 'Effort: XL'
]
tools = _create_tools_with_labels(labels)
context = "Fix critical bug for tiny 1 hour fix"
suggestions = await tools.suggest_labels(context)
# Should return colon-space format labels
assert 'Type: Bug' in suggestions
assert 'Priority: Critical' in suggestions
assert 'Effort: XS' in suggestions
@pytest.mark.asyncio
async def test_suggest_labels_bug():
"""Test label suggestion for bug context"""
labels = [
'Type/Bug', 'Type/Feature',
'Priority/Critical', 'Priority/High', 'Priority/Medium', 'Priority/Low',
'Complexity/Simple', 'Complexity/Medium', 'Complexity/Complex',
'Component/Auth'
]
tools = _create_tools_with_labels(labels)
context = "Fix critical bug in login authentication"
suggestions = await tools.suggest_labels(context)
assert 'Type/Bug' in suggestions
assert 'Priority/Critical' in suggestions
assert 'Component/Auth' in suggestions
@pytest.mark.asyncio
async def test_suggest_labels_feature():
"""Test label suggestion for feature context"""
labels = ['Type/Feature', 'Priority/Medium', 'Complexity/Medium']
tools = _create_tools_with_labels(labels)
context = "Add new feature to implement user dashboard"
suggestions = await tools.suggest_labels(context)
assert 'Type/Feature' in suggestions
assert any('Priority' in label for label in suggestions)
@pytest.mark.asyncio
async def test_suggest_labels_refactor():
"""Test label suggestion for refactor context"""
labels = ['Type/Refactor', 'Priority/Medium', 'Complexity/Medium', 'Component/Backend']
tools = _create_tools_with_labels(labels)
context = "Refactor architecture to extract service layer"
suggestions = await tools.suggest_labels(context)
assert 'Type/Refactor' in suggestions
assert 'Component/Backend' in suggestions
@pytest.mark.asyncio
async def test_suggest_labels_documentation():
"""Test label suggestion for documentation context"""
labels = ['Type/Documentation', 'Priority/Medium', 'Complexity/Medium', 'Component/API', 'Component/Docs']
tools = _create_tools_with_labels(labels)
context = "Update documentation for API endpoints"
suggestions = await tools.suggest_labels(context)
assert 'Type/Documentation' in suggestions
assert 'Component/API' in suggestions or 'Component/Docs' in suggestions
@pytest.mark.asyncio
async def test_suggest_labels_priority():
"""Test priority detection in suggestions"""
labels = ['Type/Feature', 'Priority/Critical', 'Priority/High', 'Priority/Medium', 'Priority/Low', 'Complexity/Medium']
tools = _create_tools_with_labels(labels)
# Critical priority
context = "Urgent blocker in production"
suggestions = await tools.suggest_labels(context)
assert 'Priority/Critical' in suggestions
# High priority
context = "Important feature needed asap"
suggestions = await tools.suggest_labels(context)
assert 'Priority/High' in suggestions
# Low priority
context = "Nice-to-have optional improvement"
suggestions = await tools.suggest_labels(context)
assert 'Priority/Low' in suggestions
@pytest.mark.asyncio
async def test_suggest_labels_complexity():
"""Test complexity detection in suggestions"""
labels = ['Type/Feature', 'Priority/Medium', 'Complexity/Simple', 'Complexity/Medium', 'Complexity/Complex']
tools = _create_tools_with_labels(labels)
# Simple complexity
context = "Simple quick fix for typo"
suggestions = await tools.suggest_labels(context)
assert 'Complexity/Simple' in suggestions
# Complex complexity
context = "Complex challenging architecture redesign"
suggestions = await tools.suggest_labels(context)
assert 'Complexity/Complex' in suggestions
@pytest.mark.asyncio
async def test_suggest_labels_efforts():
"""Test efforts detection in suggestions"""
labels = ['Type/Feature', 'Priority/Medium', 'Complexity/Medium', 'Efforts/XS', 'Efforts/S', 'Efforts/M', 'Efforts/L', 'Efforts/XL']
tools = _create_tools_with_labels(labels)
# XS effort
context = "Tiny fix that takes 1 hour"
suggestions = await tools.suggest_labels(context)
assert 'Efforts/XS' in suggestions
# L effort
context = "Large feature taking 1 week"
suggestions = await tools.suggest_labels(context)
assert 'Efforts/L' in suggestions
@pytest.mark.asyncio
async def test_suggest_labels_components():
"""Test component detection in suggestions"""
labels = ['Type/Feature', 'Priority/Medium', 'Complexity/Medium', 'Component/Backend', 'Component/Frontend', 'Component/API', 'Component/Database']
tools = _create_tools_with_labels(labels)
# Backend component
context = "Update backend API service"
suggestions = await tools.suggest_labels(context)
assert 'Component/Backend' in suggestions
assert 'Component/API' in suggestions
# Frontend component
context = "Fix frontend UI component"
suggestions = await tools.suggest_labels(context)
assert 'Component/Frontend' in suggestions
# Database component
context = "Add database migration for schema"
suggestions = await tools.suggest_labels(context)
assert 'Component/Database' in suggestions
@pytest.mark.asyncio
async def test_suggest_labels_tech_stack():
"""Test tech stack detection in suggestions"""
labels = ['Type/Feature', 'Priority/Medium', 'Complexity/Medium', 'Tech/Python', 'Tech/FastAPI', 'Tech/Docker', 'Tech/PostgreSQL']
tools = _create_tools_with_labels(labels)
# Python
context = "Update Python FastAPI endpoint"
suggestions = await tools.suggest_labels(context)
assert 'Tech/Python' in suggestions
assert 'Tech/FastAPI' in suggestions
# Docker
context = "Fix Dockerfile configuration"
suggestions = await tools.suggest_labels(context)
assert 'Tech/Docker' in suggestions
# PostgreSQL
context = "Optimize PostgreSQL query"
suggestions = await tools.suggest_labels(context)
assert 'Tech/PostgreSQL' in suggestions
@pytest.mark.asyncio
async def test_suggest_labels_source():
"""Test source detection in suggestions"""
labels = ['Type/Feature', 'Priority/Medium', 'Complexity/Medium', 'Source/Development', 'Source/Staging', 'Source/Production']
tools = _create_tools_with_labels(labels)
# Development
context = "Issue found in development environment"
suggestions = await tools.suggest_labels(context)
assert 'Source/Development' in suggestions
# Production
context = "Critical production issue"
suggestions = await tools.suggest_labels(context)
assert 'Source/Production' in suggestions
@pytest.mark.asyncio
async def test_suggest_labels_risk():
"""Test risk detection in suggestions"""
labels = ['Type/Feature', 'Priority/Medium', 'Complexity/Medium', 'Risk/High', 'Risk/Low']
tools = _create_tools_with_labels(labels)
# High risk
context = "Breaking change to major API"
suggestions = await tools.suggest_labels(context)
assert 'Risk/High' in suggestions
# Low risk
context = "Safe minor update with low risk"
suggestions = await tools.suggest_labels(context)
assert 'Risk/Low' in suggestions
@pytest.mark.asyncio
async def test_suggest_labels_multiple_categories():
"""Test that suggestions span multiple categories"""
labels = [
'Type/Bug', 'Type/Feature',
'Priority/Critical', 'Priority/Medium',
'Complexity/Complex', 'Complexity/Medium',
'Component/Backend', 'Component/API', 'Component/Auth',
'Tech/FastAPI', 'Tech/PostgreSQL',
'Source/Production'
]
tools = _create_tools_with_labels(labels)
context = """
Urgent critical bug in production backend API service.
Need to fix broken authentication endpoint.
This is a complex issue requiring FastAPI and PostgreSQL expertise.
"""
suggestions = await tools.suggest_labels(context)
# Should have Type
assert any('Type/' in label for label in suggestions)
# Should have Priority
assert any('Priority/' in label for label in suggestions)
# Should have Component
assert any('Component/' in label for label in suggestions)
# Should have Tech
assert any('Tech/' in label for label in suggestions)
# Should have Source
assert any('Source/' in label for label in suggestions)
@pytest.mark.asyncio
async def test_suggest_labels_empty_repo():
"""Test suggestions when no repo specified and no labels available"""
mock_client = Mock()
mock_client.repo = None
tools = LabelTools(mock_client)
context = "Fix a bug"
suggestions = await tools.suggest_labels(context)
# Should return empty list when no repo
assert suggestions == []
@pytest.mark.asyncio
async def test_suggest_labels_no_matching_labels():
"""Test suggestions return empty when no matching labels exist"""
labels = ['Custom/Label', 'Other/Thing'] # No standard labels
tools = _create_tools_with_labels(labels)
context = "Fix a bug"
suggestions = await tools.suggest_labels(context)
# Should return empty list since no Type/Bug or similar exists
assert len(suggestions) == 0
@pytest.mark.asyncio
async def test_get_labels_org_owned_repo():
"""Test getting labels for organization-owned repository"""
mock_client = Mock()
mock_client.repo = 'myorg/myrepo'
mock_client.is_org_repo = Mock(return_value=True)
mock_client.get_org_labels = Mock(return_value=[
{'name': 'Type/Bug', 'id': 1},
{'name': 'Type/Feature', 'id': 2}
])
mock_client.get_labels = Mock(return_value=[
{'name': 'Component/Backend', 'id': 3}
])
tools = LabelTools(mock_client)
result = await tools.get_labels()
# Should fetch both org and repo labels
mock_client.is_org_repo.assert_called_once_with('myorg/myrepo')
mock_client.get_org_labels.assert_called_once_with('myorg')
mock_client.get_labels.assert_called_once_with('myorg/myrepo')
assert len(result['organization']) == 2
assert len(result['repository']) == 1
assert result['total_count'] == 3
@pytest.mark.asyncio
async def test_get_labels_user_owned_repo():
"""Test getting labels for user-owned repository (no org labels)"""
mock_client = Mock()
mock_client.repo = 'lmiranda/personal-portfolio'
mock_client.is_org_repo = Mock(return_value=False)
mock_client.get_labels = Mock(return_value=[
{'name': 'bug', 'id': 1},
{'name': 'enhancement', 'id': 2}
])
tools = LabelTools(mock_client)
result = await tools.get_labels()
# Should check if org repo
mock_client.is_org_repo.assert_called_once_with('lmiranda/personal-portfolio')
# Should NOT call get_org_labels for user-owned repos
mock_client.get_org_labels.assert_not_called()
# Should still get repo labels
mock_client.get_labels.assert_called_once_with('lmiranda/personal-portfolio')
assert len(result['organization']) == 0
assert len(result['repository']) == 2
assert result['total_count'] == 2

View File

@@ -1,14 +1,23 @@
---
description: Prompt optimization and requirement clarification
name: clarity
description: Prompt optimization and requirement clarification — type /clarity <action> for commands
---
# /clarity
Prompt optimization and requirement clarification with ND-friendly accommodations.
## Sub-commands
When invoked without a sub-command, display available actions and ask which to run.
| Sub-command | Description |
|-------------|-------------|
## Available Commands
| Command | Description |
|---------|-------------|
| `/clarity clarify` | Full 4-D methodology for complex requests |
| `/clarity quick-clarify` | Rapid mode for simple disambiguation |
## Workflow
1. Display the table above
2. Ask: "Which command would you like to run?"
3. Route to the selected sub-command

View File

@@ -1,15 +1,18 @@
---
description: CLAUDE.md and settings optimization
name: claude-config
description: CLAUDE.md and settings optimization — type /claude-config <action> for commands
---
# /claude-config
CLAUDE.md and settings.local.json optimization for Claude Code projects.
## Sub-commands
When invoked without a sub-command, display available actions and ask which to run.
| Sub-command | Description |
|-------------|-------------|
## Available Commands
| Command | Description |
|---------|-------------|
| `/claude-config analyze` | Analyze CLAUDE.md for optimization opportunities |
| `/claude-config optimize` | Optimize CLAUDE.md structure with preview/backup |
| `/claude-config init` | Initialize new CLAUDE.md for a project |
@@ -18,3 +21,9 @@ CLAUDE.md and settings.local.json optimization for Claude Code projects.
| `/claude-config audit-settings` | Audit settings.local.json permissions (100-point score) |
| `/claude-config optimize-settings` | Optimize permissions (profiles, consolidation, dry-run) |
| `/claude-config permissions-map` | Visual review layer + permission coverage map |
## Workflow
1. Display the table above
2. Ask: "Which command would you like to run?"
3. Route to the selected sub-command

View File

@@ -1,14 +1,16 @@
---
description: NetBox CMDB infrastructure management
name: cmdb
description: NetBox CMDB infrastructure management — type /cmdb <action> for commands
---
# /cmdb
NetBox CMDB integration for infrastructure management.
When invoked without a sub-command, display available actions and ask which to run.
## Sub-commands
## Available Commands
| Sub-command | Description |
| Command | Description |
|-------------|-------------|
| `/cmdb search` | Search NetBox for devices, IPs, sites |
| `/cmdb device` | Manage network devices (create, view, update, delete) |
@@ -21,3 +23,9 @@ NetBox CMDB integration for infrastructure management.
| `/cmdb change-audit` | NetBox audit trail queries with filtering |
| `/cmdb ip-conflicts` | Detect IP conflicts and overlapping prefixes |
| `/cmdb setup` | Setup wizard for NetBox MCP server |
## Workflow
1. Display the table above
2. Ask: "Which command would you like to run?"
3. Route to the selected sub-command

View File

@@ -1,15 +1,24 @@
---
description: Security scanning and code refactoring
name: sentinel
description: Security scanning and code refactoring — type /sentinel <action> for commands
---
# /sentinel
Security scanning and safe code refactoring tools.
## Sub-commands
When invoked without a sub-command, display available actions and ask which to run.
| Sub-command | Description |
|-------------|-------------|
## Available Commands
| Command | Description |
|---------|-------------|
| `/sentinel scan` | Full security audit (SQL injection, XSS, secrets, etc.) |
| `/sentinel refactor` | Apply refactoring patterns to improve code |
| `/sentinel refactor-dry` | Preview refactoring without applying changes |
## Workflow
1. Display the table above
2. Ask: "Which command would you like to run?"
3. Route to the selected sub-command

View File

@@ -1,14 +1,16 @@
---
description: Cross-plugin compatibility validation
name: cv
description: Cross-plugin compatibility validation — type /cv <action> for commands
---
# /cv
Cross-plugin compatibility validation and agent verification.
When invoked without a sub-command, display available actions and ask which to run.
## Sub-commands
## Available Commands
| Sub-command | Description |
| Command | Description |
|-------------|-------------|
| `/cv validate` | Full marketplace compatibility validation |
| `/cv check-agent` | Validate single agent definition |
@@ -16,3 +18,9 @@ Cross-plugin compatibility validation and agent verification.
| `/cv dependency-graph` | Mermaid visualization of plugin dependencies |
| `/cv setup` | Setup wizard for contract-validator MCP |
| `/cv status` | Marketplace-wide health check |
## Workflow
1. Display the table above
2. Ask: "Which command would you like to run?"
3. Route to the selected sub-command

View File

@@ -1,14 +1,16 @@
---
description: Data engineering tools with pandas, PostgreSQL, and dbt
name: data
description: Data engineering tools — type /data <action> for commands
---
# /data
Data engineering tools with pandas, PostgreSQL/PostGIS, and dbt integration.
When invoked without a sub-command, display available actions and ask which to run.
## Sub-commands
## Available Commands
| Sub-command | Description |
| Command | Description |
|-------------|-------------|
| `/data ingest` | Load data from CSV, Parquet, JSON into DataFrame |
| `/data profile` | Generate data profiling report with statistics |
@@ -22,3 +24,9 @@ Data engineering tools with pandas, PostgreSQL/PostGIS, and dbt integration.
| `/data review` | Comprehensive data integrity audits |
| `/data gate` | Binary pass/fail data integrity gates |
| `/data setup` | Setup wizard for data-platform MCP servers |
## Workflow
1. Display the table above
2. Ask: "Which command would you like to run?"
3. Route to the selected sub-command

View File

@@ -1,17 +1,25 @@
---
description: Test data generation — create realistic fake data from schema definitions
name: seed
description: Test data generation — type /seed <action> for commands
---
# /seed
Test data generation and database seeding with reproducible profiles.
When invoked without a sub-command, display available actions and ask which to run.
## Sub-commands
## Available Commands
| Sub-command | Description |
| Command | Description |
|-------------|-------------|
| `/seed setup` | Setup wizard for data-seed configuration |
| `/seed generate` | Generate seed data from schema or models |
| `/seed apply` | Apply seed data to database or create fixture files |
| `/seed profile` | Define reusable data profiles (small, medium, large) |
| `/seed validate` | Validate seed data against schema constraints |
## Workflow
1. Display the table above
2. Ask: "Which command would you like to run?"
3. Route to the selected sub-command

View File

@@ -1,17 +1,25 @@
---
description: MCP debugging — inspect servers, test tools, view logs, scaffold new servers
name: debug-mcp
description: MCP debugging — type /debug-mcp <action> for commands
---
# /debug-mcp
MCP server debugging, inspection, and development toolkit.
When invoked without a sub-command, display available actions and ask which to run.
## Sub-commands
## Available Commands
| Sub-command | Description |
| Command | Description |
|-------------|-------------|
| `/debug-mcp status` | Show all MCP servers with health status |
| `/debug-mcp test` | Test a specific MCP tool call |
| `/debug-mcp logs` | View recent MCP server logs and errors |
| `/debug-mcp inspect` | Inspect MCP server config and dependencies |
| `/debug-mcp scaffold` | Generate MCP server skeleton project |
## Workflow
1. Display the table above
2. Ask: "Which command would you like to run?"
3. Route to the selected sub-command

View File

@@ -1,17 +1,26 @@
---
description: Documentation management and drift detection
name: doc
description: Documentation management and drift detection — type /doc <action> for commands
---
# /doc
Documentation management, drift detection, and synchronization.
## Sub-commands
When invoked without a sub-command, display available actions and ask which to run.
| Sub-command | Description |
|-------------|-------------|
## Available Commands
| Command | Description |
|---------|-------------|
| `/doc audit` | Full documentation audit - scans for doc drift |
| `/doc sync` | Synchronize pending documentation updates |
| `/doc changelog-gen` | Generate changelog from conventional commits |
| `/doc coverage` | Documentation coverage metrics by function/class |
| `/doc stale-docs` | Flag documentation behind code changes |
## Workflow
1. Display the table above
2. Ask: "Which command would you like to run?"
3. Route to the selected sub-command

View File

@@ -1,55 +0,0 @@
# git-flow - CLAUDE.md Integration
Add the following section to your project's CLAUDE.md file to enable git-flow.
---
## Git Workflow
This project uses the git-flow plugin for git operations.
### Workflow Style
**Style:** feature-branch
**Base Branch:** development
### Branch Naming
Use the format: `<type>/<description>`
Types: feat, fix, chore, docs, refactor, test, perf
Examples:
- `feat/add-user-auth`
- `fix/login-timeout`
- `chore/update-deps`
### Commit Messages
Use conventional commits:
```
<type>(<scope>): <description>
[body]
[footer]
```
### Commands
| Command | Use Case |
|---------|----------|
| `/gitflow commit` | Smart commit with optional --push, --merge, --sync |
| `/gitflow commit --push` | Commit and push to remote |
| `/gitflow commit --merge` | Commit and merge into target branch |
| `/gitflow branch-start` | Start new branch |
| `/gitflow status` | Enhanced status |
### Protected Branches
Do not commit directly to: main, development, staging
---
Copy the section between the horizontal rules into your CLAUDE.md.

View File

@@ -42,6 +42,7 @@ Configure git-flow settings interactively or display current configuration.
5. Protected branches
6. View all settings
7. Reset to defaults
8. Re-run setup wizard (redirects to `/gitflow setup`)
## Output

View File

@@ -0,0 +1,111 @@
---
name: gitflow setup
description: Configure git-flow for the current project — auto-detects Gitea config
agent: git-assistant
---
# /gitflow setup - Git-Flow Configuration
## Skills
- skills/visual-header.md
- skills/environment-variables.md
- skills/workflow-patterns/branching-strategies.md
- skills/claude-md-integration.md
## Purpose
Configure git-flow for a project. Auto-detects existing Gitea system configuration
and injects the git-flow section into the project's CLAUDE.md.
**Important:** Uses Bash, Read, Write tools only — NOT MCP tools.
## Workflow
### Step 1: Display Header
```
+----------------------------------------------------------------------+
| GIT-FLOW Setup |
+----------------------------------------------------------------------+
```
### Step 2: Detect Gitea System Config
Check for existing Gitea configuration:
1. Read `~/.config/claude/gitea.env`
- If found: parse GITEA_API_URL and GITEA_API_TOKEN (show URL, mask token)
- If missing: inform user and redirect — "Run `/projman setup --full` first to configure Gitea, then come back."
### Step 3: Detect Git Repository
1. Run `git remote -v` to get remote URL
2. Parse org/repo from remote URL
3. If no git remote: warn and ask for manual GITEA_REPO value
4. If multiple git remotes: ask which to use (prefer "origin")
### Step 4: Detect Existing Configuration
Check for existing git-flow settings:
1. Read project `.env` — check for GIT_WORKFLOW_STYLE, GIT_DEFAULT_BASE
2. Read CLAUDE.md — check for "## Git Workflow" section
3. If both exist and match: "Already configured. Reconfigure? (y/n)"
### Step 5: Configure Workflow Settings
Prompt for (with defaults from `skills/environment-variables.md`):
| Setting | Default | Options |
|---------|---------|---------|
| Workflow style | feature-branch | simple, feature-branch, pr-required, trunk-based |
| Default base branch | development | main, development, custom |
| Auto-push after commit | false | true, false |
| Auto-delete merged branches | true | true, false |
| Protected branches | main,staging | comma-separated list |
Save to project `.env` (append or update existing git-flow section):
```bash
# git-flow configuration
GIT_WORKFLOW_STYLE=feature-branch
GIT_DEFAULT_BASE=development
GIT_AUTO_PUSH=false
GIT_AUTO_DELETE_MERGED=true
GIT_PROTECTED_BRANCHES=main,staging
```
### Step 6: Inject CLAUDE.md Section
Read the project's CLAUDE.md. If a "## Git Workflow" section exists, replace it.
If not, append before the last section.
Use the template from `skills/claude-md-integration.md`, customizing with settings from Step 5:
- Replace workflow style, base branch, and protected branches with actual values
- Keep branch naming and commit message conventions as-is
### Step 7: Validate and Confirm
1. Verify `.env` was written correctly
2. Verify CLAUDE.md section was injected
3. Display summary:
```
Setup complete!
Gitea: {GITEA_API_URL} (detected from system config)
Repo: {org}/{repo} (detected from git remote)
Style: {GIT_WORKFLOW_STYLE}
Base: {GIT_DEFAULT_BASE}
Protected: {GIT_PROTECTED_BRANCHES}
CLAUDE.md updated with git-flow configuration.
```
## Edge Cases
- No CLAUDE.md in project root: Create one with just the Git Workflow section
- CLAUDE.md is read-only: Warn and output the section for manual paste
- No git remote: Skip Gitea detection, still configure workflow settings
- No Gitea system config: Warn but proceed with workflow setup (Gitea is optional)

View File

@@ -1,17 +1,27 @@
---
description: Git workflow automation with safety enforcement
name: gitflow
description: Git workflow automation with safety enforcement — type /gitflow <action> for commands
---
# /gitflow
Git workflow automation with smart commits, branch management, and safety enforcement.
## Sub-commands
When invoked without a sub-command, display available actions and ask which to run.
| Sub-command | Description |
|-------------|-------------|
## Available Commands
| Command | Description |
|---------|-------------|
| `/gitflow setup` | Configure git-flow for the current project |
| `/gitflow commit` | Smart commit with optional --push, --merge, --sync |
| `/gitflow branch-start` | Create a properly-named feature branch |
| `/gitflow branch-cleanup` | Clean up merged/stale branches |
| `/gitflow status` | Enhanced git status with recommendations |
| `/gitflow config` | Configure git-flow settings |
## Workflow
1. Display the table above
2. Ask: "Which command would you like to run?"
3. Route to the selected sub-command

View File

@@ -0,0 +1,70 @@
# CLAUDE.md Git-Flow Section Template
## Purpose
Template for the Git Workflow section that `/gitflow setup` injects into a project's CLAUDE.md.
## When to Use
- During `/gitflow setup` to inject the Git Workflow section
- As reference for the expected CLAUDE.md structure
## Template
The following template uses placeholders that `/gitflow setup` replaces with actual values:
```markdown
## Git Workflow
This project uses the git-flow plugin for git operations.
### Workflow Style
**Style:** {GIT_WORKFLOW_STYLE}
**Base Branch:** {GIT_DEFAULT_BASE}
### Branch Naming
Use the format: `<type>/<description>`
Types: feat, fix, chore, docs, refactor, test, perf
Examples:
- `feat/add-user-auth`
- `fix/login-timeout`
- `chore/update-deps`
### Commit Messages
Use conventional commits:
```
<type>(<scope>): <description>
[body]
[footer]
```
### Commands
| Command | Use Case |
|---------|----------|
| `/gitflow commit` | Smart commit with optional --push, --merge, --sync |
| `/gitflow commit --push` | Commit and push to remote |
| `/gitflow commit --merge` | Commit and merge into target branch |
| `/gitflow branch-start` | Start new branch |
| `/gitflow status` | Enhanced status |
### Protected Branches
Do not commit directly to: {GIT_PROTECTED_BRANCHES}
```
## Placeholder Reference
| Placeholder | Source | Default |
|-------------|--------|---------|
| `{GIT_WORKFLOW_STYLE}` | Step 5 selection | `feature-branch` |
| `{GIT_DEFAULT_BASE}` | Step 5 selection | `development` |
| `{GIT_PROTECTED_BRANCHES}` | Step 5 selection | `main,staging` |

View File

@@ -75,6 +75,13 @@ Standard header format for consistent visual output across all git-flow commands
+----------------------------------------------------------------------+
```
### /gitflow setup
```
+----------------------------------------------------------------------+
| GIT-FLOW Setup |
+----------------------------------------------------------------------+
```
## Usage
Display header immediately after command invocation, before any workflow steps.

View File

@@ -1,18 +1,27 @@
---
description: Deployment management — generate configs, validate pipelines, manage environments
name: deploy
description: Deployment management — type /deploy <action> for commands
---
# /deploy
CI/CD deployment pipeline management for Docker Compose and self-hosted infrastructure.
## Sub-commands
When invoked without a sub-command, display available actions and ask which to run.
| Sub-command | Description |
|-------------|-------------|
## Available Commands
| Command | Description |
|---------|-------------|
| `/deploy setup` | Interactive setup wizard for deployment configuration |
| `/deploy generate` | Generate docker-compose.yml, Caddyfile, and systemd units |
| `/deploy validate` | Validate deployment configs for correctness and best practices |
| `/deploy env` | Manage environment-specific config files (.env.development, .env.production) |
| `/deploy check` | Pre-deployment health check (disk, memory, ports, DNS, Docker) |
| `/deploy rollback` | Generate rollback plan for a deployment |
## Workflow
1. Display the table above
2. Ask: "Which command would you like to run?"
3. Route to the selected sub-command

View File

@@ -1,18 +1,27 @@
---
description: Release management — version bumping, changelog updates, tag creation
name: release
description: Release management — type /release <action> for commands
---
# /release
Release management with semantic versioning, changelog generation, and tag management.
## Sub-commands
When invoked without a sub-command, display available actions and ask which to run.
| Sub-command | Description |
|-------------|-------------|
## Available Commands
| Command | Description |
|---------|-------------|
| `/release setup` | Setup wizard — detect version locations and release conventions |
| `/release prepare` | Prepare release: bump versions, update changelog, create branch |
| `/release validate` | Pre-release checks — verify versions, changelog, dependencies |
| `/release tag` | Create and push git tag with release notes |
| `/release rollback` | Revert a release — remove tag, revert version bump |
| `/release status` | Show current version and unreleased changes |
## Workflow
1. Display the table above
2. Ask: "Which command would you like to run?"
3. Route to the selected sub-command

View File

@@ -1,15 +1,18 @@
---
description: Pull request review and management
name: pr
description: Pull request review and management — type /pr <action> for commands
---
# /pr
Multi-agent pull request review with confidence scoring.
## Sub-commands
When invoked without a sub-command, display available actions and ask which to run.
| Sub-command | Description |
|-------------|-------------|
## Available Commands
| Command | Description |
|---------|-------------|
| `/pr review` | Full multi-agent PR review with confidence scoring |
| `/pr summary` | Quick summary of PR changes |
| `/pr findings` | List and filter review findings by category/severity |
@@ -17,3 +20,9 @@ Multi-agent pull request review with confidence scoring.
| `/pr init` | Quick project setup for PR reviews |
| `/pr sync` | Sync config with git remote after repo move/rename |
| `/pr setup` | Setup wizard for pr-review |
## Workflow
1. Display the table above
2. Ask: "Which command would you like to run?"
3. Route to the selected sub-command

View File

@@ -0,0 +1,22 @@
---
name: hygiene
description: Project hygiene checks — type /hygiene <action> for commands
---
# /hygiene
Manual project hygiene checks for file organization and cleanup.
When invoked without a sub-command, display available actions and ask which to run.
## Available Commands
| Command | Description |
|---------|-------------|
| `/hygiene check` | Run project hygiene checks (temp files, misplaced files, empty dirs) |
## Workflow
1. Display the table above
2. Ask: "Which command would you like to run?"
3. Route to the selected sub-command

View File

@@ -1,13 +1,18 @@
---
description: Architecture Decision Records management
name: adr
description: Architecture Decision Records management — type /adr <action> for commands
---
# /adr
## Sub-commands
Architecture Decision Records management for projman.
| Sub-command | Description |
|-------------|-------------|
When invoked without a sub-command, display available actions and ask which to run.
## Available Commands
| Command | Description |
|---------|-------------|
| `/adr create` | Create a new ADR wiki page |
| `/adr list` | List all ADRs by status |
| `/adr update` | Update an existing ADR |
@@ -21,3 +26,9 @@ description: Architecture Decision Records management
/adr update <ADR-NNNN> [--status accepted|deprecated]
/adr supersede <ADR-NNNN> --by <ADR-MMMM>
```
## Workflow
1. Display the table above
2. Ask: "Which command would you like to run?"
3. Route to the selected sub-command

View File

@@ -1,13 +1,22 @@
---
description: Label management
name: labels
description: Label management — type /labels <action> for commands
---
# /labels
Label management for projman.
## Sub-commands
When invoked without a sub-command, display available actions and ask which to run.
| Sub-command | Description |
|-------------|-------------|
## Available Commands
| Command | Description |
|---------|-------------|
| `/labels sync` | Sync label taxonomy to Gitea repository |
## Workflow
1. Display the table above
2. Ask: "Which command would you like to run?"
3. Route to the selected sub-command

View File

@@ -1,13 +1,18 @@
---
description: Project lifecycle management — concept to MVP
name: project
description: Project lifecycle management — type /project <action> for commands
---
# /project
## Sub-commands
Project lifecycle management from concept to MVP.
| Sub-command | Description |
|-------------|-------------|
When invoked without a sub-command, display available actions and ask which to run.
## Available Commands
| Command | Description |
|---------|-------------|
| `/project initiation` | Analyze source, create charter, decompose into epics |
| `/project plan` | Create WBS, risk register, sprint roadmap |
| `/project status` | Full project hierarchy view |
@@ -21,3 +26,9 @@ description: Project lifecycle management — concept to MVP
/project status <project-name>
/project close <project-name>
```
## Workflow
1. Display the table above
2. Ask: "Which command would you like to run?"
3. Route to the selected sub-command

View File

@@ -1,13 +1,22 @@
---
description: projman plugin management
name: projman
description: projman plugin management — type /projman <action> for commands
---
# /projman
Plugin-level management commands for projman.
## Sub-commands
When invoked without a sub-command, display available actions and ask which to run.
| Sub-command | Description |
|-------------|-------------|
## Available Commands
| Command | Description |
|---------|-------------|
| `/projman setup` | Configure projman for a project |
## Workflow
1. Display the table above
2. Ask: "Which command would you like to run?"
3. Route to the selected sub-command

View File

@@ -1,4 +1,5 @@
---
name: rfc
description: RFC lifecycle management - create, list, review, approve, reject
agent: planner
---
@@ -15,22 +16,30 @@ agent: planner
Manage the full RFC lifecycle through sub-commands. RFCs provide a structured way to document, discuss, and approve changes before implementation.
When invoked without a sub-command, display available actions and ask which to run.
## Invocation
```
/rfc <sub-command> [arguments]
```
### Sub-Commands
## Available Commands
| Sub-Command | Usage | Description |
|-------------|-------|-------------|
| Command | Usage | Description |
|---------|-------|-------------|
| `create` | `/rfc create` | Create new RFC from conversation or clarified spec |
| `list` | `/rfc list [filter]` | List all RFCs grouped by status |
| `review` | `/rfc review <number>` | Submit Draft RFC for review |
| `approve` | `/rfc approve <number>` | Approve RFC in Review status |
| `reject` | `/rfc reject <number>` | Reject RFC with documented reason |
## Workflow
1. Display the table above
2. Ask: "Which command would you like to run?"
3. Route to the selected sub-command
---
## Sub-Command: create

View File

@@ -1,18 +1,27 @@
---
description: Sprint lifecycle management
name: sprint
description: Sprint lifecycle management — type /sprint <action> for commands
---
# /sprint
Sprint lifecycle management for projman.
## Sub-commands
When invoked without a sub-command, display available actions and ask which to run.
| Sub-command | Description |
|-------------|-------------|
## Available Commands
| Command | Description |
|---------|-------------|
| `/sprint plan` | Analyze requirements, create issues, request approval |
| `/sprint start` | Begin execution, load context, dispatch tasks |
| `/sprint status` | Check progress, blockers, completion percentage |
| `/sprint close` | Capture lessons learned, close milestone |
| `/sprint review` | Pre-close code quality review |
| `/sprint test` | Run/generate tests for sprint scope |
## Workflow
1. Display the table above
2. Ask: "Which command would you like to run?"
3. Route to the selected sub-command

View File

@@ -1,19 +1,27 @@
---
name: api
description: API development toolkit — route scaffolding, OpenAPI validation, middleware management
description: API development toolkit — type /api <action> for commands
---
# /api
REST and GraphQL API scaffolding, validation, and documentation for FastAPI and Express.
## Sub-commands
When invoked without a sub-command, display available actions and ask which to run.
| Sub-command | Description |
|-------------|-------------|
## Available Commands
| Command | Description |
|---------|-------------|
| `/api setup` | Setup wizard for saas-api-platform |
| `/api scaffold` | Generate routes, models, and schemas |
| `/api validate` | Validate routes against OpenAPI spec |
| `/api docs` | Generate or update OpenAPI specification |
| `/api test-routes` | Generate test cases for API endpoints |
| `/api middleware` | Add and configure middleware |
## Workflow
1. Display the table above
2. Ask: "Which command would you like to run?"
3. Route to the selected sub-command

View File

@@ -1,19 +1,27 @@
---
name: db-migrate
description: Database migration toolkit — generate, validate, and manage schema migrations
description: Database migration toolkit — type /db-migrate <action> for commands
---
# /db-migrate
Database migration management for Alembic, Prisma, and raw SQL.
## Sub-commands
When invoked without a sub-command, display available actions and ask which to run.
| Sub-command | Description |
|-------------|-------------|
## Available Commands
| Command | Description |
|---------|-------------|
| `/db-migrate setup` | Setup wizard for migration tool detection |
| `/db-migrate generate` | Generate migration from model diff |
| `/db-migrate validate` | Check migration safety |
| `/db-migrate plan` | Show execution plan with rollback strategy |
| `/db-migrate history` | Display migration history |
| `/db-migrate rollback` | Generate rollback migration |
## Workflow
1. Display the table above
2. Ask: "Which command would you like to run?"
3. Route to the selected sub-command

View File

@@ -1,18 +1,27 @@
---
description: React development toolkit — component scaffolding, routing, state management, and linting
name: react
description: React development toolkit — type /react <action> for commands
---
# /react
React frontend development toolkit with component scaffolding, routing, state management, and anti-pattern detection.
## Sub-commands
When invoked without a sub-command, display available actions and ask which to run.
| Sub-command | Description |
|-------------|-------------|
## Available Commands
| Command | Description |
|---------|-------------|
| `/react setup` | Setup wizard for React project detection and configuration |
| `/react component` | Scaffold component with props, types, and tests |
| `/react route` | Add route with page component and error boundary |
| `/react state` | Set up state management pattern (Context, Zustand, Redux Toolkit) |
| `/react hook` | Generate custom hook with types and tests |
| `/react lint` | Validate component tree and detect anti-patterns |
## Workflow
1. Display the table above
2. Ask: "Which command would you like to run?"
3. Route to the selected sub-command

View File

@@ -1,18 +1,27 @@
---
description: Test automation — generate tests, analyze coverage, manage fixtures
name: test
description: Test automation — type /test <action> for commands
---
# /test
Test automation toolkit for unit, integration, and end-to-end testing.
## Sub-commands
When invoked without a sub-command, display available actions and ask which to run.
| Sub-command | Description |
|-------------|-------------|
## Available Commands
| Command | Description |
|---------|-------------|
| `/test setup` | Setup wizard — detect framework, configure test runner |
| `/test generate` | Generate test cases for functions, classes, or modules |
| `/test coverage` | Analyze coverage and identify untested paths |
| `/test fixtures` | Generate or manage test fixtures and mocks |
| `/test e2e` | Generate end-to-end test scenarios |
| `/test run` | Run tests with formatted output |
## Workflow
1. Display the table above
2. Ask: "Which command would you like to run?"
3. Route to the selected sub-command

View File

@@ -1,14 +1,16 @@
---
description: Visualization tools with DMC validation, charts, and theming
name: viz
description: Visualization tools — type /viz <action> for commands
---
# /viz
Visualization tools with Dash Mantine Components validation, Plotly charts, and theming.
When invoked without a sub-command, display available actions and ask which to run.
## Sub-commands
## Available Commands
| Sub-command | Description |
| Command | Description |
|-------------|-------------|
| `/viz theme` | Apply existing theme to visualizations |
| `/viz theme-new` | Create new custom theme with design tokens |
@@ -22,3 +24,9 @@ Visualization tools with Dash Mantine Components validation, Plotly charts, and
| `/viz design-review` | Detailed design system audits |
| `/viz design-gate` | Binary pass/fail design system validation |
| `/viz setup` | Setup wizard for viz-platform MCP server |
## Workflow
1. Display the table above
2. Ask: "Which command would you like to run?"
3. Route to the selected sub-command