commitgpt 0.3.1 → 0.3.4

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.
@@ -1,33 +1,36 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "httparty"
4
- require "net/http"
5
- require "uri"
6
- require "json"
7
- require "io/console"
8
- require "tty-prompt"
9
- require_relative "string"
10
- require_relative "config_manager"
3
+ require 'httparty'
4
+ require 'net/http'
5
+ require 'uri'
6
+ require 'json'
7
+ require 'io/console'
8
+ require 'tty-prompt'
9
+ require_relative 'string'
10
+ require_relative 'config_manager'
11
+ require_relative 'diff_helpers'
11
12
 
12
13
  # CommitGpt based on GPT-3
13
14
  module CommitGpt
14
15
  # Commit AI roboter based on GPT-3
15
16
  class CommitAi
17
+ include DiffHelpers
18
+
16
19
  attr_reader :api_key, :base_url, :model, :diff_len
17
20
 
18
21
  def initialize
19
22
  provider_config = ConfigManager.get_active_provider_config
20
-
23
+
21
24
  if provider_config
22
- @api_key = provider_config["api_key"]
23
- @base_url = provider_config["base_url"]
24
- @model = provider_config["model"]
25
- @diff_len = provider_config["diff_len"] || 32768
25
+ @api_key = provider_config['api_key']
26
+ @base_url = provider_config['base_url']
27
+ @model = provider_config['model']
28
+ @diff_len = provider_config['diff_len'] || 32_768
26
29
  else
27
30
  @api_key = nil
28
31
  @base_url = nil
29
32
  @model = nil
30
- @diff_len = 32768
33
+ @diff_len = 32_768
31
34
  end
32
35
  end
33
36
 
@@ -47,7 +50,7 @@ module CommitGpt
47
50
  case action
48
51
  when :commit
49
52
  commit_command = "git commit -m \"#{ai_commit_message}\""
50
- puts "\n▲ Executing: #{commit_command}".orange
53
+ puts "\n▲ Executing: #{commit_command}".yellow
51
54
  system(commit_command)
52
55
  puts "\n\n"
53
56
  puts `git log -1`
@@ -57,18 +60,18 @@ module CommitGpt
57
60
  next
58
61
  when :edit
59
62
  prompt = TTY::Prompt.new
60
- new_message = prompt.ask("Enter your commit message:")
63
+ new_message = prompt.ask('Enter your commit message:')
61
64
  if new_message && !new_message.strip.empty?
62
65
  commit_command = "git commit -m \"#{new_message}\""
63
66
  system(commit_command)
64
67
  puts "\n"
65
68
  puts `git log -1`
66
69
  else
67
- puts "▲ Commit aborted (empty message).".red
70
+ puts '▲ Commit aborted (empty message).'.red
68
71
  end
69
72
  break
70
73
  when :exit
71
- puts "▲ Exit without commit.".yellow
74
+ puts '▲ Exit without commit.'.yellow
72
75
  break
73
76
  end
74
77
  end
@@ -76,15 +79,15 @@ module CommitGpt
76
79
 
77
80
  def list_models
78
81
  headers = {
79
- "Content-Type" => "application/json",
80
- "User-Agent" => "Ruby/#{RUBY_VERSION}"
82
+ 'Content-Type' => 'application/json',
83
+ 'User-Agent' => "Ruby/#{RUBY_VERSION}"
81
84
  }
82
- headers["Authorization"] = "Bearer #{@api_key}" if @api_key
85
+ headers['Authorization'] = "Bearer #{@api_key}" if @api_key
83
86
 
84
87
  begin
85
88
  response = HTTParty.get("#{@base_url}/models", headers: headers)
86
- models = response["data"] || []
87
- models.each { |m| puts m["id"] }
89
+ models = response['data'] || []
90
+ models.each { |m| puts m['id'] }
88
91
  rescue StandardError => e
89
92
  puts "▲ Failed to list models: #{e.message}".red
90
93
  end
@@ -92,15 +95,15 @@ module CommitGpt
92
95
 
93
96
  def list_models
94
97
  headers = {
95
- "Content-Type" => "application/json",
96
- "User-Agent" => "Ruby/#{RUBY_VERSION}"
98
+ 'Content-Type' => 'application/json',
99
+ 'User-Agent' => "Ruby/#{RUBY_VERSION}"
97
100
  }
98
- headers["Authorization"] = "Bearer #{AICM_KEY}" if AICM_KEY
101
+ headers['Authorization'] = "Bearer #{AICM_KEY}" if AICM_KEY
99
102
 
100
103
  begin
