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,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