ai_refactor 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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: []