ai_refactor 0.3.1 → 0.5.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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +65 -2
  3. data/Gemfile +9 -0
  4. data/Gemfile.lock +169 -1
  5. data/README.md +169 -43
  6. data/Rakefile +1 -1
  7. data/ai_refactor.gemspec +1 -0
  8. data/examples/.gitignore +1 -0
  9. data/examples/ex1_convert_a_rspec_test_to_minitest.yml +7 -0
  10. data/examples/ex1_input_spec.rb +32 -0
  11. data/examples/rails_helper.rb +21 -0
  12. data/examples/test_helper.rb +14 -0
  13. data/exe/ai_refactor +139 -52
  14. data/lib/ai_refactor/cli.rb +138 -0
  15. data/lib/ai_refactor/command_file_parser.rb +27 -0
  16. data/lib/ai_refactor/context.rb +33 -0
  17. data/lib/ai_refactor/file_processor.rb +34 -17
  18. data/lib/ai_refactor/prompt.rb +84 -0
  19. data/lib/ai_refactor/prompts/diff.md +17 -0
  20. data/lib/ai_refactor/prompts/input.md +1 -0
  21. data/lib/ai_refactor/refactors/base_refactor.rb +183 -0
  22. data/lib/ai_refactor/refactors/custom.rb +43 -0
  23. data/lib/ai_refactor/refactors/minitest/write_test_for_class.md +15 -0
  24. data/lib/ai_refactor/refactors/minitest/write_test_for_class.rb +51 -0
  25. data/lib/ai_refactor/refactors/project/write_changelog_from_history.md +35 -0
  26. data/lib/ai_refactor/refactors/project/write_changelog_from_history.rb +50 -0
  27. data/lib/ai_refactor/refactors/{prompts/rspec_to_minitest_rails.md → rails/minitest/rspec_to_minitest.md} +40 -1
  28. data/lib/ai_refactor/refactors/rails/minitest/rspec_to_minitest.rb +77 -0
  29. data/lib/ai_refactor/refactors/rspec/minitest_to_rspec.rb +13 -0
  30. data/lib/ai_refactor/refactors/ruby/refactor_ruby.md +10 -0
  31. data/lib/ai_refactor/refactors/ruby/refactor_ruby.rb +29 -0
  32. data/lib/ai_refactor/refactors/ruby/write_ruby.md +7 -0
  33. data/lib/ai_refactor/refactors/ruby/write_ruby.rb +33 -0
  34. data/lib/ai_refactor/refactors.rb +13 -5
  35. data/lib/ai_refactor/run_configuration.rb +115 -0
  36. data/lib/ai_refactor/{refactors/tests → test_runners}/minitest_runner.rb +2 -2
  37. data/lib/ai_refactor/{refactors/tests → test_runners}/rspec_runner.rb +1 -1
  38. data/lib/ai_refactor/{refactors/tests → test_runners}/test_run_diff_report.rb +1 -1
  39. data/lib/ai_refactor/{refactors/tests → test_runners}/test_run_result.rb +1 -1
  40. data/lib/ai_refactor/version.rb +1 -1
  41. data/lib/ai_refactor.rb +13 -8
  42. metadata +47 -13
  43. data/lib/ai_refactor/base_refactor.rb +0 -66
  44. data/lib/ai_refactor/refactors/generic.rb +0 -113
  45. data/lib/ai_refactor/refactors/minitest_to_rspec.rb +0 -11
  46. data/lib/ai_refactor/refactors/rspec_to_minitest_rails.rb +0 -103
  47. /data/lib/ai_refactor/refactors/{prompts → rspec}/minitest_to_rspec.md +0 -0
@@ -0,0 +1,14 @@
1
+ require "rails/all"
2
+ require "active_support/testing/autorun"
3
+
4
+ class MyModel
5
+ include ActiveModel::Model
6
+ include ActiveModel::Attributes
7
+ include ActiveModel::Validations
8
+ include ActiveModel::Validations::Callbacks
9
+
10
+ validates :name, presence: true
11
+
12
+ attribute :name, :string
13
+ attribute :age, :integer
14
+ end
data/exe/ai_refactor CHANGED
@@ -3,47 +3,87 @@
3
3
  require "optparse"
