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,373 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require 'json'
|
|
3
|
+
require_relative 'errors'
|
|
4
|
+
require_relative 'context_builder'
|
|
5
|
+
require_relative 'review_renderer'
|
|
6
|
+
require_relative 'review_schemas'
|
|
7
|
+
require_relative 'reviewer'
|
|
8
|
+
|
|
9
|
+
module Aireview
|
|
10
|
+
class ReviewPipeline
|
|
11
|
+
class SchemaError < StandardError
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
REPAIR_SYSTEM_PROMPT = <<~PROMPT.strip.freeze
|
|
15
|
+
You fix invalid JSON produced by another LLM call.
|
|
16
|
+
Return only valid JSON matching the requested schema.
|
|
17
|
+
Do not use markdown, code fences, comments, or text outside JSON.
|
|
18
|
+
Do not add new review findings.
|
|
19
|
+
PROMPT
|
|
20
|
+
DRY_RUN_CANDIDATES_JSON = '[{"id":"C1","file":"path/from/diff.rb","line":1,' \
|
|
21
|
+
'"quoted_code":"...","problem":"...","why":"...","suggestion":"...",' \
|
|
22
|
+
'"category":"bug","severity":"major"}]'
|
|
23
|
+
|
|
24
|
+
def initialize(config:, reviewer: nil, context_builder: nil, logger: Logger.new($stderr))
|
|
25
|
+
@config = config
|
|
26
|
+
@reviewer = reviewer || Reviewer.new(config: config, logger: logger)
|
|
27
|
+
@context_builder = context_builder || ContextBuilder.new(config: config, logger: logger)
|
|
28
|
+
@logger = logger
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def run(merge_request:, changes_text:, jira_issue: nil, critique: true)
|
|
32
|
+
generate_prompt = @context_builder.build_generate_prompt(
|
|
33
|
+
merge_request: merge_request,
|
|
34
|
+
changes_text: changes_text,
|
|
35
|
+
jira_issue: jira_issue
|
|
36
|
+
)
|
|
37
|
+
@logger.info("Pipeline generate pass started (model=#{@config.generate_model})")
|
|
38
|
+
candidates_raw = @reviewer.generate(**generate_prompt)
|
|
39
|
+
generate_result = parse_with_repair(
|
|
40
|
+
raw: candidates_raw,
|
|
41
|
+
kind: 'generate result',
|
|
42
|
+
expected: :generate,
|
|
43
|
+
repair_stage: :generate
|
|
44
|
+
)
|
|
45
|
+
summary = generate_result['summary']
|
|
46
|
+
candidates = Array(generate_result['candidates'])
|
|
47
|
+
@logger.info("Pipeline generate pass completed with #{candidates.size} candidate(s)")
|
|
48
|
+
|
|
49
|
+
accepted = if critique
|
|
50
|
+
@logger.info("Pipeline critique pass started (model=#{@config.critique_model})")
|
|
51
|
+
critique_candidates(
|
|
52
|
+
merge_request: merge_request,
|
|
53
|
+
changes_text: changes_text,
|
|
54
|
+
jira_issue: jira_issue,
|
|
55
|
+
candidates: candidates
|
|
56
|
+
)
|
|
57
|
+
else
|
|
58
|
+
@logger.info('Pipeline critique pass skipped')
|
|
59
|
+
candidates
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
@logger.info("Pipeline finished with #{accepted.size} accepted finding(s)")
|
|
63
|
+
|
|
64
|
+
ReviewRenderer.new(language: @config.review_language).render(accepted, summary: summary)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def dry_run_prompts(merge_request:, changes_text:, jira_issue: nil, critique: true)
|
|
68
|
+
@config.require_models!
|
|
69
|
+
|
|
70
|
+
generate_prompt = @context_builder.build_generate_prompt(
|
|
71
|
+
merge_request: merge_request,
|
|
72
|
+
changes_text: changes_text,
|
|
73
|
+
jira_issue: jira_issue
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
critique_prompt = if critique
|
|
77
|
+
@context_builder.build_critique_prompt(
|
|
78
|
+
merge_request: merge_request,
|
|
79
|
+
changes_text: changes_text,
|
|
80
|
+
jira_issue: jira_issue,
|
|
81
|
+
candidates_json: DRY_RUN_CANDIDATES_JSON
|
|
82
|
+
)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
{
|
|
86
|
+
generate_prompt: generate_prompt,
|
|
87
|
+
critique_prompt: critique_prompt,
|
|
88
|
+
generate_model: @config.generate_model,
|
|
89
|
+
generate_temperature: @config.generate_temperature,
|
|
90
|
+
critique_model: @config.critique_model,
|
|
91
|
+
critique_temperature: @config.critique_temperature
|
|
92
|
+
}
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
def critique_candidates(merge_request:, changes_text:, jira_issue:, candidates:)
|
|
98
|
+
candidates_json = JSON.pretty_generate(candidates)
|
|
99
|
+
candidates_by_id = index_candidates_by_id(candidates)
|
|
100
|
+
critique_prompt = @context_builder.build_critique_prompt(
|
|
101
|
+
merge_request: merge_request,
|
|
102
|
+
changes_text: changes_text,
|
|
103
|
+
jira_issue: jira_issue,
|
|
104
|
+
candidates_json: candidates_json
|
|
105
|
+
)
|
|
106
|
+
critique_raw = @reviewer.critique(**critique_prompt)
|
|
107
|
+
critique_result = parse_with_repair(
|
|
108
|
+
raw: critique_raw,
|
|
109
|
+
kind: 'critique result',
|
|
110
|
+
expected: :critique,
|
|
111
|
+
repair_stage: :critique,
|
|
112
|
+
critique_candidate_ids: candidates_by_id.keys
|
|
113
|
+
)
|
|
114
|
+
verdicts = Array(critique_result['verdicts'])
|
|
115
|
+
@logger.info("Pipeline critique pass completed with #{verdicts.size} verdict(s)")
|
|
116
|
+
apply_critique_verdicts(
|
|
117
|
+
verdicts: verdicts,
|
|
118
|
+
candidates_by_id: candidates_by_id
|
|
119
|
+
)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def parse_with_repair(raw:, kind:, expected:, repair_stage:, critique_candidate_ids: nil)
|
|
123
|
+
if raw.is_a?(Hash)
|
|
124
|
+
return parse_structured_result(
|
|
125
|
+
raw,
|
|
126
|
+
kind: kind,
|
|
127
|
+
expected: expected,
|
|
128
|
+
critique_candidate_ids: critique_candidate_ids
|
|
129
|
+
)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
raise ParseError, "LLM returned unsupported #{kind} type: #{raw.class}" unless raw.is_a?(String)
|
|
133
|
+
|
|
134
|
+
parse_string_with_repair(
|
|
135
|
+
raw: raw,
|
|
136
|
+
kind: kind,
|
|
137
|
+
expected: expected,
|
|
138
|
+
repair_stage: repair_stage,
|
|
139
|
+
critique_candidate_ids: critique_candidate_ids
|
|
140
|
+
)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def parse_structured_result(raw, kind:, expected:, critique_candidate_ids: nil)
|
|
144
|
+
parse_expected_result(raw, expected, critique_candidate_ids: critique_candidate_ids)
|
|
145
|
+
rescue SchemaError => e
|
|
146
|
+
raise ParseError, "LLM returned invalid #{kind}: #{e.message}"
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def parse_string_with_repair(raw:, kind:, expected:, repair_stage:, critique_candidate_ids: nil)
|
|
150
|
+
parse_expected_result(raw, expected, critique_candidate_ids: critique_candidate_ids)
|
|
151
|
+
rescue JSON::ParserError, SchemaError => e
|
|
152
|
+
@logger.warn("Invalid #{kind} JSON, requesting one repair: #{e.message}")
|
|
153
|
+
repaired = repair_json(
|
|
154
|
+
raw: raw,
|
|
155
|
+
kind: kind,
|
|
156
|
+
expected: expected,
|
|
157
|
+
stage: repair_stage,
|
|
158
|
+
critique_candidate_ids: critique_candidate_ids
|
|
159
|
+
)
|
|
160
|
+
begin
|
|
161
|
+
parse_expected_result(repaired, expected, critique_candidate_ids: critique_candidate_ids)
|
|
162
|
+
rescue JSON::ParserError, SchemaError => second_error
|
|
163
|
+
raise ParseError, "LLM returned invalid #{kind} JSON after repair: #{second_error.message}"
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def parse_expected_result(raw, expected, critique_candidate_ids: nil)
|
|
168
|
+
parsed = raw.is_a?(Hash) ? raw : JSON.parse(strip_code_fences(raw.to_s))
|
|
169
|
+
|
|
170
|
+
case expected
|
|
171
|
+
when :generate
|
|
172
|
+
parsed = normalize_generate_result(parsed)
|
|
173
|
+
when :critique
|
|
174
|
+
parsed = normalize_critique_result(parsed, critique_candidate_ids: critique_candidate_ids)
|
|
175
|
+
else
|
|
176
|
+
raise ArgumentError, "Unknown expected JSON schema: #{expected.inspect}"
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
parsed
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def strip_code_fences(text)
|
|
183
|
+
stripped = text.to_s.strip
|
|
184
|
+
return stripped unless stripped.start_with?('```')
|
|
185
|
+
|
|
186
|
+
stripped
|
|
187
|
+
.sub(/\A```[[:alnum:]_-]*[ \t]*\r?\n?/, '')
|
|
188
|
+
.sub(/\r?\n?```[ \t]*\z/, '')
|
|
189
|
+
.strip
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def normalize_generate_result(parsed)
|
|
193
|
+
parsed = {'summary' => nil, 'candidates' => parsed} if parsed.is_a?(Array)
|
|
194
|
+
validate_generate_result_shape!(parsed)
|
|
195
|
+
parsed['summary'] = nil unless parsed.key?('summary')
|
|
196
|
+
candidate_ids = parsed['candidates'].map { |candidate| normalize_id(value(candidate, 'id')) }
|
|
197
|
+
validate_identifiers!(
|
|
198
|
+
candidate_ids,
|
|
199
|
+
missing_message: 'each generate candidate must include a non-empty id',
|
|
200
|
+
duplicate_prefix: 'duplicate generate candidate ids'
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
parsed
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def normalize_critique_result(parsed, critique_candidate_ids:)
|
|
207
|
+
validate_critique_result_shape!(parsed)
|
|
208
|
+
verdicts = parsed['verdicts']
|
|
209
|
+
verdict_ids = verdicts.map { |verdict| normalize_id(value(verdict, 'id')) }
|
|
210
|
+
validate_identifiers!(
|
|
211
|
+
verdict_ids,
|
|
212
|
+
missing_message: 'each verdict must include a non-empty id',
|
|
213
|
+
duplicate_prefix: 'duplicate verdict ids'
|
|
214
|
+
)
|
|
215
|
+
validate_expected_verdict_ids!(verdict_ids, critique_candidate_ids)
|
|
216
|
+
verdicts.each { |verdict| validate_verdict!(verdict) }
|
|
217
|
+
|
|
218
|
+
parsed
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def validate_generate_result_shape!(parsed)
|
|
222
|
+
valid_shape = parsed.is_a?(Hash) && parsed['candidates'].is_a?(Array)
|
|
223
|
+
raise SchemaError, 'expected an object with summary and candidates array' unless valid_shape
|
|
224
|
+
raise SchemaError, 'each generate candidate must be an object' unless parsed['candidates'].all?(Hash)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def validate_critique_result_shape!(parsed)
|
|
228
|
+
valid_shape = parsed.is_a?(Hash) && parsed['verdicts'].is_a?(Array)
|
|
229
|
+
raise SchemaError, 'expected an object with verdicts array' unless valid_shape
|
|
230
|
+
raise SchemaError, 'each critique verdict must be an object' unless parsed['verdicts'].all?(Hash)
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def validate_identifiers!(identifiers, missing_message:, duplicate_prefix:)
|
|
234
|
+
raise SchemaError, missing_message unless identifiers.all?
|
|
235
|
+
|
|
236
|
+
duplicate_ids = identifiers.group_by(&:itself).select { |_, ids| ids.size > 1 }.keys
|
|
237
|
+
return if duplicate_ids.empty?
|
|
238
|
+
|
|
239
|
+
raise SchemaError, "#{duplicate_prefix}: #{duplicate_ids.join(', ')}"
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def validate_expected_verdict_ids!(verdict_ids, expected_ids)
|
|
243
|
+
return unless expected_ids
|
|
244
|
+
|
|
245
|
+
unknown_ids = verdict_ids - expected_ids
|
|
246
|
+
missing_ids = expected_ids - verdict_ids
|
|
247
|
+
raise SchemaError, "unknown verdict ids: #{unknown_ids.join(', ')}" unless unknown_ids.empty?
|
|
248
|
+
raise SchemaError, "missing verdict ids: #{missing_ids.join(', ')}" unless missing_ids.empty?
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def validate_verdict!(verdict)
|
|
252
|
+
id = normalize_id(value(verdict, 'id'))
|
|
253
|
+
decision = normalize_decision(value(verdict, 'decision'))
|
|
254
|
+
raise SchemaError, "invalid verdict decision for #{id}" unless %w[keep reject].include?(decision)
|
|
255
|
+
|
|
256
|
+
refinement = value(verdict, 'refinement')
|
|
257
|
+
raise SchemaError, "reject verdict cannot include refinement for #{id}" if invalid_refinement?(verdict, decision)
|
|
258
|
+
return if refinement.nil?
|
|
259
|
+
return if refinement.is_a?(Hash)
|
|
260
|
+
|
|
261
|
+
raise SchemaError, "refinement must be an object for #{id}"
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def invalid_refinement?(verdict, decision)
|
|
265
|
+
refinement_key?(verdict) && decision != 'keep'
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def repair_json(raw:, kind:, expected:, stage:, critique_candidate_ids: nil)
|
|
269
|
+
schema = expected == :critique ? ReviewSchemas.critique : ReviewSchemas.generate
|
|
270
|
+
user_prompt = <<~PROMPT
|
|
271
|
+
The previous #{kind} response was invalid.
|
|
272
|
+
|
|
273
|
+
Convert it into valid JSON matching this schema:
|
|
274
|
+
#{schema}
|
|
275
|
+
|
|
276
|
+
Do not add new findings. Do not remove findings unless they cannot be represented.
|
|
277
|
+
Return only JSON.
|
|
278
|
+
|
|
279
|
+
Invalid response:
|
|
280
|
+
#{raw}
|
|
281
|
+
PROMPT
|
|
282
|
+
if stage == :critique && critique_candidate_ids
|
|
283
|
+
user_prompt << "\nExpected candidate ids: #{critique_candidate_ids.join(', ')}\n"
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
@logger.info("Pipeline #{stage} repair started for #{kind}")
|
|
287
|
+
if stage == :critique
|
|
288
|
+
@reviewer.critique(system_prompt: REPAIR_SYSTEM_PROMPT, user_prompt: user_prompt)
|
|
289
|
+
else
|
|
290
|
+
@reviewer.generate(system_prompt: REPAIR_SYSTEM_PROMPT, user_prompt: user_prompt)
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def index_candidates_by_id(candidates)
|
|
295
|
+
candidates.each_with_object({}) do |candidate, result|
|
|
296
|
+
next unless candidate.is_a?(Hash)
|
|
297
|
+
|
|
298
|
+
result[normalize_id(value(candidate, 'id'))] = candidate
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def apply_critique_verdicts(verdicts:, candidates_by_id:)
|
|
303
|
+
accepted = []
|
|
304
|
+
|
|
305
|
+
verdicts.each do |verdict|
|
|
306
|
+
id = normalize_id(value(verdict, 'id'))
|
|
307
|
+
decision = normalize_decision(value(verdict, 'decision'))
|
|
308
|
+
reason = presence(value(verdict, 'reason')) || 'No reason provided.'
|
|
309
|
+
refinement = value(verdict, 'refinement')
|
|
310
|
+
|
|
311
|
+
if decision == 'reject'
|
|
312
|
+
@logger.info("Critique reject #{id}: #{reason}")
|
|
313
|
+
next
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
candidate = candidates_by_id.fetch(id)
|
|
317
|
+
merged = merge_candidate_refinement(candidate: candidate, refinement: refinement)
|
|
318
|
+
@logger.debug("Critique keep #{id}#{refinement_delta(candidate, merged)}: #{reason}")
|
|
319
|
+
accepted << merged
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
accepted
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def merge_candidate_refinement(candidate:, refinement:)
|
|
326
|
+
return candidate unless refinement.is_a?(Hash)
|
|
327
|
+
|
|
328
|
+
merged = candidate.dup
|
|
329
|
+
%w[problem why suggestion category severity].each do |key|
|
|
330
|
+
next unless refinement.key?(key) || refinement.key?(key.to_sym)
|
|
331
|
+
next unless (new_value = presence(value(refinement, key)))
|
|
332
|
+
|
|
333
|
+
merged[key] = new_value
|
|
334
|
+
end
|
|
335
|
+
merged
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def refinement_delta(original, refined)
|
|
339
|
+
changes = []
|
|
340
|
+
%w[category severity].each do |key|
|
|
341
|
+
before = presence(value(original, key))
|
|
342
|
+
after = presence(value(refined, key))
|
|
343
|
+
next if before == after
|
|
344
|
+
|
|
345
|
+
changes << "#{key} #{before || 'nil'}->#{after || 'nil'}"
|
|
346
|
+
end
|
|
347
|
+
return '' if changes.empty?
|
|
348
|
+
|
|
349
|
+
" (#{changes.join(', ')})"
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
def value(hash, key)
|
|
353
|
+
hash[key] || hash[key.to_sym]
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
def normalize_id(value)
|
|
357
|
+
presence(value)
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
def normalize_decision(value)
|
|
361
|
+
value.to_s.strip.downcase
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
def refinement_key?(verdict)
|
|
365
|
+
verdict.key?('refinement') || verdict.key?(:refinement)
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
def presence(value)
|
|
369
|
+
string = value.to_s.strip
|
|
370
|
+
string.empty? ? nil : string
|
|
371
|
+
end
|
|
372
|
+
end
|
|
373
|
+
end
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Aireview
|
|
4
|
+
class ReviewRenderer
|
|
5
|
+
MISMATCH_LIMIT = 2
|
|
6
|
+
IMPORTANT_LIMIT = 3
|
|
7
|
+
TOTAL_FINDINGS_LIMIT = 3
|
|
8
|
+
IMPORTANT_CATEGORIES = %w[
|
|
9
|
+
bug
|
|
10
|
+
regression
|
|
11
|
+
security
|
|
12
|
+
performance
|
|
13
|
+
data_loss
|
|
14
|
+
edge_case
|
|
15
|
+
].freeze
|
|
16
|
+
|
|
17
|
+
SEVERITY_ORDER = {
|
|
18
|
+
'critical' => 0,
|
|
19
|
+
'major' => 1,
|
|
20
|
+
'minor' => 2
|
|
21
|
+
}.freeze
|
|
22
|
+
|
|
23
|
+
DEFAULT_LANGUAGE = 'en'
|
|
24
|
+
LABELS = {
|
|
25
|
+
'en' => {
|
|
26
|
+
summary: 'Summary',
|
|
27
|
+
mismatches: 'Mismatches',
|
|
28
|
+
important: 'Important findings',
|
|
29
|
+
result: 'Result',
|
|
30
|
+
disclaimer: 'This report was generated by an AI and may contain mistakes. ' \
|
|
31
|
+
'Verify the findings by hand before acting on them.',
|
|
32
|
+
no_summary: 'The first pass did not return a summary of the changes.',
|
|
33
|
+
none_found: 'None found.',
|
|
34
|
+
not_specified: 'Not specified.',
|
|
35
|
+
where: 'Where',
|
|
36
|
+
problem: 'Problem',
|
|
37
|
+
why: 'Why it matters',
|
|
38
|
+
suggestion: 'Suggestion'
|
|
39
|
+
},
|
|
40
|
+
'ru' => {
|
|
41
|
+
summary: 'Сводка',
|
|
42
|
+
mismatches: 'Несоответствия',
|
|
43
|
+
important: 'Важные замечания',
|
|
44
|
+
result: 'Результат',
|
|
45
|
+
disclaimer: 'Отчёт сгенерирован ИИ и может содержать ошибки. ' \
|
|
46
|
+
'Проверьте замечания вручную перед принятием решений.',
|
|
47
|
+
no_summary: 'Сводка изменений не была возвращена на первом проходе.',
|
|
48
|
+
none_found: 'Не найдено.',
|
|
49
|
+
not_specified: 'Не указано.',
|
|
50
|
+
where: 'Где',
|
|
51
|
+
problem: 'Проблема',
|
|
52
|
+
why: 'Почему важно',
|
|
53
|
+
suggestion: 'Предложение'
|
|
54
|
+
}
|
|
55
|
+
}.freeze
|
|
56
|
+
|
|
57
|
+
def initialize(language: DEFAULT_LANGUAGE)
|
|
58
|
+
@labels = LABELS.fetch(language.to_s) { LABELS.fetch(DEFAULT_LANGUAGE) }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def render(accepted, summary:)
|
|
62
|
+
findings = sorted_findings(Array(accepted)).first(TOTAL_FINDINGS_LIMIT)
|
|
63
|
+
mismatches = findings.select { |finding| category(finding) == 'task_mismatch' }.first(MISMATCH_LIMIT)
|
|
64
|
+
important = findings.select { |finding| important_finding?(finding) }.first(IMPORTANT_LIMIT)
|
|
65
|
+
result = mismatches.empty? && important.empty? ? 'ok' : 'needs attention'
|
|
66
|
+
|
|
67
|
+
<<~MARKDOWN.rstrip
|
|
68
|
+
## #{label(:summary)}
|
|
69
|
+
|
|
70
|
+
#{summary_text(summary)}
|
|
71
|
+
|
|
72
|
+
## #{label(:mismatches)}
|
|
73
|
+
|
|
74
|
+
#{render_findings(mismatches)}
|
|
75
|
+
|
|
76
|
+
## #{label(:important)}
|
|
77
|
+
|
|
78
|
+
#{render_findings(important)}
|
|
79
|
+
|
|
80
|
+
## #{label(:result)}
|
|
81
|
+
|
|
82
|
+
#{result}
|
|
83
|
+
|
|
84
|
+
#{label(:disclaimer)}
|
|
85
|
+
MARKDOWN
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def sorted_findings(findings)
|
|
91
|
+
findings
|
|
92
|
+
.grep(Hash)
|
|
93
|
+
.sort_by { |finding| [SEVERITY_ORDER.fetch(severity(finding), 99), value(finding, 'id').to_s] }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def important_finding?(finding)
|
|
97
|
+
return false if category(finding) == 'task_mismatch'
|
|
98
|
+
return false if severity(finding) == 'minor'
|
|
99
|
+
|
|
100
|
+
IMPORTANT_CATEGORIES.include?(category(finding))
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def summary_text(summary)
|
|
104
|
+
presence(summary) || label(:no_summary)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def render_findings(findings)
|
|
108
|
+
return label(:none_found) if findings.empty?
|
|
109
|
+
|
|
110
|
+
findings.map do |finding|
|
|
111
|
+
<<~ITEM.rstrip
|
|
112
|
+
- **#{label(:where)}**: #{location(finding)}
|
|
113
|
+
- **#{label(:problem)}**: #{presence(value(finding, 'problem')) || label(:not_specified)}
|
|
114
|
+
- **#{label(:why)}**: #{presence(value(finding, 'why')) || label(:not_specified)}
|
|
115
|
+
- **#{label(:suggestion)}**: #{presence(value(finding, 'suggestion')) || label(:not_specified)}
|
|
116
|
+
ITEM
|
|
117
|
+
end.join("\n\n")
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def location(finding)
|
|
121
|
+
file = presence(value(finding, 'file'))
|
|
122
|
+
line = value(finding, 'line')
|
|
123
|
+
return label(:not_specified) unless file
|
|
124
|
+
return file if line.nil? || line.to_s.empty?
|
|
125
|
+
|
|
126
|
+
"#{file}:#{line}"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def label(key)
|
|
130
|
+
@labels.fetch(key)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def value(hash, key)
|
|
134
|
+
hash[key] || hash[key.to_sym]
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def category(finding)
|
|
138
|
+
value(finding, 'category').to_s.downcase
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def severity(finding)
|
|
142
|
+
value(finding, 'severity').to_s.downcase
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def presence(value)
|
|
146
|
+
string = value.to_s.strip
|
|
147
|
+
string.empty? ? nil : string
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Aireview
|
|
4
|
+
module ReviewSchemas
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def generate
|
|
8
|
+
<<~SCHEMA.strip
|
|
9
|
+
{
|
|
10
|
+
"summary": "1-2 sentences about the essence of the MR changes",
|
|
11
|
+
"candidates": [
|
|
12
|
+
{
|
|
13
|
+
"id": "C1",
|
|
14
|
+
"file": "path/from/diff.rb",
|
|
15
|
+
"line": 42,
|
|
16
|
+
"quoted_code": "changed code fragment",
|
|
17
|
+
"problem": "finding text",
|
|
18
|
+
"why": "why it matters",
|
|
19
|
+
"suggestion": "what to fix or check",
|
|
20
|
+
"category": "bug",
|
|
21
|
+
"severity": "major"
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
}
|
|
25
|
+
SCHEMA
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def critique
|
|
29
|
+
<<~SCHEMA.strip
|
|
30
|
+
{
|
|
31
|
+
"verdicts": [
|
|
32
|
+
{
|
|
33
|
+
"id": "C1",
|
|
34
|
+
"decision": "keep",
|
|
35
|
+
"reason": "why the finding is confirmed",
|
|
36
|
+
"refinement": {
|
|
37
|
+
"problem": "refined finding text",
|
|
38
|
+
"why": "why this is really a problem",
|
|
39
|
+
"suggestion": "what to fix or check",
|
|
40
|
+
"category": "bug",
|
|
41
|
+
"severity": "major"
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"id": "C2",
|
|
46
|
+
"decision": "reject",
|
|
47
|
+
"reason": "why the finding is rejected"
|
|
48
|
+
}
|
|
49
|
+
]
|
|
50
|
+
}
|
|
51
|
+
SCHEMA
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|