aireview 0.3.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/lib/aireview/cli.rb CHANGED
@@ -1,8 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
  require 'securerandom'
3
+ require_relative 'model_checker'
3
4
 
4
5
  module Aireview
5
- class CLI
6
+ class CLI # rubocop:disable Metrics/ClassLength
6
7
  def self.start(argv, out: $stdout, err: $stderr, env: ENV)
7
8
  new(argv, out: out, err: err, env: env).start
8
9
  end
@@ -24,6 +25,8 @@ module Aireview
24
25
  case command
25
26
  when 'review'
26
27
  run_review(@argv)
28
+ when 'models'
29
+ run_models(@argv)
27
30
  when '--help', '-h', nil
28
31
  @out.puts(help)
29
32
  0
@@ -41,6 +44,33 @@ module Aireview
41
44
 
42
45
  private
43
46
 
47
+ # aireview models check [--config PATH] [--verbose]: a probe request with
48
+ # the production schemas to every model of the chains; see ModelChecker.
49
+ def run_models(argv)
50
+ options = parse_models_options(argv)
51
+ raise ParseError, "Usage: aireview models check [options] (got: #{argv.join(' ')})" unless argv == ['check']
52
+
53
+ @logger.level = Logger::DEBUG if options[:verbose]
54
+ config = Config.load(config_path: options[:config], cwd: Dir.pwd, env: @env, logger: @logger)
55
+ config.warnings.each { |warning| @logger.warn(warning) }
56
+ ModelChecker.new(config: config, out: @out, logger: @logger, strict: options[:strict] == true).run
57
+ end
58
+
59
+ def parse_models_options(argv)
60
+ options = {}
61
+ OptionParser.new do |parser|
62
+ parser.banner = 'Usage: aireview models check [options]'
63
+ parser.on('--config PATH', 'Path to .aireview.yml') { |value| options[:config] = value }
64
+ parser.on('--strict', 'Treat a skipped model (unreachable Ollama) as a failure') { options[:strict] = true }
65
+ parser.on('--verbose', 'Enable debug logging') { options[:verbose] = true }
66
+ parser.on('-h', '--help', 'Show help') do
67
+ @out.puts(parser)
68
+ raise Aireview::HelpRequested
69
+ end
70
+ end.parse!(argv)
71
+ options
72
+ end
73
+
44
74
  def run_review(argv)
45
75
  options, mr_url = review_options_and_url(argv)
46
76
  parser_result = MrParser.parse(mr_url)
@@ -72,6 +102,7 @@ module Aireview
72
102
  no_fallbacks: options[:no_fallbacks] == true
73
103
  )
74
104
  config.require_llm_configuration!
105
+ config.warnings.each { |warning| @logger.warn(warning) }
75
106
  config
76
107
  end
77
108
 
@@ -103,8 +134,8 @@ module Aireview
103
134
  [merge_request, changes]
104
135
  end
105
136
 
106
- # Дифф уходит дальше по файлам, а не одной строкой: бюджет контекста
107
- # режет его по границам файлов и хунков.
137
+ # The diff travels on as files, not as one string: the context budget
138
+ # cuts it at file and hunk boundaries.
108
139
  def prepare_changes(changes, config)
109
140
  diff_fetcher = DiffFetcher.new(ignore_paths: config.ignore_paths, logger: @logger)
110
141
  filtered_changes = diff_fetcher.filter(changes)
@@ -141,8 +172,8 @@ module Aireview
141
172
  critique: !options[:no_critique]
142
173
  )
143
174
 
144
- # Печатаем до публикации: если публикация не состоится, текст ревью
145
- # останется хотя бы в логе джоба.
175
+ # Printed before publishing: if publishing fails, the review text at
176
+ # least stays in the job log.
146
177
  @out.puts(review)
147
178
 
148
179
  publish_review(review, context, publication) if publication
@@ -150,8 +181,8 @@ module Aireview
150
181
  0
