""" 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