Module: Aireview::ConfigLoader

Defined in:
lib/aireview/config_loader.rb

Overview

Builds a Config from layers: built-in values, image defaults (AIREVIEW_DEFAULTS), the project's .aireview.yml (searched upwards from cwd), env. Everything that knows environment variable names and file formats lives here; Config only answers questions about merged data.

Constant Summary collapse

ENV_MAPPING =
{
  'gitlab_url' => 'GITLAB_URL',
  'gitlab_token' => 'GITLAB_TOKEN',
  'jira_url' => 'JIRA_URL',
  'jira_login' => 'JIRA_LOGIN',
  'jira_password' => 'JIRA_PASSWORD',
  'review_language' => 'REVIEW_LANGUAGE',
  'review_mode' => 'REVIEW_MODE',
  'llm_api_base' => 'LLM_API_BASE',
  'ollama_api_base' => 'OLLAMA_API_BASE',
  'llm_http_proxy' => 'LLM_HTTP_PROXY',
  'jev_api_key' => 'JEV_API_KEY'
}.freeze
PROVIDER_KEY_MAPPING =
{
  'gemini' => 'GEMINI_API_KEY',
  'openai' => 'OPENAI_API_KEY',
  'anthropic' => 'ANTHROPIC_API_KEY',
  'openrouter' => 'OPENROUTER_API_KEY'
}.freeze
PROVIDER_KEYS_MAPPING =
{
  'gemini' => 'GEMINI_API_KEYS',
  'openai' => 'OPENAI_API_KEYS',
  'anthropic' => 'ANTHROPIC_API_KEYS',
  'openrouter' => 'OPENROUTER_API_KEYS'
}.freeze
CONTEXT_ENV =
{
  'max_diff_chars' => 'MAX_DIFF_CHARS',
  'max_mr_description_chars' => 'MAX_MR_DESCRIPTION_CHARS',
  'max_jira_description_chars' => 'MAX_JIRA_DESCRIPTION_CHARS',
  'max_jira_comment_chars' => 'MAX_JIRA_COMMENT_CHARS'
}.freeze
LLM_ENV =
%w[
  LLM_PROVIDER LLM_TEMPERATURE LLM_TIMEOUT LLM_MAX_PROMPT_CHARS LLM_TIME_BUDGET LLM_OVERLOADED_QUARANTINE
  LLM_MODELS LLM_CRITIQUE_RANK LLM_CRITIQUE_ALLOW_WEAKER LLM_CRITIQUE_ENGINE
  LLM_JEV_SHADOW LLM_JEV_MODEL LLM_JEV_FALLBACK LLM_JEV_KEEP_ABOVE
].freeze
LLM_STAGE_ENV_SUFFIXES =
%w[PROVIDER MODEL TEMPERATURE MAX_PROMPT_CHARS FALLBACK_MODEL START].freeze
IMAGE_DEFAULTS_ENV =
'AIREVIEW_DEFAULTS'

Class Method Summary collapse

Class Method Details

.context_env_config(env) ⇒ Object



211
212
213
214
215
216
217
# File 'lib/aireview/config_loader.rb', line 211

def context_env_config(env)
  context = CONTEXT_ENV.each_with_object({}) do |(key, env_key), config|
    value = parse_integer(env[env_key], env_key)
    config[key] = value unless value.nil?
  end
  context.empty? ? {} : {'context' => context}
end

.discover_file(cwd, basename) ⇒ Object



95
96
97
98
99
100
101
102
103
104
105
106
107
108
# File 'lib/aireview/config_loader.rb', line 95

def discover_file(cwd, basename)
  current = Pathname.new(cwd).expand_path

  loop do
    candidate = current.join(basename)
    return candidate.to_s if candidate.file?

    break if current.root?

    current = current.parent
  end

  File.join(cwd, basename)
end

.env_config(env) ⇒ Object



131
132
133
134
135
136
137
138
# File 'lib/aireview/config_loader.rb', line 131

def env_config(env)
  mapped_env_config(env)
    .merge('llm' => Aireview::Utils.deep_merge(llm_env_config(env), pool_env_config(env)))
    .merge(context_env_config(env))
    .merge(provider_key_env_config(env))
    .merge(provider_keys_env_config(env))
    .merge(generic_api_key_env_config(env))
end

.env_names ⇒ Object

Every environment variable the loader reads. The CI template passes them into the container by name: a project variable missing from this list never reaches the review.



77
78
79
80
81
82
83
84
85
# File 'lib/aireview/config_loader.rb', line 77

def env_names
  stage_env = STAGES.flat_map do |stage|
    LLM_STAGE_ENV_SUFFIXES.map { |suffix| "LLM_#{stage.upcase}_#{suffix}" }
  end
  [
    *ENV_MAPPING.values, *PROVIDER_KEY_MAPPING.values, *PROVIDER_KEYS_MAPPING.values, 'LLM_API_KEY',
    *LLM_ENV, *stage_env, *CONTEXT_ENV.values
  ].uniq