151
182
  end
152
183
 
153
- # Поиск прошлого ревью идёт до вызова LLM: иначе запросы тратятся впустую,
154
- # даже когда публиковать нечего.
184
+ # The previous review is looked up before calling the LLM: otherwise the
185
+ # requests are wasted even when there is nothing to publish.
155
186
  def prepare_publication(pipeline, config, context, options)
156
187
  return nil unless options[:post]
157
188
 
@@ -181,18 +212,41 @@ module Aireview
181
212
  up_to_date = existing[:key] == key
182
213
  return false unless up_to_date || (mode == 'once' && !retried_ci_job?(gitlab_client))
183
214
 
184
- reason = up_to_date ? 'existing review is up to date' : 'merge request already reviewed (review_mode=once)'
185
- @out.puts("Review skipped: #{reason}")
215
+ @out.puts("Review skipped: #{skip_reason(existing, up_to_date: up_to_date, mode: mode)}")
186
216
  true
187
217
  end
188
218
 
219
+ # In once mode the review is not repeated on new pushes, even when the MR
220
+ # has changed. So besides the mode the message says whether the review is
221
+ # up to date: if it is not, the Retry button of the GitLab job updates it.
222
+ def skip_reason(existing, up_to_date:, mode:)
223
+ return 'existing review is up to date' unless mode == 'once'
224
+
225
+ state = if up_to_date then 'the review is up to date'
226
+ elsif existing[:key].nil? then "review freshness is unknown: #{update_hint}"
227
+ else "review inputs changed: #{update_hint}"
228
+ end
229
+ "merge request already reviewed (review_mode=once), #{state}"
230
+ end
231
+
232
+ def update_hint
233
+ ci_job_context ? 'retry the job to update' : 'use --force to review again'
234
+ end
235
+
189
236
  def retried_ci_job?(gitlab_client)
190
- project_id, job_id = @env.values_at('CI_PROJECT_ID', 'CI_JOB_ID')
191
- return false if Aireview::Utils.blank?(project_id) || Aireview::Utils.blank?(job_id)
237
+ project_id, job_id = ci_job_context
238
+ return false unless project_id
192
239
 
193
240
  gitlab_client.retried_job?(project_id, job_id)
194
241
  end
195
242
 
243
+ def ci_job_context
244
+ project_id, job_id = @env.values_at('CI_PROJECT_ID', 'CI_JOB_ID')
245
+ return if Aireview::Utils.blank?(project_id) || Aireview::Utils.blank?(job_id)
246
+
247
+ [project_id, job_id]
248
+ end
249
+
196
250
  def publish_review(review, context, publication)
197
251
  return if merge_request_moved?(context)
198
252
 
@@ -205,19 +259,22 @@ module Aireview
205
259
  )
206
260
  end
207
261
 
208
- # Пока работала LLM, MR мог уехать: новый коммит, перебазирование или смена
209
- # целевой ветки. Публиковать ревью неактуального диффа хуже, чем не
210
- # публиковать ничего, а ошибку проверки нельзя трактовать как «всё на
211
- # месте», поэтому её не глушим.
262
+ # While the LLM was working the MR may have moved on: a new commit, a
263
+ # rebase or a target branch change. Publishing a review of a stale diff
264
+ # is worse than publishing nothing, and a failed check cannot be read as
265
+ # "all in place", so it is not swallowed.
212
266
  def merge_request_moved?(context)
213
267
  current = context[:gitlab_client].fetch_merge_request(
214
268
  context[:parser_result].project_id,
215
269
  context[:parser_result].iid
216
270
  )
217
- return false if ReviewMarker.state(current) == ReviewMarker.state(context[:merge_request])
271
+ before = ReviewMarker.state(context[:merge_request])
272
+ after = ReviewMarker.state(current)
273
+ changed = after.keys.reject { |field| after[field] == before[field] }
274
+ return false if changed.empty?
218
275
 