101
104
  response = HTTParty.get("#{AICM_LINK}/models", headers: headers)
102
- models = response["data"] || []
103
- models.each { |m| puts m["id"] }
105
+ models = response['data'] || []
106
+ models.each { |m| puts m['id'] }
104
107
  rescue StandardError => e
105
108
  puts "▲ Failed to list models: #{e.message}".red
106
109
  end
@@ -108,15 +111,15 @@ module CommitGpt
108
111
 
109
112
  private
110
113
 
111
- def confirm_commit(message)
114
+ def confirm_commit(_message)
112
115
  prompt = TTY::Prompt.new
113
-
116
+
114
117
  begin
115
- prompt.select("Action:") do |menu|
116
- menu.choice "Commit", :commit
117
- menu.choice "Regenerate", :regenerate
118
- menu.choice "Edit", :edit
119
- menu.choice "Exit without commit", :exit
118
+ prompt.select('Action:') do |menu|
119
+ menu.choice 'Commit', :commit
120
+ menu.choice 'Regenerate', :regenerate
121
+ menu.choice 'Edit', :edit
122
+ menu.choice 'Exit without commit', :exit
120
123
  end
121
124
  rescue TTY::Reader::InputInterrupt, Interrupt
122
125
  :exit
@@ -127,132 +130,12 @@ module CommitGpt
127
130
  generate_commit(diff)
128
131
  end
129
132
 
130
- def git_diff
131
- exclusions = '":(exclude)Gemfile.lock" ":(exclude)package-lock.json" ":(exclude)yarn.lock" ":(exclude)pnpm-lock.yaml"'
132
- diff_cached = `git diff --cached . #{exclusions}`.chomp
133
- diff_unstaged = `git diff . #{exclusions}`.chomp
134
-
135
- if !diff_unstaged.empty?
136
- if !diff_cached.empty?
137
- # Scenario: Mixed state (some staged, some not)
138
- puts "▲ You have both staged and unstaged changes:".yellow
139
-
140
- staged_files = `git diff --cached --name-status . #{exclusions}`.chomp
141
- unstaged_files = `git diff --name-status . #{exclusions}`.chomp
142
-
143
- puts "\n #{'Staged changes:'.green}"
144
- puts staged_files.gsub(/^/, " ")
145
-
146
- puts "\n #{'Unstaged changes:'.red}"
147
- puts unstaged_files.gsub(/^/, " ")
148
- puts ""
149
-
150
- prompt = TTY::Prompt.new
151
- choice = prompt.select("How to proceed?") do |menu|
152
- menu.choice "Include unstaged changes (git add .)", :add_all
153
- menu.choice "Use staged changes only", :staged_only
154
- menu.choice "Exit", :exit
155
- end
156
-
157
- case choice
158
- when :add_all
159
- puts "▲ Running git add .".yellow
160
- system("git add .")
161
- diff_cached = `git diff --cached . #{exclusions}`.chomp
162
- when :exit
163
- return nil
164
- end
165
- else
166
- # Scenario: Only unstaged changes
167
- choice = prompt_no_staged_changes
168
- case choice
169
- when :add_all
170
- puts "▲ Running git add .".yellow
171
- system("git add .")
172
- diff_cached = `git diff --cached . #{exclusions}`.chomp
173
- if diff_cached.empty?
174
- puts "▲ Still no changes to commit.".red
175
- return nil
176
- end
177
- when :exit
178
- return nil
179
- end
180
- end
181
- elsif diff_cached.empty?
182
- # Scenario: No changes at all (staged or unstaged)
183
- # Check if there are ANY unstaged files (maybe untracked?)
184
- # git status --porcelain includes untracked files
185
- git_status = `git status --porcelain`.chomp
186
- if git_status.empty?
187
- puts "▲ No changes to commit. Working tree clean.".yellow
188
- return nil
189
- else
190
- # Only untracked files? Or ignored files?
191
- # If diff_unstaged is empty but git status is not, it usually means untracked files.
192
- # Let's offer to add them too.
193
- choice = prompt_no_staged_changes
194
- case choice
195
- when :add_all
196
- puts "▲ Running git add .".yellow
197
- system("git add .")
198
- diff_cached = `git diff --cached . #{exclusions}`.chomp
199
- when :exit
200
- return nil
201
- end
202
- end
203
- end
204
-
205
- diff = diff_cached
206
-
207
- if diff.length > @diff_len
208
- choice = prompt_diff_handling(diff.length, @diff_len)
209
- case choice
210
- when :truncate
211
- puts "▲ Truncating diff to #{@diff_len} chars...".yellow
212
- diff = diff[0...@diff_len]
213
- when :unlimited
214
- puts "▲ Using full diff (#{diff.length} chars)...".yellow
215
- when :exit
216
- return nil
217
- end
218
- end
219
-
220
- diff
221
- end
222
-
223
- def prompt_no_staged_changes
224
- puts "▲ No staged changes found (but unstaged/untracked files exist).".yellow
225
- prompt = TTY::Prompt.new
226
- begin
227
- prompt.select("Choose an option:") do |menu|
228
- menu.choice "Run 'git add .' to stage all changes", :add_all
229
- menu.choice "Exit (stage files manually)", :exit
230
- end
231
- rescue TTY::Reader::InputInterrupt, Interrupt
232
- :exit
233
- end
234
- end
235
-
236
- def prompt_diff_handling(current_len, max_len)
237
- puts "▲ The diff is too large (#{current_len} chars, max #{max_len}).".yellow
238
- prompt = TTY::Prompt.new
239
- begin
240
- prompt.select("Choose an option:") do |menu|
241
- menu.choice "Use first #{max_len} characters to generate commit message", :truncate
242
- menu.choice "Use unlimited characters (may fail or be slow)", :unlimited
243
- menu.choice "Exit", :exit
244
- end
245
- rescue TTY::Reader::InputInterrupt, Interrupt
246
- :exit
247
- end
248
- end
249
-
250
133
  def welcome
