commitgpt 0.2.0 → 0.3.3

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,18 +1,35 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "httparty"
4
- require "json"
5
- require "io/console"
6
- require_relative "string"
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'
7
11
 
8
12
  # CommitGpt based on GPT-3
9
13
  module CommitGpt
10
14
  # Commit AI roboter based on GPT-3
11
15
  class CommitAi
12
- AICM_KEY = ENV.fetch("AICM_KEY", nil)
13
- AICM_LINK = ENV.fetch("AICM_LINK", "https://api.openai.com/v1")
14
- AICM_DIFF_LEN = ENV.fetch("AICM_DIFF_LEN", "32768").to_i
15
- AICM_MODEL = ENV.fetch("AICM_MODEL", "gpt-4o-mini")
16
+ attr_reader :api_key, :base_url, :model, :diff_len
17
+
18
+ def initialize
19
+ provider_config = ConfigManager.get_active_provider_config
20
+
21
+ 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'] || 32_768
26
+ else
27
+ @api_key = nil
28
+ @base_url = nil
29
+ @model = nil
30
+ @diff_len = 32_768
31
+ end
32
+ end
16
33
 
17
34
  def aicm(verbose: false)
18
35
  exit(1) unless welcome
@@ -22,139 +39,456 @@ module CommitGpt
22
39
  puts diff
23
40
  puts "\n"
24
41
  end
25
- ai_commit_message = message(diff) || exit(1)
26
- puts `git commit -m "#{ai_commit_message}" && echo && echo && git log -1 && echo` if confirmed
42
+
43
+ loop do
44
+ ai_commit_message = message(diff) || exit(1)
45
+ action = confirm_commit(ai_commit_message)
46
+
47
+ case action
48
+ when :commit
49
+ commit_command = "git commit -m \"#{ai_commit_message}\""
50
+ puts "\n▲ Executing: #{commit_command}".yellow
51
+ system(commit_command)
52
+ puts "\n\n"
53
+ puts `git log -1`
54
+ break
55
+ when :regenerate
56
+ puts "\n"
57
+ next
58
+ when :edit
59
+ prompt = TTY::Prompt.new
60
+ new_message = prompt.ask('Enter your commit message:')
61
+ if new_message && !new_message.strip.empty?
62
+ commit_command = "git commit -m \"#{new_message}\""
63
+ system(commit_command)
64
+ puts "\n"
65
+ puts `git log -1`
66
+ else
67
+ puts '▲ Commit aborted (empty message).'.red
68
+ end
69
+ break
70
+ when :exit
71
+ puts '▲ Exit without commit.'.yellow
72
+ break
73
+ end
74
+ end
27
75
  end
28
76
 
29
77
  def list_models
30
78
  headers = {
31
- "Content-Type" => "application/json",
32
- "User-Agent" => "Ruby/#{RUBY_VERSION}"
79
+ 'Content-Type' => 'application/json',
80
+ 'User-Agent' => "Ruby/#{RUBY_VERSION}"
33
81
  }
34
- headers["Authorization"] = "Bearer #{AICM_KEY}" if AICM_KEY
82
+ headers['Authorization'] = "Bearer #{@api_key}" if @api_key
35
83
 
36
84
  begin
37
- response = HTTParty.get("#{AICM_LINK}/models", headers: headers)
38
- models = response["data"] || []
39
- models.each { |m| puts m["id"] }
85
+ response = HTTParty.get("#{@base_url}/models", headers: headers)
86
+ models = response['data'] || []
87
+ models.each { |m| puts m['id'] }
40
88
  rescue StandardError => e
41
89
  puts "▲ Failed to list models: #{e.message}".red
42
90
  end
43
91
  end
44
92
 
45
- private
93
+ def list_models
94
+ headers = {
95
+ 'Content-Type' => 'application/json',
96
+ 'User-Agent' => "Ruby/#{RUBY_VERSION}"
97
+ }
98
+ headers['Authorization'] = "Bearer #{AICM_KEY}" if AICM_KEY
46
99
 
47
- def confirmed
48
- puts " Do you want to commit this message? [y/n]".magenta
100
+ begin
101
+ response = HTTParty.get("#{AICM_LINK}/models", headers: headers)
102
+ models = response['data'] || []
103
+ models.each { |m| puts m['id'] }
104
+ rescue StandardError => e
105
+ puts "▲ Failed to list models: #{e.message}".red
106
+ end
107
+ end
49
108
 
50
- use_commit_message = nil
51
- use_commit_message = $stdin.getch.downcase until use_commit_message =~ /\A[yn]\z/i
109
+ private
52
110
 
53
- puts "\n▲ Commit message has not been commited.\n".red if use_commit_message == "n"
111
+ def confirm_commit(_message)
112
+ prompt = TTY::Prompt.new
54
113
 