219
- @logger.warn("Merge request moved to #{current['sha']} (#{current['target_branch']}) " \
220
- 'while review was running; skipping publication')
276
+ @logger.warn("Merge request changed while review was running (#{changed.join(', ')}); " \
277
+ 'skipping publication')
221
278
  true
222
279
  end
223
280
 
@@ -333,9 +390,12 @@ module Aireview
333
390
  <<~HELP
334
391
  Usage:
335
392
  aireview review <merge_request_url> [options]
393
+ aireview models check [--config PATH] [--strict] [--verbose]
336
394
 
337
395
  Commands:
338
- review Run review for a GitLab merge request URL
396
+ review Run review for a GitLab merge request URL
397
+ models check Send a probe request with the generate and critique schemas
398
+ to every model of both stages; exit 1 if any fails
339
399
 
340
400
  Options:
341
401
  --post Post review as a merge request note
@@ -6,13 +6,17 @@ require_relative 'errors'
6
6
  require_relative 'utils'
7
7
  require_relative 'config_limits'
8
8
  require_relative 'config_fallbacks'
9
+ require_relative 'config_layers'
10
+ require_relative 'config_loader'
9
11
 
10
12
  module Aireview
13
+ # Answers questions about the merged settings: values, their source
14
+ # (layer), the routing plan, provider keys. How settings are read from
15
+ # files and the environment is ConfigLoader's business.
11
16
  class Config
12
17
  include ConfigLimits
13
18
  include ConfigFallbacks
14
- extend ConfigLimits::ClassMethods
15
- extend ConfigFallbacks::ClassMethods
19
+ include ConfigLayers
16
20
 
17
21
  DEFAULT_SECRET_FILES = [
18
22
  '.env',
@@ -43,159 +47,35 @@ module Aireview
43
47
  'temperature' => 0,
44
48
  'timeout' => 60,
45
49
  'max_prompt_chars' => ConfigLimits::DEFAULT_MAX_PROMPT_CHARS,
46
- 'time_budget' => ConfigFallbacks::DEFAULT_TIME_BUDGET
50
+ 'time_budget' => ConfigFallbacks::DEFAULT_TIME_BUDGET,
51
+ 'overloaded_quarantine' => ConfigFallbacks::DEFAULT_OVERLOADED_QUARANTINE
47
52
  },
48
53
  'context' => ConfigLimits::CONTEXT_DEFAULTS
49
54
  }.freeze
50
55
 
