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,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require 'base64'
|
|
3
|
+
require 'json'
|
|
4
|
+
require_relative 'utils'
|
|
5
|
+
|
|
6
|
+
module Aireview
|
|
7
|
+
class JiraClient
|
|
8
|
+
ISSUE_KEY = /\b([A-Z][A-Z0-9]+-\d+)\b/
|
|
9
|
+
OPEN_TIMEOUT = 10
|
|
10
|
+
READ_TIMEOUT = 30
|
|
11
|
+
|
|
12
|
+
def self.extract_issue_key(text)
|
|
13
|
+
match = ISSUE_KEY.match(text.to_s)
|
|
14
|
+
match && match[1]
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def initialize(base_url:, login:, password:, logger: Logger.new($stderr))
|
|
18
|
+
require 'faraday'
|
|
19
|
+
|
|
20
|
+
raise ConfigError, 'Jira base URL is required' if Aireview::Utils.blank?(base_url)
|
|
21
|
+
raise ConfigError, 'Jira login is required' if Aireview::Utils.blank?(login)
|
|
22
|
+
raise ConfigError, 'Jira password is required' if Aireview::Utils.blank?(password)
|
|
23
|
+
|
|
24
|
+
@logger = logger
|
|
25
|
+
@connection = Faraday.new(url: base_url) do |builder|
|
|
26
|
+
builder.request :json
|
|
27
|
+
builder.options.open_timeout = OPEN_TIMEOUT
|
|
28
|
+
builder.options.timeout = READ_TIMEOUT
|
|
29
|
+
builder.adapter Faraday.default_adapter
|
|
30
|
+
end
|
|
31
|
+
@authorization = "Basic #{Base64.strict_encode64("#{login}:#{password}")}"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def fetch_issue(key)
|
|
35
|
+
payload = get_json("/rest/api/2/issue/#{key}", fields: 'summary,description,comment')
|
|
36
|
+
|
|
37
|
+
{
|
|
38
|
+
'key' => payload['key'],
|
|
39
|
+
'summary' => payload.dig('fields', 'summary'),
|
|
40
|
+
'description' => plain_text(payload.dig('fields', 'description')),
|
|
41
|
+
'comments' => extract_comments(payload.dig('fields', 'comment', 'comments'))
|
|
42
|
+
}
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def get_json(path, params = {})
|
|
48
|
+
response = @connection.get(path, params, headers)
|
|
49
|
+
body = response.body.to_s
|
|
50
|
+
status = response.status.to_i
|
|
51
|
+
|
|
52
|
+
return JSON.parse(body) if status.between?(200, 299)
|
|
53
|
+
|
|
54
|
+
raise ApiError, "Jira API error #{status}: #{body}"
|
|
55
|
+
rescue Faraday::Error => e
|
|
56
|
+
raise ApiError, "Jira API request failed: #{e.message}"
|
|
57
|
+
rescue JSON::ParserError
|
|
58
|
+
raise ApiError, "Jira API returned invalid JSON: #{body}"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def headers
|
|
62
|
+
{
|
|
63
|
+
'Accept' => 'application/json',
|
|
64
|
+
'Authorization' => @authorization
|
|
65
|
+
}
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def extract_comments(comments)
|
|
69
|
+
Array(comments).last(3).map do |comment|
|
|
70
|
+
author = comment.dig('author', 'displayName') || 'Unknown'
|
|
71
|
+
body = plain_text(comment['body'])
|
|
72
|
+
"#{author}: #{body}".strip
|
|
73
|
+
end.reject(&:empty?)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def plain_text(value)
|
|
77
|
+
case value
|
|
78
|
+
when String
|
|
79
|
+
value
|
|
80
|
+
when Array
|
|
81
|
+
value.map { |item| plain_text(item) }.join("\n")
|
|
82
|
+
when Hash
|
|
83
|
+
hash_to_text(value)
|
|
84
|
+
else
|
|
85
|
+
value.to_s
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def hash_to_text(value)
|
|
90
|
+
if value['type'] == 'text'
|
|
91
|
+
value['text'].to_s
|
|
92
|
+
elsif value.key?('content')
|
|
93
|
+
value['content'].map { |item| plain_text(item) }.reject(&:empty?).join("\n")
|
|
94
|
+
else
|
|
95
|
+
value.values.map { |item| plain_text(item) }.reject(&:empty?).join("\n")
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require 'uri'
|
|
3
|
+
|
|
4
|
+
module Aireview
|
|
5
|
+
class MrParser
|
|
6
|
+
Result = Struct.new(:url, :base_url, :project_path, :project_id, :iid, keyword_init: true)
|
|
7
|
+
|
|
8
|
+
MR_PATH = %r{\A/(?<project>.+)/-/merge_requests/(?<iid>\d+)\z}
|
|
9
|
+
|
|
10
|
+
def self.parse(url)
|
|
11
|
+
uri = URI.parse(url.to_s)
|
|
12
|
+
raise ParseError, 'Merge request URL must include http:// or https://' unless uri.is_a?(URI::HTTP)
|
|
13
|
+
|
|
14
|
+
match = MR_PATH.match(uri.path)
|
|
15
|
+
raise ParseError, "Unsupported merge request URL: #{url}" unless match
|
|
16
|
+
|
|
17
|
+
project_path = match[:project]
|
|
18
|
+
|
|
19
|
+
Result.new(
|
|
20
|
+
url: url,
|
|
21
|
+
base_url: "#{uri.scheme}://#{uri.host}#{":#{uri.port}" if uri.port && ![80, 443].include?(uri.port)}",
|
|
22
|
+
project_path: project_path,
|
|
23
|
+
project_id: URI.encode_www_form_component(project_path),
|
|
24
|
+
iid: match[:iid].to_i
|
|
25
|
+
)
|
|
26
|
+
rescue URI::InvalidURIError => e
|
|
27
|
+
raise ParseError, "Invalid merge request URL: #{e.message}"
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require 'ruby_llm/schema'
|
|
3
|
+
|
|
4
|
+
module Aireview
|
|
5
|
+
module OutputSchemaValues
|
|
6
|
+
CATEGORIES = %w[
|
|
7
|
+
task_mismatch
|
|
8
|
+
bug
|
|
9
|
+
regression
|
|
10
|
+
security
|
|
11
|
+
performance
|
|
12
|
+
data_loss
|
|
13
|
+
edge_case
|
|
14
|
+
test_gap
|
|
15
|
+
maintainability
|
|
16
|
+
].freeze
|
|
17
|
+
SEVERITIES = %w[critical major minor].freeze
|
|
18
|
+
DECISIONS = %w[keep reject].freeze
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
class GenerateOutputSchema < RubyLLM::Schema
|
|
22
|
+
string :summary
|
|
23
|
+
array :candidates, max_items: 3 do
|
|
24
|
+
object do
|
|
25
|
+
string :id
|
|
26
|
+
string :file
|
|
27
|
+
any_of :line do
|
|
28
|
+
integer
|
|
29
|
+
null
|
|
30
|
+
end
|
|
31
|
+
string :quoted_code
|
|
32
|
+
string :problem
|
|
33
|
+
string :why
|
|
34
|
+
string :suggestion
|
|
35
|
+
string :category, enum: OutputSchemaValues::CATEGORIES
|
|
36
|
+
string :severity, enum: OutputSchemaValues::SEVERITIES
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
class CritiqueOutputSchema < RubyLLM::Schema
|
|
42
|
+
array :verdicts do
|
|
43
|
+
object do
|
|
44
|
+
string :id
|
|
45
|
+
string :decision, enum: OutputSchemaValues::DECISIONS
|
|
46
|
+
string :reason
|
|
47
|
+
object :refinement, required: false do
|
|
48
|
+
string :problem
|
|
49
|
+
string :why
|
|
50
|
+
string :suggestion
|
|
51
|
+
string :category, enum: OutputSchemaValues::CATEGORIES
|
|
52
|
+
string :severity, enum: OutputSchemaValues::SEVERITIES
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
You are doing the second pass of a merge request review.
|
|
2
|
+
|
|
3
|
+
You are given the diff, the MR description, the Jira context and the candidates
|
|
4
|
+
from the first pass. For every candidate return a verdict with decision=keep
|
|
5
|
+
or reject.
|
|
6
|
+
|
|
7
|
+
Be a strict filter:
|
|
8
|
+
- when in doubt, choose reject;
|
|
9
|
+
- keep only the most important and well-supported findings;
|
|
10
|
+
|
|
11
|
+
Keep a candidate only if the problem is directly confirmed by the diff, Jira or
|
|
12
|
+
the changed control/data flow and can lead to a real defect, regression,
|
|
13
|
+
security/performance issue or a mismatch with the task.
|
|
14
|
+
|
|
15
|
+
For category=task_mismatch apply a separate criterion: keep the candidate only
|
|
16
|
+
if the diff contains a concrete artifact: debug code (puts, p, binding.pry,
|
|
17
|
+
byebug, console.log, debugger), commented-out blocks, unused
|
|
18
|
+
debug/test/helper methods, temporary TODO/HACK/FIXME/DEBUG markers from this
|
|
19
|
+
diff, disabled tests (skip, xit, pending) without an explanation, or a change
|
|
20
|
+
that directly contradicts the MR/Jira.
|
|
21
|
+
|
|
22
|
+
Implementation details (config flags, deploy settings, new fields, helper
|
|
23
|
+
methods), accompanying refactoring and any changes whose relation to the task
|
|
24
|
+
is plausible are NOT task_mismatch. Reject such candidates.
|
|
25
|
+
|
|
26
|
+
Reject a candidate if it:
|
|
27
|
+
- is not confirmed by the diff or Jira;
|
|
28
|
+
- is based on an assumption about code outside the diff;
|
|
29
|
+
- contradicts the diff;
|
|
30
|
+
- duplicates another candidate;
|
|
31
|
+
- is not actionable;
|
|
32
|
+
- boils down to "may be redundant", "may conflict", "may be unnecessary"
|
|
33
|
+
or "worth checking" without a clear sign of breakage. This rule applies
|
|
34
|
+
to all categories, including task_mismatch.
|
|
35
|
+
|
|
36
|
+
If several candidates describe the same problem, keep only the strongest one
|
|
37
|
+
and reject the rest as duplicates.
|
|
38
|
+
|
|
39
|
+
Do not add new findings. Do not change id.
|
|
40
|
+
Do not change file, line, quoted_code. They do not need to be reinvented.
|
|
41
|
+
Use refinement only when it makes a keep finding more precise.
|
|
42
|
+
|
|
43
|
+
The answer must be a valid JSON object only, without markdown and without any
|
|
44
|
+
text outside the JSON.
|
|
45
|
+
|
|
46
|
+
All free-text fields must be written in the language given in the
|
|
47
|
+
"Response language" instruction. Do not mix languages.
|
|
48
|
+
|
|
49
|
+
Rules:
|
|
50
|
+
- verdicts: a verdict for every id from the input list.
|
|
51
|
+
- decision: keep or reject.
|
|
52
|
+
- reason: a short explanation of the decision.
|
|
53
|
+
- refinement: optional, only for keep.
|
|
54
|
+
- refinement.category: one of task_mismatch, bug, regression, security, performance, data_loss, edge_case, test_gap, maintainability.
|
|
55
|
+
- refinement.severity: one of critical, major, minor.
|
|
56
|
+
|
|
57
|
+
If there are no confirmed findings, return reject for every id.
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
You are doing the first pass of a merge request review.
|
|
2
|
+
|
|
3
|
+
Look only at the diff, the MR description and the Jira context, if present.
|
|
4
|
+
Do not draw conclusions about code outside the diff.
|
|
5
|
+
|
|
6
|
+
Look only for the most important and well-supported findings:
|
|
7
|
+
- bugs, regressions, data loss;
|
|
8
|
+
- security and performance risks;
|
|
9
|
+
- task_mismatch, only when one of the artifacts listed below is present;
|
|
10
|
+
- a missing test only when the diff changes risky logic.
|
|
11
|
+
|
|
12
|
+
Return task_mismatch only when the diff contains one of these concrete
|
|
13
|
+
artifacts:
|
|
14
|
+
- debug code in production: puts, p, binding.pry, byebug, console.log,
|
|
15
|
+
debugger;
|
|
16
|
+
- commented-out blocks of code without an explanation;
|
|
17
|
+
- unused debug/test/helper methods, for example
|
|
18
|
+
`def test; puts 'debug'; end`;
|
|
19
|
+
- temporary TODO/HACK/FIXME/DEBUG markers added in this diff;
|
|
20
|
+
- disabled tests (skip, xit, pending) without an explanation;
|
|
21
|
+
- a change that directly contradicts the MR description or Jira.
|
|
22
|
+
|
|
23
|
+
task_mismatch is NOT:
|
|
24
|
+
- implementation details that Jira does not name verbatim
|
|
25
|
+
(config flags, deploy settings, new fields, helper methods);
|
|
26
|
+
- accompanying refactoring or configuration that supports the main feature;
|
|
27
|
+
- changes whose relation to the task is plausible, even if not proven.
|
|
28
|
+
|
|
29
|
+
Jira describes product requirements, not names in the code. If you are not
|
|
30
|
+
sure the change is really unrelated, do not return task_mismatch.
|
|
31
|
+
|
|
32
|
+
If several changes relate to the same problem, return one finding rather than
|
|
33
|
+
several similar ones.
|
|
34
|
+
|
|
35
|
+
Return at most 3 candidates. It is better to return 0-2 strong candidates than
|
|
36
|
+
a long list of weak ones.
|
|
37
|
+
|
|
38
|
+
The answer must be a valid JSON object only, without markdown and without any
|
|
39
|
+
text outside the JSON.
|
|
40
|
+
|
|
41
|
+
All free-text fields must be written in the language given in the
|
|
42
|
+
"Response language" instruction. Do not mix languages.
|
|
43
|
+
|
|
44
|
+
Rules:
|
|
45
|
+
- summary: a brief summary of the MR changes.
|
|
46
|
+
- candidates: at most 3 of the most important findings.
|
|
47
|
+
- id: C1, C2, C3 in order.
|
|
48
|
+
- file: file path from the diff.
|
|
49
|
+
- line: line of the new file when it can be determined, otherwise null.
|
|
50
|
+
- quoted_code: an exact short quote from the changed code.
|
|
51
|
+
- category: one of task_mismatch, bug, regression, security, performance, data_loss, edge_case, test_gap, maintainability.
|
|
52
|
+
- severity: one of critical, major, minor.
|
|
53
|
+
- problem, why, suggestion: only a concrete defect or a concrete verifiable breakage.
|
|
54
|
+
For category=task_mismatch, a concrete reference to a debug/leftover
|
|
55
|
+
artifact from the list above or an explicit contradiction with the task.
|
|
56
|
+
|
|
57
|
+
If there are no strong findings, return the summary and an empty candidates
|
|
58
|
+
array.
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require_relative 'review_marker'
|
|
3
|
+
|
|
4
|
+
module Aireview
|
|
5
|
+
class Publisher
|
|
6
|
+
PREFIX = '**aireview review**'
|
|
7
|
+
|
|
8
|
+
def initialize(gitlab_client:, logger: Logger.new($stderr))
|
|
9
|
+
@gitlab_client = gitlab_client
|
|
10
|
+
@logger = logger
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Собственная заметка с ревью: {id:, key:} или nil. Метки недостаточно —
|
|
14
|
+
# её может процитировать кто угодно, поэтому сверяем ещё и автора. Старый
|
|
15
|
+
# формат без метки подхватываем только если заметки с меткой нет.
|
|
16
|
+
def existing_review(project_id:, iid:)
|
|
17
|
+
author_id = current_user_id
|
|
18
|
+
legacy = nil
|
|
19
|
+
|
|
20
|
+
@gitlab_client.fetch_merge_request_notes(project_id, iid).each do |note|
|
|
21
|
+
next if note['system']
|
|
22
|
+
next if note.dig('author', 'id') != author_id
|
|
23
|
+
|
|
24
|
+
key = ReviewMarker.extract(note['body'])
|
|
25
|
+
return {id: note['id'], key: key} if key
|
|
26
|
+
|
|
27
|
+
legacy ||= {id: note['id'], key: nil} if note['body'].to_s.start_with?(PREFIX)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
legacy
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def publish(project_id:, iid:, review_body:, key: nil, existing: nil)
|
|
34
|
+
body = compose(review_body, key)
|
|
35
|
+
|
|
36
|
+
if existing
|
|
37
|
+
@logger.info("Updating review note #{existing[:id]}")
|
|
38
|
+
@gitlab_client.update_merge_request_note(project_id, iid, existing[:id], body)
|
|
39
|
+
else
|
|
40
|
+
@gitlab_client.post_merge_request_note(project_id, iid, body)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def compose(review_body, key)
|
|
47
|
+
return "#{PREFIX}\n\n#{review_body}" if Aireview::Utils.blank?(key)
|
|
48
|
+
|
|
49
|
+
"#{ReviewMarker.build(key)}\n#{PREFIX}\n\n#{review_body}"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Без надёжного автора матчинг по одной метке небезопасен: процитировать её
|
|
53
|
+
# может кто угодно, и тогда чужая заметка либо отменит ревью, либо будет
|
|
54
|
+
# перезаписана. Поэтому ошибку не глушим.
|
|
55
|
+
def current_user_id
|
|
56
|
+
return @current_user_id if defined?(@current_user_id)
|
|
57
|
+
|
|
58
|
+
id = @gitlab_client.fetch_current_user['id']
|
|
59
|
+
raise ApiError, 'GitLab did not return the current user id' if Aireview::Utils.blank?(id)
|
|
60
|
+
|
|
61
|
+
@current_user_id = id
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require 'digest'
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Aireview
|
|
6
|
+
# Скрытая метка в теле заметки: по ней ревью находит собственный комментарий
|
|
7
|
+
# и понимает, менялось ли с прошлого раза то, что влияет на результат.
|
|
8
|
+
module ReviewMarker
|
|
9
|
+
PATTERN = /<!--\s*aireview:key=([0-9a-f]+)\s*-->/
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def build(key)
|
|
14
|
+
"<!-- aireview:key=#{key} -->"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def extract(body)
|
|
18
|
+
match = PATTERN.match(body.to_s)
|
|
19
|
+
match && match[1]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Ключ считается от готовых промптов, а не от одного SHA: так в него сами
|
|
23
|
+
# собой попадают дифф, описание MR, контекст Jira, инструкции ревью и
|
|
24
|
+
# ignore_paths. Модели и провайдеры добавляются рядом — на промпт они не
|
|
25
|
+
# влияют, но на результат влияют.
|
|
26
|
+
def key(prompts:, config:)
|
|
27
|
+
source = {
|
|
28
|
+
'generate' => [
|
|
29
|
+
config.generate_provider,
|
|
30
|
+
prompts[:generate_model],
|
|
31
|
+
prompts[:generate_temperature],
|
|
32
|
+
prompts[:generate_prompt]
|
|
33
|
+
],
|
|
34
|
+
'critique' => critique_source(prompts, config)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
Digest::SHA256.hexdigest(JSON.generate(source))[0, 16]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# То, что делает результат ревью устаревшим: новый коммит, смена целевой
|
|
41
|
+
# ветки, перебазирование со сдвигом базы сравнения, а также правка
|
|
42
|
+
# заголовка или описания — из них берутся требования, с которыми ревью
|
|
43
|
+
# сверяет код.
|
|
44
|
+
def state(merge_request)
|
|
45
|
+
{
|
|
46
|
+
'sha' => merge_request['sha'],
|
|
47
|
+
'target_branch' => merge_request['target_branch'],
|
|
48
|
+
'diff_refs' => merge_request['diff_refs'],
|
|
49
|
+
'title' => merge_request['title'],
|
|
50
|
+
'description' => merge_request['description']
|
|
51
|
+
}
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def critique_source(prompts, config)
|
|
55
|
+
return nil unless prompts[:critique_prompt]
|
|
56
|
+
|
|
57
|
+
[
|
|
58
|
+
config.critique_provider,
|
|
59
|
+
prompts[:critique_model],
|
|
60
|
+
prompts[:critique_temperature],
|
|
61
|
+
prompts[:critique_prompt]
|
|
62
|
+
]
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|