4
4
  require "colorize"
5
5
  require "openai"
6
+ require "shellwords"
6
7
  require_relative "../lib/ai_refactor"
7
8
 
8
- options = {}
9
+ require "dotenv/load"
9
10
 
10
11
  supported_refactors = AIRefactor::Refactors.all
11
- supported_names = AIRefactor::Refactors.names
12
+ refactors_descriptions = AIRefactor::Refactors.descriptions
13
+
14
+ arguments = ARGV.dup
15
+
16
+ options_from_config_file = AIRefactor::Cli.load_options_from_config_file
17
+ arguments += options_from_config_file if options_from_config_file
18
+
19
+ run_config = AIRefactor::RunConfiguration.new
12
20
 
13
21
  # General options for all refactor types
14
22
  option_parser = OptionParser.new do |parser|
15
- parser.banner = "Usage: ai_refactor REFACTOR_TYPE INPUT_FILE_OR_DIR [options]\n\nWhere REFACTOR_TYPE is one of: #{supported_names}\n\n"
23
+ parser.banner = "Usage: ai_refactor REFACTOR_TYPE_OR_COMMAND_FILE INPUT_FILE_OR_DIR [options]\n\nWhere REFACTOR_TYPE_OR_COMMAND_FILE is either the path to a command YML file, or one of the refactor types to run: \n- #{refactors_descriptions.to_a.map { |refactor| refactor.join(": ") }.join("\n- ")}\n\n"
24
+
25
+ parser.on("-o", "--output [FILE]", String, "Write output to given file instead of stdout. If no path provided will overwrite input file (will prompt to overwrite existing files). Some refactor tasks will write out to a new file by default. This option will override the tasks default behaviour.") do |f|
26
+ run_config.output_file_path = f
27
+ end
28
+
29
+ parser.on("-O", "--output-template TEMPLATE", String, "Write outputs to files instead of stdout. The template is used to create the output name, where the it can have substitutions, '[FILE]', '[NAME]', '[DIR]', '[REFACTOR]' & '[EXT]'. Eg `[DIR]/[NAME]_[REFACTOR][EXT]` (will prompt to overwrite existing files)") do |t|
30
+ run_config.output_template_path = t
31
+ end
32
+
33
+ parser.on("-c", "--context CONTEXT_FILES", Array, "Specify one or more files to use as context for the AI. The contents of these files will be prepended to the prompt sent to the AI.") do |c|
34
+ run_config.context_file_paths = c
35
+ end
36
+
37
+ parser.on("-x", "--extra CONTEXT_TEXT", String, "Specify some text to be prepended to the prompt sent to the AI as extra information of note.") do |c|
38
+ run_config.context_text = c
39
+ end
40
+
41
+ parser.on("-r", "--review-prompt", "Show the prompt that will be sent to ChatGPT but do not actually call ChatGPT or make changes to files.") do
42
+ run_config.review_prompt = true
43
+ end
16
44
 
17
45
  parser.on("-p", "--prompt PROMPT_FILE", String, "Specify path to a text file that contains the ChatGPT 'system' prompt.") do |f|
18
- options[:prompt_file_path] = f
46
+ run_config.prompt_file_path = f
47
+ end
48
+
49
+ parser.on("-f", "--diffs", "Request AI generate diffs of changes rather than writing out the whole file.") do
50
+ run_config.diff = true
19
51
  end
20
52
 
21
- parser.on("-c", "--continue [MAX_MESSAGES]", Integer, "If ChatGPT stops generating due to the maximum token count being reached, continue to generate more messages, until a stop condition or MAX_MESSAGES. MAX_MESSAGES defaults to 3") do |c|
22
- options[:ai_max_attempts] = c || 3
53
+ parser.on("-C", "--continue [MAX_MESSAGES]", Integer, "If ChatGPT stops generating due to the maximum token count being reached, continue to generate more messages, until a stop condition or MAX_MESSAGES. MAX_MESSAGES defaults to 3") do |c|
54
+ run_config.ai_max_attempts = c
23
55
  end
