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,280 @@
1
+ # frozen_string_literal: true
2
+ require_relative 'errors'
3
+ require_relative 'output_schemas'
4
+ require_relative 'utils'
5
+ require 'timeout'
6
+
7
+ module Aireview
8
+ class Reviewer
9
+ MAX_TRANSIENT_RETRIES = 2
10
+ TRANSIENT_RETRY_BASE_DELAY = 2.0
11
+ TRANSIENT_RETRY_JITTER_RANGE = 2.0..5.0
12
+ PROVIDER_RETRY_DELAY_MULTIPLIER_RANGE = 2.0..2.4
13
+ OVERLOADED_RETRY_DELAY_RANGE = 90.0..150.0
14
+ RETRY_WAIT_LOG_FORMAT = 'LLM %<stage>s request will sleep %<delay>.1fs before retry%<source>s ' \
15
+ '(attempt %<next_attempt>d/%<max_attempts>d, model=%<model>s)'
16
+
17
+ def initialize(config:, logger: Logger.new($stderr))
18
+ @config = config
19
+ @logger = logger
20
+ @llm_contexts = {}
21
+ end
22
+
23
+ def generate(system_prompt:, user_prompt:)
24
+ call_llm(
25
+ stage: 'generate',
26
+ system: system_prompt,
27
+ user: user_prompt,
28
+ options: {
29
+ model: @config.generate_model,
30
+ temperature: @config.generate_temperature,
31
+ provider: @config.generate_provider,
32
+ schema: GenerateOutputSchema
33
+ }
34
+ )
35
+ end
36
+
37
+ def critique(system_prompt:, user_prompt:)
38
+ call_llm(
39
+ stage: 'critique',
40
+ system: system_prompt,
41
+ user: user_prompt,
42
+ options: {
43
+ model: @config.critique_model,
44
+ temperature: @config.critique_temperature,
45
+ provider: @config.critique_provider,
46
+ schema: CritiqueOutputSchema
47
+ }
48
+ )
49
+ end
50
+
51
+ private
52
+
53
+ def call_llm(stage:, system:, user:, options:)
54
+ require 'ruby_llm'
55
+
56
+ @config.require_llm_configuration!
57
+ context = llm_context(stage, options[:provider])
58
+ response = request_with_retries(stage: stage, model: options[:model]) do
59
+ perform_llm_request(
60
+ context: context,
61
+ stage: stage,
62
+ system: system,
63
+ user: user,
64
+ options: options
65
+ )
66
+ end
67
+ response.content
68
+ rescue LoadError => e
69
+ @logger.error("LLM #{stage} setup failed: #{e.message}")
70
+ raise ConfigError, "Missing dependency: #{e.message}"
71
+ rescue Timeout::Error
72
+ @logger.warn(
73
+ "LLM #{stage} request timed out after #{@config.llm_timeout} seconds (model=#{options[:model]})"
74
+ )
75
+ raise ApiError, "LLM request timed out after #{@config.llm_timeout} seconds. " \
76
+ "Try again later or switch model via --generate-model/--critique-model."
77
+ end
78
+
79
+ def perform_llm_request(context:, stage:, system:, user:, options:)
80
+ model = options[:model]
81
+ temperature = options[:temperature]
82
+ @logger.info("LLM #{stage} request started (model=#{model}, temperature=#{temperature})")
83
+ chat = build_chat(context: context, stage: stage, model: model, provider: options[:provider])
84
+ chat = configure_reasoning(chat: chat, model: model, provider: options[:provider])
85
+ .with_temperature(temperature.to_f)
86
+ .with_schema(options[:schema])
87
+ chat.with_instructions(system)
88
+ response = Timeout.timeout(@config.llm_timeout.to_f) { chat.ask(user) }
89
+ @logger.info("LLM #{stage} request completed (model=#{model})")
90
+ response
91
+ end
92
+
93
+ def configure_reasoning(chat:, model:, provider:)
94
+ return chat unless provider == 'ollama' && model.start_with?('gpt-oss:')
95
+
96
+ chat.with_thinking(effort: :low)
97
+ end
98
+
99
+ def build_chat(context:, stage:, model:, provider:)
100
+ context.chat(model: model, provider: provider.to_sym)
101
+ rescue RubyLLM::ModelNotFoundError
102
+ @logger.warn(
103
+ "LLM #{stage}: model not found in RubyLLM registry; " \
104
+ "using fallback with incomplete model metadata " \
105
+ "(model=#{model}, provider=#{provider})"
106
+ )
107
+ context.chat(model: model, provider: provider.to_sym, assume_model_exists: true)
108
+ end
109
+
110
+ def request_with_retries(stage:, model:)
111
+ attempt = 0
112
+
113
+ begin
114
+ attempt += 1
115
+ yield
116
+ rescue StandardError => e
117
+ raise unless ruby_llm_api_error?(e)
118
+
119
+ @logger.warn("LLM #{stage} request failed (model=#{model}): #{e.class}: #{e.message}")
120
+ raise ApiError, llm_error_message(e) unless retry_llm_request?(e, attempt)
121
+
122
+ wait_before_retry(error: e, attempt: attempt, stage: stage, model: model)
123
+ retry
124
+ end
125
+ end
126
+
127
+ def retry_llm_request?(error, attempt)
128
+ transient_llm_error?(error) && attempt <= MAX_TRANSIENT_RETRIES
129
+ end
130
+
131
+ def wait_before_retry(error:, attempt:, stage:, model:)
132
+ retry_delay = transient_retry_delay(error: error, attempt: attempt)
133
+ @logger.warn(
134
+ format(
135
+ RETRY_WAIT_LOG_FORMAT,
136
+ stage: stage,
137
+ delay: retry_delay[:delay],
138
+ source: retry_delay_source(retry_delay),
139
+ next_attempt: attempt + 1,
140
+ max_attempts: MAX_TRANSIENT_RETRIES + 1,
141
+ model: model
142
+ )
143
+ )
144
+ sleep_started_at = monotonic_time
145
+ sleep(retry_delay[:delay])
146
+ log_retry_wait_completed(stage, model, monotonic_time - sleep_started_at)
147
+ end
148
+
149
+ def log_retry_wait_completed(stage, model, waited)
150
+ @logger.info(
151
+ format(
152
+ 'LLM %<stage>s retry wait completed after %<waited>.1fs (model=%<model>s)',
153
+ stage: stage,
154
+ waited: waited,
155
+ model: model
156
+ )
157
+ )
158
+ end
159
+
160
+ def ruby_llm_api_error?(error)
161
+ defined?(RubyLLM::Error) && error.is_a?(RubyLLM::Error)
162
+ end
163
+
164
+ def transient_llm_error?(error)
165
+ error.is_a?(RubyLLM::RateLimitError) ||
166
+ error.is_a?(RubyLLM::ServiceUnavailableError) ||
167
+ error.is_a?(RubyLLM::OverloadedError)
168
+ end
169
+
170
+ def monotonic_time
171
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
172
+ end
173
+
174
+ def transient_retry_delay(error:, attempt:)
175
+ provider_delay = retry_after_seconds(error.message)
176
+ if provider_delay
177
+ multiplier = rand(PROVIDER_RETRY_DELAY_MULTIPLIER_RANGE)
178
+ return {
179
+ delay: provider_delay * multiplier,
180
+ provider_delay: provider_delay,
181
+ multiplier: multiplier,
182
+ strategy: :provider_hint
183
+ }
184
+ end
185
+
186
+ if overloaded_llm_error?(error)
187
+ return {
188
+ delay: rand(OVERLOADED_RETRY_DELAY_RANGE),
189
+ provider_delay: nil,
190
+ multiplier: nil,
191
+ strategy: :overloaded
192
+ }
193
+ end
194
+
195
+ base_delay = TRANSIENT_RETRY_BASE_DELAY * (2**(attempt - 1))
196
+ {
197
+ delay: base_delay + rand(TRANSIENT_RETRY_JITTER_RANGE),
198
+ provider_delay: nil,
199
+ multiplier: nil,
200
+ strategy: :fallback
201
+ }
202
+ end
203
+
204
+ def retry_delay_source(retry_delay)
205
+ return ' (overloaded backoff)' if retry_delay[:strategy] == :overloaded
206
+ return '' unless retry_delay[:provider_delay]
207
+
208
+ format(
209
+ ' (provider retry hint %<provider_delay>.1fs, multiplier %<multiplier>.2fx)',
210
+ provider_delay: retry_delay[:provider_delay],
211
+ multiplier: retry_delay[:multiplier]
212
+ )
213
+ end
214
+
215
+ def overloaded_llm_error?(error)
216
+ error.is_a?(RubyLLM::ServiceUnavailableError) ||
217
+ error.is_a?(RubyLLM::OverloadedError)
218
+ end
219
+
220
+ def retry_after_seconds(message)
221
+ match = message.to_s.match(/retry\s+(?:in|after)\s+(\d+(?:\.\d+)?)\s*(?:s|sec|secs|second|seconds)\b/i)
222
+ match[1].to_f if match
223
+ end
224
+
225
+ def llm_error_message(error)
226
+ case error
227
+ when RubyLLM::ServiceUnavailableError, RubyLLM::OverloadedError
228
+ "LLM service is temporarily unavailable or overloaded: #{error.message}. " \
229
+ "Try again later or switch model via --generate-model/--critique-model."
230
+ when RubyLLM::RateLimitError
231
+ "LLM rate limit exceeded: #{error.message}. " \
232
+ "Try again later or switch model via --generate-model/--critique-model."
233
+ when RubyLLM::ContextLengthExceededError
234
+ "LLM context limit exceeded: #{error.message}. Try reducing the MR diff or ignore more paths."
235
+ else
236
+ "LLM API request failed: #{error.message}"
237
+ end
238
+ end
239
+
240
+ def llm_context(stage, provider)
241
+ @llm_contexts[stage] ||= build_llm_context(provider.to_s)
242
+ end
243
+
244
+ def build_llm_context(provider)
245
+ api_key = @config.provider_api_key(provider) unless provider == 'ollama'
246
+
247
+ RubyLLM.context do |ruby_config|
248
+ configure_http_proxy(ruby_config)
249
+ ruby_config.request_timeout = @config.llm_timeout.to_f
250
+ configure_provider(ruby_config, provider, api_key)
251
+ end
252
+ end
253
+
254
+ def configure_http_proxy(ruby_config)
255
+ return unless Aireview::Utils.present?(@config.llm_http_proxy)
256
+
257
+ ruby_config.http_proxy = @config.llm_http_proxy
258
+ end
259
+
260
+ def configure_provider(ruby_config, provider, api_key)
261
+ case provider
262
+ when 'gemini', 'openai', 'openrouter'
263
+ configure_remote_provider(ruby_config, provider, api_key)
264
+ when 'anthropic'
265
+ ruby_config.anthropic_api_key = api_key
266
+ when 'ollama'
267
+ ruby_config.ollama_api_base = @config.ollama_api_base
268
+ else
269
+ raise ConfigError, "Unsupported LLM provider: #{provider.inspect}"
270
+ end
271
+ end
272
+
273
+ def configure_remote_provider(ruby_config, provider, api_key)
274
+ ruby_config.public_send("#{provider}_api_key=", api_key)
275
+ return unless Aireview::Utils.present?(@config.llm_api_base)
276
+
277
+ ruby_config.public_send("#{provider}_api_base=", @config.llm_api_base)
278
+ end
279
+ end
280
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+ require_relative 'utils'
3
+
4
+ module Aireview
5
+ class SecretScrubber
6
+ REDACTED = '[REDACTED]'
7
+
8
+ BUILTIN_RULES = [
9
+ [/-----BEGIN [A-Z ]*PRIVATE KEY-----.*?-----END [A-Z ]*PRIVATE KEY-----/m, REDACTED],
10
+ [/\bAKIA[0-9A-Z]{16}\b/, REDACTED],
11
+ [/\bsk-(?:proj-|ant-)?[A-Za-z0-9_-]{20,}\b/, REDACTED],
12
+ [/\b(?:gh[ps]|glpat)_[A-Za-z0-9_-]{20,}\b/, REDACTED],
13
+ [/(Authorization:\s*Bearer\s+)[A-Za-z0-9._-]+/i, '\1[REDACTED]'],
14
+ [/(Bearer\s+)[A-Za-z0-9._-]{20,}/i, '\1[REDACTED]'],
15
+ [/(\baws_secret_access_key\b\s*[:=]\s*["']?)[A-Za-z0-9\/+=]{20,}(["']?)/i, '\1[REDACTED]\2'],
16
+ [/(\b(?:api[_-]?key|token|secret|password|passwd|client_secret)\w*\s*[:=]\s*["']?)[^"'\s]+(["']?)/i,
17
+ '\1[REDACTED]\2'],
18
+ [/(x-api-key:\s*)[A-Za-z0-9._-]+/i, '\1[REDACTED]']
19
+ ].freeze
20
+
21
+ def initialize(secret_patterns:, secret_files:, logger: Logger.new($stderr))
22
+ @logger = logger
23
+ @secret_files = Array(secret_files).compact
24
+ @custom_rules = compile_patterns(secret_patterns)
25
+ end
26
+
27
+ def scrub_changes(changes)
28
+ Array(changes).map { |change| scrub_change(change) }
29
+ end
30
+
31
+ def scrub_change(change)
32
+ path = change['new_path'] || change['old_path']
33
+ scrubbed = change.dup
34
+
35
+ scrubbed['diff'] =
36
+ if secret_file?(path)
37
+ "[REDACTED: secret file #{path}]"
38
+ else
39
+ scrub_text(change['diff'].to_s)
40
+ end
41
+
42
+ scrubbed
43
+ end
44
+
45
+ def scrub_text(text)
46
+ (BUILTIN_RULES + @custom_rules).reduce(text.to_s) do |memo, (pattern, replacement)|
47
+ memo.gsub(pattern, replacement)
48
+ end
49
+ end
50
+
51
+ private
52
+
53
+ def compile_patterns(patterns)
54
+ Array(patterns).compact.filter_map do |pattern|
55
+ [Regexp.new(pattern, Regexp::IGNORECASE), REDACTED]
56
+ rescue RegexpError => e
57
+ @logger.warn("Skipping invalid secret pattern #{pattern.inspect}: #{e.message}")
58
+ nil
59
+ end
60
+ end
61
+
62
+ def secret_file?(path)
63
+ return false if Aireview::Utils.blank?(path)
64
+
65
+ @secret_files.any? do |pattern|
66
+ File.fnmatch?(pattern, path, File::FNM_DOTMATCH | File::FNM_EXTGLOB)
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+ module Aireview
3
+ module Utils
4
+ module_function
5
+
6
+ def blank?(value)
7
+ value.nil? || value.to_s.strip.empty?
8
+ end
9
+
10
+ def present?(value)
11
+ !blank?(value)
12
+ end
13
+
14
+ def presence(value)
15
+ present?(value) ? value.to_s.strip : nil
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+ module Aireview
3
+ VERSION = '0.1.0'
4
+ end
data/lib/aireview.rb ADDED
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+ require 'json'
3
+ require 'logger'
4
+ require 'optparse'
5
+ require 'pathname'
6
+ require 'uri'
7
+ require 'yaml'
8
+
9
+ require_relative 'aireview/version'
10
+ require_relative 'aireview/errors'
11
+ require_relative 'aireview/utils'
12
+ require_relative 'aireview/config'
13
+ require_relative 'aireview/mr_parser'
14
+ require_relative 'aireview/diff_fetcher'
15
+ require_relative 'aireview/secret_scrubber'
16
+ require_relative 'aireview/context_builder'
17
+ require_relative 'aireview/reviewer'
18
+ require_relative 'aireview/review_pipeline'
19
+ require_relative 'aireview/gitlab_client'
20
+ require_relative 'aireview/jira_client'
21
+ require_relative 'aireview/review_marker'
22
+ require_relative 'aireview/publisher'
23
+ require_relative 'aireview/cli'
metadata ADDED
@@ -0,0 +1,119 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: aireview
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Denis Levenko
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-09-11 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: dotenv
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '3.1'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '3.1'
27
+ - !ruby/object:Gem::Dependency
28
+ name: faraday
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '2.14'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '2.14'
41
+ - !ruby/object:Gem::Dependency
42
+ name: ruby_llm
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - '='
46
+ - !ruby/object:Gem::Version
47
+ version: 1.16.0
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - '='
53
+ - !ruby/object:Gem::Version
54
+ version: 1.16.0
55
+ description: 'Reviews self-hosted GitLab merge requests with a two-pass LLM pipeline:
56
+ the first pass finds candidate findings, the second one critiques them and drops
57
+ the weak ones. Supports Gemini and local Ollama, optional Jira context and posting
58
+ a single updatable review note back to the merge request.'
59
+ email:
60
+ executables:
61
+ - aireview
62
+ extensions: []
63
+ extra_rdoc_files: []
64
+ files:
65
+ - CHANGELOG.md
66
+ - LICENSE
67
+ - README.md
68
+ - bin/aireview
69
+ - config/.aireview.yml.example
70
+ - lib/aireview.rb
71
+ - lib/aireview/cli.rb
72
+ - lib/aireview/config.rb
73
+ - lib/aireview/context_builder.rb
74
+ - lib/aireview/diff_fetcher.rb
75
+ - lib/aireview/errors.rb
76
+ - lib/aireview/gitlab_client.rb
77
+ - lib/aireview/jira_client.rb
78
+ - lib/aireview/mr_parser.rb
79
+ - lib/aireview/output_schemas.rb
80
+ - lib/aireview/prompts/critique.txt
81
+ - lib/aireview/prompts/generate.txt
82
+ - lib/aireview/publisher.rb
83
+ - lib/aireview/review_marker.rb
84
+ - lib/aireview/review_pipeline.rb
85
+ - lib/aireview/review_renderer.rb
86
+ - lib/aireview/review_schemas.rb
87
+ - lib/aireview/reviewer.rb
88
+ - lib/aireview/secret_scrubber.rb
89
+ - lib/aireview/utils.rb
90
+ - lib/aireview/version.rb
91
+ homepage: https://github.com/DenisDenis9331/aireview
92
+ licenses:
93
+ - MIT
94
+ metadata:
95
+ homepage_uri: https://github.com/DenisDenis9331/aireview
96
+ source_code_uri: https://github.com/DenisDenis9331/aireview
97
+ changelog_uri: https://github.com/DenisDenis9331/aireview/blob/main/CHANGELOG.md
98
+ bug_tracker_uri: https://github.com/DenisDenis9331/aireview/issues
99
+ rubygems_mfa_required: 'true'
100
+ post_install_message:
101
+ rdoc_options: []
102
+ require_paths:
103
+ - lib
104
+ required_ruby_version: !ruby/object:Gem::Requirement
105
+ requirements:
106
+ - - ">="
107
+ - !ruby/object:Gem::Version
108
+ version: 3.1.3
109
+ required_rubygems_version: !ruby/object:Gem::Requirement
110
+ requirements:
111
+ - - ">="
112
+ - !ruby/object:Gem::Version
113
+ version: '0'
114
+ requirements: []
115
+ rubygems_version: 3.3.26
116
+ signing_key:
117
+ specification_version: 4
118
+ summary: Local GitLab merge request review CLI powered by LLMs
119
+ test_files: []