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,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,8 @@
1
+ # frozen_string_literal: true
2
+ module Aireview
3
+ class Error < StandardError; end
4
+ class ConfigError < Error; end
5
+ class ParseError < Error; end
6
+ class ApiError < Error; end
7
+ class HelpRequested < Error; end
8
+ 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