ai_refactor 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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: db3b53eb0cea61e83d0d7f5632316be07ced02da11e065626cc5d72329191538
4
+ data.tar.gz: 9b475208c15ccfaf9185d8c516e62efc28632dc2a6dab6d25beaba28be726278
5
+ SHA512:
6
+ metadata.gz: 97e8c839f7c5e2dd6fc1edc12b6c46f45e99e07701da53c313c7677a159529dffd91a724cf54cb33b0c51d1b84939fb07e94c9dae516906a72030b30f3c9fcaa
7
+ data.tar.gz: cd4250db8c71efad4daf61ac551b930904a103b573c595cbc3f2a8be7d6b9947576b00bce52f9c8f5858962802eb8827959b8aba5d32193a3732acfb7a108aeb
data/.standard.yml ADDED
@@ -0,0 +1,3 @@
1
+ # For available configuration options, see:
2
+ # https://github.com/testdouble/standard
3
+ ruby_version: 2.7
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2023-05-19
4
+
5
+ - Initial release
data/Gemfile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ # Specify your gem's dependencies in ai_refactor.gemspec
6
+ gemspec
7
+
8
+ gem "rake", "~> 13.0"
9
+
10
+ gem "minitest", "~> 5.0"
11
+
12
+ gem "standard", "~> 1.3"
data/Gemfile.lock ADDED
@@ -0,0 +1,76 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ ai_refactor (0.1.0)
5
+ colorize (< 2.0)
6
+ open3 (< 2.0)
7
+ ruby-openai (>= 3.4.0, < 5.0)
8
+
9
+ GEM
10
+ remote: https://rubygems.org/
11
+ specs:
12
+ ast (2.4.2)
13
+ colorize (0.8.1)
14
+ faraday (2.7.4)
15
+ faraday-net_http (>= 2.0, < 3.1)
16
+ ruby2_keywords (>= 0.0.4)
17
+ faraday-multipart (1.0.4)
18
+ multipart-post (~> 2)
19
+ faraday-net_http (3.0.2)
20
+ json (2.6.3)
21
+ language_server-protocol (3.17.0.3)
22
+ lint_roller (1.0.0)
23
+ minitest (5.18.0)
24
+ multipart-post (2.3.0)
25
+ open3 (0.1.2)
26
+ parallel (1.23.0)
27
+ parser (3.2.2.1)
28
+ ast (~> 2.4.1)
29
+ rainbow (3.1.1)
30
+ rake (13.0.6)
31
+ regexp_parser (2.8.0)
32
+ rexml (3.2.5)
33
+ rubocop (1.50.2)
34
+ json (~> 2.3)
35
+ parallel (~> 1.10)
36
+ parser (>= 3.2.0.0)
37
+ rainbow (>= 2.2.2, < 4.0)
38
+ regexp_parser (>= 1.8, < 3.0)
39
+ rexml (>= 3.2.5, < 4.0)
40
+ rubocop-ast (>= 1.28.0, < 2.0)
41
+ ruby-progressbar (~> 1.7)
42
+ unicode-display_width (>= 2.4.0, < 3.0)
43
+ rubocop-ast (1.28.1)
44
+ parser (>= 3.2.1.0)
45
+ rubocop-performance (1.16.0)
46
+ rubocop (>= 1.7.0, < 2.0)
47
+ rubocop-ast (>= 0.4.0)
48
+ ruby-openai (4.1.0)
49
+ faraday (>= 1)
50
+ faraday-multipart (>= 1)
51
+ ruby-progressbar (1.13.0)
52
+ ruby2_keywords (0.0.5)
53
+ standard (1.28.2)
54
+ language_server-protocol (~> 3.17.0.2)
55
+ lint_roller (~> 1.0)
56
+ rubocop (~> 1.50.2)
57
+ standard-custom (~> 1.0.0)
58
+ standard-performance (~> 1.0.1)
59
+ standard-custom (1.0.0)
60
+ lint_roller (~> 1.0)
61
+ standard-performance (1.0.1)
62
+ lint_roller (~> 1.0)
63
+ rubocop-performance (~> 1.16.0)
64
+ unicode-display_width (2.4.2)
65
+
66
+ PLATFORMS
67
+ arm64-darwin-22
68
+
69
+ DEPENDENCIES
70
+ ai_refactor!
71
+ minitest (~> 5.0)
72
+ rake (~> 13.0)
73
+ standard (~> 1.3)
74
+
75
+ BUNDLED WITH
76
+ 2.4.10
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2023 Stephen Ierodiaconou
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,86 @@
1
+ # AI Refactor
2
+
3
+ AI Refactor is an experimental tool to see how AI (specifically [OpenAI's ChatGPT](https://platform.openai.com/)) can be used to help apply refactoring to code.
4
+
5
+ The goal is **not** that the AI decides what refactoring to do, but rather, given refactoring tasks specified by the human user,
6
+ the AI can help identify which code to change and apply the relevant refactor.
7
+
8
+ This is based on the assumption that the LLM AIs are pretty good at identifying patterns.
9
+
10
+ ## Available refactors
11
+
12
+ Currently only one is available:
13
+
14
+ ### `rspec_to_minitest_rails`
15
+
16
+ Converts RSpec tests to minitest tests for Rails test suites (ie generated minitest tests are actually `ActiveSupport::TestCase`s).
17
+
18
+ The tool first runs the original RSpec spec file and then runs the generated minitest test file, and compares the output of both.
19
+
20
+ The comparison is simply the count of successful and failed tests but this is probably enough to determine if the conversion worked.
21
+
22
+ ```shellq
23
+ stephen$ OPENAI_API_KEY=my-key ai_refactor rspec_to_minitest_rails spec/models/my_thing_spec.rb -v
24
+ AI Refactor 1 files(s)/dir(s) '["spec/models/my_thing_spec.rb"]' with rspec_to_minitest_rails refactor
25
+ ====================
26
+ Processing spec/models/my_thing_spec.rb...
27
+ [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)
29
+ y
30
+ [Converting spec/models/my_thing_spec.rb...]
31
+ [Generate AI output. Generation attempts left: 3]
32
+ [OpenAI finished, with reason 'stop'...]
33
+ [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
+ No differences found! Conversion worked!
38
+ Refactor succeeded on spec/models/my_thing_spec.rb
39
+
40
+ Done processing all files!
41
+ ```
42
+
43
+ ## Installation
44
+
45
+ Install the gem and add to the application's Gemfile by executing:
46
+
47
+ $ bundle add ai_refactor
48
+
49
+ If bundler is not being used to manage dependencies, install the gem by executing:
50
+
51
+ $ gem install ai_refactor
52
+
53
+ ## Usage
54
+
55
+ See `ai_refactor --help` for more information.
56
+
57
+ ```
58
+ Usage: ai_refactor REFACTOR_TYPE INPUT_FILE_OR_DIR [options]
59
+
60
+ Where REFACTOR_TYPE is one of: ["generic", "rspec_to_minitest_rails", "minitest_to_rspec"]
61
+
62
+ -p, --prompt PROMPT_FILE Specify path to a text file that contains the ChatGPT 'system' prompt.
63
+ -c, --continue [MAX_MESSAGES] 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
64
+ -m, --model MODEL_NAME Specify a ChatGPT model to use (default gpt-3.5-turbo).
65
+ --temperature TEMP Specify the temperature parameter for ChatGPT (default 0.7).
66
+ --max-tokens MAX_TOKENS Specify the max number of tokens of output ChatGPT can generate. Max will depend on the size of the prompt (default 1500)
67
+ -t, --timeout SECONDS Specify the max wait time for ChatGPT response.
68
+ -v, --verbose Show extra output and progress info
69
+ -d, --debug Show debugging output to help diagnose issues
70
+ -h, --help Prints this help
71
+ ```
72
+
73
+
74
+ ## Development
75
+
76
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
77
+
78
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
79
+
80
+ ## Contributing
81
+
82
+ Bug reports and pull requests are welcome on GitHub at https://github.com/stevegeek/ai_refactor.
83
+
84
+ ## License
85
+
86
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rake/testtask"
5
+
6
+ Rake::TestTask.new(:test) do |t|
7
+ t.libs << "test"
8
+ t.libs << "lib"
9
+ t.test_files = FileList["test/**/test_*.rb"]
10
+ end
11
+
12
+ require "standard/rake"
13
+
14
+ task default: %i[test standard]
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/ai_refactor/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "ai_refactor"
7
+ spec.version = AIRefactor::VERSION
8
+ spec.authors = ["Stephen Ierodiaconou"]
9
+ spec.email = ["stevegeek@gmail.com"]
10
+
11
+ spec.summary = "Use AI to convert a Rails RSpec test suite to minitest."
12
+ spec.description = "Use OpenAI's ChatGPT to automate converting Rails RSpec tests to minitest (ActiveSupport::TestCase)."
13
+ spec.homepage = "https://github.com/stevegeek/ai_refactor"
14
+ spec.license = "MIT"
15
+ spec.required_ruby_version = ">= 2.7.0"
16
+
17
+ spec.metadata["homepage_uri"] = spec.homepage
18
+ spec.metadata["source_code_uri"] = "https://github.com/stevegeek/ai_refactor"
19
+
20
+ # Specify which files should be added to the gem when it is released.
21
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
22
+ spec.files = Dir.chdir(__dir__) do
23
+ `git ls-files -z`.split("\x0").reject do |f|
24
+ (File.expand_path(f) == __FILE__) || f.start_with?(*%w[bin/ test/ spec/ features/ .git .circleci appveyor])
25
+ end
26
+ end
27
+ spec.bindir = "exe"
28
+ spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
29
+ spec.require_paths = ["lib"]
30
+
31
+ # Uncomment to register a new dependency of your gem
32
+ spec.add_dependency "colorize", "< 2.0"
33
+ spec.add_dependency "open3", "< 2.0"
34
+ spec.add_dependency "ruby-openai", ">= 3.4.0", "< 5.0"
35
+ end
data/exe/ai_refactor ADDED
@@ -0,0 +1,102 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "optparse"
4
+ require "colorize"
5
+ require "openai"
6
+ require_relative "../lib/ai_refactor"
7
+
8
+ options = {}
9
+
10
+ supported_refactors = AIRefactor::Refactors.all
11
+ supported_names = AIRefactor::Refactors.names
12
+
13
+ # General options for all refactor types
14
+ 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"
16
+
17
+ # todo: support for a sort of generic process which uses a custom prompt file
18
+ parser.on("-p", "--prompt PROMPT_FILE", String, "Specify path to a text file that contains the ChatGPT 'system' prompt.") do |f|
19
+ options[:prompt_file_path] = f
20
+ end
21
+
22
+ 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|
23
+ options[:ai_max_attempts] = c || 3
24
+ end
25
+
26
+ parser.on("-m", "--model MODEL_NAME", String, "Specify a ChatGPT model to use (default gpt-3.5-turbo).") do |m|
27
+ options[:ai_model] = m
28
+ end
29
+
30
+ parser.on(nil, "--temperature TEMP", Float, "Specify the temperature parameter for ChatGPT (default 0.7).") do |p|
31
+ options[:ai_temperature] = p
32
+ end
33
+
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|
35
+ options[:ai_max_tokens] = m
36
+ end
37
+
38
+ parser.on("-t", "--timeout SECONDS", Integer, "Specify the max wait time for ChatGPT response.") do |m|
39
+ options[:ai_timeout] = m
40
+ end
41
+
42
+ parser.on("-v", "--verbose", "Show extra output and progress info") do
43
+ options[:verbose] = true
44
+ end
45
+
46
+ parser.on("-d", "--debug", "Show debugging output to help diagnose issues") do
47
+ options[:debug] = true
48
+ end
49
+
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
+ parser.on("-h", "--help", "Prints this help") do
59
+ puts parser
60
+ exit
61
+ end
62
+ end
63
+
64
+ option_parser.parse!
65
+
66
+ logger = AIRefactor::Logger.new(verbose: options[:verbose], debug: options[:debug])
67
+
68
+ refactoring_type = ARGV.shift
69
+ input_file_path = ARGV
70
+
71
+ if !AIRefactor::Refactors.supported?(refactoring_type) || input_file_path.nil? || input_file_path.empty?
72
+ puts option_parser.help
73
+ exit 1
74
+ end
75
+
76
+ OpenAI.configure do |config|
77
+ config.access_token = ENV.fetch("OPENAI_API_KEY")
78
+ config.organization_id = ENV.fetch("OPENAI_ORGANIZATION_ID", nil)
79
+ config.request_timeout = options[:ai_timeout] || 240
80
+ end
81
+
82
+ refactorer = AIRefactor::Refactors.get(refactoring_type)
83
+
84
+ inputs = input_file_path.map do |path|
85
+ File.exist?(path) ? path : Dir.glob(path)
86
+ end.flatten
87
+
88
+ logger.info "AI Refactor #{inputs.size} files(s)/dir(s) '#{input_file_path}' with #{refactorer.refactor_name} refactor\n"
89
+ logger.info "====================\n"
90
+
91
+ inputs.each do |file|
92
+ logger.info "Processing #{file}..."
93
+
94
+ refactor = refactorer.new(file, options, logger)
95
+
96
+ if refactor.run
97
+ logger.success "Refactor succeeded on #{file}\n"
98
+ else
99
+ logger.warn "Refactor failed on #{file}\n"
100
+ end
101
+ end
102
+ logger.info "Done processing all files!"
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openai"
4
+ require "json"
5
+
6
+ module AIRefactor
7
+ class FileProcessor
8
+ attr_reader :file_path, :output_path, :logger
9
+
10
+ def initialize(file_path, output_path, prompt_file_path:, ai_client:, logger:)
11
+ @file_path = file_path
12
+ @output_path = output_path
13
+ @prompt_file_path = prompt_file_path
14
+ @ai_client = ai_client
15
+ @logger = logger
16
+ end
17
+
18
+ def output_exists?
19
+ File.exist?(output_path)
20
+ end
21
+
22
+ def process!(options)
23
+ logger.debug("Processing #{file_path} with prompt in #{@prompt_file_path}")
24
+ prompt = File.read(@prompt_file_path)
25
+ input = File.read(@file_path)
26
+ messages = [
27
+ {role: "system", content: prompt},
28
+ {role: "user", content: "Convert: ```#{input}```"}
29
+ ]
30
+ content, finished_reason, usage = generate_next_message(messages, prompt, options, options[:ai_max_attempts] || 3)
31
+
32
+ if content && content.length > 0
33
+ processed = block_given? ? yield(content) : content
34
+ File.write(output_path, processed)
35
+ end
36
+
37
+ [content, finished_reason, usage]
38
+ end
39
+
40
+ private
41
+
42
+ def generate_next_message(messages, prompt, options, attempts_left)
43
+ logger.verbose "Generate AI output. Generation attempts left: #{attempts_left}"
44
+ logger.debug "Options: #{options.inspect}"
45
+ logger.debug "Messages: #{messages.inspect}"
46
+
47
+ response = @ai_client.chat(
48
+ parameters: {
49
+ model: options[:ai_model] || "gpt-3.5-turbo",
50
+ messages: messages,
51
+ temperature: options[:ai_temperature] || 0.7,
52
+ max_tokens: options[:ai_max_tokens] || 1500
53
+ }
54
+ )
55
+
56
+ if response["error"]
57
+ raise StandardError.new("OpenAI error: #{response["error"]["type"]}: #{response["error"]["message"]} (#{response["error"]["code"]})")
58
+ end
59
+
60
+ content = response.dig("choices", 0, "message", "content")
61
+ finished_reason = response.dig("choices", 0, "finish_reason")
62
+
63
+ if finished_reason == "length" && attempts_left > 0
64
+ generate_next_message(messages + [
65
+ {role: "assistant", content: content},
66
+ {role: "user", content: "Continue"}
67
+ ], prompt, options, attempts_left - 1)
68
+ else
69
+ previous_messages = messages.filter { |m| m[:role] == "assistant" }.map { |m| m[:content] }.join
70
+ content = if previous_messages.length > 0
71
+ content ? previous_messages + content : previous_messages
72
+ else
73
+ content
74
+ end
75
+ [content, finished_reason, response["usage"]]
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AIRefactor
4
+ class Logger
5
+ def initialize(verbose: false, debug: false)
6
+ @verbose = verbose
7
+ @debug = debug
8
+ end
9
+
10
+ def info(message)
11
+ puts message
12
+ end
13
+
14
+ def debug(message)
15
+ return unless @debug
16
+ puts message.colorize(:light_black)
17
+ end
18
+
19
+ def verbose(message)
20
+ return unless @verbose
21
+ puts "[#{message}]".colorize(:light_blue)
22
+ end
23
+
24
+ def warn(message)
25
+ puts message.colorize(:yellow)
26
+ end
27
+
28
+ def success(message)
29
+ puts message.colorize(color: :green, mode: :bold)
30
+ end
31
+
32
+ def error(message, bold: false)
33
+ puts message.colorize(color: :red, mode: bold ? :bold : :default)
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AIRefactor
4
+ module Refactors
5
+ class Generic
6
+ attr_reader :input_file, :options, :logger
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 "Not implemented"
16
+ end
17
+
18
+ private
19
+
20
+ def ai_client
21
+ @ai_client ||= OpenAI::Client.new
22
+ end
23
+
24
+ class << self
25
+ def command_line_options
26
+ []
27
+ end
28
+
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
37
+ end
38
+
39
+ def prompt_file_path
40
+ File.join(File.dirname(File.expand_path(__FILE__)), "prompts", "#{refactor_name}.md")
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AIRefactor
4
+ module Refactors
5
+ class MinitestToRspec < Generic
6
+ def run
7
+ raise "Not implemented"
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,233 @@
1
+ You convert RSpec tests to ActiveSupport::TestCase tests for Ruby on Rails.
2
+ ActiveSupport::TestCase uses MiniTest under the hood.
3
+ Remember that MiniTest does not support `context` blocks, instead these should be removed and the context
4
+ specified in them should be moved directly into the relevant tests.
5
+ Always enclose the output code in triple backticks (```).
6
+
7
+ Here are some examples to use as a guide:
8
+
9
+ Example 1) RSpec:
10
+ ```
11
+ require "rails_helper"
12
+
13
+ RSpec.describe Address, type: :model do
14
+ subject(:model) { described_class.new }
15
+
16
+ it { is_expected.not_to have_many(:assigned_companies) }
17
+ it { is_expected.not_to belong_to(:delivery_location) }
18
+ end
19
+ ```
20
+
21
+ Result 1) minitest:
22
+ ```
23
+ require "test_helper"
24
+
25
+ class AddressTest < ActiveSupport::TestCase
26
+ @model = Address.new
27
+
28
+ test "model should not have any assigned_companies" do
29
+ assert_empty @model.assigned_companies
30
+ end
31
+
32
+ test "model should not have a delivery_location" do
33
+ refute @model.delivery_location
34
+ end
35
+ end
36
+ ```
37
+
38
+ Example 2) RSpec:
39
+ ```
40
+ subject(:model) { create(:order_state) }
41
+
42
+ context "when rejected" do
43
+ before { model.rejected_at = 1.day.ago }
44
+
45
+ context "with reason and message" do
46
+ before do
47
+ model.rejected_message = reason
48
+ model.rejected_reason = RejectedReasons.reason(:out_of_stock)
49
+ end
50
+
51
+ let(:reason) { "my reason" }
52
+
53
+ it "should be valid" do
54
+ expect(model).to be_valid
55
+ end
56
+
57
+ it "should have a rejected message" do
58
+ expect(model.rejected_message).to eq reason
59
+ end
60
+ end
61
+ end
62
+ ```
63
+
64
+ Result 2) minitest:
65
+ ```
66
+ setup do
67
+ @model = FactoryBot.create(:order_state)
68
+ @reason = "my reason"
69
+ end
70
+
71
+ test "when rejected, with reason and message, model should be valid" do
72
+ @model.rejected_at = 1.day.ago
73
+ @model.rejected_message = @reason
74
+ @model.rejected_reason = RejectedReasons.reason(:out_of_stock)
75
+ assert @model.valid?
76
+ assert_equal @reason, @model.rejected_message
77
+ end
78
+ ```
79
+
80
+ Example 3) RSpec:
81
+ ```
82
+ RSpec.describe Address, type: :model do
83
+ subject(:model) { build_stubbed(:address, geo: point_1) }
84
+
85
+ let(:factory) { RGeo::Geographic.spherical_factory(srid: 4326) }
86
+ let(:point_1) { factory.point(-84.3804222, 33.6502466) }
87
+ let(:point_2) { factory.point(-84.00, 33.00) }
88
+
89
+ it { is_expected.to be_instance_of(described_class) }
90
+ end
91
+ ```
92
+
93
+ Result 3) minitest:
94
+ ```
95
+ class AddressTest < ActiveSupport::TestCase
96
+ setup do
97
+ @factory = RGeo::Geographic.spherical_factory(srid: 4326)
98
+ @point_1 = @factory.point(-84.3804222, 33.6502466)
99
+ @point_2 = @factory.point(-84.00, 33.00)
100
+ @model = FactoryBot.build_stubbed(:address, geo: @point_1)
101
+ end
102
+
103
+ test "model should be an instance of the Address" do
104
+ assert_instance_of Address, @model
105
+ end
106
+ end
107
+ ```
108
+
109
+ Example 4) RSpec:
110
+ ```
111
+ describe "geocoding" do
112
+ context "when address line changed" do
113
+ before { model.line_1 = "1 Test Road" }
114
+
115
+ it "geocodes when validated" do
116
+ model.validate
117
+ expect(model.geo).to eq point_2
118
+ end
119
+ end
120
+ end
121
+ ```
122
+
123
+ Result 4) minitest:
124
+ ```
125
+ test "model should geocode, when address line changed, and when validated" do
126
+ @model.line_1 = "1 Test Road"
127
+ @model.validate
128
+ assert_equal @point_2, @model.geo
129
+ end
130
+ ```
131
+
132
+ Example 5) RSpec:
133
+ ```
134
+ context "when address line changed" do
135
+ it "geocodes when address changed" do
136
+ expect(PointFromLatLng).to receive(:call).with(33.00, -84.00).and_call_original
137
+ model.validate
138
+ expect(model.geo).to eq point_2
139
+ end
140
+ end
141
+ ```
142
+
143
+ Result 5) minitest:
144
+ ```
145
+ test "model should geocode, when address line changed, and when address changed" do
146
+ mock = Minitest::Mock.new
147
+ mock.expect :call, @point_2, [33.00, -84.00]
148
+
149
+ PointFromLatLng.stub :call, mock do
150
+ @model.line_1 = "1 Test Road"
151
+ @model.validate
152
+ assert_equal @point_2, @model.geo
153
+ end
154
+
155
+ mock.verify
156
+ end
157
+ ```
158
+
159
+ Example 6) RSpec:
160
+ ```
161
+ context "when address line changed" do
162
+ describe "setting timezone" do
163
+ it "sets timezone on successful fetch" do
164
+ other = build(:address)
165
+ expect(model).to be_valid
166
+ expect(model.timezone).to eq "America/New_York"
167
+ end
168
+
169
+ it "sets default timezone on timezone error" do
170
+ allow(Timezone).to receive(:lookup).and_raise(Timezone::Error::Base)
171
+ expect(model).to be_valid
172
+ expect(model.timezone).to eq ::Config[:vendor_location_info][:timezone]
173
+ end
174
+ end
175
+ end
176
+ ```
177
+
178
+ Result 6) minitest:
179
+ ```
180
+ test "when address line changed, model should set timezone on successful fetch" do
181
+ @model.line_1 = "1 Test Road"
182
+ other = FactoryBot.build(:address)
183
+ assert @model.valid?
184
+ assert_equal "America/New_York", @model.timezone
185
+ end
186
+
187
+ test "when address line changed, model should set default timezone on timezone error" do
188
+ @model.line_1 = "1 Test Road"
189
+ Timezone.stub :lookup, ->(*) { raise Timezone::Error::Base.new } do
190
+ assert @model.valid?
191
+ assert_equal ::Config[:vendor_location_info][:timezone], @model.timezone
192
+ end
193
+ end
194
+ ```
195
+
196
+ Example 7) RSpec:
197
+ ```
198
+ context "when address line untouched" do
199
+ it "does not geocode" do
200
+ expect(PointFromLatLng).not_to receive(:call)
201
+ expect(model).to be_valid
202
+ expect(model.geo).to eq point_1
203
+ end
204
+ end
205
+ ```
206
+
207
+ Result 7) minitest:
208
+ ```
209
+ test "when address line untouched, model should not geocode" do
210
+ PointFromLatLng.stub(:call, ->(*) { raise "shouldn't be called" }) do
211
+ assert @model.valid?
212
+ assert_equal @point_1, @model.geo
213
+ end
214
+ end
215
+ ```
216
+
217
+ Example 8) RSpec:
218
+ ```
219
+ it "stubs any instance" do
220
+ allow_any_instance_of(PointFromLatLng).to receive(:foo).and_return(true)
221
+ expect(model).to be_valid
222
+ end
223
+
224
+ ```
225
+
226
+ Result 8) minitest:
227
+ ```
228
+ test "stubs any instance" do
229
+ PointFromLatLng.stub_any_instance :foo, true do
230
+ assert @model.valid?
231
+ end
232
+ end
233
+ ```
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "tests/test_run_result"
4
+ require_relative "tests/rspec_runner"
5
+ require_relative "tests/minitest_runner"
6
+ require_relative "tests/test_run_diff_report"
7
+
8
+ module AIRefactor
9
+ module Refactors
10
+ class RspecToMinitestRails < Generic
11
+ def run
12
+ spec_runner = AIRefactor::Tests::RSpecRunner.new(input_file)
13
+ logger.verbose "Run spec #{input_file}... (#{spec_runner.command})"
14
+
15
+ spec_run = spec_runner.run
16
+
17
+ if spec_run.failed?
18
+ logger.warn "Skipping #{input_file}..."
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
+ return false
21
+ end
22
+
23
+ logger.debug "Original test run results:"
24
+ logger.debug ">> Examples: #{spec_run.example_count}, Failures: #{spec_run.failure_count}, Pendings: #{spec_run.pending_count}"
25
+
26
+ output_path = input_file.gsub("_spec.rb", "_test.rb").gsub("spec/", "test/")
27
+
28
+ processor = AIRefactor::FileProcessor.new(
29
+ input_file,
30
+ output_path,
31
+ prompt_file_path: self.class.prompt_file_path,
32
+ ai_client: ai_client,
33
+ logger: logger
34
+ )
35
+
36
+ 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
43
+ end
44
+
45
+ logger.verbose "Converting #{input_file}..."
46
+
47
+ begin
48
+ output_content, finished_reason, usage = processor.process!(options) do |content|
49
+ content.gsub("```", "")
50
+ end
51
+ rescue => e
52
+ logger.error "Request to OpenAI failed: #{e.message}"
53
+ logger.warn "Skipping #{input_file}..."
54
+ return false
55
+ end
56
+
57
+ logger.verbose "OpenAI finished, with reason '#{finished_reason}'..."
58
+ logger.verbose "Used tokens: #{usage["total_tokens"]}".colorize(:light_black) if usage
59
+
60
+ if finished_reason == "length"
61
+ 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."
62
+ logger.warn "Continuing to test the translated file... but it is likely to fail."
63
+ end
64
+
65
+ if !output_content || output_content.length == 0
66
+ logger.warn "Skipping #{input_file}, no translated output..."
67
+ logger.error "Failed to translate #{input_file}, finished reason #{finished_reason}"
68
+ return false
69
+ end
70
+
71
+ logger.verbose "Converted #{input_file} to #{output_path}..."
72
+
73
+ minitest_runner = AIRefactor::Tests::MinitestRunner.new(processor.output_path)
74
+
75
+ logger.verbose "Run generated test file #{output_path} (#{minitest_runner.command})..."
76
+ test_run = minitest_runner.run
77
+
78
+ if test_run.failed?
79
+ logger.warn "Skipping #{input_file}..."
80
+ 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
+ logger.error "Conversion failed!", bold: true
82
+ return false
83
+ end
84
+
85
+ logger.debug "Translated test file results:"
86
+ logger.debug ">> Runs: #{test_run.example_count}, Failures: #{test_run.failure_count}, Skips: #{test_run.pending_count}"
87
+
88
+ report = AIRefactor::Tests::TestRunDiffReport.new(spec_run, test_run)
89
+
90
+ if report.no_differences?
91
+ logger.verbose "Done converting #{input_file} to #{output_path}..."
92
+ logger.success "No differences found! Conversion worked!"
93
+ true
94
+ else
95
+ logger.warn report.diff.colorize(:yellow)
96
+ logger.verbose "Done converting #{input_file} to #{output_path}..."
97
+ logger.error "Differences found! Conversion failed!", bold: true
98
+ false
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module AIRefactor
6
+ module Tests
7
+ class MinitestRunner
8
+ def initialize(file_path, command_template: "bundle exec rails test __FILE__")
9
+ @file_path = file_path
10
+ @command_template = command_template
11
+ end
12
+
13
+ def command
14
+ @command_template.gsub("__FILE__", @file_path)
15
+ end
16
+
17
+ def run
18
+ stdout, stderr, status = Open3.capture3(command)
19
+ _matched, runs, _assertions, failures, errors, skips = stdout.match(/(\d+) runs, (\d+) assertions, (\d+) failures, (\d+) errors, (\d+) skips/).to_a
20
+ TestRunResult.new(stdout, stderr, status, runs, failures, skips, errors)
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module AIRefactor
6
+ module Tests
7
+ class RSpecRunner
8
+ def initialize(file_path, command_template: "bundle exec rspec __FILE__")
9
+ @file_path = file_path
10
+ @command_template = command_template
11
+ end
12
+
13
+ def command
14
+ @command_template.gsub("__FILE__", @file_path)
15
+ end
16
+
17
+ def run
18
+ stdout, stderr, status = Open3.capture3(command)
19
+ _matched, example_count, failure_count = stdout.match(/(\d+) examples?, (\d+) failures?/).to_a
20
+ pending_count = stdout.match(/(\d+) pending/)&.values_at(1) || "0"
21
+ errored = stdout.match(/, (\d+) errors? occurred outside of examples/)&.values_at(1) || "0"
22
+ TestRunResult.new(stdout, stderr, status, example_count, failure_count, pending_count, errored)
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AIRefactor
4
+ module Tests
5
+ class TestRunDiffReport
6
+ def initialize(previous_test_run_result, test_run_result)
7
+ @current = test_run_result
8
+ @previous = previous_test_run_result
9
+ end
10
+
11
+ def no_differences?
12
+ @current.example_count == @previous.example_count && @current.failure_count == @previous.failure_count && @current.pending_count == @previous.pending_count
13
+ end
14
+
15
+ def diff
16
+ report = ""
17
+ if @current.example_count != @previous.example_count
18
+ report += "Example count mismatch: #{@current.example_count} != #{@previous.example_count}"
19
+ end
20
+ if @current.failure_count != @previous.failure_count
21
+ report += "Failure count mismatch: #{@current.failure_count} != #{@previous.failure_count}"
22
+ end
23
+ if @current.pending_count != @previous.pending_count
24
+ report += "Pending count mismatch: #{@current.pending_count} != #{@previous.pending_count}"
25
+ end
26
+ report
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AIRefactor
4
+ module Tests
5
+ class TestRunResult
6
+ attr_reader :stdout, :stderr, :example_count, :failure_count, :pending_count
7
+
8
+ def initialize(stdout, stderr, status, example_count, failure_count, pending_count, errored)
9
+ @stdout = stdout
10
+ @stderr = stderr
11
+ @status = status
12
+ @example_count = example_count
13
+ @failure_count = failure_count
14
+ @pending_count = pending_count
15
+ @errored = errored
16
+ end
17
+
18
+ def failed?
19
+ return true unless @status.success?
20
+ @errored && @errored.to_i > 0
21
+ end
22
+
23
+ def exitstatus
24
+ @status.exitstatus
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AIRefactor
4
+ module Refactors
5
+ def get(name)
6
+ all[name]
7
+ end
8
+ module_function :get
9
+
10
+ def names
11
+ all.keys
12
+ end
13
+ module_function :names
14
+
15
+ def all
16
+ @all ||= constants.map { |n| const_get(n) }.select { |c| c.is_a? Class }.each_with_object({}) do |klass, hash|
17
+ hash[klass.refactor_name] = klass
18
+ end
19
+ end
20
+ module_function :all
21
+
22
+ def supported?(name)
23
+ names.include?(name)
24
+ end
25
+ module_function :supported?
26
+ end
27
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AIRefactor
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "ai_refactor/version"
4
+
5
+ require_relative "ai_refactor/logger"
6
+ require_relative "ai_refactor/file_processor"
7
+
8
+ require_relative "ai_refactor/refactors"
9
+ require_relative "ai_refactor/refactors/generic"
10
+ require_relative "ai_refactor/refactors/rspec_to_minitest_rails"
11
+ require_relative "ai_refactor/refactors/minitest_to_rspec"
metadata ADDED
@@ -0,0 +1,118 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ai_refactor
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Stephen Ierodiaconou
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2023-05-23 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: colorize
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "<"
18
+ - !ruby/object:Gem::Version
19
+ version: '2.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "<"
25
+ - !ruby/object:Gem::Version
26
+ version: '2.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: open3
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "<"
32
+ - !ruby/object:Gem::Version
33
+ version: '2.0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "<"
39
+ - !ruby/object:Gem::Version
40
+ version: '2.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: ruby-openai
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: 3.4.0
48
+ - - "<"
49
+ - !ruby/object:Gem::Version
50
+ version: '5.0'
51
+ type: :runtime
52
+ prerelease: false
53
+ version_requirements: !ruby/object:Gem::Requirement
54
+ requirements:
55
+ - - ">="
56
+ - !ruby/object:Gem::Version
57
+ version: 3.4.0
58
+ - - "<"
59
+ - !ruby/object:Gem::Version
60
+ version: '5.0'
61
+ description: Use OpenAI's ChatGPT to automate converting Rails RSpec tests to minitest
62
+ (ActiveSupport::TestCase).
63
+ email:
64
+ - stevegeek@gmail.com
65
+ executables:
66
+ - ai_refactor
67
+ extensions: []
68
+ extra_rdoc_files: []
69
+ files:
70
+ - ".standard.yml"
71
+ - CHANGELOG.md
72
+ - Gemfile
73
+ - Gemfile.lock
74
+ - LICENSE.txt
75
+ - README.md
76
+ - Rakefile
77
+ - ai_refactor.gemspec
78
+ - exe/ai_refactor
79
+ - lib/ai_refactor.rb
80
+ - lib/ai_refactor/file_processor.rb
81
+ - lib/ai_refactor/logger.rb
82
+ - lib/ai_refactor/refactors.rb
83
+ - lib/ai_refactor/refactors/generic.rb
84
+ - lib/ai_refactor/refactors/minitest_to_rspec.rb
85
+ - lib/ai_refactor/refactors/prompts/minitest_to_rspec.md
86
+ - lib/ai_refactor/refactors/prompts/rspec_to_minitest_rails.md
87
+ - lib/ai_refactor/refactors/rspec_to_minitest_rails.rb
88
+ - lib/ai_refactor/refactors/tests/minitest_runner.rb
89
+ - lib/ai_refactor/refactors/tests/rspec_runner.rb
90
+ - lib/ai_refactor/refactors/tests/test_run_diff_report.rb
91
+ - lib/ai_refactor/refactors/tests/test_run_result.rb
92
+ - lib/ai_refactor/version.rb
93
+ homepage: https://github.com/stevegeek/ai_refactor
94
+ licenses:
95
+ - MIT
96
+ metadata:
97
+ homepage_uri: https://github.com/stevegeek/ai_refactor
98
+ source_code_uri: https://github.com/stevegeek/ai_refactor
99
+ post_install_message:
100
+ rdoc_options: []
101
+ require_paths:
102
+ - lib
103
+ required_ruby_version: !ruby/object:Gem::Requirement
104
+ requirements:
105
+ - - ">="
106
+ - !ruby/object:Gem::Version
107
+ version: 2.7.0
108
+ required_rubygems_version: !ruby/object:Gem::Requirement
109
+ requirements:
110
+ - - ">="
111
+ - !ruby/object:Gem::Version
112
+ version: '0'
113
+ requirements: []
114
+ rubygems_version: 3.4.10
115
+ signing_key:
116
+ specification_version: 4
117
+ summary: Use AI to convert a Rails RSpec test suite to minitest.
118
+ test_files: []