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
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require 'logger'
|
|
3
|
+
require 'pathname'
|
|
4
|
+
require 'yaml'
|
|
5
|
+
require_relative 'errors'
|
|
6
|
+
require_relative 'utils'
|
|
7
|
+
|
|
8
|
+
module Aireview
|
|
9
|
+
class Config
|
|
10
|
+
DEFAULT_SECRET_FILES = [
|
|
11
|
+
'.env',
|
|
12
|
+
'.env.*',
|
|
13
|
+
'config/secrets.yml',
|
|
14
|
+
'config/credentials/*.key',
|
|
15
|
+
'spec/fixtures/cassettes/*.yml',
|
|
16
|
+
'spec/fixtures/cassettes/**/*.yml',
|
|
17
|
+
'spec/cassettes/*.yml',
|
|
18
|
+
'spec/cassettes/**/*.yml',
|
|
19
|
+
'test/fixtures/cassettes/*.yml',
|
|
20
|
+
'test/fixtures/cassettes/**/*.yml'
|
|
21
|
+
].freeze
|
|
22
|
+
|
|
23
|
+
REVIEW_MODES = %w[update once].freeze
|
|
24
|
+
|
|
25
|
+
DEFAULTS = {
|
|
26
|
+
'review_language' => 'en',
|
|
27
|
+
'review_mode' => 'update',
|
|
28
|
+
'ignore_paths' => [],
|
|
29
|
+
'secret_patterns' => [],
|
|
30
|
+
'secret_files' => DEFAULT_SECRET_FILES,
|
|
31
|
+
'review_instructions' => nil,
|
|
32
|
+
'llm_api_base' => nil,
|
|
33
|
+
'ollama_api_base' => 'http://localhost:11434/v1',
|
|
34
|
+
'llm_http_proxy' => nil,
|
|
35
|
+
'llm' => {
|
|
36
|
+
'provider' => 'gemini',
|
|
37
|
+
'temperature' => 0,
|
|
38
|
+
'timeout' => 60
|
|
39
|
+
}
|
|
40
|
+
}.freeze
|
|
41
|
+
|
|
42
|
+
ENV_MAPPING = {
|
|
43
|
+
'gitlab_url' => 'GITLAB_URL',
|
|
44
|
+
'gitlab_token' => 'GITLAB_TOKEN',
|
|
45
|
+
'jira_url' => 'JIRA_URL',
|
|
46
|
+
'jira_login' => 'JIRA_LOGIN',
|
|
47
|
+
'jira_password' => 'JIRA_PASSWORD',
|
|
48
|
+
'review_language' => 'REVIEW_LANGUAGE',
|
|
49
|
+
'review_mode' => 'REVIEW_MODE',
|
|
50
|
+
'llm_api_base' => 'LLM_API_BASE',
|
|
51
|
+
'ollama_api_base' => 'OLLAMA_API_BASE',
|
|
52
|
+
'llm_http_proxy' => 'LLM_HTTP_PROXY'
|
|
53
|
+
}.freeze
|
|
54
|
+
|
|
55
|
+
PROVIDER_KEY_MAPPING = {
|
|
56
|
+
'gemini' => 'GEMINI_API_KEY',
|
|
57
|
+
'ollama' => nil
|
|
58
|
+
}.freeze
|
|
59
|
+
|
|
60
|
+
attr_reader :config_path
|
|
61
|
+
|
|
62
|
+
def self.load(config_path: nil, cwd: Dir.pwd, env: ENV, logger: Logger.new($stderr))
|
|
63
|
+
load_dotenv(cwd)
|
|
64
|
+
|
|
65
|
+
file_path = config_path ? File.expand_path(config_path, cwd) : discover_file(cwd, '.aireview.yml')
|
|
66
|
+
file_config = File.file?(file_path) ? normalize_hash(YAML.load_file(file_path) || {}) : {}
|
|
67
|
+
|
|
68
|
+
merged = deep_merge(DEFAULTS, file_config)
|
|
69
|
+
merged = deep_merge(merged, env_config(env))
|
|
70
|
+
|
|
71
|
+
new(merged, config_path: File.file?(file_path) ? file_path : nil, logger: logger)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def self.load_dotenv(cwd)
|
|
75
|
+
require 'dotenv'
|
|
76
|
+
dotenv_path = discover_file(cwd, '.env')
|
|
77
|
+
Dotenv.load(dotenv_path) if File.file?(dotenv_path)
|
|
78
|
+
rescue LoadError
|
|
79
|
+
nil
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def self.discover_file(cwd, basename)
|
|
83
|
+
current = Pathname.new(cwd).expand_path
|
|
84
|
+
|
|
85
|
+
loop do
|
|
86
|
+
candidate = current.join(basename)
|
|
87
|
+
return candidate.to_s if candidate.file?
|
|
88
|
+
|
|
89
|
+
break if current.root?
|
|
90
|
+
|
|
91
|
+
current = current.parent
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
File.join(cwd, basename)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def self.env_config(env)
|
|
98
|
+
mapped_env_config(env)
|
|
99
|
+
.merge('llm' => llm_env_config(env))
|
|
100
|
+
.merge(provider_key_env_config(env))
|
|
101
|
+
.merge(generic_api_key_env_config(env))
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def self.mapped_env_config(env)
|
|
105
|
+
ENV_MAPPING.each_with_object({}) do |(key, env_key), config|
|
|
106
|
+
value = env[env_key]
|
|
107
|
+
config[key] = value unless Aireview::Utils.blank?(value)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def self.llm_env_config(env)
|
|
112
|
+
{
|
|
113
|
+
'provider' => env['LLM_PROVIDER'],
|
|
114
|
+
'temperature' => parse_float(env['LLM_TEMPERATURE']),
|
|
115
|
+
'timeout' => parse_float(env['LLM_TIMEOUT']),
|
|
116
|
+
'generate' => llm_stage_env_config(env, 'GENERATE'),
|
|
117
|
+
'critique' => llm_stage_env_config(env, 'CRITIQUE')
|
|
118
|
+
}.compact.reject { |key, value| %w[generate critique].include?(key) && value.empty? }
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def self.llm_stage_env_config(env, stage)
|
|
122
|
+
{
|
|
123
|
+
'provider' => env["LLM_#{stage}_PROVIDER"],
|
|
124
|
+
'model' => env["LLM_#{stage}_MODEL"],
|
|
125
|
+
'temperature' => parse_float(env["LLM_#{stage}_TEMPERATURE"])
|
|
126
|
+
}.compact
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def self.provider_key_env_config(env)
|
|
130
|
+
PROVIDER_KEY_MAPPING.each_with_object({}) do |(provider, env_key), config|
|
|
131
|
+
next unless env_key
|
|
132
|
+
|
|
133
|
+
value = env[env_key]
|
|
134
|
+
config["#{provider}_api_key"] = value unless Aireview::Utils.blank?(value)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def self.generic_api_key_env_config(env)
|
|
139
|
+
api_key = env['LLM_API_KEY']
|
|
140
|
+
return {} if Aireview::Utils.blank?(api_key)
|
|
141
|
+
|
|
142
|
+
{'llm_api_key' => api_key}
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def self.parse_float(value)
|
|
146
|
+
return nil if Aireview::Utils.blank?(value)
|
|
147
|
+
|
|
148
|
+
Float(value)
|
|
149
|
+
rescue ArgumentError
|
|
150
|
+
nil
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def self.deep_merge(left, right)
|
|
154
|
+
left.merge(right) do |_, old_value, new_value|
|
|
155
|
+
if old_value.is_a?(Hash) && new_value.is_a?(Hash)
|
|
156
|
+
deep_merge(old_value, new_value)
|
|
157
|
+
else
|
|
158
|
+
new_value
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def self.normalize_hash(value)
|
|
164
|
+
case value
|
|
165
|
+
when Hash
|
|
166
|
+
value.each_with_object({}) do |(key, inner_value), result|
|
|
167
|
+
result[key.to_s] = normalize_hash(inner_value)
|
|
168
|
+
end
|
|
169
|
+
when Array
|
|
170
|
+
value.map { |item| normalize_hash(item) }
|
|
171
|
+
else
|
|
172
|
+
value
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def initialize(data, config_path:, logger:)
|
|
177
|
+
@data = self.class.normalize_hash(data)
|
|
178
|
+
@config_path = config_path
|
|
179
|
+
@logger = logger
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def with_overrides(
|
|
183
|
+
generate_model: nil,
|
|
184
|
+
critique_model: nil,
|
|
185
|
+
generate_temperature: nil,
|
|
186
|
+
critique_temperature: nil
|
|
187
|
+
)
|
|
188
|
+
llm_config = {
|
|
189
|
+
'generate' => stage_overrides(model: generate_model, temperature: generate_temperature),
|
|
190
|
+
'critique' => stage_overrides(model: critique_model, temperature: critique_temperature)
|
|
191
|
+
}.reject { |_, overrides| overrides.empty? }
|
|
192
|
+
return self if llm_config.empty?
|
|
193
|
+
|
|
194
|
+
merged = self.class.deep_merge(
|
|
195
|
+
@data,
|
|
196
|
+
'llm' => llm_config
|
|
197
|
+
)
|
|
198
|
+
self.class.new(merged, config_path: config_path, logger: @logger)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def gitlab_url
|
|
202
|
+
@data['gitlab_url']
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def gitlab_token
|
|
206
|
+
@data['gitlab_token']
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def jira_url
|
|
210
|
+
@data['jira_url']
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def jira_login
|
|
214
|
+
@data['jira_login']
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def jira_password
|
|
218
|
+
@data['jira_password']
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def llm_provider
|
|
222
|
+
dig('llm', 'provider') || DEFAULTS.dig('llm', 'provider')
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def llm_temperature
|
|
226
|
+
dig('llm', 'temperature') || DEFAULTS.dig('llm', 'temperature')
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def llm_timeout
|
|
230
|
+
dig('llm', 'timeout') || DEFAULTS.dig('llm', 'timeout')
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def generate_model
|
|
234
|
+
dig('llm', 'generate', 'model')
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def critique_model
|
|
238
|
+
dig('llm', 'critique', 'model')
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def generate_provider
|
|
242
|
+
dig('llm', 'generate', 'provider') || llm_provider
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def critique_provider
|
|
246
|
+
dig('llm', 'critique', 'provider') || llm_provider
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def generate_temperature
|
|
250
|
+
dig('llm', 'generate', 'temperature') || llm_temperature
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def critique_temperature
|
|
254
|
+
dig('llm', 'critique', 'temperature') || llm_temperature
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def llm_api_base
|
|
258
|
+
@data['llm_api_base']
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def ollama_api_base
|
|
262
|
+
@data['ollama_api_base'] || DEFAULTS['ollama_api_base']
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def llm_http_proxy
|
|
266
|
+
@data['llm_http_proxy'] || dig('llm', 'http_proxy')
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def review_language
|
|
270
|
+
@data['review_language'] || DEFAULTS['review_language']
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# update — обновляем свою заметку, когда дифф или настройки изменились,
|
|
274
|
+
# once — ревьюим один раз автоматически; Retry джоба обновляет ревью при изменениях.
|
|
275
|
+
def review_mode
|
|
276
|
+
mode = (@data['review_mode'] || DEFAULTS['review_mode']).to_s
|
|
277
|
+
return mode if REVIEW_MODES.include?(mode)
|
|
278
|
+
|
|
279
|
+
@logger.warn("Unknown review_mode #{mode.inspect}, using #{DEFAULTS['review_mode']}")
|
|
280
|
+
DEFAULTS['review_mode']
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def ignore_paths
|
|
284
|
+
Array(@data['ignore_paths']).compact
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def secret_patterns
|
|
288
|
+
Array(@data['secret_patterns']).compact
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def secret_files
|
|
292
|
+
(DEFAULT_SECRET_FILES + Array(@data['secret_files']).compact).uniq
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def review_instructions
|
|
296
|
+
@data['review_instructions']
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def llm_api_key
|
|
300
|
+
@data['llm_api_key']
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def provider_api_key(provider = llm_provider)
|
|
304
|
+
@data["#{provider}_api_key"] || llm_api_key
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def jira_configured?
|
|
308
|
+
Aireview::Utils.present?(jira_url) &&
|
|
309
|
+
Aireview::Utils.present?(jira_login) &&
|
|
310
|
+
Aireview::Utils.present?(jira_password)
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def require_gitlab_token!
|
|
314
|
+
return gitlab_token if Aireview::Utils.present?(gitlab_token)
|
|
315
|
+
|
|
316
|
+
raise ConfigError, 'GITLAB_TOKEN is required'
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def require_models!
|
|
320
|
+
missing = []
|
|
321
|
+
missing << 'llm.generate.model (or LLM_GENERATE_MODEL)' if Aireview::Utils.blank?(generate_model)
|
|
322
|
+
missing << 'llm.critique.model (or LLM_CRITIQUE_MODEL)' if Aireview::Utils.blank?(critique_model)
|
|
323
|
+
raise ConfigError, "LLM models are required: #{missing.join(', ')}" unless missing.empty?
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def require_llm_configuration!
|
|
327
|
+
require_models!
|
|
328
|
+
|
|
329
|
+
missing_keys = {
|
|
330
|
+
'generate' => generate_provider,
|
|
331
|
+
'critique' => critique_provider
|
|
332
|
+
}.filter_map do |stage, provider|
|
|
333
|
+
next if provider.to_s == 'ollama'
|
|
334
|
+
next if Aireview::Utils.present?(provider_api_key(provider))
|
|
335
|
+
|
|
336
|
+
"#{stage}: API key is required for provider #{provider.inspect}"
|
|
337
|
+
end
|
|
338
|
+
raise ConfigError, missing_keys.join(', ') unless missing_keys.empty?
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
private
|
|
342
|
+
|
|
343
|
+
def stage_overrides(model:, temperature:)
|
|
344
|
+
{
|
|
345
|
+
'model' => model,
|
|
346
|
+
'temperature' => temperature
|
|
347
|
+
}.compact
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
def dig(*keys)
|
|
351
|
+
keys.reduce(@data) do |accumulator, key|
|
|
352
|
+
accumulator.is_a?(Hash) ? accumulator[key] : nil
|
|
353
|
+
end
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require_relative 'utils'
|
|
3
|
+
require_relative 'secret_scrubber'
|
|
4
|
+
|
|
5
|
+
module Aireview
|
|
6
|
+
class ContextBuilder
|
|
7
|
+
MAX_DIFF_CHARS = 120_000
|
|
8
|
+
GENERATE_PROMPT_TEMPLATE = File.read(File.expand_path('prompts/generate.txt', __dir__)).strip.freeze
|
|
9
|
+
CRITIQUE_PROMPT_TEMPLATE = File.read(File.expand_path('prompts/critique.txt', __dir__)).strip.freeze
|
|
10
|
+
LANGUAGE_NAMES = {
|
|
11
|
+
'ru' => 'Russian',
|
|
12
|
+
'en' => 'English'
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
15
|
+
def initialize(config:, logger: Logger.new($stderr))
|
|
16
|
+
@config = config
|
|
17
|
+
@logger = logger
|
|
18
|
+
@secret_scrubber = SecretScrubber.new(
|
|
19
|
+
secret_patterns: config.secret_patterns,
|
|
20
|
+
secret_files: config.secret_files,
|
|
21
|
+
logger: logger
|
|
22
|
+
)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def build(merge_request:, changes_text:, jira_issue: nil)
|
|
26
|
+
build_generate_prompt(merge_request: merge_request, changes_text: changes_text, jira_issue: jira_issue)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def build_generate_prompt(merge_request:, changes_text:, jira_issue: nil)
|
|
30
|
+
{
|
|
31
|
+
system_prompt: system_prompt(GENERATE_PROMPT_TEMPLATE),
|
|
32
|
+
user_prompt: user_prompt(merge_request: merge_request, changes_text: changes_text, jira_issue: jira_issue)
|
|
33
|
+
}
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def build_critique_prompt(merge_request:, changes_text:, candidates_json:, jira_issue: nil)
|
|
37
|
+
user = "#{user_prompt(merge_request: merge_request, changes_text: changes_text, jira_issue: jira_issue)}\n\n"
|
|
38
|
+
user << "Candidates JSON from Generate:\n#{scrub_text(candidates_json)}"
|
|
39
|
+
|
|
40
|
+
{
|
|
41
|
+
system_prompt: system_prompt(CRITIQUE_PROMPT_TEMPLATE),
|
|
42
|
+
user_prompt: user
|
|
43
|
+
}
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def system_prompt(template)
|
|
49
|
+
extras = []
|
|
50
|
+
if Aireview::Utils.present?(@config.review_instructions)
|
|
51
|
+
extras << "Additional project instructions:\n#{scrub_text(@config.review_instructions.strip)}"
|
|
52
|
+
end
|
|
53
|
+
extras << "Response language: #{language_name(@config.review_language)}."
|
|
54
|
+
|
|
55
|
+
[template, *extras].join("\n\n")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def user_prompt(merge_request:, changes_text:, jira_issue:)
|
|
59
|
+
truncated_changes = truncate_changes(scrub_text(changes_text))
|
|
60
|
+
|
|
61
|
+
sections = merge_request_sections(merge_request)
|
|
62
|
+
sections << jira_section(jira_issue) if jira_issue
|
|
63
|
+
sections << "Changes:\n#{truncated_changes}"
|
|
64
|
+
sections.join("\n\n")
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def merge_request_sections(merge_request)
|
|
68
|
+
source_branch = scrub_text(merge_request['source_branch'])
|
|
69
|
+
target_branch = scrub_text(merge_request['target_branch'])
|
|
70
|
+
|
|
71
|
+
[
|
|
72
|
+
"MR: #{scrub_text(merge_request['title'])}",
|
|
73
|
+
"Author: #{scrub_text(merge_request.dig('author', 'name'))}",
|
|
74
|
+
"Branch: #{source_branch} -> #{target_branch}",
|
|
75
|
+
"Description:\n#{scrub_optional_text(merge_request['description'])}"
|
|
76
|
+
]
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def jira_section(jira_issue)
|
|
80
|
+
section = "Jira task (#{scrub_text(jira_issue['key'])}):\n"
|
|
81
|
+
section << "Summary: #{scrub_text(jira_issue['summary'])}\n"
|
|
82
|
+
section << "Description:\n#{scrub_optional_text(jira_issue['description'])}"
|
|
83
|
+
|
|
84
|
+
comments = jira_issue['comments']
|
|
85
|
+
return section unless comments && !comments.empty?
|
|
86
|
+
|
|
87
|
+
section << "\nRecent comments:\n#{comments.map { |comment| scrub_text(comment) }.join("\n")}"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def truncate_changes(changes_text)
|
|
91
|
+
text = changes_text.to_s
|
|
92
|
+
return text if text.length <= MAX_DIFF_CHARS
|
|
93
|
+
|
|
94
|
+
omitted = text.length - MAX_DIFF_CHARS
|
|
95
|
+
"#{text[0, MAX_DIFF_CHARS]}\n\n[TRUNCATED #{omitted} chars]"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def scrub_optional_text(text)
|
|
99
|
+
scrubbed = scrub_text(text)
|
|
100
|
+
Aireview::Utils.presence(scrubbed) || '(empty)'
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def scrub_text(text)
|
|
104
|
+
@secret_scrubber.scrub_text(text.to_s)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def language_name(code)
|
|
108
|
+
LANGUAGE_NAMES.fetch(code.to_s, code.to_s)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require_relative 'utils'
|
|
3
|
+
|
|
4
|
+
module Aireview
|
|
5
|
+
class DiffFetcher
|
|
6
|
+
DIFF_UNAVAILABLE = '[DIFF_NOT_AVAILABLE]'
|
|
7
|
+
|
|
8
|
+
def initialize(ignore_paths:, logger: Logger.new($stderr))
|
|
9
|
+
@ignore_paths = Array(ignore_paths).compact
|
|
10
|
+
@logger = logger
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def filter(changes)
|
|
14
|
+
Array(changes).reject do |change|
|
|
15
|
+
ignored_path?(change['new_path']) || ignored_path?(change['old_path'])
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def render(changes)
|
|
20
|
+
Array(changes).map { |change| render_change(change) }.join("\n")
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def ignored_path?(path)
|
|
26
|
+
return false if Aireview::Utils.blank?(path)
|
|
27
|
+
|
|
28
|
+
@ignore_paths.any? do |pattern|
|
|
29
|
+
File.fnmatch?(pattern, path, File::FNM_DOTMATCH | File::FNM_EXTGLOB)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def render_change(change)
|
|
34
|
+
old_path = change['old_path'] || change['new_path']
|
|
35
|
+
new_path = change['new_path'] || change['old_path']
|
|
36
|
+
diff = change['diff'].to_s
|
|
37
|
+
diff = DIFF_UNAVAILABLE if diff.strip.empty?
|
|
38
|
+
|
|
39
|
+
<<~DIFF
|
|
40
|
+
diff --git a/#{old_path} b/#{new_path}
|
|
41
|
+
--- a/#{old_path}
|
|
42
|
+
+++ b/#{new_path}
|
|
43
|
+
#{diff}
|
|
44
|
+
DIFF
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require 'json'
|
|
3
|
+
require_relative 'utils'
|
|
4
|
+
|
|
5
|
+
module Aireview
|
|
6
|
+
class GitlabClient
|
|
7
|
+
OPEN_TIMEOUT = 10
|
|
8
|
+
READ_TIMEOUT = 30
|
|
9
|
+
NOTES_PER_PAGE = 100
|
|
10
|
+
NOTES_PAGE_LIMIT = 100
|
|
11
|
+
JOBS_PER_PAGE = 100
|
|
12
|
+
JOBS_PAGE_LIMIT = 100
|
|
13
|
+
|
|
14
|
+
def initialize(base_url:, token:, logger: Logger.new($stderr), connection: nil)
|
|
15
|
+
require 'faraday'
|
|
16
|
+
|
|
17
|
+
raise ConfigError, 'GitLab base URL is required' if Aireview::Utils.blank?(base_url)
|
|
18
|
+
raise ConfigError, 'GitLab token is required' if Aireview::Utils.blank?(token)
|
|
19
|
+
|
|
20
|
+
@logger = logger
|
|
21
|
+
@token = token
|
|
22
|
+
@connection = connection || build_connection(base_url)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def fetch_merge_request(project_id, iid)
|
|
26
|
+
get_json("projects/#{project_id}/merge_requests/#{iid}")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def fetch_merge_request_changes(project_id, iid)
|
|
30
|
+
payload = get_json("projects/#{project_id}/merge_requests/#{iid}/changes")
|
|
31
|
+
Array(payload['changes'])
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def fetch_current_user
|
|
35
|
+
get_json('user')
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Retry создаёт новый job в том же pipeline. Старые попытки видны только
|
|
39
|
+
# с include_retried; один лишь новый CI_JOB_ID бывает и после обычного push.
|
|
40
|
+
def retried_job?(project_id, job_id)
|
|
41
|
+
current = get_json("projects/#{project_id}/jobs/#{job_id}")
|
|
42
|
+
validate_retry_job!(current, pipeline: true)
|
|
43
|
+
path = "projects/#{project_id}/pipelines/#{current.fetch('pipeline').fetch('id')}/jobs"
|
|
44
|
+
|
|
45
|
+
(1..JOBS_PAGE_LIMIT).each do |page|
|
|
46
|
+
jobs = get_json(path, include_retried: true, per_page: JOBS_PER_PAGE, page: page)
|
|
47
|
+
raise ApiError, 'GitLab Jobs API returned an invalid jobs list' unless jobs.is_a?(Array)
|
|
48
|
+
|
|
49
|
+
jobs.each { |job| validate_retry_job!(job) }
|
|
50
|
+
return true if jobs.any? { |job| previous_attempt?(job, current) }
|
|
51
|
+
return false if jobs.size < JOBS_PER_PAGE
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
raise ApiError, 'Too many pipeline jobs to determine whether this job is a retry'
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Заметки отдаются страницами и сортируются по времени создания, а
|
|
58
|
+
# обновление комментария в этом порядке его не поднимает: на длинном MR
|
|
59
|
+
# своё ревью оказывается далеко не на первой странице. Обрывать обход молча
|
|
60
|
+
# нельзя — по неполному списку ревью решит, что заметки нет, и создаст
|
|
61
|
+
# вторую, поэтому упираемся в предел с ошибкой. Порядок задаётся явно: от
|
|
62
|
+
# него зависит, какую из старых заметок без метки подхватит ревью, и
|
|
63
|
+
# полагаться тут на дефолт API не стоит.
|
|
64
|
+
def fetch_merge_request_notes(project_id, iid)
|
|
65
|
+
notes = []
|
|
66
|
+
page = 1
|
|
67
|
+
|
|
68
|
+
loop do
|
|
69
|
+
batch = Array(
|
|
70
|
+
get_json(
|
|
71
|
+
"projects/#{project_id}/merge_requests/#{iid}/notes",
|
|
72
|
+
order_by: 'created_at', sort: 'desc', per_page: NOTES_PER_PAGE, page: page
|
|
73
|
+
)
|
|
74
|
+
)
|
|
75
|
+
notes.concat(batch)
|
|
76
|
+
break if batch.size < NOTES_PER_PAGE
|
|
77
|
+
|
|
78
|
+
page += 1
|
|
79
|
+
next unless page > NOTES_PAGE_LIMIT
|
|
80
|
+
|
|
81
|
+
raise ApiError, "Merge request has more than #{NOTES_PAGE_LIMIT * NOTES_PER_PAGE} notes"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
notes
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def post_merge_request_note(project_id, iid, body)
|
|
88
|
+
post_json("projects/#{project_id}/merge_requests/#{iid}/notes", body: body)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def update_merge_request_note(project_id, iid, note_id, body)
|
|
92
|
+
put_json("projects/#{project_id}/merge_requests/#{iid}/notes/#{note_id}", body: body)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
def validate_retry_job!(job, pipeline: false)
|
|
98
|
+
fields = {'id' => Integer, 'name' => String, 'stage' => String}
|
|
99
|
+
valid = job.is_a?(Hash) && fields.all? { |key, type| job[key].is_a?(type) }
|
|
100
|
+
if valid && pipeline
|
|
101
|
+
data = job['pipeline']
|
|
102
|
+
valid = data.is_a?(Hash) && data['id'].is_a?(Integer)
|
|
103
|
+
end
|
|
104
|
+
raise ApiError, 'GitLab Jobs API returned an invalid job payload' unless valid
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def previous_attempt?(job, current)
|
|
108
|
+
job.fetch('id') < current.fetch('id') &&
|
|
109
|
+
job['name'] == current.fetch('name') && job['stage'] == current.fetch('stage')
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def build_connection(base_url)
|
|
113
|
+
Faraday.new(url: "#{base_url}/api/v4/") do |builder|
|
|
114
|
+
builder.request :json
|
|
115
|
+
builder.options.open_timeout = OPEN_TIMEOUT
|
|
116
|
+
builder.options.timeout = READ_TIMEOUT
|
|
117
|
+
builder.adapter Faraday.default_adapter
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def get_json(path, params = {})
|
|
122
|
+
response = @connection.get(path, params, headers)
|
|
123
|
+
parse_json(response)
|
|
124
|
+
rescue Faraday::Error => e
|
|
125
|
+
raise ApiError, "GitLab API request failed: #{e.message}"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def post_json(path, body)
|
|
129
|
+
response = @connection.post(path) do |request|
|
|
130
|
+
request.headers.update(headers)
|
|
131
|
+
request.body = JSON.generate(body)
|
|
132
|
+
end
|
|
133
|
+
parse_json(response)
|
|
134
|
+
rescue Faraday::Error => e
|
|
135
|
+
raise ApiError, "GitLab API request failed: #{e.message}"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def put_json(path, body)
|
|
139
|
+
response = @connection.put(path) do |request|
|
|
140
|
+
request.headers.update(headers)
|
|
141
|
+
request.body = JSON.generate(body)
|
|
142
|
+
end
|
|
143
|
+
parse_json(response)
|
|
144
|
+
rescue Faraday::Error => e
|
|
145
|
+
raise ApiError, "GitLab API request failed: #{e.message}"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def parse_json(response)
|
|
149
|
+
status = response.status.to_i
|
|
150
|
+
body = response.body.to_s
|
|
151
|
+
|
|
152
|
+
return JSON.parse(body) if status.between?(200, 299)
|
|
153
|
+
|
|
154
|
+
raise ApiError, "GitLab API error #{status}: #{body}"
|
|
155
|
+
rescue JSON::ParserError
|
|
156
|
+
raise ApiError, "GitLab API returned invalid JSON: #{body}"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def headers
|
|
160
|
+
{
|
|
161
|
+
'Content-Type' => 'application/json',
|
|
162
|
+
'PRIVATE-TOKEN' => @token
|
|
163
|
+
}
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|