package config import ( "errors" "fmt" "os" "strconv" "strings" ) type Settings struct { GiteaBaseURL string GiteaToken string GiteaBotUsername string GiteaBotMentions string GiteaWebhookSecret string OpenAIAPIKey string OpenAIProjectID string OpenAIOrgID string OpenAIReviewModel string CodexAuthMode string CodexAuthJSONPath string AllowedRepos []string CooldownSeconds int WebhookMode string DatabaseURL string DBHost string DBPort int DBName string DBUser string DBPassword string Workdir string MaxDiffBytes int MaxReviewMinutes int Concurrency int RunnerImage string AllowUntrustedForks bool WebhookMaxBytes int64 } func Load() (Settings, error) { s := Settings{ GiteaBaseURL: strings.TrimRight(os.Getenv("GITEA_BASE_URL"), "/"), GiteaToken: os.Getenv("GITEA_TOKEN"), GiteaBotUsername: os.Getenv("GITEA_BOT_USERNAME"), GiteaBotMentions: os.Getenv("GITEA_BOT_MENTIONS"), GiteaWebhookSecret: os.Getenv("GITEA_WEBHOOK_SECRET"), OpenAIAPIKey: os.Getenv("OPENAI_API_KEY"), OpenAIProjectID: os.Getenv("OPENAI_PROJECT_ID"), OpenAIOrgID: os.Getenv("OPENAI_ORG_ID"), OpenAIReviewModel: envString("OPENAI_REVIEW_MODEL", "gpt-5.3-codex"), CodexAuthMode: envString("CODEX_AUTH_MODE", "api_key"), CodexAuthJSONPath: envString("CODEX_AUTH_JSON_PATH", "~/.codex/auth.json"), AllowedRepos: splitCSV(os.Getenv("ALLOWED_REPOS")), WebhookMode: envString("WEBHOOK_MODE", "repo"), DatabaseURL: os.Getenv("DATABASE_URL"), DBHost: os.Getenv("DB_HOST"), DBName: os.Getenv("DB_NAME"), DBUser: os.Getenv("DB_USER"), DBPassword: os.Getenv("DB_PASSWORD"), Workdir: envString("WORKDIR", "/var/lib/gitea-codex/worktrees"), RunnerImage: envString("REVIEW_RUNNER_IMAGE", "node:22-bookworm-slim"), } var err error if s.DBPort, err = envInt("DB_PORT", 3306); err != nil { return Settings{}, err } if s.CooldownSeconds, err = envInt("COOLDOWN_SECONDS", 60); err != nil { return Settings{}, err } if s.MaxDiffBytes, err = envInt("MAX_DIFF_BYTES", 200000); err != nil { return Settings{}, err } if s.MaxReviewMinutes, err = envInt("MAX_REVIEW_MINUTES", 10); err != nil { return Settings{}, err } if s.Concurrency, err = envInt("CONCURRENCY", 1); err != nil { return Settings{}, err } maxBytes, err := envInt("WEBHOOK_MAX_BYTES", 2*1024*1024) if err != nil { return Settings{}, err } s.WebhookMaxBytes = int64(maxBytes) s.AllowUntrustedForks, err = envBool("ALLOW_UNTRUSTED_FORKS", false) if err != nil { return Settings{}, err } if err := s.Validate(); err != nil { return Settings{}, err } return s, nil } func (s Settings) Validate() error { for name, value := range map[string]string{"GITEA_BASE_URL": s.GiteaBaseURL, "GITEA_TOKEN": s.GiteaToken, "GITEA_BOT_USERNAME": s.GiteaBotUsername, "GITEA_WEBHOOK_SECRET": s.GiteaWebhookSecret} { if strings.TrimSpace(value) == "" { return fmt.Errorf("%s is required", name) } } if len(s.AllowedRepos) == 0 { return errors.New("ALLOWED_REPOS is required") } if s.CodexAuthMode != "api_key" && s.CodexAuthMode != "chatgpt" { return errors.New("CODEX_AUTH_MODE must be api_key or chatgpt") } if s.CodexAuthMode == "api_key" && strings.TrimSpace(s.OpenAIAPIKey) == "" { return errors.New("OPENAI_API_KEY is required") } if s.CooldownSeconds < 0 || s.MaxDiffBytes <= 0 || s.MaxReviewMinutes <= 0 || s.Concurrency <= 0 || s.WebhookMaxBytes <= 0 { return errors.New("numeric configuration values are invalid") } return nil } func (s Settings) RepoAllowed(repo string) bool { for _, allowed := range s.AllowedRepos { if allowed == repo { return true } } return false } func (s Settings) Aliases() map[string]bool { out := map[string]bool{"codex": true, strings.ToLower(strings.TrimPrefix(strings.TrimSpace(s.GiteaBotUsername), "@")): true} for _, x := range splitCSV(s.GiteaBotMentions) { out[strings.ToLower(strings.TrimPrefix(x, "@"))] = true } return out } func envString(name, fallback string) string { if v := os.Getenv(name); v != "" { return v } return fallback } func envInt(name string, fallback int) (int, error) { v := envString(name, strconv.Itoa(fallback)) n, err := strconv.Atoi(v) if err != nil { return 0, fmt.Errorf("%s: %w", name, err) } return n, nil } func envBool(name string, fallback bool) (bool, error) { v := os.Getenv(name) if v == "" { return fallback, nil } b, err := strconv.ParseBool(v) if err != nil { return false, fmt.Errorf("%s: %w", name, err) } return b, nil } func splitCSV(value string) []string { var out []string for _, item := range strings.Split(value, ",") { if v := strings.TrimSpace(item); v != "" { out = append(out, v) } } return out }