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,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