51
- ENV_MAPPING = {
52
- 'gitlab_url' => 'GITLAB_URL',
53
- 'gitlab_token' => 'GITLAB_TOKEN',
54
- 'jira_url' => 'JIRA_URL',
55
- 'jira_login' => 'JIRA_LOGIN',
56
- 'jira_password' => 'JIRA_PASSWORD',
57
- 'review_language' => 'REVIEW_LANGUAGE',
58
- 'review_mode' => 'REVIEW_MODE',
59
- 'llm_api_base' => 'LLM_API_BASE',
60
- 'ollama_api_base' => 'OLLAMA_API_BASE',
61
- 'llm_http_proxy' => 'LLM_HTTP_PROXY'
62
- }.freeze
63
-
64
- PROVIDER_KEY_MAPPING = {
65
- 'gemini' => 'GEMINI_API_KEY',
66
- 'ollama' => nil
67
- }.freeze
68
-
69
- attr_reader :config_path
70
-
71
- def self.load(config_path: nil, cwd: Dir.pwd, env: ENV, logger: Logger.new($stderr))
72
- load_dotenv(cwd)
73
-
74
- file_path = config_path ? File.expand_path(config_path, cwd) : discover_file(cwd, '.aireview.yml')
75
- file_config = File.file?(file_path) ? normalize_hash(YAML.load_file(file_path) || {}) : {}
76
-
77
- merged = deep_merge(DEFAULTS, file_config)
78
- merged = deep_merge(merged, env_config(env))
79
-
80
- new(merged, config_path: File.file?(file_path) ? file_path : nil, logger: logger)
81
- end
82
-
83
- def self.load_dotenv(cwd)
84
- require 'dotenv'
85
- dotenv_path = discover_file(cwd, '.env')
86
- Dotenv.load(dotenv_path) if File.file?(dotenv_path)
87
- rescue LoadError
88
- nil
89
- end
90
-
91
- def self.discover_file(cwd, basename)
92
- current = Pathname.new(cwd).expand_path
93
-
94
- loop do
95
- candidate = current.join(basename)
96
- return candidate.to_s if candidate.file?
97
-
98
- break if current.root?
99
-
100
- current = current.parent
101
- end
102
-
103
- File.join(cwd, basename)
104
- end
105
-
106
- def self.env_config(env)
107
- mapped_env_config(env)
108
- .merge('llm' => llm_env_config(env))
109
- .merge(context_env_config(env))
110
- .merge(provider_key_env_config(env))
111
- .merge(provider_keys_env_config(env))
112
- .merge(generic_api_key_env_config(env))
113
- end
114
-
115
- def self.mapped_env_config(env)
116
- ENV_MAPPING.each_with_object({}) do |(key, env_key), config|
117
- value = env[env_key]
118
- config[key] = value unless Aireview::Utils.blank?(value)
119
- end
120
- end
121
-
122
- def self.llm_env_config(env)
123
- {
124
- 'provider' => env['LLM_PROVIDER'],
125
- 'temperature' => parse_float(env['LLM_TEMPERATURE']),
126
- 'timeout' => parse_float(env['LLM_TIMEOUT']),
127
- 'max_prompt_chars' => parse_integer(env['LLM_MAX_PROMPT_CHARS'], 'LLM_MAX_PROMPT_CHARS'),
128
- 'time_budget' => parse_integer(env['LLM_TIME_BUDGET'], 'LLM_TIME_BUDGET'),
129
- 'generate' => llm_stage_env_config(env, 'GENERATE'),
130
- 'critique' => llm_stage_env_config(env, 'CRITIQUE')
131
- }.compact.reject { |key, value| %w[generate critique].include?(key) && value.empty? }
132
- end
133
-
134
- def self.llm_stage_env_config(env, stage)
135
- {
136
- 'provider' => env["LLM_#{stage}_PROVIDER"],
137
- 'model' => env["LLM_#{stage}_MODEL"],
138
- 'temperature' => parse_float(env["LLM_#{stage}_TEMPERATURE"]),
139
- 'max_prompt_chars' => parse_integer(env["LLM_#{stage}_MAX_PROMPT_CHARS"], "LLM_#{stage}_MAX_PROMPT_CHARS"),
140
- 'fallbacks' => fallback_models_env_config(env, stage)
141
- }.compact
142
- end
56
+ attr_reader :config_path, :layers
143
57
 
144
- def self.provider_key_env_config(env)
145
- PROVIDER_KEY_MAPPING.each_with_object({}) do |(provider, env_key), config|
146
- next unless env_key
147
-
148
- value = env[env_key]
149
- config["#{provider}_api_key"] = value unless Aireview::Utils.blank?(value)
150
- end
151
- end
152
-
153
- def self.generic_api_key_env_config(env)
154
- api_key = env['LLM_API_KEY']
155
- return {} if Aireview::Utils.blank?(api_key)
156
-
157
- {'llm_api_key' => api_key}
58
+ def self.load(**options)
59
+ ConfigLoader.load(**options)
158
60
  end
159
61
 
160
- def self.parse_float(value)
161
- return nil if Aireview::Utils.blank?(value)
162
-
163
- Float(value)
164
- rescue ArgumentError
165
- nil
62
+ def self.env_names
63
+ ConfigLoader.env_names
166
64
  end
167
65
 
