ai_refactor 0.1.0 → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: db3b53eb0cea61e83d0d7f5632316be07ced02da11e065626cc5d72329191538
4
- data.tar.gz: 9b475208c15ccfaf9185d8c516e62efc28632dc2a6dab6d25beaba28be726278
3
+ metadata.gz: fa9421b9791dda3c02b930e63d0fa7bd3dc587d121a0b3748a0f9d8c649f98a3
4
+ data.tar.gz: a5cb385968939847ebb1ceb1d663b1926e410fce0571fa2ab7463440347c0f50
5
5
  SHA512:
6
- metadata.gz: 97e8c839f7c5e2dd6fc1edc12b6c46f45e99e07701da53c313c7677a159529dffd91a724cf54cb33b0c51d1b84939fb07e94c9dae516906a72030b30f3c9fcaa
7
- data.tar.gz: cd4250db8c71efad4daf61ac551b930904a103b573c595cbc3f2a8be7d6b9947576b00bce52f9c8f5858962802eb8827959b8aba5d32193a3732acfb7a108aeb
6
+ metadata.gz: 6ff997f0533fe95da7f0361789d88b4a73ecd8fbd82a1f54179fbce3b6a3b466c40330ae615a00f850e549212fe2463ba60ca4467cc0d9f65bbd6679f326dff1
7
+ data.tar.gz: 5898b17ebaa96c24a89cae1ff741a53d3afbb7a6c4844f52a271ddfecc1ebc1923026324601cb98e56b5aed0d673bba5d583d388a7a936e4cc86b272e1858bd8
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- ai_refactor (0.1.0)
4
+ ai_refactor (0.3.0)
5
5
  colorize (< 2.0)
6
6
  open3 (< 2.0)
7
7
  ruby-openai (>= 3.4.0, < 5.0)
data/README.md CHANGED
@@ -9,7 +9,10 @@ This is based on the assumption that the LLM AIs are pretty good at identifying
9
9
 
10
10
  ## Available refactors
11
11
 
12
- Currently only one is available:
12
+ Currently available:
13
+
14
+ - `generic`
15
+ - `rspec_to_minitest_rails`
13
16
 
14
17
  ### `rspec_to_minitest_rails`
15
18
 
@@ -25,21 +28,27 @@ AI Refactor 1 files(s)/dir(s) '["spec/models/my_thing_spec.rb"]' with rspec_to_m
25
28
  ====================
26
29
  Processing spec/models/my_thing_spec.rb...
27
30
  [Run spec spec/models/my_thing_spec.rb... (bundle exec rspec spec/models/my_thing_spec.rb)]
28
- Do you wish to overwrite test/models/company_buyer_test.rb? (y/n)
31
+ Do you wish to overwrite test/models/my_thing_test.rb? (y/n)
29
32
  y
30
33
  [Converting spec/models/my_thing_spec.rb...]
31
34
  [Generate AI output. Generation attempts left: 3]
32
35
  [OpenAI finished, with reason 'stop'...]
33
36
  [Used tokens: 1869]
34
- [Converted spec/models/my_thing_spec.rb to test/models/company_buyer_test.rb...]
35
- [Run generated test file test/models/company_buyer_test.rb (bundle exec rails test test/models/company_buyer_test.rb)...]
36
- [Done converting spec/models/my_thing_spec.rb to test/models/company_buyer_test.rb...]
37
+ [Converted spec/models/my_thing_spec.rb to test/models/my_thing_test.rb...]
38
+ [Run generated test file test/models/my_thing_test.rb (bundle exec rails test test/models/my_thing_test.rb)...]
39
+ [Done converting spec/models/my_thing_spec.rb to test/models/my_thing_test.rb...]
37
40
  No differences found! Conversion worked!
38
41
  Refactor succeeded on spec/models/my_thing_spec.rb
39
42
 
40
43
  Done processing all files!