55
- use_commit_message == "y"
114
+ 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
120
+ end
121
+ rescue TTY::Reader::InputInterrupt, Interrupt
122
+ :exit
123
+ end
56
124
  end
57
125
 
58
126
  def message(diff = nil)
59
- puts "▲ Generating your AI commit message...\n".gray
60
- ai_commit_message = generate_commit(diff)
61
- return nil if ai_commit_message.nil?
62
-
63
- puts "#{"▲ Commit message: ".green}git commit -am \"#{ai_commit_message}\"\n\n"
64
- ai_commit_message
127
+ generate_commit(diff)
65
128
  end
66
129
 
67
130
  def git_diff
68
- diff = `git diff --cached . ":(exclude)Gemfile.lock" ":(exclude)package-lock.json" ":(exclude)yarn.lock" ":(exclude)pnpm-lock.yaml"`.chomp
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
69
134
 
70
- if diff.empty?
71
- puts "▲ No staged changes found. Make sure there are changes and run `git add .`".red
72
- return nil
135
+ if !diff_unstaged.empty?
136
+ if diff_cached.empty?
137
+ # Scenario: Only unstaged changes
138
+ choice = prompt_no_staged_changes
139
+ case choice
140
+ when :add_all
141
+ puts '▲ Running git add .'.yellow
142
+ system('git add .')
143
+ diff_cached = `git diff --cached . #{exclusions}`.chomp
144
+ if diff_cached.empty?
145
+ puts '▲ Still no changes to commit.'.red
146
+ return nil
147
+ end
148
+ when :exit
149
+ return nil
150
+ end
151
+ else
152
+ # Scenario: Mixed state (some staged, some not)
153
+ puts '▲ You have both staged and unstaged changes:'.yellow
154
+
155
+ staged_files = `git diff --cached --name-status . #{exclusions}`.chomp
156
+ unstaged_files = `git diff --name-status . #{exclusions}`.chomp
157
+
158
+ puts "\n #{'Staged changes:'.green}"
159
+ puts staged_files.gsub(/^/, ' ')
160
+
161
+ puts "\n #{'Unstaged changes:'.red}"
162
+ puts unstaged_files.gsub(/^/, ' ')
163
+ puts ''
164
+
165
+ prompt = TTY::Prompt.new
166
+ choice = prompt.select('How to proceed?') do |menu|
167
+ menu.choice 'Include unstaged changes (git add .)', :add_all
168
+ menu.choice 'Use staged changes only', :staged_only
169
+ menu.choice 'Exit', :exit
170
+ end
171
+
172
+ case choice
173
+ when :add_all
174
+ puts '▲ Running git add .'.yellow
175
+ system('git add .')
176
+ diff_cached = `git diff --cached . #{exclusions}`.chomp
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
73
203
  end
74
204
 
75
- if diff.length > AICM_DIFF_LEN
76
- puts "▲ The diff is too large (#{diff.length} chars, max #{AICM_DIFF_LEN}). Set AICM_DIFF_LEN to increase limit.".red
77
- return nil
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
78
218
  end
79
219
 
80
220
  diff
81
221
  end
82
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
+
83
250
  def welcome
84
251
  puts "\n▲ Welcome to AI Commits!".green
85
252
 
86
- if AICM_KEY.nil? && AICM_LINK == "https://api.openai.com/v1"
87
- puts "▲ Please save your API key as an env variable by doing 'export AICM_KEY=YOUR_API_KEY'".red
253
+ # Check if config exists
254
+ unless ConfigManager.config_exists?
255
+ puts '▲ Configuration not found. Generating default config...'.yellow
256
+ ConfigManager.generate_default_configs
257
+ puts "▲ Please run 'aicm setup' to configure your provider.".red
258
+ return false
259
+ end
260
+
261
+ # Check if active provider is configured
262
+ if @api_key.nil? || @api_key.empty?
263
+ puts "▲ No active provider configured. Please run 'aicm setup'.".red
264
+ return false
265
+ end
266
+
267
+ if @model.nil? || @model.empty?
268
+ puts "▲ No model selected. Please run 'aicm setup'.".red
88
269
  return false
89
270
  end
90
271
 
91
272
  begin
92
273
  `git rev-parse --is-inside-work-tree`
93
274
  rescue StandardError
94
- puts "▲ This is not a git repository".red
275
+ puts '▲ This is not a git repository'.red
95
276
  return false
96
277
  end
97
278
 
98
279
  true
99
280
  end
100
281
 