168
- def self.deep_merge(left, right)
169
- left.merge(right) do |_, old_value, new_value|
170
- if old_value.is_a?(Hash) && new_value.is_a?(Hash)
171
- deep_merge(old_value, new_value)
172
- else
173
- new_value
174
- end
175
- end
176
- end
177
-
178
- def self.normalize_hash(value)
179
- case value
180
- when Hash
181
- value.each_with_object({}) do |(key, inner_value), result|
182
- result[key.to_s] = normalize_hash(inner_value)
183
- end
184
- when Array
185
- value.map { |item| normalize_hash(item) }
186
- else
187
- value
188
- end
189
- end
190
-
191
- def initialize(data, config_path:, logger:)
192
- @data = self.class.normalize_hash(data)
66
+ # The layers are the single source of truth: the merged data is computed
67
+ # from them. A config built from a hash (without ConfigLoader) is one
68
+ # layer, otherwise the CLI layer from with_overrides would be the only
69
+ # one and the stage settings of the original hash would be lost.
70
+ def initialize(data = nil, config_path: nil, logger: Logger.new($stderr), layers: nil)
71
+ @layers = layers || [ConfigLayers::Layer.new(name: ConfigLayers::DATA_LAYER, data: Utils.normalize_hash(data))]
72
+ @data = @layers.map(&:data).reduce({}) { |merged, layer_data| Utils.deep_merge(merged, layer_data) }
193
73
  @config_path = config_path
194
74
  @logger = logger
195
75
  end
196
76
 
