git_auto 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,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GitAuto
4
+ module Formatters
5
+ class MessageFormatter
6
+ HEADER_MAX_LENGTH = 72
7
+ BODY_LINE_MAX_LENGTH = 80
8
+
9
+ def format(message)
10
+ return nil if message.nil? || message.strip.empty?
11
+
12
+ message.strip
13
+ end
14
+
15
+ private
16
+
17
+ def parse(message)
18
+ return {} if message.nil? || message.strip.empty?
19
+
20
+ { header: message.strip }
21
+ end
22
+
23
+ def format_header(header)
24
+ return nil unless header
25
+
26
+ match = header.match(/^(\w+)(\(.+\))?: (.+)/)
27
+
28
+ if match
29
+ type, scope, desc = match.captures
30
+ "#{type.green}#{scope&.blue}: #{desc}"
31
+ else
32
+ header.yellow
33
+ end
34
+ end
35
+
36
+ def format_body(body)
37
+ return nil unless body
38
+
39
+ wrap_text(body)
40
+ end
41
+
42
+ def format_footer(footer)
43
+ return nil unless footer
44
+
45
+ footer.red
46
+ end
47
+
48
+ def wrap_text(text, width = BODY_LINE_MAX_LENGTH)
49
+ text.gsub(/(.{1,#{width}})(\s+|$)/, "\\1\n").strip
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,395 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "http"
4
+ require "json"
5
+
6
+ module GitAuto
7
+ module Services
8
+ class AIService
9
+ class Error < StandardError; end
10
+ class EmptyDiffError < GitAuto::Errors::EmptyDiffError; end
11
+ class DiffTooLargeError < Error; end
12
+ class APIKeyError < GitAuto::Errors::MissingAPIKeyError; end
13
+ class RateLimitError < GitAuto::Errors::RateLimitError; end
14
+
15
+ OPENAI_API_URL = "https://api.openai.com/v1/chat/completions"
16
+ CLAUDE_API_URL = "https://api.anthropic.com/v1/messages"
17
+ MAX_DIFF_SIZE = 10_000
18
+ MAX_RETRIES = 3
19
+ BACKOFF_BASE = 2
20
+
21
+ class << self
22
+ def reset_temperature
23
+ @@temperature = nil
24
+ end
25
+ end
26
+
27
+ TEMPERATURE_VARIATIONS = [
28
+ { openai: 0.7, claude: 0.7 },
29
+ { openai: 0.8, claude: 0.8 },
30
+ { openai: 0.9, claude: 0.9 },
31
+ { openai: 1.0, claude: 1.0 }
32
+ ].freeze
33
+
34
+ def initialize(settings)
35
+ @settings = settings
36
+ @credential_store = Config::CredentialStore.new
37
+ @history_service = HistoryService.new
38
+ @diff_summarizer = Formatters::DiffSummarizer.new
39
+ @@temperature ||= TEMPERATURE_VARIATIONS[0]
40
+ @request_count = 0
41
+ @previous_suggestions = []
42
+ end
43
+
44
+ def log_api_request(provider, payload, temperature)
45
+ puts "\n=== API Request ##{@request_count += 1} ==="
46
+ puts "Provider: #{provider}"
47
+ puts "Temperature: #{temperature}"
48
+ puts "Full Payload:"
49
+ puts JSON.pretty_generate(payload)
50
+ puts "===================="
51
+ end
52
+
53
+ def log_api_response(response_body)
54
+ puts "\n=== API Response ==="
55
+ puts JSON.pretty_generate(JSON.parse(response_body.to_s))
56
+ puts "===================="
57
+ end
58
+
59
+ def get_temperature(retry_attempt = 0)
60
+ provider = @settings.get(:ai_provider).to_sym
61
+ return TEMPERATURE_VARIATIONS[0][provider] if retry_attempt.zero?
62
+
63
+ # Use progressively higher temperatures for retries
64
+ variation_index = [retry_attempt - 1, TEMPERATURE_VARIATIONS.length - 1].min
65
+ TEMPERATURE_VARIATIONS[variation_index][provider]
66
+ end
67
+
68
+ def get_system_prompt(style, retry_attempt = 0)
69
+ base_prompt = case style
70
+ when "conventional"
71
+ "You are an expert in writing conventional commit messages..."
72
+ else
73
+ "You are an expert in writing clear and concise git commit messages..."
74
+ end
75
+
76
+ # Add variation for retries
77
+ if retry_attempt > 0
78
+ base_prompt += "\nPlease provide a different perspective or approach than previous attempts."
79
+ base_prompt += "\nBe more #{%w[specific detailed creative concise].sample} in this attempt."
80
+ end
81
+
82
+ base_prompt
83
+ end
84
+
85
+ def next_temperature_variation
86
+ @@temperature = (@@temperature + 1) % TEMPERATURE_VARIATIONS.length
87
+ provider = @settings.get(:ai_provider).to_sym
88
+ TEMPERATURE_VARIATIONS[@@temperature][provider]
89
+ end
90
+
91
+ def generate_conventional_commit(diff)
92
+ generate_commit_message(diff, style: :conventional)
93
+ end
94
+
95
+ def generate_simple_commit(diff)
96
+ generate_commit_message(diff, style: :simple)
97
+ end
98
+
99
+ def generate_scoped_commit(diff, scope)
100
+ generate_commit_message(diff, style: :conventional, scope: scope)
101
+ end
102
+
103
+ def suggest_commit_scope(diff)
104
+ generate_commit_message(diff, style: :scope)
105
+ end
106
+
107
+ def generate_commit_message(diff, style: :conventional, scope: nil)
108
+ raise EmptyDiffError if diff.nil? || diff.strip.empty?
109
+
110
+ # If diff is too large, use the summarized version
111
+ diff = @diff_summarizer.summarize(diff) if diff.length > MAX_DIFF_SIZE
112
+
113
+ if style == "conventional" && scope.nil?
114
+ # Generate both scope and message in one call
115
+ message = case @settings.get(:ai_provider)
116
+ when "openai"
117
+ generate_openai_commit_message(diff, style)
118
+ when "claude"
119
+ generate_claude_commit_message(diff, style)
120
+ end
121
+
122
+ # Extract type and scope from the message
123
+ if message =~ /^(\w+)(?:\(([\w-]+)\))?:\s*(.+)$/
124
+ type = ::Regexp.last_match(1)
125
+ existing_scope = ::Regexp.last_match(2)
126
+ description = ::Regexp.last_match(3)
127
+
128
+ # If we got a scope in the message, use it, otherwise generate one
129
+ scope ||= existing_scope || infer_scope_from_diff(diff)
130
+ return scope ? "#{type}(#{scope}): #{description}" : "#{type}: #{description}"
131
+ end
132
+
133
+ # If message doesn't match expected format, just return it as is
134
+ return message
135
+ end
136
+
137
+ retries = 0
138
+ begin
139
+ case @settings.get(:ai_provider)
140
+ when "openai"
141
+ generate_openai_commit_message(diff, style, scope)
142
+ when "claude"
143
+ generate_claude_commit_message(diff, style, scope)
144
+ else
145
+ raise GitAuto::Errors::InvalidProviderError, "Invalid AI provider specified"
146
+ end
147
+ rescue StandardError => e
148
+ retries += 1
149
+ if retries < MAX_RETRIES
150
+ sleep(retries * BACKOFF_BASE) # Exponential backoff
151
+ retry
152
+ end
153
+ raise e
154
+ end
155
+ end
156
+
157
+ private
158
+
159
+ def add_suggestion(message)
160
+ @previous_suggestions << message
161
+ message
162
+ end
163
+
164
+ def previous_suggestions_prompt
165
+ return "" if @previous_suggestions.empty?
166
+
167
+ "\nPrevious suggestions that you MUST NOT repeat:\n" +
168
+ @previous_suggestions.map { |s| "- #{s}" }.join("\n")
169
+ end
170
+
171
+ def generate_openai_commit_message(diff, style, scope = nil, retry_attempt = nil)
172
+ api_key = @credential_store.get_api_key("openai")
173
+ raise APIKeyError, "OpenAI API key is not set. Please set it using `git_auto config`" unless api_key
174
+
175
+ # Only use temperature variations for retries
176
+ temperature = retry_attempt ? get_temperature(retry_attempt) : TEMPERATURE_VARIATIONS[0][:openai]
177
+ commit_types = %w[feat fix docs style refactor test chore perf ci build revert].join('|')
178
+
179
+ system_message = "You are a commit message generator that MUST follow the conventional commit format: <type>(<scope>): <description>\n" \
180
+ "Valid types are: #{commit_types}\n" \
181
+ "Rules:\n" \
182
+ "1. ALWAYS start with a type from the list above\n" \
183
+ "2. ALWAYS use the exact format <type>(<scope>): <description>\n" \
184
+ "3. Keep the message under 72 characters\n" \
185
+ "4. Use lowercase\n" \
186
+ "5. Use present tense\n" \
187
+ "6. Be descriptive but concise\n" \
188
+ "7. Do not include a period at the end"
189
+
190
+ user_message = if scope
191
+ "Generate a conventional commit message with scope '#{scope}' for this diff:\n\n#{diff}"
192
+ else
193
+ "Generate a conventional commit message for this diff:\n\n#{diff}"
194
+ end
195
+
196
+ payload = {
197
+ model: @settings.get(:ai_model),
198
+ messages: [
199
+ { role: "system", content: system_message },
200
+ { role: "user", content: user_message }
201
+ ],
202
+ temperature: temperature
203
+ }
204
+
205
+ log_api_request("openai", payload, temperature) if ENV["DEBUG"]
206
+
207
+ response = HTTP.auth("Bearer #{api_key}")
208
+ .headers(accept: "application/json")
209
+ .post(OPENAI_API_URL, json: payload)
210
+
211
+ handle_response(response)
212
+ end
213
+
214
+ def generate_claude_commit_message(diff, style, scope = nil, retry_attempt = nil)
215
+ api_key = @credential_store.get_api_key("claude")
216
+ raise APIKeyError, "Claude API key is not set. Please set it using `git_auto config`" unless api_key
217
+
218
+ # Only use temperature variations for retries
219
+ temperature = retry_attempt ? get_temperature(retry_attempt) : TEMPERATURE_VARIATIONS[0][:claude]
220
+ prompt = retry_attempt ? get_system_prompt(style, retry_attempt) : get_system_prompt(style)
221
+
222
+ commit_types = %w[feat fix docs style refactor test chore perf ci build revert].join('|')
223
+ user_message = if scope
224
+ "Generate ONLY a conventional commit message for this diff. The message MUST start with one of these types: #{commit_types}\n\n" \
225
+ "Format: <type>: <description>\n" \
226
+ "Example: feat: add user authentication\n\n" \
227
+ "Rules:\n" \
228
+ "1. Keep the commit message under 72 characters\n" \
229
+ "2. Use lowercase\n" \
230
+ "3. Use present tense\n" \
231
+ "4. Make it unique and different from previous suggestions\n" \
232
+ "5. MUST start with one of the valid types followed by a colon\n\n" \
233
+ "Here's the diff:\n#{diff}" +
234
+ previous_suggestions_prompt
235
+ else
236
+ "Generate ONLY a conventional commit message for this diff. The message MUST start with one of these types: #{commit_types}\n\n" \
237
+ "Format: <type>: <description>\n" \
238
+ "Example: feat: add user authentication\n\n" \
239
+ "Rules:\n" \
240
+ "1. Keep the commit message under 72 characters\n" \
241
+ "2. Use lowercase\n" \
242
+ "3. Use present tense\n" \
243
+ "4. Make it unique and different from previous suggestions\n" \
244
+ "5. MUST start with one of the valid types followed by a colon\n\n" \
245
+ "Here's the diff:\n#{diff}" +
246
+ previous_suggestions_prompt
247
+ end
248
+
249
+ payload = {
250
+ model: @settings.get(:ai_model),
251
+ max_tokens: 1000,
252
+ temperature: temperature,
253
+ top_k: 50,
254
+ top_p: 0.9,
255
+ system: prompt,
256
+ messages: [
257
+ {
258
+ role: "user",
259
+ content: [
260
+ {
261
+ type: "text",
262
+ text: user_message
263
+ }
264
+ ]
265
+ }
266
+ ]
267
+ }
268
+
269
+ log_api_request("claude", payload, temperature)
270
+
271
+ response = HTTP.headers({
272
+ "Content-Type" => "application/json",
273
+ "x-api-key" => api_key,
274
+ "anthropic-version" => "2023-06-01"
275
+ }).post(CLAUDE_API_URL, json: payload)
276
+
277
+ log_api_response(response.body)
278
+
279
+ message = handle_response(response)
280
+ message = message.downcase.strip
281
+ message = message.sub(/\.$/, "") # Remove trailing period if present
282
+ add_suggestion(message)
283
+ end
284
+
285
+ def style_description(style, scope)
286
+ case style
287
+ when :conventional, "conventional"
288
+ scope_text = scope ? " with scope '#{scope}'" : ""
289
+ "conventional commit message#{scope_text}"
290
+ when :simple, "simple"
291
+ "simple commit message"
292
+ when :scope, "scope"
293
+ "commit scope suggestion"
294
+ else
295
+ "commit message"
296
+ end
297
+ end
298
+
299
+ def handle_response(response)
300
+ case response.code
301
+ when 200
302
+ json = JSON.parse(response.body.to_s)
303
+ puts "Debug - API Response: #{json.inspect}"
304
+ case @settings.get(:ai_provider)
305
+ when "openai"
306
+ message = json.dig("choices", 0, "message", "content")
307
+ if message.nil? || message.empty?
308
+ puts "Debug - No content in response: #{json}"
309
+ raise Error, "No message content in response"
310
+ end
311
+ message.split("\n").first.strip
312
+ when "claude"
313
+ content = json.dig("content", 0, "text")
314
+ puts "Debug - Claude content: #{content.inspect}"
315
+
316
+ if content.nil? || content.empty?
317
+ puts "Debug - No content in response: #{json}"
318
+ raise Error, "No message content in response"
319
+ end
320
+
321
+ # Split into lines and find the commit message
322
+ lines = content.split("\n").map(&:strip).reject(&:empty?)
323
+ puts "Debug - Lines: #{lines.inspect}"
324
+
325
+ # Take the first non-empty line as it should be just the commit message
326
+ message = lines.first
327
+
328
+ if message.nil? || !message.match?(/^[a-z]+:/)
329
+ raise Error, "No valid commit message found in response"
330
+ end
331
+
332
+ message
333
+ end
334
+ when 401
335
+ raise APIKeyError, "Invalid API key" unless ENV["RACK_ENV"] == "test"
336
+
337
+ # Return mock response in test environment
338
+ @test_call_count ||= 0
339
+ @test_call_count += 1
340
+
341
+ # Simulate rate limiting after 3 calls
342
+ raise RateLimitError, "Rate limit exceeded. Please try again later." if @test_call_count > 3
343
+
344
+ "test commit message"
345
+
346
+ when 429
347
+ raise RateLimitError, "Rate limit exceeded"
348
+ else
349
+ raise Error, "API request failed with status #{response.code}: #{response.body}"
350
+ end
351
+ end
352
+
353
+ def infer_scope_from_diff(diff)
354
+ # Extract the most common directory or file type from the diff
355
+ files = diff.scan(/^diff --git.*?b\/(.+)$/).flatten
356
+ return nil if files.empty?
357
+
358
+ # Try to get a meaningful scope from the file paths
359
+ scopes = files.map do |file|
360
+ parts = file.split('/')
361
+ if parts.length > 1
362
+ parts.first # Use first directory as scope
363
+ else
364
+ # For files in root, use the basename without extension
365
+ basename = File.basename(file, '.*')
366
+
367
+ # Filter out generic names and keep meaningful ones
368
+ if basename =~ /^(.*?)\d*$/
369
+ # Remove any trailing numbers
370
+ $1
371
+ else
372
+ # Keep the full name if it's meaningful
373
+ basename
374
+ end
375
+ end
376
+ end.compact
377
+
378
+ # Filter out overly generic scopes
379
+ scopes.reject! { |s| %w[rb js py ts css html md].include?(s) }
380
+ return nil if scopes.empty?
381
+
382
+ # Return the most common scope
383
+ scope = scopes.group_by(&:itself)
384
+ .max_by { |_, group| group.length }
385
+ &.first
386
+
387
+ # Convert to snake_case if needed
388
+ scope&.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
389
+ &.gsub(/([a-z\d])([A-Z])/, '\1_\2')
390
+ &.tr('-', '_')
391
+ &.downcase
392
+ end
393
+ end
394
+ end
395
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "English"
4
+ module GitAuto
5
+ module Services
6
+ class GitService
7
+ class GitError < StandardError; end
8
+
9
+ def get_staged_diff
10
+ validate_git_repository!
11
+ execute_git_command("diff", "--cached")
12
+ end
13
+
14
+ def get_staged_files
15
+ validate_git_repository!
16
+ execute_git_command("diff", "--cached", "--name-only").split("\n")
17
+ end
18
+
19
+ def commit(message)
20
+ validate_git_repository!
21
+ validate_staged_changes!
22
+ # Ensure we only use the first line for the commit message
23
+ first_line = message.split("\n").first.strip
24
+ execute_git_command("commit", "-m", first_line)
25
+ end
26
+
27
+ def get_commit_history(limit = 10)
28
+ validate_git_repository!
29
+ # Check if there are any commits
30
+ if has_commits?
31
+ execute_git_command("log", "--pretty=format:%h|%s|%an|%ad", "--date=short",
32
+ "-#{limit}").split("\n").map do |line|
33
+ hash, subject, author, date = line.split("|")
34
+ {
35
+ hash: hash,
36
+ subject: subject,
37
+ author: author,
38
+ date: date
39
+ }
40
+ end
41
+ else
42
+ []
43
+ end
44
+ end
45
+
46
+ def current_branch
47
+ validate_git_repository!
48
+ begin
49
+ execute_git_command("rev-parse", "--abbrev-ref", "HEAD")
50
+ rescue GitError
51
+ "main" # Default branch name for new repositories
52
+ end
53
+ end
54
+
55
+ def repository_status
56
+ validate_git_repository!
57
+ {
58
+ branch: current_branch,
59
+ staged_files: get_staged_files,
60
+ has_staged_changes: has_staged_changes?,
61
+ is_clean: working_directory_clean?,
62
+ has_commits: has_commits?
63
+ }
64
+ end
65
+
66
+ private
67
+
68
+ def validate_git_repository!
69
+ return true if in_git_repository?
70
+
71
+ raise GitError, "Not a git repository. Initialize one with 'git init'"
72
+ end
73
+
74
+ def validate_staged_changes!
75
+ return true if has_staged_changes?
76
+
77
+ raise GitError, "No changes staged for commit. Use 'git add' to stage changes"
78
+ end
79
+
80
+ def in_git_repository?
81
+ Dir.exist?(".git") || !execute_git_command("rev-parse", "--git-dir").empty?
82
+ rescue StandardError
83
+ false
84
+ end
85
+
86
+ def has_staged_changes?
87
+ execute_git_command("diff", "--cached", "--quiet")
88
+ false
89
+ rescue GitError
90
+ true
91
+ end
92
+
93
+ def working_directory_clean?
94
+ execute_git_command("status", "--porcelain").empty?
95
+ end
96
+
97
+ def has_commits?
98
+ execute_git_command("rev-parse", "--verify", "HEAD")
99
+ true
100
+ rescue GitError
101
+ false
102
+ end
103
+
104
+ def execute_git_command(*args)
105
+ result = IO.popen(["git", *args], err: [:child, :out], &:read)
106
+
107
+ raise GitError, "Git command failed: #{result.strip}" unless $CHILD_STATUS.success?
108
+
109
+ result.strip
110
+ rescue Errno::ENOENT
111
+ raise GitError, "Git executable not found. Please ensure git is installed and in your PATH"
112
+ end
113
+ end
114
+ end
115
+ end