end

.fallback_models_env_config(env, stage) ⇒ Object

LLM_GENERATE_FALLBACK_MODEL=gemini-3.8-flash (or a comma-separated list) — the provider is separated by a slash because Ollama tags contain a colon.



185
186
187
188
189
190
# File 'lib/aireview/config_loader.rb', line 185

def fallback_models_env_config(env, stage)
  value = env["LLM_#{stage}_FALLBACK_MODEL"]
  return nil if Aireview::Utils.blank?(value)

  value.split(',').map(&:strip).reject(&:empty?).map { |item| ModelCandidate.parse_item(item) }
end

.file_layer(file_path) ⇒ Object



121
122
123
124
125
# File 'lib/aireview/config_loader.rb', line 121

def file_layer(file_path)
  return nil unless File.file?(file_path)

  ConfigLayers::Layer.new(name: ConfigLayers::FILE_LAYER, path: file_path, data: read_yaml(file_path))
end

.generic_api_key_env_config(env) ⇒ Object



233
234
235
236
237
238
# File 'lib/aireview/config_loader.rb', line 233

def generic_api_key_env_config(env)
  api_key = env['LLM_API_KEY']
  return {} if Aireview::Utils.blank?(api_key)

  {'llm_api_key' => api_key}
end

.image_defaults_layer(env) ⇒ Object

Image defaults: the Dockerfile sets the path through AIREVIEW_DEFAULTS. A path that is set but missing means a broken image; better to learn that at once.

Raises:



113
114
115
116
117
118
119
# File 'lib/aireview/config_loader.rb', line 113

def image_defaults_layer(env)
  path = env[IMAGE_DEFAULTS_ENV]
  return nil if Aireview::Utils.blank?(path)
  raise ConfigError, "#{IMAGE_DEFAULTS_ENV} points to a missing file: #{path}" unless File.file?(path)

  ConfigLayers::Layer.new(name: ConfigLayers::IMAGE_LAYER, path: path, data: read_yaml(path))
end

.jev_env_config(env) ⇒ Object

LLM_JEV_SHADOW=true|false, LLM_JEV_MODEL — a pinned Jev version, LLM_JEV_FALLBACK=model|fail, LLM_JEV_KEEP_ABOVE — the keep threshold.



163
164
165
166
167
168
169
170
171
# File 'lib/aireview/config_loader.rb', line 163

def jev_env_config(env)
  {
    'shadow' => parse_boolean(env['LLM_JEV_SHADOW'], 'LLM_JEV_SHADOW'),
    'model' => Aireview::Utils.presence(env['LLM_JEV_MODEL']),
    'fallback' => Aireview::Utils.presence(env['LLM_JEV_FALLBACK']),
    # Validated by ConfigJev: a typo must fail, not fall back to the default.
    'keep_above' => Aireview::Utils.presence(env['LLM_JEV_KEEP_ABOVE'])
  }.compact
end

.llm_env_config(env) ⇒ Object



147
148
149
150
151
152
153
154
155
156
157
158
159
# File 'lib/aireview/config_loader.rb', line 147

def llm_env_config(env)
  {
    'provider' => env['LLM_PROVIDER'],
    'temperature' => parse_float(env['LLM_TEMPERATURE']),
    'timeout' => parse_float(env['LLM_TIMEOUT']),
    'max_prompt_chars' => parse_integer(env['LLM_MAX_PROMPT_CHARS'], 'LLM_MAX_PROMPT_CHARS'),
    'time_budget' => parse_integer(env['LLM_TIME_BUDGET'], 'LLM_TIME_BUDGET'),
    'overloaded_quarantine' => parse_integer(env['LLM_OVERLOADED_QUARANTINE'], 'LLM_OVERLOADED_QUARANTINE'),
    'generate' => llm_stage_env_config(env, 'GENERATE'),
    'critique' => llm_stage_env_config(env, 'CRITIQUE'),
    'jev' => jev_env_config(env)
  }.compact.reject { |key, value| %w[generate critique jev].include?(key) && value.empty? }
end

.llm_stage_env_config(env, stage) ⇒ Object



173
174
175
176
177
178
179
180
181
# File 'lib/aireview/config_loader.rb', line 173

def llm_stage_env_config(env, stage)
  {
    'provider' => env["LLM_#{stage}_PROVIDER"],
    'model' => env["LLM_#{stage}_MODEL"],
    'temperature' => parse_float(env["LLM_#{stage}_TEMPERATURE"]),
    'max_prompt_chars' => parse_integer(env["LLM_#{stage}_MAX_PROMPT_CHARS"], "LLM_#{stage}_MAX_PROMPT_CHARS"),
    'fallbacks' => fallback_models_env_config(env, stage)
  }.compact
end

.load(config_path: nil, cwd: Dir.pwd, env: ENV, logger: Logger.new($stderr)) ⇒ Object



60
61
62
63
64
65
66
67
68
69
70
71
72
# File 'lib/aireview/config_loader.rb', line 60

