1. Repo Auto-Detection (config.py):
- Added _detect_repo_from_git() to parse git remote URL
- Supports SSH, SSH short, HTTPS, HTTP URL formats
- Falls back to git remote when GITEA_REPO not set
2. Organization Validation (gitea_client.py):
- Changed is_org_repo() to use /orgs/{owner} endpoint
- Added _is_organization() method for reliable org detection
- Fixes issue where owner.type was null in Gitea API
3. Tests:
- Added 6 tests for git URL parsing
- Added 3 tests for org detection
Fixes #64
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
176 lines
5.5 KiB
Python
176 lines
5.5 KiB
Python
"""
|
|
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"
|
|
)
|
|
|
|
# Load project config (overrides system)
|
|
project_config = Path.cwd() / '.env'
|
|
if project_config.exists():
|
|
load_dotenv(project_config, override=True)
|
|
logger.info(f"Loaded project configuration from {project_config}")
|
|
|
|
# Extract values
|
|
self.api_url = os.getenv('GITEA_API_URL')
|
|
self.api_token = os.getenv('GITEA_API_TOKEN')
|
|
self.repo = os.getenv('GITEA_REPO') # Optional, must be owner/repo format
|
|
|
|
# Auto-detect repo from git remote if not specified
|
|
if not self.repo:
|
|
self.repo = self._detect_repo_from_git()
|
|
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 _detect_repo_from_git(self) -> Optional[str]:
|
|
"""
|
|
Auto-detect repository from git remote origin URL.
|
|
|
|
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
|
|
)
|
|
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
|