# Task 3: Configuration Management
## Overview
**Priority**: Medium
**Goal**: Centralize configuration handling into a unified system
**Target**: Single source of truth for all configuration with validation
**Status**: ✅ COMPLETE
## Current Problem
Configuration logic is currently scattered across multiple classes:
- Commitizen configuration in `CommitzenService`
- Git configuration mixed with operations
- Environment variable handling in multiple places
- No centralized validation or type safety
- Plugin configuration spread across adapters
This makes the code:
- Difficult to understand configuration flow
- Hard to validate configuration consistency
- Challenging to add new configuration options
- Complex to handle environment-specific overrides
## Target Structure
```
src/commit_helper_mcp/config/
├── __init__.py
├── settings.py # Application settings
├── repository_config.py # Repository-specific config
└── plugin_config.py # Plugin configuration management
```
## Implementation Steps
### Step 1: Create Configuration Package
**File**: `src/commit_helper_mcp/config/__init__.py`
```python
"""
Configuration Management for Commit Helper MCP
This package provides centralized configuration handling with validation,
type safety, and environment-specific overrides.
"""
from .settings import Settings, get_settings
from .repository_config import RepositoryConfig
from .plugin_config import PluginConfig
__all__ = [
'Settings',
'get_settings',
'RepositoryConfig',
'PluginConfig'
]
```
### Step 2: Create Application Settings
**File**: `src/commit_helper_mcp/config/settings.py`
**Responsibilities**:
- Application-wide configuration
- Environment variable management
- Default values and validation
- Type safety with Pydantic or dataclasses
```python
"""
Application Settings
Centralized application configuration with environment variable support
and validation.
"""
import os
from dataclasses import dataclass, field
from typing import Optional, Dict, Any, List
from pathlib import Path
@dataclass
class Settings:
"""Application-wide settings with environment variable support."""
# Git Operation Settings
default_sign_off: bool = field(default=True)
git_timeout: int = field(default=30)
max_commit_message_length: int = field(default=1000)
# Repository Settings
default_repo_path: Optional[str] = field(default=None)
allow_repository_traversal: bool = field(default=False)
# Plugin Settings
supported_plugins: List[str] = field(default_factory=lambda: [
'ConventionalCommitsCz',
'Cz',
'ConventionalCommits'
])
plugin_timeout: int = field(default=10)
# MCP Server Settings
server_name: str = field(default="Commit Helper MCP")
log_level: str = field(default="INFO")
# Performance Settings
enable_caching: bool = field(default=True)
cache_ttl: int = field(default=300) # 5 minutes
# Security Settings
validate_file_paths: bool = field(default=True)
sanitize_inputs: bool = field(default=True)
def __post_init__(self):
"""Load settings from environment variables."""
self._load_from_environment()
self._validate_settings()
def _load_from_environment(self):
"""Load configuration from environment variables."""
# Git settings
if os.getenv('COMMITIZEN_SIGN_OFF'):
self.default_sign_off = os.getenv('COMMITIZEN_SIGN_OFF', '').lower() == 'true'
if os.getenv('COMMITIZEN_GIT_TIMEOUT'):
self.git_timeout = int(os.getenv('COMMITIZEN_GIT_TIMEOUT', '30'))
# Repository settings
if os.getenv('COMMITIZEN_REPO_PATH'):
self.default_repo_path = os.getenv('COMMITIZEN_REPO_PATH')
# Logging
if os.getenv('COMMITIZEN_LOG_LEVEL'):
self.log_level = os.getenv('COMMITIZEN_LOG_LEVEL', 'INFO').upper()
# Performance
if os.getenv('COMMITIZEN_ENABLE_CACHING'):
self.enable_caching = os.getenv('COMMITIZEN_ENABLE_CACHING', '').lower() == 'true'
def _validate_settings(self):
"""Validate configuration values."""
if self.git_timeout <= 0:
raise ValueError("git_timeout must be positive")
if self.max_commit_message_length <= 0:
raise ValueError("max_commit_message_length must be positive")
if self.log_level not in ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']:
raise ValueError(f"Invalid log_level: {self.log_level}")
if self.cache_ttl <= 0:
raise ValueError("cache_ttl must be positive")
def to_dict(self) -> Dict[str, Any]:
"""Convert settings to dictionary."""
return {
field.name: getattr(self, field.name)
for field in self.__dataclass_fields__.values()
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'Settings':
"""Create settings from dictionary."""
return cls(**data)
# Global settings instance
_settings: Optional[Settings] = None
def get_settings() -> Settings:
"""Get global settings instance (singleton pattern)."""
global _settings
if _settings is None:
_settings = Settings()
return _settings
def reload_settings() -> Settings:
"""Reload settings from environment."""
global _settings
_settings = Settings()
return _settings
```
### Step 3: Create Repository Configuration
**File**: `src/commit_helper_mcp/config/repository_config.py`
**Responsibilities**:
- Repository-specific configuration detection
- Git configuration integration
- Commitizen configuration file parsing
- Repository validation and setup
```python
"""
Repository Configuration
Handles repository-specific configuration including Commitizen config files,
git configuration, and repository validation.
"""
import os
import json
import toml
from pathlib import Path
from typing import Dict, Any, Optional, List
from dataclasses import dataclass, field
@dataclass
class RepositoryConfig:
"""Repository-specific configuration."""
repo_path: Path
commitizen_config: Dict[str, Any] = field(default_factory=dict)
git_config: Dict[str, Any] = field(default_factory=dict)
is_valid_repo: bool = field(default=False)
config_file_path: Optional[Path] = field(default=None)
def __post_init__(self):
"""Initialize repository configuration."""
self.repo_path = Path(self.repo_path).resolve()
self._detect_repository()
self._load_commitizen_config()
self._load_git_config()
def _detect_repository(self):
"""Detect if path is a valid git repository."""
git_dir = self.repo_path / '.git'
self.is_valid_repo = git_dir.exists() and (git_dir.is_dir() or git_dir.is_file())
def _load_commitizen_config(self):
"""Load Commitizen configuration from various sources."""
config_files = [
'pyproject.toml',
'.cz.toml',
'.cz.json',
'setup.cfg'
]
for config_file in config_files:
config_path = self.repo_path / config_file
if config_path.exists():
try:
config = self._parse_config_file(config_path)
if config:
self.commitizen_config = config
self.config_file_path = config_path
break
except Exception as e:
# Log warning but continue to next file
pass
def _parse_config_file(self, config_path: Path) -> Optional[Dict[str, Any]]:
"""Parse configuration file based on extension."""
if config_path.suffix == '.toml':
return self._parse_toml_config(config_path)
elif config_path.suffix == '.json':
return self._parse_json_config(config_path)
elif config_path.name == 'setup.cfg':
return self._parse_setup_cfg(config_path)
return None
def _parse_toml_config(self, config_path: Path) -> Optional[Dict[str, Any]]:
"""Parse TOML configuration file."""
try:
data = toml.load(config_path)
# Check for commitizen config in pyproject.toml
if 'tool' in data and 'commitizen' in data['tool']:
return data['tool']['commitizen']
# Check for direct commitizen config in .cz.toml
if 'commitizen' in data:
return data['commitizen']
# Return root level for .cz.toml files
return data
except Exception:
return None
def _parse_json_config(self, config_path: Path) -> Optional[Dict[str, Any]]:
"""Parse JSON configuration file."""
try:
with open(config_path, 'r') as f:
return json.load(f)
except Exception:
return None
def _parse_setup_cfg(self, config_path: Path) -> Optional[Dict[str, Any]]:
"""Parse setup.cfg configuration file."""
try:
import configparser
config = configparser.ConfigParser()
config.read(config_path)
if 'tool:commitizen' in config:
return dict(config['tool:commitizen'])
except Exception:
pass
return None
def _load_git_config(self):
"""Load git configuration if available."""
if not self.is_valid_repo:
return
try:
# Try to load git config using GitPython if available
from git import Repo
repo = Repo(self.repo_path)
config_reader = repo.config_reader()
self.git_config = {
'user_name': config_reader.get_value('user', 'name', fallback=''),
'user_email': config_reader.get_value('user', 'email', fallback=''),
'core_editor': config_reader.get_value('core', 'editor', fallback=''),
}
except Exception:
# Fallback to basic git config detection
self.git_config = {}
def get_commitizen_setting(self, key: str, default: Any = None) -> Any:
"""Get specific Commitizen setting."""
return self.commitizen_config.get(key, default)
def get_plugin_name(self) -> str:
"""Get configured plugin name."""
return self.get_commitizen_setting('name', 'cz_conventional_commits')
def get_version_scheme(self) -> str:
"""Get version scheme."""
return self.get_commitizen_setting('version_scheme', 'semver')
def get_tag_format(self) -> str:
"""Get tag format."""
return self.get_commitizen_setting('tag_format', 'v$version')
def validate_configuration(self) -> List[str]:
"""Validate repository configuration and return issues."""
issues = []
if not self.is_valid_repo:
issues.append("Not a valid git repository")
if not self.commitizen_config:
issues.append("No Commitizen configuration found")
if not self.git_config.get('user_name'):
issues.append("Git user.name not configured")
if not self.git_config.get('user_email'):
issues.append("Git user.email not configured")
return issues
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary representation."""
return {
'repo_path': str(self.repo_path),
'commitizen_config': self.commitizen_config,
'git_config': self.git_config,
'is_valid_repo': self.is_valid_repo,
'config_file_path': str(self.config_file_path) if self.config_file_path else None,
}
def load_repository_config(repo_path: str) -> RepositoryConfig:
"""Load configuration for a specific repository."""
return RepositoryConfig(repo_path=repo_path)
```
### Step 4: Create Plugin Configuration
**File**: `src/commit_helper_mcp/config/plugin_config.py`
**Responsibilities**:
- Plugin discovery and validation
- Plugin-specific configuration
- Plugin compatibility checking
- Plugin adapter management
```python
"""
Plugin Configuration
Manages Commitizen plugin configuration, discovery, and validation.
"""
from typing import Dict, Any, List, Optional, Type
from dataclasses import dataclass, field
from pathlib import Path
@dataclass
class PluginConfig:
"""Plugin configuration and management."""
plugin_name: str
plugin_class: Optional[Type] = field(default=None)
config: Dict[str, Any] = field(default_factory=dict)
capabilities: Dict[str, bool] = field(default_factory=dict)
adapter_class: Optional[Type] = field(default=None)
def __post_init__(self):
"""Initialize plugin configuration."""
self._discover_plugin()
self._analyze_capabilities()
self._load_adapter()
def _discover_plugin(self):
"""Discover and load plugin class."""
try:
from commitizen import factory
from commitizen.config import BaseConfig
# Create temporary config to load plugin
temp_config = BaseConfig()
temp_config.settings['name'] = self.plugin_name
# Load plugin using commitizen factory
self.plugin_class = factory.committer_factory(temp_config).__class__
except Exception as e:
# Plugin not found or invalid
self.plugin_class = None
def _analyze_capabilities(self):
"""Analyze plugin capabilities."""
if not self.plugin_class:
return
self.capabilities = {
'has_questions': hasattr(self.plugin_class, 'questions'),
'has_message': hasattr(self.plugin_class, 'message'),
'has_pattern': hasattr(self.plugin_class, 'pattern'),
'has_example': hasattr(self.plugin_class, 'example'),
'has_schema': hasattr(self.plugin_class, 'schema') or hasattr(self.plugin_class, 'schema_pattern'),
'has_bump_pattern': hasattr(self.plugin_class, 'bump_pattern'),
'has_bump_map': hasattr(self.plugin_class, 'bump_map'),
}
def _load_adapter(self):
"""Load appropriate plugin adapter."""
try:
from ..plugin_adapters import PluginAdapterFactory
self.adapter_class = PluginAdapterFactory.create_adapter(self.plugin_name).__class__
except Exception:
self.adapter_class = None
def is_valid(self) -> bool:
"""Check if plugin is valid and usable."""
return (
self.plugin_class is not None and
self.capabilities.get('has_message', False) and
self.adapter_class is not None
)
def get_validation_pattern(self) -> Optional[str]:
"""Get validation pattern from plugin."""
if not self.plugin_class or not hasattr(self.plugin_class, 'pattern'):
return None
try:
pattern = self.plugin_class.pattern
return pattern if isinstance(pattern, str) else str(pattern)
except Exception:
return None
def get_supported_types(self) -> List[str]:
"""Get supported commit types from plugin."""
if not self.plugin_class or not hasattr(self.plugin_class, 'questions'):
return []
try:
questions = self.plugin_class.questions
if callable(questions):
questions = questions()
for question in questions:
if question.get('name') == 'prefix' and 'choices' in question:
choices = question['choices']
return [
choice.get('value', choice) if isinstance(choice, dict) else str(choice)
for choice in choices
]
except Exception:
pass
return []
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary representation."""
return {
'plugin_name': self.plugin_name,
'plugin_class_name': self.plugin_class.__name__ if self.plugin_class else None,
'config': self.config,
'capabilities': self.capabilities,
'adapter_class_name': self.adapter_class.__name__ if self.adapter_class else None,
'is_valid': self.is_valid(),
'validation_pattern': self.get_validation_pattern(),
'supported_types': self.get_supported_types(),
}
def discover_available_plugins() -> List[str]:
"""Discover all available Commitizen plugins."""
try:
from commitizen import factory
# This is a simplified discovery - in practice, you might want to
# scan installed packages or use commitizen's plugin discovery
known_plugins = [
'cz_conventional_commits',
'cz_jira',
'cz_customize',
]
return known_plugins
except Exception:
return []
def load_plugin_config(plugin_name: str, config: Dict[str, Any] = None) -> PluginConfig:
"""Load configuration for a specific plugin."""
plugin_config = PluginConfig(plugin_name=plugin_name)
if config:
plugin_config.config = config
return plugin_config
```
## Integration with Existing Services
### Update Services to Use Configuration
**CommitzenCore Integration**:
```python
from ..config import get_settings, RepositoryConfig, PluginConfig
class CommitzenCore:
def __init__(self, repo_path: Optional[str] = None):
self.settings = get_settings()
self.repo_config = RepositoryConfig(repo_path or self.settings.default_repo_path)
self.plugin_config = PluginConfig(self.repo_config.get_plugin_name())
```
**RepositoryManager Integration**:
```python
from ..config import get_settings, RepositoryConfig
class RepositoryManager:
def __init__(self):
self.settings = get_settings()
self._repository_configs = {}
def get_repository_config(self, repo_path: str) -> RepositoryConfig:
if repo_path not in self._repository_configs:
self._repository_configs[repo_path] = RepositoryConfig(repo_path)
return self._repository_configs[repo_path]
```
## Implementation Guidelines
### Configuration Principles
1. **Environment Variables**: Support environment variable overrides
2. **Type Safety**: Use dataclasses or Pydantic for validation
3. **Defaults**: Provide sensible defaults for all settings
4. **Validation**: Validate configuration at load time
### Migration Strategy
1. **Gradual Adoption**: Services can adopt configuration incrementally
2. **Backward Compatibility**: Maintain existing configuration behavior
3. **Testing**: Comprehensive tests for configuration loading and validation
## Validation Criteria
### Success Metrics
- [ ] All configuration is centralized and validated
- [ ] Environment variables work correctly
- [ ] Repository-specific configuration is properly detected
- [ ] Plugin configuration is automatically discovered
- [ ] All services use centralized configuration
- [ ] Configuration is type-safe and well-documented
### Configuration Coverage
- [ ] Application settings (timeouts, defaults, etc.)
- [ ] Repository configuration (Commitizen, git)
- [ ] Plugin configuration (capabilities, validation)
- [ ] Environment variable overrides
- [ ] Validation and error handling
## Testing Plan
### Unit Tests
- Test configuration loading from different sources
- Verify environment variable overrides work
- Test validation logic for invalid configurations
- Test plugin discovery and capability detection
### Integration Tests
- Test services using centralized configuration
- Verify repository-specific configuration works
- Test configuration changes are properly propagated
## Dependencies
### Prerequisites
- Task 2 (Service Refactoring) completed
- Understanding of configuration patterns
- Familiarity with dataclasses or Pydantic
### Affected Files
- All service files (configuration integration)
- Test files (configuration setup)
## Estimated Effort
**Time Estimate**: 15 minutes
**Complexity**: Medium
**Risk Level**: Low (additive changes, backward compatible)
## Next Steps
After completing this task:
1. Proceed to Task 4: Error Handling Standardization
2. Update documentation for configuration options
3. Consider adding configuration validation CLI command