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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +15 -0
- data/LICENSE +21 -0
- data/README.md +362 -0
- data/bin/aireview +8 -0
- data/config/.aireview.yml.example +31 -0
- data/lib/aireview/cli.rb +377 -0
- data/lib/aireview/config.rb +356 -0
- data/lib/aireview/context_builder.rb +111 -0
- data/lib/aireview/diff_fetcher.rb +47 -0
- data/lib/aireview/errors.rb +8 -0
- data/lib/aireview/gitlab_client.rb +166 -0
- data/lib/aireview/jira_client.rb +99 -0
- data/lib/aireview/mr_parser.rb +30 -0
- data/lib/aireview/output_schemas.rb +57 -0
- data/lib/aireview/prompts/critique.txt +57 -0
- data/lib/aireview/prompts/generate.txt +58 -0
- data/lib/aireview/publisher.rb +64 -0
- data/lib/aireview/review_marker.rb +65 -0
- data/lib/aireview/review_pipeline.rb +373 -0
- data/lib/aireview/review_renderer.rb +150 -0
- data/lib/aireview/review_schemas.rb +54 -0
- data/lib/aireview/reviewer.rb +280 -0
- data/lib/aireview/secret_scrubber.rb +70 -0
- data/lib/aireview/utils.rb +18 -0
- data/lib/aireview/version.rb +4 -0
- data/lib/aireview.rb +23 -0
- metadata +119 -0
data/lib/aireview/cli.rb
ADDED
|
@@ -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
|