24
56
 
25
- parser.on("-m", "--model MODEL_NAME", String, "Specify a ChatGPT model to use (default gpt-3.5-turbo).") do |m|
26
- options[:ai_model] = m
57
+ parser.on("-m", "--model MODEL_NAME", String, "Specify a ChatGPT model to use (default gpt-4).") do |m|
58
+ run_config.ai_model = m
27
59
  end
28
60
 
29
61
  parser.on("--temperature TEMP", Float, "Specify the temperature parameter for ChatGPT (default 0.7).") do |p|
30
- options[:ai_temperature] = p
62
+ run_config.ai_temperature = p
31
63
  end
32
64
 
33
65
  parser.on("--max-tokens MAX_TOKENS", Integer, "Specify the max number of tokens of output ChatGPT can generate. Max will depend on the size of the prompt (default 1500)") do |m|
34
- options[:ai_max_tokens] = m
66
+ run_config.ai_max_tokens = m
35
67
  end
36
68
 
37
69
  parser.on("-t", "--timeout SECONDS", Integer, "Specify the max wait time for ChatGPT response.") do |m|
38
- options[:ai_timeout] = m
70
+ run_config.ai_timeout = m
71
+ end
72
+
73
+ parser.on("--overwrite ANSWER", "Always overwrite existing output files, 'y' for yes, 'n' for no, or 'a' for ask. Default to ask.") do |a|
74
+ run_config.overwrite = a
75
+ end
76
+
77
+ parser.on("-N", "--no", "Never overwrite existing output files, same as --overwrite=n.") do |a|
78
+ run_config.overwrite = "n"
39
79
  end
40
80
 
41
81
  parser.on("-v", "--verbose", "Show extra output and progress info") do
42
- options[:verbose] = true
82
+ run_config.verbose = true
43
83
  end
44
84
 
45
85
  parser.on("-d", "--debug", "Show debugging output to help diagnose issues") do
46
- options[:debug] = true
86
+ run_config.debug = true
47
87
  end
48
88
 
49
89
  parser.on("-h", "--help", "Prints this help") do
@@ -53,67 +93,114 @@ option_parser = OptionParser.new do |parser|
53
93
 
54
94
  parser.separator ""
55
95
 
96
+ # Example in Refactor class:
97
+ #
98
+ # class << self
99
+ # def command_line_options
100
+ # [
101
+ # {
102
+ # key: :my_option_key,
103
+ # short: "-s",
104
+ # long: "--long-form-cli-param [FILE]",
105
+ # type: String,
106
+ # help: "help text"
107
+ # },
108
+ # ...
109
+ # ]
110
+ # end
111
+ # end
56
112
  supported_refactors.each do |name, refactorer|
57
113
  parser.separator "For refactor type '#{name}':" if refactorer.command_line_options.size.positive?
58
114
  refactorer.command_line_options.each do |option|
59
115
  args = [option[:long], option[:type], option[:help]]
60
116
  args.unshift(option[:short]) if option[:short]
117
+ AIRefactor::RunConfiguration.add_new_option(option[:key])
61
118
  parser.on(*args) do |o|
62
- options[option[:key]] = o.nil? ? true : o
119
+ run_config.send("#{option[:key]}=", o.nil? ? true : o)
63
120
  end
64
121
  end
65
122
  end
66
123
  end
67
124
 
68
- option_parser.parse!
125
+ def exit_with_option_error(message, option_parser = nil, logger = nil)
126
+ logger ? logger.error(message, bold: true) : puts(message)
127
+ puts option_parser if option_parser
128
+ exit false
129
+ end
69
130
 
70
- logger = AIRefactor::Logger.new(verbose: options[:verbose], debug: options[:debug])
131
+ def exit_with_error(message, logger = nil)
132
+ logger ? logger.error(message, bold: true) : puts(message)
133
+ exit false
134
+ end
71
135
 