101
- def generate_commit(diff = "")
282
+ def generate_commit(diff = '')
102
283
  messages = [
103
284
  {
104
- role: "system",
105
- 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. " \
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. ' \
106
287
  "Message language: English. Rules:\n" \
107
288
  "- Commit message must be a maximum of 100 characters.\n" \
108
289
  "- Exclude anything unnecessary such as translation. Your entire response will be passed directly into git commit.\n" \
109
290
  "- 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" \
110
291
  "- Be specific: include concrete details (package names, versions, functionality) rather than generic statements. \n" \
111
- "- Return ONLY the commit message, nothing else."
292
+ '- Return ONLY the commit message, nothing else.'
112
293
  },
113
294
  {
114
- role: "user",
295
+ role: 'user',
115
296
  content: "Generate a commit message for the following git diff:\n\n#{diff}"
116
297
  }
117
298
  ]
118
299
 
300
+ # Check config for disable_reasoning support (default true if not set)
301
+ provider_config = ConfigManager.get_active_provider_config
302
+ can_disable_reasoning = provider_config.key?('can_disable_reasoning') ? provider_config['can_disable_reasoning'] : true
303
+ # Get configured max_tokens or default to 2000
304
+ configured_max_tokens = provider_config['max_tokens'] || 2000
305
+
119
306
  payload = {
120
- model: AICM_MODEL,
307
+ model: @model,
121
308
  messages: messages,
122
- temperature: 0.7,
123
- max_tokens: 300,
124
- disable_reasoning: true
309
+ temperature: 0.5,
310
+ stream: true
125
311
  }
126
312
 
313
+ if can_disable_reasoning
314
+ payload[:disable_reasoning] = true
315
+ payload[:max_tokens] = 300
316
+ else
317
+ payload[:max_tokens] = configured_max_tokens
318
+ end
319
+
320
+ # Initial UI feedback (only on first try)
321
+ puts '....... Generating your AI commit message ......'.gray unless defined?(@is_retrying) && @is_retrying
322
+
323
+ full_content = ''
324
+ full_reasoning = ''
325
+ printed_reasoning = false
326
+ printed_content_prefix = false
327
+ stop_stream = false
328
+
329
+ uri = URI("#{@base_url}/chat/completions")
330
+ http = Net::HTTP.new(uri.host, uri.port)
331
+ http.use_ssl = (uri.scheme == 'https')
332
+ http.read_timeout = 120
333
+
334
+ request = Net::HTTP::Post.new(uri)
335
+ request['Content-Type'] = 'application/json'
336
+ request['Authorization'] = "Bearer #{@api_key}" if @api_key
337
+ request.body = payload.to_json
338
+
127
339
  begin
128
- headers = {
129
- "Content-Type" => "application/json",
130
- "User-Agent" => "Ruby/#{RUBY_VERSION}"
131
- }
132
- headers["Authorization"] = "Bearer #{AICM_KEY}" if AICM_KEY
340
+ http.request(request) do |response|
341
+ if response.code != '200'
342
+ # Parse error body
343
+ error_body = response.read_body
344
+ result = begin
345
+ JSON.parse(error_body)
346
+ rescue StandardError
347
+ nil
348
+ end
133
349
 
134
- response = HTTParty.post("#{AICM_LINK}/chat/completions",
135
- headers: headers,
136
- body: payload.to_json)
350
+ error_msg = if result
351
+ result.dig('error', 'message') || result['error'] || result['message']
352
+ else
353
+ error_body
354
+ end
137
355
 
138
- # Check for API error response
139
- if response["error"]
140
- puts " API Error: #{response['error']['message']}".red
141
- return nil
142
- end
356
+ if error_msg.nil? || error_msg.to_s.strip.empty?
357
+ error_msg = "HTTP #{response.code}"
358
+ error_msg += " Raw: #{error_body}" unless error_body.to_s.strip.empty?
359
+ end
143
360
 
