ai_refactor 0.1.0 → 0.3.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 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