aireview 0.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.
@@ -0,0 +1,377 @@
1
+ # frozen_string_literal: true
2
+ require 'securerandom'
3
+
4
+ module Aireview
5
+ class CLI
6
+ def self.start(argv, out: $stdout, err: $stderr, env: ENV)
7
+ new(argv, out: out, err: err, env: env).start
8
+ end
9
+
10
+ def initialize(argv, out:, err:, env: ENV)
11
+ @argv = argv.dup
12
+ @env = env
13
+ @out = out
14
+ @err = err
15
+ @run_id = SecureRandom.hex(4)
16
+ @logger = Logger.new(err)
17
+ @logger.level = Logger::INFO
18
+ @logger.formatter = proc { |severity, _, _, message| "[#{severity.downcase}] [run=#{@run_id}] #{message}\n" }
19
+ end
20
+
21
+ def start
22
+ command = @argv.shift
23
+
24
+ case command
25
+ when 'review'
26
+ run_review(@argv)
27
+ when '--help', '-h', nil
28
+ @out.puts(help)
29
+ 0
30
+ else
31
+ @err.puts("Unknown command: #{command}")
32
+ @err.puts(help)
33
+ 1
34
+ end
35
+ rescue Aireview::HelpRequested
36
+ 0
37
+ rescue Aireview::Error => e
38
+ @err.puts("Error: #{e.message}")
39
+ 1
40
+ end
41
+
42
+ private
43
+
44
+ def run_review(argv)
45
+ options, mr_url = review_options_and_url(argv)
46
+ parser_result = MrParser.parse(mr_url)
47
+ config = load_review_config(options)
48
+ context = load_review_context(parser_result, config, options)
49
+
50
+ execute_review(config, context, options)
51
+ end
52
+
53
+ def review_options_and_url(argv)
54
+ options = parse_review_options(argv)
55
+ @logger.level = Logger::DEBUG if options[:verbose]
56
+
57
+ mr_url = argv.shift
58
+ raise ParseError, 'Merge request URL is required' unless mr_url
59
+ raise ParseError, "Unexpected arguments: #{argv.join(' ')}" unless argv.empty?
60
+
61
+ @logger.info("Starting review command for #{mr_url}")
62
+ [options, mr_url]
63
+ end
64
+
65
+ def load_review_config(options)
66
+ config = Config.load(config_path: options[:config], cwd: Dir.pwd, env: @env, logger: @logger)
67
+ config = config.with_overrides(
68
+ generate_model: options[:generate_model],
69
+ critique_model: options[:critique_model],
70
+ generate_temperature: options[:generate_temperature],
71
+ critique_temperature: options[:critique_temperature]
72
+ )
73
+ config.require_llm_configuration!
74
+ config
75
+ end
76
+
77
+ def load_review_context(parser_result, config, options)
78
+ gitlab_client = build_gitlab_client(config, parser_result)
79
+ merge_request, changes = fetch_merge_request_data(gitlab_client, parser_result)
80
+
81
+ {
82
+ parser_result: parser_result,
83
+ gitlab_client: gitlab_client,
84
+ merge_request: merge_request,
85
+ changes_text: render_changes(changes, config),
86
+ jira_issue: maybe_load_jira_issue(config, merge_request, options)
87
+ }
88
+ end
89
+
90
+ def build_gitlab_client(config, parser_result)
91
+ GitlabClient.new(
92
+ base_url: config.gitlab_url || parser_result.base_url,
93
+ token: config.require_gitlab_token!,
94
+ logger: @logger
95
+ )
96
+ end
97
+
98
+ def fetch_merge_request_data(gitlab_client, parser_result)
99
+ @logger.info("Loading MR #{parser_result.project_path}!#{parser_result.iid}")
100
+ merge_request = gitlab_client.fetch_merge_request(parser_result.project_id, parser_result.iid)
101
+ changes = gitlab_client.fetch_merge_request_changes(parser_result.project_id, parser_result.iid)
102
+ [merge_request, changes]
103
+ end
104
+
105
+ def render_changes(changes, config)
106
+ diff_fetcher = DiffFetcher.new(ignore_paths: config.ignore_paths, logger: @logger)
107
+ filtered_changes = diff_fetcher.filter(changes)
108
+ raise Error, 'No changes left after filtering ignore_paths' if filtered_changes.empty?
109
+
110
+ scrubbed_changes = SecretScrubber.new(
111
+ secret_patterns: config.secret_patterns,
112
+ secret_files: config.secret_files,
113
+ logger: @logger
114
+ ).scrub_changes(filtered_changes)
115
+
116
+ diff_fetcher.render(scrubbed_changes)
117
+ end
118
+
119
+ def execute_review(config, context, options)
120
+ pipeline = ReviewPipeline.new(config: config, logger: @logger)
121
+
122
+ if options[:dry_run]
123
+ dry_run = pipeline.dry_run_prompts(
124
+ merge_request: context[:merge_request],
125
+ changes_text: context[:changes_text],
126
+ jira_issue: context[:jira_issue],
127
+ critique: !options[:no_critique]
128
+ )
129
+ render_dry_run(dry_run)
130
+ return 0
131
+ end
132
+
133
+ publication = prepare_publication(pipeline, config, context, options)
134
+ return 0 if publication == :skip
135
+
136
+ review = pipeline.run(
137
+ merge_request: context[:merge_request],
138
+ changes_text: context[:changes_text],
139
+ jira_issue: context[:jira_issue],
140
+ critique: !options[:no_critique]
141
+ )
142
+
143
+ # Печатаем до публикации: если публикация не состоится, текст ревью
144
+ # останется хотя бы в логе джоба.
145
+ @out.puts(review)
146
+
147
+ publish_review(review, context, publication) if publication
148
+
149
+ 0
150
+ end
151
+
152
+ # Поиск прошлого ревью идёт до вызова LLM: иначе запросы тратятся впустую,
153
+ # даже когда публиковать нечего.
154
+ def prepare_publication(pipeline, config, context, options)
155
+ return nil unless options[:post]
156
+
157
+ publisher = Publisher.new(gitlab_client: context[:gitlab_client], logger: @logger)
158
+ prompts = pipeline.dry_run_prompts(
159
+ merge_request: context[:merge_request],
160
+ changes_text: context[:changes_text],
161
+ jira_issue: context[:jira_issue],
162
+ critique: !options[:no_critique]
163
+ )
164
+ key = ReviewMarker.key(prompts: prompts, config: config)
165
+ existing = publisher.existing_review(
166
+ project_id: context[:parser_result].project_id,
167
+ iid: context[:parser_result].iid
168
+ )
169
+
170
+ mode = options[:review_mode] || config.review_mode
171
+ return :skip if skip_review?(existing, key: key, mode: mode, force: options[:force],
172
+ gitlab_client: context[:gitlab_client])
173
+
174
+ {publisher: publisher, existing: existing, key: key}
175
+ end
176
+
177
+ def skip_review?(existing, key:, mode:, force:, gitlab_client:)
178
+ return false if existing.nil? || force
179
+
180
+ up_to_date = existing[:key] == key
181
+ return false unless up_to_date || (mode == 'once' && !retried_ci_job?(gitlab_client))
182
+
183
+ reason = up_to_date ? 'existing review is up to date' : 'merge request already reviewed (review_mode=once)'
184
+ @out.puts("Review skipped: #{reason}")
185
+ true
186
+ end
187
+
188
+ def retried_ci_job?(gitlab_client)
189
+ project_id, job_id = @env.values_at('CI_PROJECT_ID', 'CI_JOB_ID')
190
+ return false if Aireview::Utils.blank?(project_id) || Aireview::Utils.blank?(job_id)
191
+
192
+ gitlab_client.retried_job?(project_id, job_id)
193
+ end
194
+
195
+ def publish_review(review, context, publication)
196
+ return if merge_request_moved?(context)
197
+
198
+ publication[:publisher].publish(
199
+ project_id: context[:parser_result].project_id,
200
+ iid: context[:parser_result].iid,
201
+ review_body: review,
202
+ key: publication[:key],
203
+ existing: publication[:existing]
204
+ )
205
+ end
206
+
207
+ # Пока работала LLM, MR мог уехать: новый коммит, перебазирование или смена
208
+ # целевой ветки. Публиковать ревью неактуального диффа хуже, чем не
209
+ # публиковать ничего, а ошибку проверки нельзя трактовать как «всё на
210
+ # месте», поэтому её не глушим.
211
+ def merge_request_moved?(context)
212
+ current = context[:gitlab_client].fetch_merge_request(
213
+ context[:parser_result].project_id,
214
+ context[:parser_result].iid
215
+ )
216
+ return false if ReviewMarker.state(current) == ReviewMarker.state(context[:merge_request])
217
+
218
+ @logger.warn("Merge request moved to #{current['sha']} (#{current['target_branch']}) " \
219
+ 'while review was running; skipping publication')
220
+ true
221
+ end
222
+
223
+ def maybe_load_jira_issue(config, merge_request, options)
224
+ return nil if options[:no_jira]
225
+
226
+ key = JiraClient.extract_issue_key([merge_request['title'], merge_request['description']].compact.join("\n"))
227
+ return nil unless key
228
+ return nil unless config.jira_configured?
229
+
230
+ @logger.info("Loading Jira issue #{key}")
231
+ JiraClient.new(
232
+ base_url: config.jira_url,
233
+ login: config.jira_login,
234
+ password: config.jira_password,
235
+ logger: @logger
236
+ ).fetch_issue(key)
237
+ rescue Aireview::Error => e
238
+ @logger.warn("Jira lookup skipped: #{e.message}")
239
+ nil
240
+ end
241
+
242
+ def parse_review_options(argv)
243
+ options = {
244
+ post: false,
245
+ no_jira: false,
246
+ dry_run: false,
247
+ verbose: false,
248
+ no_critique: false,
249
+ force: false
250
+ }
251
+
252
+ OptionParser.new do |parser|
253
+ parser.banner = 'Usage: aireview review <merge_request_url> [options]'
254
+
255
+ add_llm_options(parser, options)
256
+ add_publication_options(parser, options)
257
+ add_general_options(parser, options)
258
+ end.parse!(argv)
259
+
260
+ options
261
+ end
262
+
263
+ def add_llm_options(parser, options)
264
+ parser.on('--generate-model MODEL', 'Override Generate pass model') do |value|
265
+ options[:generate_model] = value
266
+ end
267
+
268
+ parser.on('--critique-model MODEL', 'Override Critique pass model') do |value|
269
+ options[:critique_model] = value
270
+ end
271
+
272
+ parser.on('--generate-temperature VALUE', Float, 'Override Generate pass temperature') do |value|
273
+ options[:generate_temperature] = value
274
+ end
275
+
276
+ parser.on('--critique-temperature VALUE', Float, 'Override Critique pass temperature') do |value|
277
+ options[:critique_temperature] = value
278
+ end
279
+
280
+ parser.on('--no-critique', 'Skip critique pass and render Generate candidates directly') do
281
+ options[:no_critique] = true
282
+ end
283
+ end
284
+
285
+ def add_publication_options(parser, options)
286
+ parser.on('--post', 'Post review back to GitLab merge request') do
287
+ options[:post] = true
288
+ end
289
+
290
+ parser.on('--review-mode MODE', Aireview::Config::REVIEW_MODES,
291
+ 'How to treat an existing review: update or once') do |value|
292
+ options[:review_mode] = value
293
+ end
294
+
295
+ parser.on('--force', 'Review again even if the merge request was already reviewed') do
296
+ options[:force] = true
297
+ end
298
+ end
299
+
300
+ def add_general_options(parser, options)
301
+ parser.on('--config PATH', 'Path to .aireview.yml') do |value|
302
+ options[:config] = value
303
+ end
304
+
305
+ parser.on('--no-jira', 'Disable Jira enrichment') do
306
+ options[:no_jira] = true
307
+ end
308
+
309
+ parser.on('--dry-run', 'Print prompts and skip LLM calls') do
310
+ options[:dry_run] = true
311
+ end
312
+
313
+ parser.on('--verbose', 'Enable verbose logging') do
314
+ options[:verbose] = true
315
+ end
316
+
317
+ parser.on('-h', '--help', 'Show help') do
318
+ @out.puts(parser)
319
+ raise HelpRequested
320
+ end
321
+ end
322
+
323
+ def render_dry_run(dry_run)
324
+ @out.puts('=== LLM SETTINGS ===')
325
+ @out.puts("Generate: #{dry_run[:generate_model]} temperature=#{dry_run[:generate_temperature]}")
326
+ if dry_run[:critique_prompt]
327
+ @out.puts("Critique: #{dry_run[:critique_model]} temperature=#{dry_run[:critique_temperature]}")
328
+ else
329
+ @out.puts('Critique: disabled')
330
+ end
331
+ @out.puts
332
+ @out.puts('=== GENERATE SYSTEM PROMPT ===')
333
+ @out.puts(dry_run.dig(:generate_prompt, :system_prompt))
334
+ @out.puts
335
+ @out.puts('=== GENERATE USER PROMPT ===')
336
+ @out.puts(dry_run.dig(:generate_prompt, :user_prompt))
337
+ return unless dry_run[:critique_prompt]
338
+
339
+ @out.puts
340
+ @out.puts('=== CRITIQUE SYSTEM PROMPT ===')
341
+ @out.puts(dry_run.dig(:critique_prompt, :system_prompt))
342
+ @out.puts
343
+ @out.puts('=== CRITIQUE USER PROMPT ===')
344
+ @out.puts(dry_run.dig(:critique_prompt, :user_prompt))
345
+ end
346
+
347
+ def help
348
+ <<~HELP
349
+ Usage:
350
+ aireview review <merge_request_url> [options]
351
+
352
+ Commands:
353
+ review Run review for a GitLab merge request URL
354
+
355
+ Options:
356
+ --post Post review as a merge request note
357
+ --generate-model MODEL
358
+ Override Generate pass model
359
+ --critique-model MODEL
360
+ Override Critique pass model
361
+ --generate-temperature VALUE
362
+ Override Generate pass temperature
363
+ --critique-temperature VALUE
364
+ Override Critique pass temperature
365
+ --config PATH Path to .aireview.yml
366
+ --review-mode MODE
367
+ How to treat an existing review: update (default) or once
368
+ --force Review again even if the merge request was already reviewed
369
+ --no-jira Disable Jira enrichment
370
+ --dry-run Print prompts without LLM calls
371
+ --no-critique Skip second LLM critique pass
372
+ --verbose Enable verbose logging
373
+ -h, --help Show help
374
+ HELP
375
+ end
376
+ end
377
+ end