from __future__ import annotations import os from dataclasses import dataclass from pathlib import Path _DEFAULT_SETUP_COMMANDS = ( "composer install --no-interaction --prefer-dist;;" "npm ci;;" "npm run build;;" "php artisan migrate:fresh --force" ) def _bool(name: str, default: bool = False) -> bool: return os.getenv(name, str(default)).lower() in {"1", "true", "yes", "on"} def _commands(value: str) -> tuple[str, ...]: return tuple(command.strip() for command in value.split(";;") if command.strip()) @dataclass(frozen=True) class Settings: database_host: str database_port: int database_name: str database_user: str database_password: str source_repository: str source_branch: str workspace_root: Path mode: str allow_direct_main: bool gitea_url: str gitea_owner: str gitea_repository: str gitea_token: str gitea_username: str agent_command: str agent_model: str agent_timeout_seconds: int agent_idle_timeout_seconds: int setup_commands: tuple[str, ...] test_commands: tuple[str, ...] test_database_host: str test_database_port: int test_database_name: str test_database_user: str test_database_password: str test_customer_database: str test_config_database: str poll_seconds: int max_changed_files: int max_changed_lines: int max_agent_attempts: int stale_claim_minutes: int dry_run: bool @classmethod def from_env(cls) -> Settings: mode = os.getenv("AUTOMATION_MODE", "dry-run").lower() if mode not in {"dry-run", "pr", "direct-main"}: raise ValueError("AUTOMATION_MODE must be dry-run, pr, or direct-main") settings = cls( database_host=os.getenv("FAILURE_DB_HOST", "host.docker.internal"), database_port=int(os.getenv("FAILURE_DB_PORT", "33060")), database_name=os.getenv("FAILURE_DB_NAME", "laravel_ai_chat"), database_user=os.getenv("FAILURE_DB_USER", "laravel"), database_password=os.getenv("FAILURE_DB_PASSWORD", ""), source_repository=os.getenv( "SOURCE_REPOSITORY", "https://gitea.tisinfra.de/christian.meyer/lai.git" ), source_branch=os.getenv("SOURCE_BRANCH", "main"), workspace_root=Path(os.getenv("WORKSPACE_ROOT", "/workspaces")), mode=mode, allow_direct_main=_bool("ALLOW_DIRECT_MAIN"), gitea_url=os.getenv("GITEA_URL", "https://gitea.tisinfra.de"), gitea_owner=os.getenv("GITEA_OWNER", "christian.meyer"), gitea_repository=os.getenv("GITEA_REPOSITORY", "lai"), gitea_token=os.getenv("GITEA_TOKEN", ""), gitea_username=os.getenv("GITEA_USERNAME", "automation-bot"), agent_command=os.getenv("AGENT_COMMAND", "aider"), agent_model=os.getenv("AGENT_MODEL", "mistral/devstral-latest"), agent_timeout_seconds=int(os.getenv("AGENT_TIMEOUT_SECONDS", "900")), agent_idle_timeout_seconds=int(os.getenv("AGENT_IDLE_TIMEOUT_SECONDS", "300")), setup_commands=_commands( os.getenv("SETUP_COMMANDS", _DEFAULT_SETUP_COMMANDS) ), test_commands=_commands( os.getenv("TEST_COMMANDS", "php artisan test") ), test_database_host=os.getenv("TEST_DB_HOST", "host.docker.internal"), test_database_port=int(os.getenv("TEST_DB_PORT", "33060")), test_database_name=os.getenv("TEST_DB_NAME", "laravel_ai_chat_agent_test"), test_database_user=os.getenv("TEST_DB_USER", "lai_test_runner"), test_database_password=os.getenv("TEST_DB_PASSWORD", ""), test_customer_database=os.getenv("TEST_CUSTOMER_DB", "customer_agent_test"), test_config_database=os.getenv("TEST_CONFIG_DB", "cockpit_agent_test"), poll_seconds=int(os.getenv("POLL_SECONDS", "60")), max_changed_files=int(os.getenv("MAX_CHANGED_FILES", "12")), max_changed_lines=int(os.getenv("MAX_CHANGED_LINES", "800")), max_agent_attempts=int(os.getenv("MAX_AGENT_ATTEMPTS", "2")), stale_claim_minutes=int(os.getenv("STALE_CLAIM_MINUTES", "90")), dry_run=mode == "dry-run", ) settings.validate() return settings def validate(self) -> None: if self.mode == "direct-main" and not self.allow_direct_main: raise ValueError("direct-main requires ALLOW_DIRECT_MAIN=true") if self.mode in {"pr", "direct-main"} and not self.gitea_token: raise ValueError(f"{self.mode} requires GITEA_TOKEN") if not self.database_password: raise ValueError("FAILURE_DB_PASSWORD is required") if not self.test_database_password: raise ValueError("TEST_DB_PASSWORD is required") if not self.test_commands: raise ValueError("TEST_COMMANDS must contain at least one command") test_databases = { "TEST_DB_NAME": self.test_database_name, "TEST_CUSTOMER_DB": self.test_customer_database, "TEST_CONFIG_DB": self.test_config_database, } for setting_name, database_name in test_databases.items(): if "test" not in database_name.lower(): raise ValueError(f"{setting_name} must name an isolated test database") if database_name == self.database_name: raise ValueError(f"{setting_name} must differ from FAILURE_DB_NAME") if len(set(test_databases.values())) != len(test_databases): raise ValueError("TEST_DB_NAME, TEST_CUSTOMER_DB, and TEST_CONFIG_DB must be distinct") if not 60 <= self.agent_timeout_seconds <= 3600: raise ValueError("AGENT_TIMEOUT_SECONDS must be between 60 and 3600") if not 60 <= self.agent_idle_timeout_seconds <= self.agent_timeout_seconds: raise ValueError( "AGENT_IDLE_TIMEOUT_SECONDS must be between 60 and AGENT_TIMEOUT_SECONDS" ) if not 1 <= self.max_agent_attempts <= 7: raise ValueError("MAX_AGENT_ATTEMPTS must be between 1 and 7")