72
- refactoring_type = ARGV.shift
73
- input_file_path = ARGV
136
+ # If no command was provided, prompt for one in interactive mode
137
+ if arguments.empty? || arguments.all? { |arg| arg.start_with?("-") && !(arg == "-h" || arg == "--help") }
138
+ interactive_log = AIRefactor::Logger.new
139
+ # For each option that is required but not provided, prompt for it
140
+ # Put the option in arguments to parse with option_parser
141
+ interactive_log.info "Interactive mode started. You can use tab to autocomplete:"
142
+ predefined_commands = AIRefactor::Refactors.names
74
143
 
75
- if !AIRefactor::Refactors.supported?(refactoring_type) || input_file_path.nil? || input_file_path.empty?
76
- puts option_parser.help
77
- exit 1
78
- end
144
+ interactive_log.info "Available refactors: #{predefined_commands.join(", ")}\n"
145
+ command = AIRefactor::Cli.request_input_with_autocomplete("Enter refactor name: ", predefined_commands)
146
+ exit_with_option_error("No refactor name provided.", option_parser) if command.nil? || command.empty?
147
+ initial = [command]
79
148
 
80
- OpenAI.configure do |config|
81
- config.access_token = ENV.fetch("OPENAI_API_KEY")
82
- config.organization_id = ENV.fetch("OPENAI_ORGANIZATION_ID", nil)
83
- config.request_timeout = options[:ai_timeout] || 240
84
- end
149
+ input_path = AIRefactor::Cli.request_file_inputs("Enter input file path: ", multiple: false)
150
+ exit_with_option_error("No input file path provided.", option_parser) if input_path.nil? || input_path.empty?
151
+ initial << input_path
85
152
 
86
- refactorer = AIRefactor::Refactors.get(refactoring_type)
153
+ arguments.prepend(*initial)
87
154
 
88
- inputs = input_file_path.map do |path|
89
- File.exist?(path) ? path : Dir.glob(path)
90
- end.flatten
155
+ # Ask if template should be used - then prompt for it
91
156
 
92
- logger.info "AI Refactor #{inputs.size} files(s)/dir(s) '#{input_file_path}' with #{refactorer.refactor_name} refactor\n"
93
- logger.info "====================\n"
157
+ output = AIRefactor::Cli.request_file_inputs("Enter output file path (blank for refactor default): ", multiple: false)
158
+ arguments.concat(["-o", " #{output}"]) unless output.nil? || output.empty?
94
159
 
95
- return_values = inputs.map do |file|
96
- logger.info "Processing #{file}..."
160
+ context_text = AIRefactor::Cli.request_text_input("Enter extra text to add to prompt (blank for none): ")
161
+ arguments.concat(["-x", context_text]) unless context_text.nil? || context_text.empty?
97
162
 
98
- refactor = refactorer.new(file, options, logger)
99
- refactor_returned = refactor.run
100
- failed = refactor_returned == false
101
- if failed
102
- logger.warn "Refactor failed on #{file}\nFailed due to: #{refactor.failed_message}\n"
103
- else
104
- logger.success "Refactor succeeded on #{file}\n"
105
- if refactor_returned.is_a?(String)
106
- logger.info "Refactor #{file} output:\n\n#{refactor_returned}\n\n"
107
- end
108
- end
109
- failed ? [file, refactor.failed_message] : true
163
+ context_files = AIRefactor::Cli.request_file_inputs("Enter extra context file path(s) (blank for none): ")
164
+ arguments.concat(["-c", context_files]) unless context_files.nil? || context_files.empty?
165
+
166
+ prompt_file = AIRefactor::Cli.request_file_inputs("Enter Prompt file path (blank for refactor default): ", multiple: false)
167
+ arguments.concat(["-p", prompt_file]) unless prompt_file.nil? || prompt_file.empty?
168
+
169
+ review = AIRefactor::Cli.request_switch("Dry-run (review prompt only)? (y/N) (blank for 'N'): ")
170
+ arguments << "-r" if review
171
+ end
172
+
173
+ File.write(".ai_refactor_history", arguments.join(" ") + "\n", mode: "a")
174
+
175
+ begin
176
+ option_parser.parse!(arguments)
177
+ rescue OptionParser::InvalidOption, OptionParser::MissingArgument
178
+ exit_with_option_error($!, option_parser)
110
179
  end