41
44
  ```
42
45
 
46
+ ### `generic` (user supplied prompt)
47
+
48
+ Applies the refactor specified by prompting the AI with the user supplied prompt. You must supply a prompt file with the `-p` option.
49
+
50
+ The output is written to `stdout`.
51
+
43
52
  ## Installation
44
53
 
45
54
  Install the gem and add to the application's Gemfile by executing:
data/exe/ai_refactor CHANGED
@@ -14,7 +14,6 @@ supported_names = AIRefactor::Refactors.names
14
14
  option_parser = OptionParser.new do |parser|
15
15
  parser.banner = "Usage: ai_refactor REFACTOR_TYPE INPUT_FILE_OR_DIR [options]\n\nWhere REFACTOR_TYPE is one of: #{supported_names}\n\n"
16
16
 
17
- # todo: support for a sort of generic process which uses a custom prompt file
18
17
  parser.on("-p", "--prompt PROMPT_FILE", String, "Specify path to a text file that contains the ChatGPT 'system' prompt.") do |f|
19
18
  options[:prompt_file_path] = f
20
19
  end
@@ -27,11 +26,11 @@ option_parser = OptionParser.new do |parser|
27
26
  options[:ai_model] = m
28
27
  end
29
28
 
30
- parser.on(nil, "--temperature TEMP", Float, "Specify the temperature parameter for ChatGPT (default 0.7).") do |p|
29
+ parser.on("--temperature TEMP", Float, "Specify the temperature parameter for ChatGPT (default 0.7).") do |p|
31
30
  options[:ai_temperature] = p
32
31
  end
33
32
 
34
- parser.on(nil, "--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|
33
+ 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|
35
34
  options[:ai_max_tokens] = m
36
35
  end
37
36
 
@@ -47,18 +46,23 @@ option_parser = OptionParser.new do |parser|
47
46
  options[:debug] = true
48
47
  end
49
48
 
50
- supported_refactors.each do |_name, refactorer|
51
- refactorer.command_line_options.each do |option|
52
- parser.on(option[:short], option[:long], option[:type], option[:help]) do |o|
53
- options[option[:key]] = o
54
- end
55
- end
56
- end
57
-
58
49
  parser.on("-h", "--help", "Prints this help") do
59
50
  puts parser
60
51
  exit
61
52
  end
53
+
54
+ parser.separator ""
55
+
56
+ supported_refactors.each do |name, refactorer|
57
+ parser.separator "For refactor type '#{name}':" if refactorer.command_line_options.size.positive?
58
+ refactorer.command_line_options.each do |option|
59
+ args = [option[:long], option[:type], option[:help]]
60
+ args.unshift(option[:short]) if option[:short]
61
+ parser.on(*args) do |o|
62
+ options[option[:key]] = o.nil? ? true : o
63
+ end
64
+ end
65
+ end
62
66
  end
63
67
 
64
68
  option_parser.parse!
@@ -88,15 +92,28 @@ end.flatten
88
92
  logger.info "AI Refactor #{inputs.size} files(s)/dir(s) '#{input_file_path}' with #{refactorer.refactor_name} refactor\n"
89
93
  logger.info "====================\n"
90
94
 
91
- inputs.each do |file|
95
+ return_values = inputs.map do |file|
92
96
  logger.info "Processing #{file}..."
93
97
 
94
98
  refactor = refactorer.new(file, options, logger)
95
-
96
- if refactor.run
97
- logger.success "Refactor succeeded on #{file}\n"
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"
98
103
  else
99
- logger.warn "Refactor failed on #{file}\n"
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
100
108
  end
109
+ failed ? [file, refactor.failed_message] : true
110
+ end
111
+
112
+ if return_values.all?(true)
113
+ logger.success "All files processed successfully!"
114
+ 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")}"
101
117
  end
118
+
102
119
  logger.info "Done processing all files!"
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AIRefactor
4
+ class BaseRefactor
5
+ attr_reader :input_file, :options, :logger
6
+ attr_writer :failed_message
7
+
8
+ def initialize(input_file, options, logger)
9
+ @input_file = input_file
10
+ @options = options
11
+ @logger = logger
12
+ end
13
+
14
+ def run
15
+ raise NotImplementedError
16
+ end
17
+
18
+ def failed_message
19
+ @failed_message || "Reason not specified"
20
+ end
21
+
22
+ private
23
+
24
+ def can_overwrite_output_file?(output_path)
25
+ logger.info "Do you wish to overwrite #{output_path}? (y/n)"
26
+ answer = $stdin.gets.chomp
27
+ unless answer == "y" || answer == "Y"
28
+ logger.warn "Skipping #{input_file}..."
29
+ self.failed_message = "Skipped as output file already exists"
30
+ return false
31
+ end
32
+ true
33
+ end
34
+
35
+ def prompt_file_path
36
+ self.class.prompt_file_path
37
+ end
38
+
39
+ def ai_client
40
+ @ai_client ||= OpenAI::Client.new
41
+ end
42
+
43
+ class << self
44
+ def command_line_options
45
+ []
46
+ end
47
+
48
+ def refactor_name
49
+ name.split("::")
50
+ .last
51
+ .gsub(/::/, "/")
52
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
53
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
54
+ .tr("-", "_")
55
+ .downcase
56
+ end
57
+
58
+ def prompt_file_path
59
+ file = if options[:prompt_file_path]&.length&.positive?
60
+ options[:prompt_file_path]
61
+ else
62
+ File.join(File.dirname(File.expand_path(__FILE__)), "prompts", "#{refactor_name}.md")
63
+ end
64
+ file.tap do |prompt|
65
+ raise "No prompt file '#{prompt}' found for #{refactor_name}" unless File.exist?(prompt)
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -7,15 +7,16 @@ module AIRefactor
7
7
  class FileProcessor
8
8
  attr_reader :file_path, :output_path, :logger
9
9
 
10
- def initialize(file_path, output_path, prompt_file_path:, ai_client:, logger:)
11
- @file_path = file_path
12
- @output_path = output_path
10
+ def initialize(input_path:, prompt_file_path:, ai_client:, logger:, output_path: nil)
11
+ @file_path = input_path
13
12
  @prompt_file_path = prompt_file_path
14
13
  @ai_client = ai_client
15
14
  @logger = logger
15
+ @output_path = output_path
16
16
  end
17
17
 
18
18
  def output_exists?
19
+ return false unless output_path
19
20
  File.exist?(output_path)
20
21
  end
21
22
 
@@ -29,9 +30,13 @@ module AIRefactor
29
30
  ]
30
31
  content, finished_reason, usage = generate_next_message(messages, prompt, options, options[:ai_max_attempts] || 3)
31
32
 
32
- if content && content.length > 0
33
+ content = if content && content.length > 0
33
34
  processed = block_given? ? yield(content) : content
34
- File.write(output_path, processed)
35
+ if output_path
36
+ File.write(output_path, processed)
37
+ logger.verbose "Wrote output to #{output_path}..."
38
+ end
39
+ processed
35
40
  end
36
41
 
37
42
  [content, finished_reason, usage]
@@ -2,42 +2,114 @@
2
2
 
3
3
  module AIRefactor
4
4
  module Refactors
5
- class Generic
6
- attr_reader :input_file, :options, :logger
5
+ class Generic < BaseRefactor
6
+ def run
7
+ logger.verbose "Generic refactor to #{input_file}... (using user supplied prompt #{prompt_file_path})"
8
+ logger.verbose "Write output to #{output_file_path}..." if output_file_path
7
9
 
8
- def initialize(input_file, options, logger)
9
- @input_file = input_file
10
- @options = options
11
- @logger = logger
12
- end
10
+ processor = AIRefactor::FileProcessor.new(
11
+ input_path: input_file,
12
+ prompt_file_path: prompt_file_path,
13
+ ai_client: ai_client,
14
+ logger: logger,
15
+ output_path: output_file_path
16
+ )
13
17
 
14
- def run
15
- raise "Not implemented"
18
+ if processor.output_exists?
19
+ return false unless can_overwrite_output_file?(output_file_path)
20
+ end
21
+
22
+ logger.verbose "Converting #{input_file}..."
23
+
24
+ begin
25
+ output_content, finished_reason, usage = processor.process!(options)
26
+ rescue => e
27
+ logger.error "Request to OpenAI failed: #{e.message}"
28
+ logger.warn "Skipping #{input_file}..."
29
+ self.failed_message = "Request to OpenAI failed"
30
+ return false
31
+ end
32
+
33
+ logger.verbose "OpenAI finished, with reason '#{finished_reason}'..."
34
+ logger.verbose "Used tokens: #{usage["total_tokens"]}".colorize(:light_black) if usage
35
+
36
+ if finished_reason == "length"
37
+ logger.warn "Translation may contain an incomplete output as the max token length was reached. You can try using the '--continue' option next time to increase the length of generated output."
38
+ end
39
+
40
+ if !output_content || output_content.length == 0
41
+ logger.warn "Skipping #{input_file}, no translated output..."
42
+ logger.error "Failed to translate #{input_file}, finished reason #{finished_reason}"
43
+ self.failed_message = "AI conversion failed, no output was generated"
44
+ return false
45
+ end
46
+
47
+ output_file_path ? true : output_content
16
48
  end
17
49
 
18
50
  private
19
51
 
20
- def ai_client
21
- @ai_client ||= OpenAI::Client.new
22
- end
52
+ def output_file_path
53
+ return output_file_path_from_template if output_template_path
23
54
 
24
- class << self
25
- def command_line_options
26
- []
55
+ path = options[:output_file_path]
56
+ return unless path
57
+
58
+ if path == true
59
+ input_file
60
+ else
61
+ path
27
62
  end
63
+ end
28
64
 
29
- def refactor_name
30
- name.split("::")
31
- .last
32
- .gsub(/::/, "/")
33
- .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
34
- .gsub(/([a-z\d])([A-Z])/, '\1_\2')
35
- .tr("-", "_")
36
- .downcase
65
+ def output_template_path
66
+ options[:output_template_path]
67
+ end
68
+
69
+ def output_file_path_from_template
70
+ path = output_template_path.gsub("[FILE]", File.basename(input_file))
71
+ .gsub("[NAME]", File.basename(input_file, ".*"))
72
+ .gsub("[DIR]", File.dirname(input_file))
73
+ .gsub("[REFACTOR]", self.class.refactor_name)
74
+ .gsub("[EXT]", File.extname(input_file))
75
+ raise "Output template could not be used" unless path.length.positive?
76
+ path
77
+ end
78
+
79
+ def prompt_file_path
80
+ specified_prompt_path = options[:prompt_file_path]
81
+ if specified_prompt_path&.length&.positive?
82
+ if File.exist?(specified_prompt_path)
83
+ return specified_prompt_path
84
+ else
85
+ logger.error "No prompt file '#{specified_prompt_path}' found"
86
+ end
87
+ else
88
+ logger.error "No prompt file was specified!"
37
89
  end
90
+ exit 1
91
+ end
38
92
 
93
+ class << self
39
94
  def prompt_file_path
40
- File.join(File.dirname(File.expand_path(__FILE__)), "prompts", "#{refactor_name}.md")
95
+ raise "Generic refactor requires prompt file to be user specified."
96
+ end
97
+
98
+ def command_line_options
99
+ [
100
+ {
101
+ key: :output_file_path,
102
+ long: "--output [FILE]",
103
+ type: String,
104
+ help: "Write output to file instead of stdout. If no path provided will overwrite input file (will prompt to overwrite existing files)"
105
+ },
106
+ {
107
+ key: :output_template_path,
108
+ long: "--output-template TEMPLATE",
109
+ type: String,
110
+ help: "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)"
111
+ }
112
+ ]
41
113
  end
42
114
  end
43
115
  end
@@ -2,7 +2,7 @@
2
2
 
3
3
  module AIRefactor
4
4
  module Refactors
5
- class MinitestToRspec < Generic
5
+ class MinitestToRspec < BaseRefactor
6
6
  def run
7
7
  raise "Not implemented"
8
8
  end
@@ -7,7 +7,7 @@ require_relative "tests/test_run_diff_report"
7
7
 
8
8
  module AIRefactor
9
9
  module Refactors
10
- class RspecToMinitestRails < Generic
10
+ class RspecToMinitestRails < BaseRefactor
11
11
  def run
12
12
  spec_runner = AIRefactor::Tests::RSpecRunner.new(input_file)
13
13
  logger.verbose "Run spec #{input_file}... (#{spec_runner.command})"
@@ -17,6 +17,7 @@ module AIRefactor
17
17
  if spec_run.failed?
18
18
  logger.warn "Skipping #{input_file}..."
19
19
  logger.error "Failed to run #{input_file}, exited with status #{spec_run.exitstatus}. Stdout: #{spec_run.stdout}\n\nStderr: #{spec_run.stderr}\n\n"
20
+ self.failed_message = "Failed to run RSpec file, has errors"
20
21
  return false
21
22
  end
22
23
 
@@ -26,20 +27,15 @@ module AIRefactor
26
27
  output_path = input_file.gsub("_spec.rb", "_test.rb").gsub("spec/", "test/")
27
28
 
28
29
  processor = AIRefactor::FileProcessor.new(
29
- input_file,
30
- output_path,
31
- prompt_file_path: self.class.prompt_file_path,
30
+ input_path: input_file,
31
+ output_path: output_path,
32
+ prompt_file_path: prompt_file_path,
32
33
  ai_client: ai_client,
33
34
  logger: logger
34
35
  )
35
36
 
36
37
  if processor.output_exists?
37
- logger.info "Do you wish to overwrite #{output_path}? (y/n)"
38
- answer = $stdin.gets.chomp
39
- unless answer == "y" || answer == "Y"
40
- logger.warn "Skipping #{input_file}..."
41
- return false
42
- end
38
+ return false unless can_overwrite_output_file?(output_path)
43
39
  end
44
40
 
45
41
  logger.verbose "Converting #{input_file}..."
@@ -51,6 +47,7 @@ module AIRefactor
51
47
  rescue => e
52
48
  logger.error "Request to OpenAI failed: #{e.message}"
53
49
  logger.warn "Skipping #{input_file}..."
50
+ self.failed_message = "Request to OpenAI failed"
54
51
  return false
55
52
  end
56
53
 
@@ -65,6 +62,7 @@ module AIRefactor
65
62
  if !output_content || output_content.length == 0
66
63
  logger.warn "Skipping #{input_file}, no translated output..."
67
64
  logger.error "Failed to translate #{input_file}, finished reason #{finished_reason}"
65
+ self.failed_message = "AI conversion failed, no output was generated"
68
66
  return false
69
67
  end
70
68
 
@@ -79,6 +77,7 @@ module AIRefactor
79
77
  logger.warn "Skipping #{input_file}..."
80
78
  logger.error "Failed to run translated #{output_path}, exited with status #{test_run.exitstatus}. Stdout: #{test_run.stdout}\n\nStderr: #{test_run.stderr}\n\n"
81
79
  logger.error "Conversion failed!", bold: true
80
+ self.failed_message = "Generated test file failed to run correctly"
82
81
  return false
83
82
  end
84
83
 
@@ -95,6 +94,7 @@ module AIRefactor
95
94
  logger.warn report.diff.colorize(:yellow)
96
95
  logger.verbose "Done converting #{input_file} to #{output_path}..."
97
96
  logger.error "Differences found! Conversion failed!", bold: true
97
+ self.failed_message = "Generated test file run output did not match original RSpec spec run output"
98
98
  false
99
99
  end
100
100
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AIRefactor
4
- VERSION = "0.1.0"
4
+ VERSION = "0.3.0"
5
5
  end
data/lib/ai_refactor.rb CHANGED
@@ -6,6 +6,7 @@ require_relative "ai_refactor/logger"
6
6
  require_relative "ai_refactor/file_processor"
7
7
 
8
8
  require_relative "ai_refactor/refactors"
9
+ require_relative "ai_refactor/base_refactor"
9
10
  require_relative "ai_refactor/refactors/generic"
10
11
  require_relative "ai_refactor/refactors/rspec_to_minitest_rails"
11
12
  require_relative "ai_refactor/refactors/minitest_to_rspec"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ai_refactor
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stephen Ierodiaconou
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2023-05-23 00:00:00.000000000 Z
11
+ date: 2023-05-25 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: colorize
@@ -77,6 +77,7 @@ files:
77
77
  - ai_refactor.gemspec
78
78
  - exe/ai_refactor
79
79
  - lib/ai_refactor.rb
80
+ - lib/ai_refactor/base_refactor.rb
80
81
  - lib/ai_refactor/file_processor.rb
81
82
  - lib/ai_refactor/logger.rb
82
83
  - lib/ai_refactor/refactors.rb