code_sage 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,161 @@
1
+ require 'thor'
2
+ require 'colorize'
3
+
4
+ module CodeSage
5
+ class CLI < Thor
6
+ desc "review", "Review code changes in current repository"
7
+ option :branch, aliases: '-b', desc: "Branch to compare against (default: main)"
8
+ option :files, aliases: '-f', type: :array, desc: "Specific files to review"
9
+ option :format, aliases: '--format', default: 'console', desc: "Output format (console, json, markdown)"
10
+ option :config, aliases: '-c', desc: "Path to configuration file"
11
+ option :verbose, aliases: '-v', type: :boolean, desc: "Verbose output"
12
+ option :rag, type: :boolean,
13
+ desc: "Enable RAG (Retrieval Augmented Generation) functionality (requires vector database)"
14
+ def review
15
+ puts "šŸ”® CodeSage - Wisdom for your code".colorize(:cyan)
16
+ puts
17
+
18
+ begin
19
+ reviewer = Reviewer.new(
20
+ branch: options[:branch] || 'main',
21
+ files: options[:files],
22
+ format: options[:format],
23
+ config_path: options[:config],
24
+ verbose: options[:verbose],
25
+ enable_rag: options[:rag] || false
26
+ )
27
+
28
+ result = reviewer.review
29
+
30
+ if result[:success]
31
+ puts "āœ… Code review completed successfully!".colorize(:green)
32
+ else
33
+ puts "āŒ Code review failed: #{result[:error]}".colorize(:red)
34
+ exit 1
35
+ end
36
+
37
+ rescue => e
38
+ puts "šŸ’„ Error: #{e.message}".colorize(:red)
39
+ puts e.backtrace.join("\n").colorize(:yellow) if options[:verbose]
40
+ exit 1
41
+ end
42
+ end
43
+
44
+ desc "config", "Show or set configuration"
45
+ option :show, type: :boolean, desc: "Show current configuration"
46
+ option :reset, type: :boolean, desc: "Reset configuration to defaults"
47
+ option :key, type: :string, desc: "Configuration key to set"
48
+ option :value, type: :string, desc: "Configuration value to set"
49
+ def config
50
+ config_instance = Config.new
51
+
52
+ if options[:show]
53
+ puts "šŸ“‹ CodeSage Configuration".colorize(:cyan).bold
54
+ puts "=" * 50
55
+ puts YAML.dump(config_instance.data)
56
+ elsif options[:reset]
57
+ config_instance.reset!
58
+ puts "āœ… Configuration reset to defaults".colorize(:green)
59
+ elsif options[:key] && options[:value]
60
+ config_instance.set(options[:key], options[:value])
61
+ config_instance.save!
62
+ puts "āœ… Configuration updated: #{options[:key]} = #{options[:value]}".colorize(:green)
63
+ else
64
+ puts "šŸ“‹ Current configuration file: #{config_instance.config_path}".colorize(:cyan)
65
+ puts "Use --show to display configuration"
66
+ puts "Use --key KEY --value VALUE to update settings"
67
+ puts "Use --reset to restore defaults"
68
+ end
69
+ end
70
+
71
+ desc "diagnose", "Run system diagnostics"
72
+ def diagnose
73
+ puts "šŸ” CodeSage System Diagnostics".colorize(:cyan).bold
74
+ puts "=" * 50
75
+
76
+ # Check Ruby version
77
+ print "Ruby: "
78
+ puts "āœ… (#{RUBY_VERSION})".colorize(:green)
79
+
80
+ # Check Git
81
+ print "Git: "
82
+ begin
83
+ git_version = `git --version 2>/dev/null`.strip
84
+ if $?.success?
85
+ puts "āœ… (#{git_version})".colorize(:green)
86
+ else
87
+ puts "āŒ Not found".colorize(:red)
88
+ end
89
+ rescue
90
+ puts "āŒ Not found".colorize(:red)
91
+ end
92
+
93
+ # Check llm_chain availability
94
+ print "LLMChain: "
95
+ begin
96
+ require 'llm_chain'
97
+ puts "āœ… Available".colorize(:green)
98
+
99
+ # Run llm_chain diagnostics if available
100
+ if defined?(LLMChain) && LLMChain.respond_to?(:diagnose_system)
101
+ puts "\nšŸ” LLMChain Diagnostics:".colorize(:yellow)
102
+ LLMChain.diagnose_system
103
+ end
104
+ rescue LoadError
105
+ puts "āŒ Not available".colorize(:red)
106
+ puts " Run: gem install llm_chain".colorize(:yellow)
107
+ end
108
+
109
+ # Check API Keys
110
+ puts "\nšŸ”‘ API Keys:".colorize(:yellow)
111
+
112
+ api_keys = {
113
+ 'OpenAI' => ENV['OPENAI_API_KEY'],
114
+ 'Google' => ENV['GOOGLE_API_KEY'],
115
+ 'Anthropic' => ENV['ANTHROPIC_API_KEY']
116
+ }
117
+
118
+ api_keys.each do |name, key|
119
+ print "#{name}: "
120
+ if key && !key.empty?
121
+ puts "āœ… Configured".colorize(:green)
122
+ else
123
+ puts "āŒ Not set".colorize(:red)
124
+ end
125
+ end
126
+
127
+ # Check configuration
128
+ puts "\nāš™ļø Configuration:".colorize(:yellow)
129
+ config = Config.new
130
+ llm_config = config.data['llm']
131
+
132
+ print "LLM Provider: "
133
+ puts "#{llm_config['provider'] || 'openai'}".colorize(:cyan)
134
+
135
+ print "Model: "
136
+ puts "#{llm_config['model'] || 'gpt-4'}".colorize(:cyan)
137
+
138
+ puts "\nšŸ’” Recommendations:".colorize(:yellow)
139
+ recommendations = []
140
+
141
+ recommendations << "• Configure API keys for your chosen LLM provider" unless api_keys.values.any? do |key|
142
+ key && !key.empty?
143
+ end
144
+ if llm_config['provider'] == 'ollama'
145
+ recommendations << "• Install and start Ollama for local models: ollama serve"
146
+ end
147
+ recommendations << "• Ensure you're in a Git repository for code review" unless Dir.exist?('.git')
148
+
149
+ if recommendations.empty?
150
+ puts "āœ… System looks good!".colorize(:green)
151
+ else
152
+ recommendations.each { |rec| puts rec }
153
+ end
154
+ end
155
+
156
+ desc "version", "Show version"
157
+ def version
158
+ puts "CodeSage version #{CodeSage::VERSION}"
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,87 @@
1
+ require 'yaml'
2
+
3
+ module CodeSage
4
+ class Config
5
+ DEFAULT_CONFIG = {
6
+ 'llm' => {
7
+ 'provider' => 'openai', # openai, ollama, qwen, gemini
8
+ 'model' => 'gpt-4',
9
+ 'temperature' => 0.1,
10
+ 'max_tokens' => 2000,
11
+ 'api_key' => nil # Will use ENV variables
12
+ },
13
+ 'git' => {
14
+ 'default_branch' => 'main',
15
+ 'include_patterns' => ['*.rb', '*.rake', 'Gemfile', 'Rakefile'],
16
+ 'exclude_patterns' => ['spec/**/*', 'test/**/*']
17
+ },
18
+ 'review' => {
19
+ 'focus_areas' => ['security', 'performance', 'maintainability', 'best_practices'],
20
+ 'severity_levels' => ['low', 'medium', 'high', 'critical']
21
+ },
22
+ 'output' => {
23
+ 'format' => 'console',
24
+ 'verbose' => false,
25
+ 'colors' => true
26
+ }
27
+ }.freeze
28
+
29
+ attr_reader :config_path, :data
30
+
31
+ def initialize(config_path = nil)
32
+ @config_path = config_path || default_config_path
33
+ @data = load_config
34
+ end
35
+
36
+ def get(key_path)
37
+ keys = key_path.split('.')
38
+ keys.reduce(@data) { |config, key| config&.dig(key) }
39
+ end
40
+
41
+ def set(key_path, value)
42
+ keys = key_path.split('.')
43
+ last_key = keys.pop
44
+ target = keys.reduce(@data) { |config, key| config[key] ||= {} }
45
+ target[last_key] = value
46
+ end
47
+
48
+ def save!
49
+ File.write(@config_path, YAML.dump(@data))
50
+ end
51
+
52
+ def reset!
53
+ @data = DEFAULT_CONFIG.dup
54
+ save!
55
+ end
56
+
57
+ private
58
+
59
+ def default_config_path
60
+ File.expand_path('~/.code_sage.yml')
61
+ end
62
+
63
+ def load_config
64
+ if File.exist?(@config_path)
65
+ loaded = YAML.load_file(@config_path) || {}
66
+ deep_merge(DEFAULT_CONFIG, loaded)
67
+ else
68
+ DEFAULT_CONFIG.dup
69
+ end
70
+ rescue => e
71
+ puts "Warning: Could not load config file #{@config_path}: #{e.message}".colorize(:yellow)
72
+ DEFAULT_CONFIG.dup
73
+ end
74
+
75
+ def deep_merge(hash1, hash2)
76
+ result = hash1.dup
77
+ hash2.each do |key, value|
78
+ if result[key].is_a?(Hash) && value.is_a?(Hash)
79
+ result[key] = deep_merge(result[key], value)
80
+ else
81
+ result[key] = value
82
+ end
83
+ end
84
+ result
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,183 @@
1
+ require 'rugged'
2
+
3
+ module CodeSage
4
+ class GitAnalyzer
5
+ attr_reader :repo_path, :options
6
+
7
+ def initialize(options = {})
8
+ @options = options
9
+ @repo_path = Dir.pwd
10
+ @repo = Rugged::Repository.new(@repo_path)
11
+ end
12
+
13
+ def get_changes
14
+ if @options[:files]
15
+ analyze_specific_files(@options[:files])
16
+ else
17
+ analyze_branch_changes(@options[:branch])
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ def analyze_specific_files(files)
24
+ changes = []
25
+
26
+ files.each do |file|
27
+ next unless File.exist?(file)
28
+ next unless ruby_file?(file)
29
+
30
+ change = analyze_file(file)
31
+ changes << change if change
32
+ end
33
+
34
+ changes
35
+ end
36
+
37
+ def analyze_branch_changes(target_branch)
38
+ changes = []
39
+
40
+ begin
41
+ # Get current HEAD
42
+ head = @repo.head.target
43
+
44
+ # Get target branch
45
+ target = @repo.branches[target_branch]&.target || @repo.branches["origin/#{target_branch}"]&.target
46
+
47
+ unless target
48
+ # If target branch doesn't exist, analyze staged changes
49
+ return analyze_staged_changes
50
+ end
51
+
52
+ # Get diff between branches
53
+ diff = @repo.diff(target, head)
54
+
55
+ diff.each_delta do |delta|
56
+ file_path = delta.new_file[:path]
57
+ next unless ruby_file?(file_path)
58
+
59
+ change = {
60
+ file: file_path,
61
+ type: delta.status.to_s,
62
+ diff: get_file_diff(delta),
63
+ content: get_file_content(file_path),
64
+ lines_added: 0,
65
+ lines_removed: 0
66
+ }
67
+
68
+ # Count lines
69
+ patch = diff.patch(delta)
70
+ if patch
71
+ change[:lines_added] = patch.scan(/^\+/).length
72
+ change[:lines_removed] = patch.scan(/^-/).length
73
+ end
74
+
75
+ changes << change
76
+ end
77
+
78
+ rescue => e
79
+ puts "Error analyzing git changes: #{e.message}".colorize(:red) if @options[:verbose]
80
+ # Fallback to staged changes
81
+ return analyze_staged_changes
82
+ end
83
+
84
+ changes
85
+ end
86
+
87
+ def analyze_staged_changes
88
+ changes = []
89
+
90
+ # Get staged changes
91
+ index = @repo.index
92
+ diff = @repo.diff_index_to_workdir
93
+
94
+ diff.each_delta do |delta|
95
+ file_path = delta.new_file[:path]
96
+ next unless ruby_file?(file_path)
97
+
98
+ change = {
99
+ file: file_path,
100
+ type: delta.status.to_s,
101
+ diff: get_file_diff(delta),
102
+ content: get_file_content(file_path),
103
+ lines_added: 0,
104
+ lines_removed: 0
105
+ }
106
+
107
+ # Count lines from patch
108
+ patch = diff.patch(delta)
109
+ if patch
110
+ change[:lines_added] = patch.scan(/^\+/).length
111
+ change[:lines_removed] = patch.scan(/^-/).length
112
+ end
113
+
114
+ changes << change
115
+ end
116
+
117
+ changes
118
+ rescue => e
119
+ puts "Error analyzing staged changes: #{e.message}".colorize(:red) if @options[:verbose]
120
+ []
121
+ end
122
+
123
+ def analyze_file(file_path)
124
+ return nil unless File.exist?(file_path)
125
+
126
+ {
127
+ file: file_path,
128
+ type: 'modified',
129
+ diff: get_simple_diff(file_path),
130
+ content: get_file_content(file_path),
131
+ lines_added: 0,
132
+ lines_removed: 0
133
+ }
134
+ end
135
+
136
+ def get_file_diff(delta)
137
+ # Generate diff for the file
138
+ begin
139
+ patch = @repo.diff(delta.old_file[:oid], delta.new_file[:oid]).patch
140
+ patch.to_s
141
+ rescue
142
+ "Diff not available"
143
+ end
144
+ end
145
+
146
+ def get_simple_diff(file_path)
147
+ # For single file analysis, return recent changes or full content
148
+ "Full file content (diff not available for single file analysis)"
149
+ end
150
+
151
+ def get_file_content(file_path)
152
+ return nil unless File.exist?(file_path)
153
+
154
+ File.read(file_path)
155
+ rescue => e
156
+ puts "Error reading file #{file_path}: #{e.message}".colorize(:red) if @options[:verbose]
157
+ nil
158
+ end
159
+
160
+ def ruby_file?(file_path)
161
+ return false unless file_path
162
+
163
+ ruby_extensions = %w[.rb .rake .gemspec]
164
+ ruby_files = %w[Gemfile Rakefile Guardfile]
165
+
166
+ # Check extension
167
+ return true if ruby_extensions.any? { |ext| file_path.end_with?(ext) }
168
+
169
+ # Check specific filenames
170
+ basename = File.basename(file_path)
171
+ return true if ruby_files.include?(basename)
172
+
173
+ # Check if file starts with ruby shebang
174
+ if File.exist?(file_path)
175
+ first_line = File.open(file_path, &:readline).strip rescue ""
176
+ return true if first_line.start_with?('#!/usr/bin/env ruby') ||
177
+ first_line.start_with?('#!/usr/bin/ruby')
178
+ end
179
+
180
+ false
181
+ end
182
+ end
183
+ end
@@ -0,0 +1,171 @@
1
+ require 'json'
2
+ require 'colorize'
3
+
4
+ module CodeSage
5
+ class ReportFormatter
6
+ attr_reader :format
7
+
8
+ def initialize(format = 'console')
9
+ @format = format.to_s.downcase
10
+ end
11
+
12
+ def format(report)
13
+ case @format
14
+ when 'json'
15
+ format_json(report)
16
+ when 'markdown'
17
+ format_markdown(report)
18
+ else
19
+ format_console(report)
20
+ end
21
+ end
22
+
23
+ private
24
+
25
+ def format_console(report)
26
+ output = []
27
+
28
+ # Header
29
+ output << "=" * 80
30
+ output << "šŸ”® CodeSage Review Report".colorize(:cyan).bold
31
+ output << "Generated at: #{report[:generated_at]}"
32
+ output << "=" * 80
33
+ output << ""
34
+
35
+ # Summary
36
+ summary = report[:summary]
37
+ output << "šŸ“Š SUMMARY".colorize(:yellow).bold
38
+ output << "-" * 40
39
+ output << "Files reviewed: #{summary[:total_files_reviewed]}"
40
+ output << "Files with potential issues: #{summary[:files_with_issues]}"
41
+ output << "Total lines changed: #{summary[:total_lines_changed]}"
42
+ output << ""
43
+
44
+ # Metrics
45
+ if report[:metrics]
46
+ metrics = report[:metrics]
47
+ output << "šŸ“ˆ METRICS".colorize(:yellow).bold
48
+ output << "-" * 40
49
+ output << "Average lines per file: #{metrics[:avg_lines_per_file].round(1)}"
50
+
51
+ if metrics[:files_by_type].any?
52
+ output << "Files by change type:"
53
+ metrics[:files_by_type].each do |type, count|
54
+ output << " #{type}: #{count}"
55
+ end
56
+ end
57
+ output << ""
58
+ end
59
+
60
+ # Individual reviews
61
+ output << "šŸ“ DETAILED REVIEWS".colorize(:yellow).bold
62
+ output << "-" * 80
63
+
64
+ report[:reviews].each_with_index do |review, index|
65
+ output << ""
66
+ output << "#{index + 1}. #{review[:file]}".colorize(:cyan).bold
67
+ output << " Type: #{review[:change_type]} | +#{review[:lines_added]} -#{review[:lines_removed]}"
68
+ output << " " + "-" * 70
69
+
70
+ # Format review content
71
+ review_lines = review[:review].split("\n")
72
+ review_lines.each do |line|
73
+ if line.match(/^(Issue|Problem|Warning):/i)
74
+ output << " #{line}".colorize(:red)
75
+ elsif line.match(/^(Suggestion|Recommendation):/i)
76
+ output << " #{line}".colorize(:yellow)
77
+ elsif line.match(/^(Good|Positive):/i)
78
+ output << " #{line}".colorize(:green)
79
+ else
80
+ output << " #{line}"
81
+ end
82
+ end
83
+ end
84
+
85
+ # Recommendations
86
+ if report[:recommendations]&.any?
87
+ output << ""
88
+ output << "šŸ’” RECOMMENDATIONS".colorize(:yellow).bold
89
+ output << "-" * 40
90
+ report[:recommendations].each_with_index do |rec, index|
91
+ output << "#{index + 1}. #{rec}"
92
+ end
93
+ end
94
+
95
+ output << ""
96
+ output << "=" * 80
97
+
98
+ output.join("\n")
99
+ end
100
+
101
+ def format_json(report)
102
+ JSON.pretty_generate(report)
103
+ end
104
+
105
+ def format_markdown(report)
106
+ output = []
107
+
108
+ # Header
109
+ output << "# šŸ”® CodeSage Review Report"
110
+ output << ""
111
+ output << "**Generated at:** #{report[:generated_at]}"
112
+ output << ""
113
+
114
+ # Summary
115
+ summary = report[:summary]
116
+ output << "## šŸ“Š Summary"
117
+ output << ""
118
+ output << "| Metric | Value |"
119
+ output << "|--------|-------|"
120
+ output << "| Files reviewed | #{summary[:total_files_reviewed]} |"
121
+ output << "| Files with potential issues | #{summary[:files_with_issues]} |"
122
+ output << "| Total lines changed | #{summary[:total_lines_changed]} |"
123
+ output << ""
124
+
125
+ # Metrics
126
+ if report[:metrics]
127
+ metrics = report[:metrics]
128
+ output << "## šŸ“ˆ Metrics"
129
+ output << ""
130
+ output << "- **Average lines per file:** #{metrics[:avg_lines_per_file].round(1)}"
131
+
132
+ if metrics[:files_by_type].any?
133
+ output << "- **Files by change type:**"
134
+ metrics[:files_by_type].each do |type, count|
135
+ output << " - #{type}: #{count}"
136
+ end
137
+ end
138
+ output << ""
139
+ end
140
+
141
+ # Individual reviews
142
+ output << "## šŸ“ Detailed Reviews"
143
+ output << ""
144
+
145
+ report[:reviews].each_with_index do |review, index|
146
+ output << "### #{index + 1}. `#{review[:file]}`"
147
+ output << ""
148
+ change_info = "**Change Type:** #{review[:change_type]} | " \
149
+ "**Lines:** +#{review[:lines_added]} -#{review[:lines_removed]}"
150
+ output << change_info
151
+ output << ""
152
+ output << "```"
153
+ output << review[:review]
154
+ output << "```"
155
+ output << ""
156
+ end
157
+
158
+ # Recommendations
159
+ if report[:recommendations]&.any?
160
+ output << "## šŸ’” Recommendations"
161
+ output << ""
162
+ report[:recommendations].each_with_index do |rec, index|
163
+ output << "#{index + 1}. #{rec}"
164
+ end
165
+ output << ""
166
+ end
167
+
168
+ output.join("\n")
169
+ end
170
+ end
171
+ end