111
180
 
112
- if return_values.all?(true)
113
- logger.success "All files processed successfully!"
181
+ logger = AIRefactor::Logger.new(verbose: run_config.verbose, debug: run_config.debug)
182
+ logger.info "Loaded config from '#{options_from_config_file}'..." if options_from_config_file
183
+
184
+ command_or_file = arguments.shift
185
+ if AIRefactor::CommandFileParser.command_file?(command_or_file)
186
+ logger.info "Loading refactor command file '#{command_or_file}'..."
187
+ begin
188
+ run_config.set!(AIRefactor::CommandFileParser.new(command_or_file).parse)
189
+ rescue => e
190
+ exit_with_option_error(e.message, option_parser, logger)
191
+ end
114
192
  else
115
- files = return_values.select { |v| v != true }
116
- logger.warn "Some files failed to process:\n#{files.map { |f| "#{f[0]} :\n > #{f[1]}" }.join("\n")}"
193
+ logger.info "Requested to run refactor '#{command_or_file}'..."
117
194
  end
118
195
 
119
- logger.info "Done processing all files!"
196
+ run_config.input_file_paths = arguments
197
+
198
+ job = AIRefactor::Cli.new(run_config, logger: logger)
199
+
200
+ unless job.valid?
201
+ exit_with_error("Refactor job failed or was not correctly configured. Did you specify the required inputs or options?.", logger)
202
+ end
203
+
204
+ unless job.run
205
+ exit false
206
+ end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "readline"
4
+
5
+ module AIRefactor
6
+ class Cli
7
+ class << self
8
+ def load_options_from_config_file
9
+ # Load config from ~/.ai_refactor or .ai_refactor
10
+ home_config_file_path = File.expand_path("~/.ai_refactor")
11
+ local_config_file_path = File.join(Dir.pwd, ".ai_refactor")
12
+
13
+ config_file_path = if File.exist?(local_config_file_path)
14
+ local_config_file_path
15
+ elsif File.exist?(home_config_file_path)
16
+ home_config_file_path
17
+ end
18
+ return unless config_file_path
19
+
20
+ config_string = File.read(config_file_path)
21
+ config_lines = config_string.split(/\n+/).reject { |s| s =~ /\A\s*#/ }.map(&:strip)
22
+ config_lines.flat_map(&:shellsplit)
23
+ end
24
+
25
+ def request_text_input(prompt)
26
+ puts prompt
27
+ gets.chomp
28
+ end
29
+
30
+ def request_input_with_autocomplete(prompt, completion_list)
31
+ Readline.completion_append_character = nil
32
+ Readline.completion_proc = proc do |str|
33
+ completion_list.grep(/^#{Regexp.escape(str)}/)
34
+ end
35
+ Readline.readline(prompt, true)
36
+ end
37
+
38
+ def request_file_inputs(prompt, multiple: true)
39
+ Readline.completion_append_character = multiple ? " " : nil
40
+ Readline.completion_proc = Readline::FILENAME_COMPLETION_PROC
41
+
42
+ paths = Readline.readline(prompt, true)
43
+ multiple ? paths.gsub(/[^\\] /, ",") : paths
44
+ end
45
+
46
+ def request_switch(prompt)
47
+ (Readline.readline(prompt, true) =~ /^y/i) ? true : false
48
+ end
49
+ end
50
+
51
+ def initialize(configuration, logger:)
52
+ @configuration = configuration
53
+ @logger = logger
54
+ end
55
+
56
+ attr_reader :configuration, :logger
57
+
58
+ def refactoring_type
59
+ configuration.refactor
60
+ end
61
+
62
+ def inputs
63
+ configuration.input_file_paths
64
+ end
65
+
66
+ def valid?
67
+ return false unless refactorer
68
+ inputs_valid = refactorer.takes_input_files? ? !(inputs.nil? || inputs.empty?) : true
69
+ AIRefactor::Refactors.supported?(refactoring_type) && inputs_valid
70
+ end
71
+
72
+ def run
73
+ return false unless valid?
74
+
75
+ OpenAI.configure do |config|
76
+ config.access_token = ENV.fetch("OPENAI_API_KEY")
77
+ config.organization_id = ENV.fetch("OPENAI_ORGANIZATION_ID", nil)
78
+ config.request_timeout = configuration.ai_timeout || 240
79
+ end
80
+
81
+ if refactorer.takes_input_files?
82
+ expanded_inputs = inputs.map do |path|
83
+ File.exist?(path) ? path : Dir.glob(path)
84
+ end.flatten
85
+
86
+ logger.info "AI Refactor #{expanded_inputs.size} files(s)/dir(s) '#{expanded_inputs}' with #{refactorer.refactor_name} refactor\n"
87
+ logger.info "====================\n"
88
+
89
+ return_values = expanded_inputs.map do |file|
90
+ logger.info "Processing #{file}..."
91
+
92
+ refactor = refactorer.new(file, configuration, logger)
93
+ refactor_returned = refactor.run
94
+ failed = refactor_returned == false
95
+ if failed
96
+ logger.warn "Refactor failed on #{file}\nFailed due to: #{refactor.failed_message}\n"
97
+ else
98
+ logger.success "Refactor succeeded on #{file}\n"
99
+ if refactor_returned.is_a?(String)
100
+ logger.info "Refactor #{file} output:\n\n#{refactor_returned}\n\n"
101
+ end
102
+ end
103
+ failed ? [file, refactor.failed_message] : true
104
+ end
105
+
106
+ if return_values.all?(true)
107
+ logger.success "All files processed successfully!"
108
+ else
109
+ files = return_values.select { |v| v != true }
110
+ logger.warn "Some files failed to process:\n#{files.map { |f| "#{f[0]} :\n > #{f[1]}" }.join("\n")}"
111
+ end
112
+
113
+ logger.info "Done processing all files!"
114
+ else
115
+ name = refactorer.refactor_name
116
+ logger.info "AI Refactor - #{name} refactor\n"
117
+ logger.info "====================\n"
118
+ refactor = refactorer.new(nil, configuration, logger)
119
+ refactor_returned = refactor.run
120
+ failed = refactor_returned == false
121
+ if failed
122
+ logger.warn "Refactor failed with #{name}\nFailed due to: #{refactor.failed_message}\n"
123
+ else
124
+ logger.success "Refactor succeeded with #{name}\n"
125
+ if refactor_returned.is_a?(String)
126
+ logger.info "Refactor output:\n\n#{refactor_returned}\n\n"
127
+ end
128
+ end
129
+ end
130
+ end
131
+
132
+ private
133
+
134
+ def refactorer
135
+ @refactorer ||= AIRefactor::Refactors.get(refactoring_type)
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module AIRefactor
6
+ class CommandFileParser
7
+ def self.command_file?(name)
8
+ name.match?(/\.ya?ml$/)
9
+ end
10
+
11
+ def initialize(path)
12
+ @path = path
13
+ end
14
+
15
+ def parse
16
+ raise StandardError, "Invalid command file: file does not exist" unless File.exist?(@path)
17
+
18
+ options = YAML.safe_load_file(@path, permitted_classes: [Symbol], symbolize_names: true, aliases: true)
19
+
20
+ unless options && options[:refactor]
21
+ raise StandardError, "Invalid command file format, a 'refactor' key is required"
22
+ end
23
+
24
+ options
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AIRefactor
4
+ class Context
5
+ def initialize(files:, text:, logger:)
6
+ @files = files
7
+ @text = text
8
+ @logger = logger
9
+ end
10
+
11
+ def prepare_context
12
+ context = read_contexts&.compact
13
+ file_context = (context && context.size.positive?) ? "Here is some related files:\n\n#{context.join("\n")}" : ""
14
+ if @text.nil? || @text.empty?
15
+ file_context
16
+ else
17
+ "Also note: #{@text}\n\n#{file_context}"
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ def read_contexts
24
+ @files&.map do |file|
25
+ unless File.exist?(file)
26
+ @logger.warn "Context file #{file} does not exist"
27
+ next
28
+ end
29
+ "#---\n# File '#{file}':\n\n```#{File.read(file)}```\n"
30
+ end
31
+ end
32
+ end
33
+ end
@@ -1,18 +1,19 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "fileutils"
3
4
  require "openai"
4
5
  require "json"
5
6
 
6
7
  module AIRefactor
7
8
  class FileProcessor
8
- attr_reader :file_path, :output_path, :logger
9
+ attr_reader :input_file_path, :output_path, :logger, :options
9
10
 
10
- def initialize(input_path:, prompt_file_path:, ai_client:, logger:, output_path: nil)
11
- @file_path = input_path
12
- @prompt_file_path = prompt_file_path
11
+ def initialize(prompt:, ai_client:, logger:, output_path: nil, options: {})
12
+ @prompt = prompt
13
13
  @ai_client = ai_client
14
14
  @logger = logger
15
15
  @output_path = output_path
16
+ @options = options
16
17
  end
17
18
 
18
19
  def output_exists?
@@ -20,20 +21,26 @@ module AIRefactor
20
21
  File.exist?(output_path)
21
22
  end
22
23
 
23
- def process!(options)
24
- logger.debug("Processing #{file_path} with prompt in #{@prompt_file_path}")
25
- prompt = File.read(@prompt_file_path)
26
- input = File.read(@file_path)
27
- messages = [
28
- {role: "system", content: prompt},
29
- {role: "user", content: "Convert: ```#{input}```"}
30
- ]
31
- content, finished_reason, usage = generate_next_message(messages, prompt, options, options[:ai_max_attempts] || 3)
24
+ def process!
25
+ logger.debug("Processing #{@prompt.input_file_path} with prompt in #{options.prompt_file_path}")
26
+ logger.debug("Options: #{options.inspect}")
27
+ messages = @prompt.chat_messages
28
+ if options[:review_prompt]
29
+ logger.info "Review prompt:\n"
30
+ messages.each do |message|
31
+ logger.info "\n-- Start of prompt for Role #{message[:role]} --\n"
32
+ logger.info message[:content]
33
+ logger.info "\n-- End of prompt for Role #{message[:role]} --\n"
34
+ end
35
+ return [nil, "Skipped as review prompt was requested", nil]
36
+ end
37
+
38
+ content, finished_reason, usage = generate_next_message(messages, options, ai_max_attempts)
32
39
 
33
40
  content = if content && content.length > 0
34
41
  processed = block_given? ? yield(content) : content
35
42
  if output_path
36
- File.write(output_path, processed)
43
+ write_output(output_path, processed)
37
44
  logger.verbose "Wrote output to #{output_path}..."
38
45
  end
39
46
  processed
@@ -44,14 +51,18 @@ module AIRefactor
44
51
 
45
52
  private
46
53
 
47
- def generate_next_message(messages, prompt, options, attempts_left)
54
+ def ai_max_attempts
55
+ options[:ai_max_attempts] || 1
56
+ end
57
+
58
+ def generate_next_message(messages, options, attempts_left)
48
59
  logger.verbose "Generate AI output. Generation attempts left: #{attempts_left}"
49
60
  logger.debug "Options: #{options.inspect}"
50
61
  logger.debug "Messages: #{messages.inspect}"
51
62
 
52
63
  response = @ai_client.chat(
53
64
  parameters: {
54
- model: options[:ai_model] || "gpt-3.5-turbo",
65
+ model: options[:ai_model] || "gpt-4",
55
66
  messages: messages,
56
67
  temperature: options[:ai_temperature] || 0.7,
57
68
  max_tokens: options[:ai_max_tokens] || 1500
@@ -69,7 +80,7 @@ module AIRefactor
69
80
  generate_next_message(messages + [
70
81
  {role: "assistant", content: content},
71
82
  {role: "user", content: "Continue"}
72
- ], prompt, options, attempts_left - 1)
83
+ ], options, attempts_left - 1)
73
84
  else
74
85
  previous_messages = messages.filter { |m| m[:role] == "assistant" }.map { |m| m[:content] }.join
75
86
  content = if previous_messages.length > 0
@@ -80,5 +91,11 @@ module AIRefactor
80
91
  [content, finished_reason, response["usage"]]
81
92
  end
82
93
  end
94
+
95
+ def write_output(output_path, processed)
96
+ dir = File.dirname(output_path)
97
+ FileUtils.mkdir_p(dir) unless File.directory?(dir)
98
+ File.write(output_path, processed)
99
+ end
83
100
  end
84
101
  end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AIRefactor
4
+ class Prompt
5
+ INPUT_FILE_PATH_MARKER = "__{{input_file_path}}__"
6
+ OUTPUT_FILE_PATH_MARKER = "__{{output_file_path}}__"
7
+ HEADER_MARKER = "__{{prompt_header}}__"
8
+ FOOTER_MARKER = "__{{prompt_footer}}__"
9
+ CONTEXT_MARKER = "__{{context}}__"
10
+ CONTENT_MARKER = "__{{content}}__"
11
+
12
+ attr_reader :input_file_path
13
+
14
+ def initialize(options:, logger:, context: nil, input_content: nil, input_path: nil, output_file_path: nil, prompt: nil, prompt_header: nil, prompt_footer: nil)
15
+ @input_content = input_content
16
+ @input_file_path = input_path
17
+ @output_file_path = output_file_path
18
+ @logger = logger
19
+ @header = prompt_header
20
+ @footer = prompt_footer
21
+ @diff = options[:diff]
22
+ @context = context
23
+ @prompt = prompt || raise(StandardError, "Prompt not provided")
24
+ end
25
+
26
+ def chat_messages
27
+ [
28
+ {role: "system", content: system_prompt},
29
+ {role: "user", content: user_prompt}
30
+ ]
31
+ end
32
+
33
+ private
34
+
35
+ def system_prompt
36
+ prompt = expand_prompt(system_prompt_template, HEADER_MARKER, @header || "")
37
+ prompt = expand_prompt(prompt, CONTEXT_MARKER, @context&.prepare_context || "")
38
+ prompt = expand_prompt(prompt, INPUT_FILE_PATH_MARKER, @input_file_path || "")
39
+ prompt = expand_prompt(prompt, OUTPUT_FILE_PATH_MARKER, @output_file_path || "")
40
+ expand_prompt(prompt, FOOTER_MARKER, system_prompt_footer)
41
+ end
42
+
43
+ def system_prompt_template
44
+ @prompt
45
+ end
46
+
47
+ def system_prompt_footer
48
+ if @diff && @footer
49
+ "#{@footer}\n\n#{diff_prompt}"
50
+ elsif @diff
51
+ diff_prompt
52
+ elsif @footer
53
+ @footer
54
+ else
55
+ ""
56
+ end
57
+ end
58
+
59
+ def diff_prompt
60
+ File.read(prompt_path("diff.md"))
61
+ end
62
+
63
+ def prompt_path(file)
64
+ File.join(File.dirname(File.expand_path(__FILE__)), "prompts", file)
65
+ end
66
+
67
+ def user_prompt
68
+ expand_prompt(input_prompt, CONTENT_MARKER, input_to_process)
69
+ end
70
+
71
+ def input_to_process
72
+ return File.read(@input_file_path) if @input_file_path
73
+ @input_content
74
+ end
75
+
76
+ def input_prompt
77
+ File.read(prompt_path("input.md"))
78
+ end
79
+
80
+ def expand_prompt(prompt, marker, content)
81
+ prompt.gsub(marker, content)
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,17 @@
1
+ You MUST generate a diff in a format that can be understood and applied using git.
2
+
3
+ Generate diff hunks that capture the modifications you see. The diff hunks should be in a format that git can understand and apply, including a hunk header and
4
+ the lines of code that have been modified.
5
+ Finally, output the generated diff as your answer. Do not provide further instruction.
6
+
7
+ Example diff:
8
+
9
+ ```
10
+ @@ -27,7 +27,7 @@ module AIRefactor
11
+ File.read(@prompt_file_path)
12
+ end
13
+
14
+ - def user_prompt
15
+ + def user_prompt_with_diff
16
+ input = File.read(@file_path)
17
+ ```
@@ -0,0 +1 @@
1
+ ```__{{content}}__```