251
134
  puts "\n▲ Welcome to AI Commits!".green
252
135
 
253
136
  # Check if config exists
254
137
  unless ConfigManager.config_exists?
255
- puts "▲ Configuration not found. Generating default config...".yellow
138
+ puts '▲ Configuration not found. Generating default config...'.yellow
256
139
  ConfigManager.generate_default_configs
257
140
  puts "▲ Please run 'aicm setup' to configure your provider.".red
258
141
  return false
@@ -272,36 +155,36 @@ module CommitGpt
272
155
  begin
273
156
  `git rev-parse --is-inside-work-tree`
274
157
  rescue StandardError
275
- puts "▲ This is not a git repository".red
158
+ puts '▲ This is not a git repository'.red
276
159
  return false
277
160
  end
278
161
 
279
162
  true
280
163
  end
281
164
 
282
- def generate_commit(diff = "")
165
+ def generate_commit(diff = '')
283
166
  messages = [
284
167
  {
285
- role: "system",
286
- content: "Generate a concise git commit message title in present tense that precisely describes the key changes in the following code diff. Focus on what was changed, not just file names. Provide only the title, no description or body. " \
168
+ role: 'system',
169
+ content: 'Generate a concise git commit message title in present tense that precisely describes the key changes in the following code diff. Focus on what was changed, not just file names. Provide only the title, no description or body. ' \
287
170
  "Message language: English. Rules:\n" \
288
171
  "- Commit message must be a maximum of 100 characters.\n" \
289
172
  "- Exclude anything unnecessary such as translation. Your entire response will be passed directly into git commit.\n" \
290
173
  "- IMPORTANT: Do not include any explanations, introductions, or additional text. Do not wrap the commit message in quotes or any other formatting. The commit message must not exceed 100 characters. Respond with ONLY the commit message text. \n" \
291
174
  "- Be specific: include concrete details (package names, versions, functionality) rather than generic statements. \n" \
292
- "- Return ONLY the commit message, nothing else."
175
+ '- Return ONLY the commit message, nothing else.'
293
176
  },
294
177
  {
295
- role: "user",
178
+ role: 'user',
296
179
  content: "Generate a commit message for the following git diff:\n\n#{diff}"
297
180
  }
298
181
  ]
299
182
 
300
183
  # Check config for disable_reasoning support (default true if not set)
301
184
  provider_config = ConfigManager.get_active_provider_config
302
- can_disable_reasoning = provider_config.key?("can_disable_reasoning") ? provider_config["can_disable_reasoning"] : true
185
+ can_disable_reasoning = provider_config.key?('can_disable_reasoning') ? provider_config['can_disable_reasoning'] : true
303
186
  # Get configured max_tokens or default to 2000
304
- configured_max_tokens = provider_config["max_tokens"] || 2000
187
+ configured_max_tokens = provider_config['max_tokens'] || 2000
305
188
 