197
- # Переопределения из CLI меняют только основную модель стадии, запасные
198
- # из конфига остаются; no_fallbacks оставляет одну модель и один ключ.
77
+ # CLI overrides change only the primary model of a stage, the reserves
78
+ # from the config stay; no_fallbacks leaves one model and one key.
199
79
  def with_overrides(
200
80
  generate_model: nil,
201
81
  critique_model: nil,
@@ -212,7 +92,11 @@ module Aireview
212
92
  overrides['fallbacks_disabled'] = true if no_fallbacks
213
93
  return self if overrides.empty?
214
94
 
215
- self.class.new(self.class.deep_merge(@data, overrides), config_path: config_path, logger: @logger)
95
+ self.class.new(
96
+ config_path: config_path,
97
+ logger: @logger,
98
+ layers: @layers + [ConfigLayers::Layer.new(name: ConfigLayers::CLI_LAYER, data: overrides)]
99
+ )
216
100
  end
217
101
 
218
102
  def gitlab_url
@@ -247,28 +131,42 @@ module Aireview
247
131
  dig('llm', 'timeout') || DEFAULTS.dig('llm', 'timeout')
248
132
  end
249
133
 
134
+ # The primary model of a stage is the first in its chain: the start for
135
+ # Generate, the first of the pool for Critique. These go into the review key.
250
136
  def generate_model
251
- dig('llm', 'generate', 'model')
137
+ routing.primary('generate').model
252
138
  end
253
139
 
254
140
  def critique_model
255
- dig('llm', 'critique', 'model')
141
+ routing.primary('critique').model
256
142
  end
257
143
 
258
144
  def generate_provider
259
- dig('llm', 'generate', 'provider') || llm_provider
145
+ routing.primary('generate').provider
146
+ end
147
+
148
+ # Everything besides the prompt that affects the review result goes into
149
+ # the note key (see ReviewMarker): provider, model and temperature of the
150
+ # stages, the shared pool with its critique policy. Reserves of per-stage
151
+ # chains do not change the result.
152
+ def result_signature
153
+ {
154
+ 'generate' => [generate_provider, generate_model, generate_temperature],
155
+ 'critique' => [critique_provider, critique_model, critique_temperature],
156
+ 'pool' => routing.signature
157
+ }
260
158
  end
261
159
 
262
160
  def critique_provider
263
- dig('llm', 'critique', 'provider') || llm_provider
161
+ routing.primary('critique').provider
264
162
  end
265
163
 
266
164
  def generate_temperature
267
- dig('llm', 'generate', 'temperature') || llm_temperature
165
+ stage_setting('generate', 'temperature') || DEFAULTS.dig('llm', 'temperature')
268
166
  end
269
167
 
270
168
  def critique_temperature
271
- dig('llm', 'critique', 'temperature') || llm_temperature
169
+ stage_setting('critique', 'temperature') || DEFAULTS.dig('llm', 'temperature')
272
170
  end
273
171
 
274
172
  def llm_api_base
@@ -287,8 +185,8 @@ module Aireview
287
185
  @data['review_language'] || DEFAULTS['review_language']
288
186
  end
289
187
 
290
- # update — обновляем свою заметку, когда дифф или настройки изменились,
291
- # once — ревьюим один раз автоматически; Retry джоба обновляет ревью при изменениях.
188
+ # update — our note is updated when the diff or the settings changed,
189
+ # once — one automatic review; a job Retry updates the review on changes.
292
190
  def review_mode
293
191
  mode = (@data['review_mode'] || DEFAULTS['review_mode']).to_s
294
192
  return mode if REVIEW_MODES.include?(mode)
@@ -333,42 +231,29 @@ module Aireview
333
231
  raise ConfigError, 'GITLAB_TOKEN is required'
334
232
  end
335
233
 
336
- def require_models!
337
- missing = []
338
- missing << 'llm.generate.model (or LLM_GENERATE_MODEL)' if Aireview::Utils.blank?(generate_model)
339
- missing << 'llm.critique.model (or LLM_CRITIQUE_MODEL)' if Aireview::Utils.blank?(critique_model)
340
- raise ConfigError, "LLM models are required: #{missing.join(', ')}" unless missing.empty?
341
- end
342
-
343
- def require_llm_configuration!
344
- require_models!
345
-
346
- missing_keys = ConfigLimits::LLM_STAGES.flat_map do |stage|
347
- providers = stage_chain(stage).map(&:provider).uniq.reject { |provider| provider_keys_present?(provider) }
348
- providers.map { |provider| "#{stage}: API key is required for provider #{provider.inspect}" }
349
- end
350
- raise ConfigError, missing_keys.join(', ') unless missing_keys.empty?
351
- end
352
-
353
234
  private
354
235
 
355
- def provider_keys_present?(provider)
356
- return true if ConfigFallbacks::KEYLESS_PROVIDERS.include?(provider.to_s)
236
+ # Without a pool an override changes only the primary model of the stage,
237
+ # the reserves stay. With a pool the stage mode switches explicitly: a
238
+ # model from the pool becomes the start and the stage's own model and
239
+ # fallbacks are reset; a model outside the pool is a single chain, start
240
+ # and fallbacks are reset.
241
+ def stage_overrides(model:, temperature:)
242
+ overrides = {'temperature' => temperature}.compact
243
+ return overrides unless model
357
244
 
358
- provider_api_keys(provider).any? { |key| Aireview::Utils.present?(key) }
359
- end
245
+ items = Array(dig('llm', 'models'))
246
+ return overrides.merge('model' => model) if items.empty?
360
247
 
361
- def stage_overrides(model:, temperature:)
362
- {
363
- 'model' => model,
364
- 'temperature' => temperature
365
- }.compact
248
+ if ModelPool.member?(items, llm_provider, model)
249
+ overrides.merge('start' => model, 'model' => nil, 'fallbacks' => nil)
250
+ else
251
+ overrides.merge('start' => nil, 'model' => model, 'fallbacks' => [])
252
+ end
366
253
  end
367
254
 
368
255
  def dig(*keys)
369
- keys.reduce(@data) do |accumulator, key|
370
- accumulator.is_a?(Hash) ? accumulator[key] : nil
371
- end
256
+ Utils.dig(@data, *keys)
372
257
  end
373
258
  end
374
259
  end