144
- message = response.dig("choices", 0, "message")
145
- # Some models (like zai-glm) use 'reasoning' instead of 'content'
146
- ai_commit = message&.dig("content") || message&.dig("reasoning")
147
- if ai_commit.nil?
148
- puts "▲ Unexpected API response format:".red
149
- puts response.inspect
150
- return nil
361
+ if can_disable_reasoning && (error_msg =~ /parameter|reasoning|unsupported/i || response.code == '400')
362
+ puts "▲ Provider does not support 'disable_reasoning'. Updating config and retrying...".yellow
363
+ ConfigManager.update_provider(provider_config['name'], { 'can_disable_reasoning' => false })
364
+ @is_retrying = true
365
+ return generate_commit(diff)
366
+ else
367
+ puts "▲ API Error: #{error_msg}".red
368
+ return nil
369
+ end
370
+ end
371
+
372
+ # Process Streaming Response
373
+ buffer = ''
374
+ response.read_body do |chunk|
375
+ break if stop_stream
376
+
377
+ buffer += chunk
378
+ while (line_end = buffer.index("\n"))
379
+ line = buffer.slice!(0, line_end + 1).strip
380
+ next if line.empty?
381
+ next unless line.start_with?('data: ')
382
+
383
+ data_str = line[6..]
384
+ next if data_str == '[DONE]'
385
+
386
+ begin
387
+ data = JSON.parse(data_str)
388
+ delta = data.dig('choices', 0, 'delta')
389
+ next unless delta
390
+
391
+ # Handle Reasoning
392
+ reasoning_chunk = delta['reasoning_content'] || delta['reasoning']
393
+ if reasoning_chunk && !reasoning_chunk.empty?
394
+ puts "\nThinking...".gray unless printed_reasoning
395
+ print reasoning_chunk.gray
396
+ full_reasoning += reasoning_chunk
397
+ printed_reasoning = true
398
+ $stdout.flush
399
+ end
400
+
401
+ # Handle Content
402
+ content_chunk = delta['content']
403
+ if content_chunk && !content_chunk.empty?
404
+ if printed_reasoning && !printed_content_prefix
405
+ puts '' # Newline after reasoning block
406
+ end
407
+
408
+ unless printed_content_prefix
409
+ print "\n▲ Commit message: git commit -am \"".green
410
+ printed_content_prefix = true
411
+ end
412
+
413
+ # Prevent infinite loops/repetitive garbage
414
+ if full_content.length + content_chunk.length > 300
415
+ stop_stream = true
416
+ break
417
+ end
418
+
419
+ print content_chunk.green
420
+ full_content += content_chunk
421
+ $stdout.flush
422
+ end
423
+
424
+ # Handle Usage (some providers send usage at the end)
425
+ @last_usage = data['usage'] if data['usage']
426
+ rescue JSON::ParserError
427
+ # Partial JSON, wait for more data
428
+ end
429
+ end
430
+ end
151
431
  end
152
432
  rescue StandardError => e
153
433
  puts "▲ Error: #{e.message}".red
154
434
  return nil
155
435
  end
156
436
 
157
- ai_commit.gsub(/(\r\n|\n|\r)/, "").gsub(/\A["']|["']\z/, "")
437
+ # Close the quote
438
+ puts '"'.green if printed_content_prefix
439
+
440
+ # Post-processing Logic (Retry if empty content)
441
+ if (full_content.nil? || full_content.strip.empty?) && (full_reasoning && !full_reasoning.strip.empty?)
442
+ if can_disable_reasoning
443
+ puts "\n▲ Model returned reasoning despite 'disable_reasoning: true'. Updating config and retrying...".yellow
444
+ ConfigManager.update_provider(provider_config['name'], { 'can_disable_reasoning' => false })
445
+ @is_retrying = true
446
+ return generate_commit(diff)
447
+ else
448
+ puts "\n▲ Model output truncated (Reasoning consumed all #{configured_max_tokens} tokens).".red
449
+ prompt = TTY::Prompt.new
450
+ choice = prompt.select('Choose an action:') do |menu|
451
+ menu.choice "Double max_tokens to #{configured_max_tokens * 2}", :double
452
+ menu.choice 'Set custom max_tokens...', :custom
453
+ menu.choice 'Abort', :abort
454
+ end
455
+
456
+ new_max = case choice
457
+ when :double
458
+ configured_max_tokens * 2
459
+ when :custom
460
+ prompt.ask('Enter new max_tokens:', convert: :int)
461
+ when :abort
462
+ return nil
463
+ end
464
+
465
+ if new_max
466
+ puts "▲ Updating max_tokens to #{new_max} and retrying...".yellow
467
+ ConfigManager.update_provider(provider_config['name'], { 'max_tokens' => new_max })
468
+ @is_retrying = true
469
+ return generate_commit(diff)
470
+ end
471
+ return nil
472
+ end
473
+ end
474
+
475
+ if full_content.empty? && full_reasoning.empty?
476
+ puts '▲ No response from AI.'.red
477
+ return nil
478
+ end
479
+
480
+ # Print usage info if available (saved from stream or approximated)
481
+ if defined?(@last_usage) && @last_usage
482
+ puts "\n...... Tokens: #{@last_usage['total_tokens']} (Prompt: #{@last_usage['prompt_tokens']}, Completion: #{@last_usage['completion_tokens']})\n\n".gray
483
+ @last_usage = nil
484
+ end
485
+
486
+ # Reset retrying flag
487
+ @is_retrying = false
488
+
489
+ # Take only the first non-empty line to avoid repetition or multi-line garbage
490
+ first_line = full_content.split("\n").map(&:strip).reject(&:empty?).first
491
+ first_line&.gsub(/\A["']|["']\z/, '') || ''
158
492
  end
159
493
  end
160
494
  end