306
189
  payload = {
307
190
  model: @model,
@@ -318,33 +201,37 @@ module CommitGpt
318
201
  end
319
202
 
320
203
  # Initial UI feedback (only on first try)
321
- puts "....... Generating your AI commit message ......".gray unless defined?(@is_retrying) && @is_retrying
204
+ puts '....... Generating your AI commit message ......'.gray unless defined?(@is_retrying) && @is_retrying
322
205
 
323
- full_content = ""
324
- full_reasoning = ""
206
+ full_content = ''
207
+ full_reasoning = ''
325
208
  printed_reasoning = false
326
209
  printed_content_prefix = false
327
210
  stop_stream = false
328
-
211
+
329
212
  uri = URI("#{@base_url}/chat/completions")
330
213
  http = Net::HTTP.new(uri.host, uri.port)
331
- http.use_ssl = (uri.scheme == "https")
214
+ http.use_ssl = (uri.scheme == 'https')
332
215
  http.read_timeout = 120
333
216
 
334
217
  request = Net::HTTP::Post.new(uri)
335
- request["Content-Type"] = "application/json"
336
- request["Authorization"] = "Bearer #{@api_key}" if @api_key
218
+ request['Content-Type'] = 'application/json'
219
+ request['Authorization'] = "Bearer #{@api_key}" if @api_key
337
220
  request.body = payload.to_json
338
221
 
339
222
  begin
340
223
  http.request(request) do |response|
341
- if response.code != "200"
224
+ if response.code != '200'
342
225
  # Parse error body
343
- error_body = response.read_body
344
- result = JSON.parse(error_body) rescue nil
345
-
226
+ error_body = response.read_body
227
+ result = begin
228
+ JSON.parse(error_body)
229
+ rescue StandardError
230
+ nil
231
+ end
232
+
346
233
  error_msg = if result
347
- result.dig("error", "message") || result["error"] || result["message"]
234
+ result.dig('error', 'message') || result['error'] || result['message']
348
235
  else
349
236
  error_body
350
237
  end
@@ -353,10 +240,10 @@ module CommitGpt
353
240
  error_msg = "HTTP #{response.code}"
354
241
  error_msg += " Raw: #{error_body}" unless error_body.to_s.strip.empty?
355
242
  end
356
-
357
- if can_disable_reasoning && (error_msg =~ /parameter|reasoning|unsupported/i || response.code == "400")
243
+
244
+ if can_disable_reasoning && (error_msg =~ /parameter|reasoning|unsupported/i || response.code == '400')
358
245
  puts "▲ Provider does not support 'disable_reasoning'. Updating config and retrying...".yellow
359
- ConfigManager.update_provider(provider_config["name"], { "can_disable_reasoning" => false })
246
+ ConfigManager.update_provider(provider_config['name'], { 'can_disable_reasoning' => false })
360
247
  @is_retrying = true
361
248
  return generate_commit(diff)
362
249
  else
@@ -364,9 +251,9 @@ module CommitGpt
364
251
  return nil
365
252
  end
366
253
  end
367
-
254
+
368
255
  # Process Streaming Response
369
- buffer = ""
256
+ buffer = ''
370
257
  response.read_body do |chunk|
371
258
  break if stop_stream
372
259
 
@@ -374,22 +261,20 @@ module CommitGpt
374
261
  while (line_end = buffer.index("\n"))
375
262
  line = buffer.slice!(0, line_end + 1).strip
376
263
  next if line.empty?
377
- next unless line.start_with?("data: ")
264
+ next unless line.start_with?('data: ')
378
265
 
379
- data_str = line[6..-1]
380
- next if data_str == "[DONE]"
266
+ data_str = line[6..]
267
+ next if data_str == '[DONE]'
381
268
 
382
269
  begin
383
270
  data = JSON.parse(data_str)
384
- delta = data.dig("choices", 0, "delta")
271
+ delta = data.dig('choices', 0, 'delta')
385
272
  next unless delta
386
273
 
387
274
  # Handle Reasoning
388
- reasoning_chunk = delta["reasoning_content"] || delta["reasoning"]
275
+ reasoning_chunk = delta['reasoning_content'] || delta['reasoning']
389
276
  if reasoning_chunk && !reasoning_chunk.empty?
390
- unless printed_reasoning
391
- puts "\nThinking...".gray
392
- end
277
+ puts "\nThinking...".gray unless printed_reasoning
393
278
  print reasoning_chunk.gray
394
279
  full_reasoning += reasoning_chunk
395
280
  printed_reasoning = true
@@ -397,17 +282,17 @@ module CommitGpt
397
282
  end
398
283
 
399
284
  # Handle Content
400
- content_chunk = delta["content"]
285
+ content_chunk = delta['content']
401
286
  if content_chunk && !content_chunk.empty?
402
287
  if printed_reasoning && !printed_content_prefix
403
- puts "" # Newline after reasoning block
288
+ puts '' # Newline after reasoning block
404
289
  end
405
-
290
+
406
291
  unless printed_content_prefix
407
292
  print "\n▲ Commit message: git commit -am \"".green
408
293
  printed_content_prefix = true
409
294
  end
410
-
295
+
411
296
  # Prevent infinite loops/repetitive garbage
412
297
  if full_content.length + content_chunk.length > 300
413
298
  stop_stream = true
@@ -418,12 +303,9 @@ module CommitGpt
418
303
  full_content += content_chunk
419
304
  $stdout.flush
420
305
  end
421
-
422
- # Handle Usage (some providers send usage at the end)
423
- if data["usage"]
424
- @last_usage = data["usage"]
425
- end
426
306
 
307
+ # Handle Usage (some providers send usage at the end)
308
+ @last_usage = data['usage'] if data['usage']
427
309
  rescue JSON::ParserError
428
310
  # Partial JSON, wait for more data
429
311
  end
@@ -434,38 +316,38 @@ module CommitGpt
434
316
  puts "▲ Error: #{e.message}".red
435
317
  return nil
436
318
  end
437
-
319
+
438
320
  # Close the quote
439
- puts "\"".green if printed_content_prefix
321
+ puts '"'.green if printed_content_prefix
440
322
 
441
323
  # Post-processing Logic (Retry if empty content)
442
324
  if (full_content.nil? || full_content.strip.empty?) && (full_reasoning && !full_reasoning.strip.empty?)
443
325
  if can_disable_reasoning
444
326
  puts "\n▲ Model returned reasoning despite 'disable_reasoning: true'. Updating config and retrying...".yellow
445
- ConfigManager.update_provider(provider_config["name"], { "can_disable_reasoning" => false })
327
+ ConfigManager.update_provider(provider_config['name'], { 'can_disable_reasoning' => false })
446
328
  @is_retrying = true
447
329
  return generate_commit(diff)
448
330
  else
449
331
  puts "\n▲ Model output truncated (Reasoning consumed all #{configured_max_tokens} tokens).".red
450
332
  prompt = TTY::Prompt.new
451
- choice = prompt.select("Choose an action:") do |menu|
333
+ choice = prompt.select('Choose an action:') do |menu|
452
334
  menu.choice "Double max_tokens to #{configured_max_tokens * 2}", :double
453
- menu.choice "Set custom max_tokens...", :custom
454
- menu.choice "Abort", :abort
335
+ menu.choice 'Set custom max_tokens...', :custom
336
+ menu.choice 'Abort', :abort
455
337
  end
456
338
 
457
339
  new_max = case choice
458
340
  when :double
459
341
  configured_max_tokens * 2
460
342
  when :custom
461
- prompt.ask("Enter new max_tokens:", convert: :int)
343
+ prompt.ask('Enter new max_tokens:', convert: :int)
462
344
  when :abort
463
345
  return nil
464
346
  end
465
-
347
+
466
348
  if new_max
467
349
  puts "▲ Updating max_tokens to #{new_max} and retrying...".yellow
468
- ConfigManager.update_provider(provider_config["name"], { "max_tokens" => new_max })
350
+ ConfigManager.update_provider(provider_config['name'], { 'max_tokens' => new_max })
469
351
  @is_retrying = true
470
352
  return generate_commit(diff)
471
353
  end
@@ -474,10 +356,10 @@ module CommitGpt
474
356
  end
475
357
 
476
358
  if full_content.empty? && full_reasoning.empty?
477
- puts "▲ No response from AI.".red
359
+ puts '▲ No response from AI.'.red
478
360
  return nil
479
361
  end
480
-
362
+
481
363
  # Print usage info if available (saved from stream or approximated)
482
364
  if defined?(@last_usage) && @last_usage
483
365
  puts "\n...... Tokens: #{@last_usage['total_tokens']} (Prompt: #{@last_usage['prompt_tokens']}, Completion: #{@last_usage['completion_tokens']})\n\n".gray
@@ -489,7 +371,7 @@ module CommitGpt
489
371
 
490
372
  # Take only the first non-empty line to avoid repetition or multi-line garbage
491
373
  first_line = full_content.split("\n").map(&:strip).reject(&:empty?).first
492
- first_line&.gsub(/\A["']|["']\z/, "") || ""
374
+ first_line&.gsub(/\A["']|["']\z/, '') || ''
493
375
  end
494
376
  end
495
377
  end