def load(config_path: nil, cwd: Dir.pwd, env: ENV, logger: Logger.new($stderr))
  load_dotenv(cwd)

  file_path = config_path ? File.expand_path(config_path, cwd) : discover_file(cwd, '.aireview.yml')
  layers = [
    ConfigLayers::Layer.new(name: ConfigLayers::BUILT_IN_LAYER, data: Config::DEFAULTS),
    image_defaults_layer(env),
    file_layer(file_path),
    ConfigLayers::Layer.new(name: ConfigLayers::ENV_LAYER, data: env_config(env))
  ].compact

  Config.new(config_path: File.file?(file_path) ? file_path : nil, logger: logger, layers: layers)
end

.load_dotenv(cwd) ⇒ Object



87
88
89
90
91
92
93
# File 'lib/aireview/config_loader.rb', line 87

def load_dotenv(cwd)
  require 'dotenv'
  dotenv_path = discover_file(cwd, '.env')
  Dotenv.load(dotenv_path) if File.file?(dotenv_path)
rescue LoadError
  nil
end

.mapped_env_config(env) ⇒ Object



140
141
142
143
144
145
# File 'lib/aireview/config_loader.rb', line 140

def mapped_env_config(env)
  ENV_MAPPING.each_with_object({}) do |(key, env_key), config|
    value = env[env_key]
    config[key] = value unless Aireview::Utils.blank?(value)
  end
end

.parse_boolean(value, name) ⇒ Object

Raises:



258
259
260
261
262
263
264
# File 'lib/aireview/config_loader.rb', line 258

def parse_boolean(value, name)
  return nil if Aireview::Utils.blank?(value)
  return true if %w[true 1 yes].include?(value.to_s.downcase)
  return false if %w[false 0 no].include?(value.to_s.downcase)

  raise ConfigError, "#{name} must be true or false, got #{value.inspect}"
end

.parse_float(value) ⇒ Object



240
241
242
243
244
245
246
# File 'lib/aireview/config_loader.rb', line 240

def parse_float(value)
  return nil if Aireview::Utils.blank?(value)

  Float(value)
rescue ArgumentError
  nil
end

.parse_integer(value, name) ⇒ Object

A limit that failed to parse must not silently fall back to the default: the request would go to a model with a window it does not have.



250
251
252
253
254
255
256
# File 'lib/aireview/config_loader.rb', line 250

def parse_integer(value, name)
  return nil if Aireview::Utils.blank?(value)

  Integer(value.to_s, 10)
rescue ArgumentError
  raise ConfigError, "#{name} must be an integer, got #{value.inspect}"
end

.pool_env_config(env) ⇒ Object

LLM_MODELS=gemini/gemini-3.8-flash,gemini/gemini-3.7-flash; LLM_GENERATE_START / LLM_CRITIQUE_START — a model from the pool; LLM_CRITIQUE_RANK, LLM_CRITIQUE_ALLOW_WEAKER=true|false.



195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
# File 'lib/aireview/config_loader.rb', line 195

def pool_env_config(env)
  models = env['LLM_MODELS'].to_s.split(',').map(&:strip).reject(&:empty?).map do |item|
    ModelCandidate.parse_item(item)
  end
  {
    'models' => models.empty? ? nil : models,
    'generate' => {'start' => env['LLM_GENERATE_START']}.compact,
    'critique' => {
      'start' => env['LLM_CRITIQUE_START'],
      'engine' => Aireview::Utils.presence(env['LLM_CRITIQUE_ENGINE']),
      'rank' => env['LLM_CRITIQUE_RANK'],
      'allow_weaker' => parse_boolean(env['LLM_CRITIQUE_ALLOW_WEAKER'], 'LLM_CRITIQUE_ALLOW_WEAKER')
    }.compact
  }.compact.reject { |_, value| value.is_a?(Hash) && value.empty? }
end

.provider_key_env_config(env) ⇒ Object



219
220
221
222
223
224
# File 'lib/aireview/config_loader.rb', line 219

def provider_key_env_config(env)
  PROVIDER_KEY_MAPPING.each_with_object({}) do |(provider, env_key), config|
    value = env[env_key]
    config["#{provider}_api_key"] = value unless Aireview::Utils.blank?(value)
  end
end

.provider_keys_env_config(env) ⇒ Object



226
227
228
229
230
231
# File 'lib/aireview/config_loader.rb', line 226

def provider_keys_env_config(env)
  PROVIDER_KEYS_MAPPING.each_with_object({}) do |(provider, env_key), config|
    keys = env[env_key].to_s.split(',').map(&:strip).reject(&:empty?)
    config["#{provider}_api_keys"] = keys unless keys.empty?
  end
end

.read_yaml(path) ⇒ Object



127
128
129
# File 'lib/aireview/config_loader.rb', line 127

def read_yaml(path)
  Aireview::Utils.normalize_hash(YAML.load_file(path) || {})
end