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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +113 -0
- data/exe/git_auto +7 -0
- data/lib/git_auto/cli.rb +79 -0
- data/lib/git_auto/commands/commit_message_command.rb +315 -0
- data/lib/git_auto/commands/config_command.rb +175 -0
- data/lib/git_auto/commands/history_analysis_command.rb +87 -0
- data/lib/git_auto/commands/setup_command.rb +113 -0
- data/lib/git_auto/config/credential_store.rb +73 -0
- data/lib/git_auto/config/settings.rb +95 -0
- data/lib/git_auto/errors.rb +12 -0
- data/lib/git_auto/formatters/diff_formatter.rb +49 -0
- data/lib/git_auto/formatters/diff_summarizer.rb +99 -0
- data/lib/git_auto/formatters/message_formatter.rb +53 -0
- data/lib/git_auto/services/ai_service.rb +395 -0
- data/lib/git_auto/services/git_service.rb +115 -0
- data/lib/git_auto/services/history_service.rb +150 -0
- data/lib/git_auto/validators/commit_message_validator.rb +89 -0
- data/lib/git_auto/version.rb +5 -0
- data/lib/git_auto.rb +52 -0
- metadata +268 -0
@@ -0,0 +1,175 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "tty-prompt"
|
4
|
+
require "colorize"
|
5
|
+
|
6
|
+
module GitAuto
|
7
|
+
module Commands
|
8
|
+
class ConfigCommand
|
9
|
+
def initialize
|
10
|
+
@prompt = TTY::Prompt.new
|
11
|
+
@credential_store = Config::CredentialStore.new
|
12
|
+
@settings = Config::Settings.new
|
13
|
+
end
|
14
|
+
|
15
|
+
def execute(args = [])
|
16
|
+
if args.empty?
|
17
|
+
interactive_config
|
18
|
+
else
|
19
|
+
handle_config_args(args)
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
private
|
24
|
+
|
25
|
+
def handle_config_args(args)
|
26
|
+
case args[0]
|
27
|
+
when "get"
|
28
|
+
get_setting(args[1])
|
29
|
+
when "set"
|
30
|
+
set_setting(args[1], args[2])
|
31
|
+
else
|
32
|
+
puts "❌ Unknown command: #{args[0]}".red
|
33
|
+
puts "Usage: git_auto config [get|set] <key> [value]"
|
34
|
+
exit 1
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
def get_setting(key)
|
39
|
+
if key.nil?
|
40
|
+
puts "❌ Missing key".red
|
41
|
+
puts "Usage: git_auto config get <key>"
|
42
|
+
exit 1
|
43
|
+
end
|
44
|
+
|
45
|
+
value = @settings.get(key.to_sym)
|
46
|
+
if value.nil?
|
47
|
+
puts "❌ Setting '#{key}' not found".red
|
48
|
+
exit 1
|
49
|
+
end
|
50
|
+
|
51
|
+
puts value
|
52
|
+
end
|
53
|
+
|
54
|
+
def set_setting(key, value)
|
55
|
+
if key.nil? || value.nil?
|
56
|
+
puts "❌ Missing key or value".red
|
57
|
+
puts "Usage: git_auto config set <key> <value>"
|
58
|
+
exit 1
|
59
|
+
end
|
60
|
+
|
61
|
+
@settings.set(key.to_sym, value)
|
62
|
+
puts "✓ Setting '#{key}' updated to '#{value}'".green
|
63
|
+
end
|
64
|
+
|
65
|
+
def interactive_config
|
66
|
+
puts "\n⚙️ GitAuto Configuration".blue
|
67
|
+
|
68
|
+
loop do
|
69
|
+
choice = main_menu
|
70
|
+
break if choice == "exit"
|
71
|
+
|
72
|
+
case choice
|
73
|
+
when "show"
|
74
|
+
display_configuration
|
75
|
+
when "provider"
|
76
|
+
configure_ai_provider
|
77
|
+
when "model"
|
78
|
+
configure_ai_model
|
79
|
+
when "api_key"
|
80
|
+
configure_api_key
|
81
|
+
when "style"
|
82
|
+
configure_commit_style
|
83
|
+
when "preferences"
|
84
|
+
configure_preferences
|
85
|
+
when "history"
|
86
|
+
configure_history_settings
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
def main_menu
|
92
|
+
@prompt.select("Choose an option:", {
|
93
|
+
"📊 Show current configuration" => "show",
|
94
|
+
"🤖 Configure AI provider" => "provider",
|
95
|
+
"🔧 Configure AI model" => "model",
|
96
|
+
"🔑 Configure API key" => "api_key",
|
97
|
+
"💫 Configure commit style" => "style",
|
98
|
+
"⚙️ Configure preferences" => "preferences",
|
99
|
+
"📜 Configure history settings" => "history",
|
100
|
+
"❌ Exit" => "exit"
|
101
|
+
})
|
102
|
+
end
|
103
|
+
|
104
|
+
def display_configuration
|
105
|
+
puts "\nCurrent Configuration:".blue
|
106
|
+
puts "AI Provider: #{@settings.get(:ai_provider)}"
|
107
|
+
puts "AI Model: #{@settings.get(:ai_model)}"
|
108
|
+
puts "Commit Style: #{@settings.get(:commit_style)}"
|
109
|
+
puts "Show Diff: #{@settings.get(:show_diff)}"
|
110
|
+
puts "Save History: #{@settings.get(:save_history)}"
|
111
|
+
puts "\nPress any key to continue..."
|
112
|
+
@prompt.keypress
|
113
|
+
end
|
114
|
+
|
115
|
+
def configure_ai_provider
|
116
|
+
provider = @prompt.select("Choose AI provider:", {
|
117
|
+
"OpenAI (GPT-4, GPT-3.5 Turbo)" => "openai",
|
118
|
+
"Anthropic (Claude 3.5 Sonnet, Claude 3.5 Haiku)" => "claude"
|
119
|
+
})
|
120
|
+
|
121
|
+
@settings.save(ai_provider: provider)
|
122
|
+
puts "✓ AI provider updated to #{provider}".green
|
123
|
+
|
124
|
+
# Check if API key exists for the new provider
|
125
|
+
unless @credential_store.api_key_exists?(provider)
|
126
|
+
puts "\nNo API key found for #{provider.upcase}. Let's set it up.".blue
|
127
|
+
configure_api_key
|
128
|
+
end
|
129
|
+
|
130
|
+
# Auto-configure model after provider change
|
131
|
+
configure_ai_model
|
132
|
+
end
|
133
|
+
|
134
|
+
def configure_ai_model
|
135
|
+
models = Config::Settings::SUPPORTED_PROVIDERS[@settings.get(:ai_provider)][:models]
|
136
|
+
model_choices = models.map { |name, value| { name: name, value: value } }
|
137
|
+
|
138
|
+
model = @prompt.select("Choose AI model:", model_choices)
|
139
|
+
@settings.save(ai_model: model)
|
140
|
+
puts "✓ AI model updated to #{model}".green
|
141
|
+
end
|
142
|
+
|
143
|
+
def configure_api_key
|
144
|
+
provider = @settings.get(:ai_provider)
|
145
|
+
puts "\nConfiguring API key for #{provider.upcase}".blue
|
146
|
+
|
147
|
+
key = @prompt.mask("Enter your API key:")
|
148
|
+
@credential_store.store_api_key(key, provider)
|
149
|
+
puts "✓ API key updated".green
|
150
|
+
end
|
151
|
+
|
152
|
+
def configure_commit_style
|
153
|
+
style = @prompt.select("Choose commit message style:", {
|
154
|
+
"Conventional (type(scope): description)" => "conventional",
|
155
|
+
"Simple (description only)" => "simple"
|
156
|
+
})
|
157
|
+
|
158
|
+
@settings.set(:commit_style, style)
|
159
|
+
puts "✓ Commit style updated to #{style}".green
|
160
|
+
end
|
161
|
+
|
162
|
+
def configure_preferences
|
163
|
+
show_diff = @prompt.yes?("Show diff before committing?")
|
164
|
+
@settings.set(:show_diff, show_diff)
|
165
|
+
puts "✓ Show diff preference updated".green
|
166
|
+
end
|
167
|
+
|
168
|
+
def configure_history_settings
|
169
|
+
save_history = @prompt.yes?("Save commit history for analysis?")
|
170
|
+
@settings.set(:save_history, save_history)
|
171
|
+
puts "✓ History settings updated".green
|
172
|
+
end
|
173
|
+
end
|
174
|
+
end
|
175
|
+
end
|
@@ -0,0 +1,87 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module GitAuto
|
4
|
+
module Commands
|
5
|
+
class HistoryAnalysisCommand
|
6
|
+
def initialize(options = {})
|
7
|
+
@limit = options[:limit] || 10
|
8
|
+
@git_service = Services::GitService.new
|
9
|
+
@prompt = TTY::Prompt.new
|
10
|
+
@spinner = TTY::Spinner.new("[:spinner] Analyzing commit history...")
|
11
|
+
end
|
12
|
+
|
13
|
+
def execute
|
14
|
+
@spinner.auto_spin
|
15
|
+
commits = fetch_commits
|
16
|
+
analysis = analyze_commits(commits)
|
17
|
+
@spinner.success
|
18
|
+
display_results(analysis)
|
19
|
+
rescue StandardError => e
|
20
|
+
puts "❌ Error analyzing commits: #{e.message}".red
|
21
|
+
exit 1
|
22
|
+
end
|
23
|
+
|
24
|
+
private
|
25
|
+
|
26
|
+
def fetch_commits
|
27
|
+
@git_service.get_commit_history(@limit)
|
28
|
+
end
|
29
|
+
|
30
|
+
def analyze_commits(commits)
|
31
|
+
{
|
32
|
+
total_commits: commits.size,
|
33
|
+
types: analyze_types(commits),
|
34
|
+
avg_length: average_message_length(commits),
|
35
|
+
common_patterns: find_common_patterns(commits)
|
36
|
+
}
|
37
|
+
end
|
38
|
+
|
39
|
+
def analyze_types(commits)
|
40
|
+
commits.each_with_object(Hash.new(0)) do |commit, types|
|
41
|
+
type = extract_type(commit)
|
42
|
+
types[type] += 1
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
def extract_type(commit)
|
47
|
+
return "conventional" if commit.match?(/^(feat|fix|docs|style|refactor|test|chore)(\(.+\))?:/)
|
48
|
+
return "detailed" if commit.include?("\n\n")
|
49
|
+
|
50
|
+
"simple"
|
51
|
+
end
|
52
|
+
|
53
|
+
def average_message_length(commits)
|
54
|
+
return 0 if commits.empty?
|
55
|
+
|
56
|
+
commits.sum(&:length) / commits.size
|
57
|
+
end
|
58
|
+
|
59
|
+
def find_common_patterns(commits)
|
60
|
+
words = commits.flat_map { |c| c.downcase.scan(/\w+/) }
|
61
|
+
words.each_with_object(Hash.new(0)) { |word, counts| counts[word] += 1 }
|
62
|
+
.sort_by { |_, count| -count }
|
63
|
+
.first(5)
|
64
|
+
.to_h
|
65
|
+
end
|
66
|
+
|
67
|
+
def display_results(analysis)
|
68
|
+
puts "\n📊 Commit History Analysis".blue
|
69
|
+
puts "Total commits analyzed: #{analysis[:total_commits]}"
|
70
|
+
|
71
|
+
puts "\n📝 Commit Types:".blue
|
72
|
+
analysis[:types].each do |type, count|
|
73
|
+
percentage = (count.to_f / analysis[:total_commits] * 100).round(1)
|
74
|
+
puts "#{type}: #{count} (#{percentage}%)"
|
75
|
+
end
|
76
|
+
|
77
|
+
puts "\n📈 Statistics:".blue
|
78
|
+
puts "Average message length: #{analysis[:avg_length]} characters"
|
79
|
+
|
80
|
+
puts "\n🔍 Common words:".blue
|
81
|
+
analysis[:common_patterns].each do |word, count|
|
82
|
+
puts "#{word}: #{count} times"
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
@@ -0,0 +1,113 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "tty-prompt"
|
4
|
+
require "colorize"
|
5
|
+
|
6
|
+
module GitAuto
|
7
|
+
module Commands
|
8
|
+
class SetupCommand
|
9
|
+
def initialize
|
10
|
+
@prompt = TTY::Prompt.new
|
11
|
+
@credential_store = Config::CredentialStore.new
|
12
|
+
@settings = Config::Settings.new
|
13
|
+
end
|
14
|
+
|
15
|
+
def execute
|
16
|
+
puts "\n🔧 Setting up GitAuto...".blue
|
17
|
+
puts "This wizard will help you configure GitAuto with your preferred AI provider.\n"
|
18
|
+
|
19
|
+
# Select AI provider
|
20
|
+
configure_ai_provider
|
21
|
+
|
22
|
+
# Configure preferences
|
23
|
+
configure_preferences
|
24
|
+
|
25
|
+
puts "\n✅ Setup completed successfully!".green
|
26
|
+
display_configuration
|
27
|
+
rescue StandardError => e
|
28
|
+
puts "\n❌ Setup failed: #{e.message}".red
|
29
|
+
exit 1
|
30
|
+
end
|
31
|
+
|
32
|
+
private
|
33
|
+
|
34
|
+
def configure_ai_provider
|
35
|
+
# Select provider
|
36
|
+
provider_choices = Config::Settings::SUPPORTED_PROVIDERS.map do |key, info|
|
37
|
+
{ name: info[:name], value: key }
|
38
|
+
end
|
39
|
+
|
40
|
+
provider = @prompt.select(
|
41
|
+
"Choose your AI provider:",
|
42
|
+
provider_choices,
|
43
|
+
help: "(Use ↑/↓ and Enter to select)"
|
44
|
+
)
|
45
|
+
|
46
|
+
# Select model for the chosen provider
|
47
|
+
models = Config::Settings::SUPPORTED_PROVIDERS[provider][:models]
|
48
|
+
model_choices = models.map { |name, value| { name: name, value: value } }
|
49
|
+
|
50
|
+
model = @prompt.select(
|
51
|
+
"Choose the AI model:",
|
52
|
+
model_choices,
|
53
|
+
help: "More capable models may be slower but produce better results"
|
54
|
+
)
|
55
|
+
|
56
|
+
# Get and validate API key
|
57
|
+
provider_name = Config::Settings::SUPPORTED_PROVIDERS[provider][:name]
|
58
|
+
puts "\nℹ️ The API key will be securely stored in your system's credential store"
|
59
|
+
api_key = @prompt.mask("Enter your #{provider_name} API key:") do |q|
|
60
|
+
q.required true
|
61
|
+
q.validate(/\S+/, "API key cannot be empty")
|
62
|
+
end
|
63
|
+
|
64
|
+
# Save configuration
|
65
|
+
@settings.save(
|
66
|
+
ai_provider: provider,
|
67
|
+
ai_model: model
|
68
|
+
)
|
69
|
+
@credential_store.store_api_key(api_key, provider)
|
70
|
+
|
71
|
+
puts "✓ #{provider_name} configured successfully".green
|
72
|
+
end
|
73
|
+
|
74
|
+
def configure_preferences
|
75
|
+
puts "\n🔧 Configuring preferences...".blue
|
76
|
+
|
77
|
+
commit_style = select_commit_style
|
78
|
+
show_diff = @prompt.yes?("Show diff preview before generating commit messages?", default: true)
|
79
|
+
save_history = @prompt.yes?("Save commit history for pattern analysis?", default: true)
|
80
|
+
|
81
|
+
settings = { commit_style: commit_style, show_diff: show_diff, save_history: save_history }
|
82
|
+
|
83
|
+
@settings.save(settings)
|
84
|
+
end
|
85
|
+
|
86
|
+
def select_commit_style
|
87
|
+
@prompt.select(
|
88
|
+
"Select default commit message style:",
|
89
|
+
[
|
90
|
+
{ name: "Conventional (type(scope): description)", value: "conventional" },
|
91
|
+
{ name: "Simple (verb + description)", value: "simple" },
|
92
|
+
{ name: "Detailed (summary + bullet points)", value: "detailed" }
|
93
|
+
],
|
94
|
+
help: "This can be changed later using git_auto config"
|
95
|
+
)
|
96
|
+
end
|
97
|
+
|
98
|
+
def display_configuration
|
99
|
+
config = @settings.all
|
100
|
+
provider_info = Config::Settings::SUPPORTED_PROVIDERS[config[:ai_provider]]
|
101
|
+
|
102
|
+
puts "\nCurrent Configuration:"
|
103
|
+
puts "──────────────────────"
|
104
|
+
puts "AI Provider: #{provider_info[:name]} (#{config[:ai_provider]})".cyan
|
105
|
+
puts "Model: #{config[:ai_model]}".cyan
|
106
|
+
puts "Commit Style: #{config[:commit_style]}".cyan
|
107
|
+
puts "Show Diff: #{config[:show_diff]}".cyan
|
108
|
+
puts "Save History: #{config[:save_history]}".cyan
|
109
|
+
puts "\nYou can change these settings anytime using: git_auto config"
|
110
|
+
end
|
111
|
+
end
|
112
|
+
end
|
113
|
+
end
|
@@ -0,0 +1,73 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "openssl"
|
4
|
+
require "base64"
|
5
|
+
require "fileutils"
|
6
|
+
require "yaml"
|
7
|
+
|
8
|
+
module GitAuto
|
9
|
+
module Config
|
10
|
+
class CredentialStore
|
11
|
+
CREDENTIALS_FILE = File.join(File.expand_path("~/.git_auto"), "credentials.yml")
|
12
|
+
ENCRYPTION_KEY = ENV["GIT_AUTO_SECRET"] || "default_development_key"
|
13
|
+
|
14
|
+
def initialize
|
15
|
+
ensure_credentials_file
|
16
|
+
end
|
17
|
+
|
18
|
+
def store_api_key(key, provider)
|
19
|
+
credentials = load_credentials
|
20
|
+
credentials[provider.to_s] = encrypt(key)
|
21
|
+
save_credentials(credentials)
|
22
|
+
end
|
23
|
+
|
24
|
+
def get_api_key(provider)
|
25
|
+
credentials = load_credentials
|
26
|
+
encrypted_key = credentials[provider.to_s]
|
27
|
+
return nil unless encrypted_key
|
28
|
+
|
29
|
+
decrypt(encrypted_key)
|
30
|
+
end
|
31
|
+
|
32
|
+
def api_key_exists?(provider)
|
33
|
+
credentials = load_credentials
|
34
|
+
credentials.key?(provider.to_s)
|
35
|
+
end
|
36
|
+
|
37
|
+
private
|
38
|
+
|
39
|
+
def ensure_credentials_file
|
40
|
+
dir = File.dirname(CREDENTIALS_FILE)
|
41
|
+
FileUtils.mkdir_p(dir) unless File.directory?(dir)
|
42
|
+
FileUtils.touch(CREDENTIALS_FILE) unless File.exist?(CREDENTIALS_FILE)
|
43
|
+
end
|
44
|
+
|
45
|
+
def load_credentials
|
46
|
+
content = File.read(CREDENTIALS_FILE).strip
|
47
|
+
content.empty? ? {} : YAML.safe_load(content)
|
48
|
+
end
|
49
|
+
|
50
|
+
def save_credentials(credentials)
|
51
|
+
File.write(CREDENTIALS_FILE, YAML.dump(credentials))
|
52
|
+
end
|
53
|
+
|
54
|
+
def encrypt(text)
|
55
|
+
cipher = OpenSSL::Cipher.new("aes-256-cbc")
|
56
|
+
cipher.encrypt
|
57
|
+
cipher.key = Digest::SHA256.digest(ENCRYPTION_KEY)
|
58
|
+
iv = cipher.random_iv
|
59
|
+
encrypted = cipher.update(text) + cipher.final
|
60
|
+
Base64.strict_encode64(iv + encrypted)
|
61
|
+
end
|
62
|
+
|
63
|
+
def decrypt(encrypted_data)
|
64
|
+
encrypted = Base64.strict_decode64(encrypted_data)
|
65
|
+
decipher = OpenSSL::Cipher.new("aes-256-cbc")
|
66
|
+
decipher.decrypt
|
67
|
+
decipher.key = Digest::SHA256.digest(ENCRYPTION_KEY)
|
68
|
+
decipher.iv = encrypted[0..15]
|
69
|
+
decipher.update(encrypted[16..]) + decipher.final
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
@@ -0,0 +1,95 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "yaml"
|
4
|
+
require "fileutils"
|
5
|
+
|
6
|
+
module GitAuto
|
7
|
+
module Config
|
8
|
+
class Settings
|
9
|
+
CONFIG_DIR = File.expand_path("~/.git_auto")
|
10
|
+
CONFIG_FILE = File.join(CONFIG_DIR, "config.yml")
|
11
|
+
|
12
|
+
SUPPORTED_PROVIDERS = {
|
13
|
+
"claude" => {
|
14
|
+
name: "Anthropic (Claude 3.5 Sonnet, Claude 3.5 Haiku)",
|
15
|
+
models: {
|
16
|
+
"Claude 3.5 Sonnet" => "claude-3-5-sonnet-latest",
|
17
|
+
"Claude 3.5 Haiku" => "claude-3-5-haiku-latest"
|
18
|
+
}
|
19
|
+
},
|
20
|
+
"openai" => {
|
21
|
+
name: "OpenAI (GPT-4o, GPT-4o mini)",
|
22
|
+
models: {
|
23
|
+
"GPT-4o" => "gpt-4o",
|
24
|
+
"GPT-4o mini" => "gpt-4o-mini"
|
25
|
+
}
|
26
|
+
}
|
27
|
+
}.freeze
|
28
|
+
|
29
|
+
DEFAULT_SETTINGS = {
|
30
|
+
commit_style: "conventional",
|
31
|
+
ai_provider: "openai",
|
32
|
+
ai_model: "gpt-4o",
|
33
|
+
show_diff: true,
|
34
|
+
save_history: true,
|
35
|
+
max_retries: 3
|
36
|
+
}.freeze
|
37
|
+
|
38
|
+
def initialize
|
39
|
+
ensure_config_dir
|
40
|
+
@settings = load_settings
|
41
|
+
end
|
42
|
+
|
43
|
+
def save(options = {})
|
44
|
+
validate_settings!(options)
|
45
|
+
@settings = @settings.merge(options)
|
46
|
+
File.write(CONFIG_FILE, YAML.dump(@settings))
|
47
|
+
end
|
48
|
+
|
49
|
+
def get(key)
|
50
|
+
@settings[key.to_sym]
|
51
|
+
end
|
52
|
+
|
53
|
+
def all
|
54
|
+
@settings
|
55
|
+
end
|
56
|
+
|
57
|
+
def provider_info
|
58
|
+
SUPPORTED_PROVIDERS[get(:ai_provider)]
|
59
|
+
end
|
60
|
+
|
61
|
+
def available_models
|
62
|
+
provider_info[:models]
|
63
|
+
end
|
64
|
+
|
65
|
+
private
|
66
|
+
|
67
|
+
def ensure_config_dir
|
68
|
+
FileUtils.mkdir_p(CONFIG_DIR)
|
69
|
+
end
|
70
|
+
|
71
|
+
def load_settings
|
72
|
+
return DEFAULT_SETTINGS.dup unless File.exist?(CONFIG_FILE)
|
73
|
+
|
74
|
+
user_settings = YAML.load_file(CONFIG_FILE) || {}
|
75
|
+
DEFAULT_SETTINGS.merge(user_settings)
|
76
|
+
rescue StandardError
|
77
|
+
DEFAULT_SETTINGS.dup
|
78
|
+
end
|
79
|
+
|
80
|
+
def validate_settings!(options)
|
81
|
+
if options[:ai_provider] && !SUPPORTED_PROVIDERS.key?(options[:ai_provider])
|
82
|
+
raise Error, "Unsupported AI provider. Available providers: #{SUPPORTED_PROVIDERS.keys.join(", ")}"
|
83
|
+
end
|
84
|
+
|
85
|
+
return unless options[:ai_model]
|
86
|
+
|
87
|
+
provider = options[:ai_provider] || get(:ai_provider)
|
88
|
+
available_models = SUPPORTED_PROVIDERS[provider][:models]
|
89
|
+
return if available_models.values.include?(options[:ai_model])
|
90
|
+
|
91
|
+
raise Error, "Unsupported model for #{provider}. Available models: #{available_models.keys.join(", ")}"
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|
@@ -0,0 +1,12 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module GitAuto
|
4
|
+
module Errors
|
5
|
+
class Error < StandardError; end
|
6
|
+
class MissingAPIKeyError < Error; end
|
7
|
+
class EmptyDiffError < Error; end
|
8
|
+
class RateLimitError < Error; end
|
9
|
+
class APIError < Error; end
|
10
|
+
class InvalidProviderError < Error; end
|
11
|
+
end
|
12
|
+
end
|
@@ -0,0 +1,49 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module GitAuto
|
4
|
+
module Formatters
|
5
|
+
class DiffFormatter
|
6
|
+
def format(diff)
|
7
|
+
return "No changes" if diff.empty?
|
8
|
+
|
9
|
+
formatted = []
|
10
|
+
current_file = nil
|
11
|
+
|
12
|
+
diff.each_line do |line|
|
13
|
+
case line
|
14
|
+
when /^diff --git/
|
15
|
+
current_file = extract_file_name(line)
|
16
|
+
formatted << "\nChanges in #{current_file}:"
|
17
|
+
when /^index |^---|\+\+\+/
|
18
|
+
next # Skip index and file indicator lines
|
19
|
+
when /^@@ .* @@/
|
20
|
+
formatted << format_hunk_header(line)
|
21
|
+
when /^\+/
|
22
|
+
formatted << "Added: #{line[1..].strip}"
|
23
|
+
when /^-/
|
24
|
+
formatted << "Removed: #{line[1..].strip}"
|
25
|
+
when /^ /
|
26
|
+
formatted << "Context: #{line.strip}" unless line.strip.empty?
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
formatted.join("\n")
|
31
|
+
end
|
32
|
+
|
33
|
+
private
|
34
|
+
|
35
|
+
def extract_file_name(line)
|
36
|
+
line.match(%r{b/(.+)$})[1]
|
37
|
+
end
|
38
|
+
|
39
|
+
def format_hunk_header(line)
|
40
|
+
match = line.match(/@@ -(\d+),?(\d*) \+(\d+),?(\d*) @@(.*)/)
|
41
|
+
return line unless match
|
42
|
+
|
43
|
+
line_info = "@ #{match[1]}-#{match[3]}"
|
44
|
+
context = match[5].strip
|
45
|
+
"\nSection #{line_info} #{context}"
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
@@ -0,0 +1,99 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module GitAuto
|
4
|
+
module Formatters
|
5
|
+
class DiffSummarizer
|
6
|
+
FileChange = Struct.new(:name, :additions, :deletions, :key_changes)
|
7
|
+
|
8
|
+
def summarize(diff)
|
9
|
+
return "No changes" if diff.empty?
|
10
|
+
|
11
|
+
file_changes = parse_diff(diff)
|
12
|
+
generate_summary(file_changes)
|
13
|
+
end
|
14
|
+
|
15
|
+
private
|
16
|
+
|
17
|
+
def parse_diff(diff)
|
18
|
+
changes = {}
|
19
|
+
current_file = nil
|
20
|
+
current_changes = nil
|
21
|
+
|
22
|
+
diff.each_line do |line|
|
23
|
+
case line
|
24
|
+
when /^diff --git/
|
25
|
+
changes[current_file] = current_changes if current_file
|
26
|
+
current_file = extract_file_name(line)
|
27
|
+
current_changes = FileChange.new(current_file, 0, 0, [])
|
28
|
+
when /^\+(?!\+\+)/
|
29
|
+
next if current_changes.nil?
|
30
|
+
|
31
|
+
current_changes.additions += 1
|
32
|
+
content = line[1..].strip
|
33
|
+
current_changes.key_changes << "+#{content}" if key_change?(content)
|
34
|
+
when /^-(?!--)/
|
35
|
+
next if current_changes.nil?
|
36
|
+
|
37
|
+
current_changes.deletions += 1
|
38
|
+
content = line[1..].strip
|
39
|
+
current_changes.key_changes << "-#{content}" if key_change?(content)
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
# Add the last file's changes
|
44
|
+
changes[current_file] = current_changes if current_file
|
45
|
+
|
46
|
+
changes
|
47
|
+
end
|
48
|
+
|
49
|
+
def generate_summary(changes)
|
50
|
+
return "No changes" if changes.empty?
|
51
|
+
|
52
|
+
total_additions = 0
|
53
|
+
total_deletions = 0
|
54
|
+
summary = []
|
55
|
+
|
56
|
+
summary << "[Summary: Changes across #{changes.size} files]"
|
57
|
+
|
58
|
+
changes.each_value do |change|
|
59
|
+
total_additions += change.additions
|
60
|
+
total_deletions += change.deletions
|
61
|
+
end
|
62
|
+
|
63
|
+
summary << "Total: +#{total_additions} lines added, -#{total_deletions} lines removed"
|
64
|
+
summary << "\nFiles modified:"
|
65
|
+
|
66
|
+
changes.each_value do |change|
|
67
|
+
summary << "- #{change.name}:"
|
68
|
+
if change.key_changes.any?
|
69
|
+
change.key_changes.take(5).each do |key_change|
|
70
|
+
summary << " #{key_change}"
|
71
|
+
end
|
72
|
+
summary << " [...#{change.key_changes.size - 5} more changes omitted...]" if change.key_changes.size > 5
|
73
|
+
else
|
74
|
+
summary << " #{change.additions} additions, #{change.deletions} deletions"
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
summary << "\n[Note: Some context and minor changes have been omitted for brevity]"
|
79
|
+
summary.join("\n")
|
80
|
+
end
|
81
|
+
|
82
|
+
def extract_file_name(line)
|
83
|
+
line.match(%r{b/(.+)$})[1]
|
84
|
+
end
|
85
|
+
|
86
|
+
def key_change?(line)
|
87
|
+
# Consider a change "key" if it matches certain patterns
|
88
|
+
return true if line.match?(/^(class|module|def|private|protected|public)/)
|
89
|
+
return true if line.match?(/^[A-Z][A-Za-z0-9_]*\s*=/) # Constants
|
90
|
+
return true if line.match?(/^\s*attr_(reader|writer|accessor)/)
|
91
|
+
return true if line.match?(/^\s*validates?/)
|
92
|
+
return true if line.match?(/^\s*has_(many|one|and_belongs_to_many)/)
|
93
|
+
return true if line.match?(/^\s*belongs_to/)
|
94
|
+
|
95
|
+
false
|
96
|
+
end
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|