From 394d16d1c07e584d8ebba248a10941ba88e79907 Mon Sep 17 00:00:00 2001 From: Savannah Albanez Date: Thu, 6 Feb 2025 18:18:22 -0700 Subject: [PATCH 1/2] Breaking task into steps Breaking the task file into individual steps for better testing. This will enable a safer refactor with the end goal of breaking this into ruby objects that can more easily have changed behavior. --- Gemfile | 4 + Gemfile.lock | 7 + lib/discharger/helpers/sys_helper.rb | 57 +++++ lib/discharger/steps/prepare.rb | 70 ++++++ lib/discharger/steps/release.rb | 126 ++++++++++ lib/discharger/steps/stage.rb | 42 ++++ lib/discharger/task.rb | 280 +--------------------- test/lib/discharger/steps/prepare_test.rb | 143 +++++++++++ test/lib/discharger/steps/release_test.rb | 138 +++++++++++ test/lib/discharger/steps/stage_test.rb | 135 +++++++++++ test/lib/discharger/task_test.rb | 117 +++++++++ test/task_test.rb | 76 ------ test/test_helper.rb | 29 +++ 13 files changed, 881 insertions(+), 343 deletions(-) create mode 100644 lib/discharger/helpers/sys_helper.rb create mode 100644 lib/discharger/steps/prepare.rb create mode 100644 lib/discharger/steps/release.rb create mode 100644 lib/discharger/steps/stage.rb create mode 100644 test/lib/discharger/steps/prepare_test.rb create mode 100644 test/lib/discharger/steps/release_test.rb create mode 100644 test/lib/discharger/steps/stage_test.rb create mode 100644 test/lib/discharger/task_test.rb delete mode 100644 test/task_test.rb diff --git a/Gemfile b/Gemfile index 0667417..409dd70 100644 --- a/Gemfile +++ b/Gemfile @@ -10,3 +10,7 @@ gem "sqlite3" gem "simplecov" # Release management gem "reissue" + +group :development, :test do + gem "minitest-reporters" +end diff --git a/Gemfile.lock b/Gemfile.lock index 57cf4d8..46135cd 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -82,6 +82,7 @@ GEM securerandom (>= 0.3) tzinfo (~> 2.0, >= 2.0.5) uri (>= 0.13.1) + ansi (1.5.0) ast (2.4.2) base64 (0.2.0) benchmark (0.4.0) @@ -135,6 +136,11 @@ GEM mini_mime (1.1.5) mini_portile2 (2.8.8) minitest (5.25.4) + minitest-reporters (1.7.1) + ansi + builder + minitest (>= 5.0) + ruby-progressbar multipart-post (2.4.1) net-http (0.6.0) uri @@ -273,6 +279,7 @@ PLATFORMS DEPENDENCIES debug discharger! + minitest-reporters puma reissue simplecov diff --git a/lib/discharger/helpers/sys_helper.rb b/lib/discharger/helpers/sys_helper.rb new file mode 100644 index 0000000..2a62954 --- /dev/null +++ b/lib/discharger/helpers/sys_helper.rb @@ -0,0 +1,57 @@ +require "rainbow/refinement" + +using Rainbow + +module SysHelper + # Run a multiple system commands and return true if all commands succeed + # If any command fails, the method will return false and stop executing + # any further commands. + # + # Provide a block to evaluate the output of the command and return true + # if the command was successful. If the block returns false, the method + # will return false and stop executing any further commands. + # + # @param *steps [Array>] an array of commands to run + # @param block [Proc] a block to evaluate the output of the command + # @return [Boolean] true if all commands succeed, false otherwise + # + # @example + # syscall( + # ["echo Hello, World!"], + # ["ls -l"] + # ) + def syscall(*steps, output: $stdout, error: $stderr) + success = false + stdout, stderr, status = nil + steps.each do |cmd| + puts cmd.join(" ").bg(:green).black + stdout, stderr, status = Open3.capture3(*cmd) + if status.success? + output.puts stdout + success = true + else + error.puts stderr + success = false + exit(status.exitstatus) + end + end + if block_given? + success = !!yield(stdout, stderr, status) + # If the error reports that a rule was bypassed, consider the command successful + # because we are bypassing the rule intentionally when merging the release branch + # to the production branch. + success = true if stderr.match?(/bypassed rule violations/i) + abort(stderr) unless success + end + success + end + + # Echo a message to the console + # + # @param message [String] the message to echo + # return [TrueClass] + def sysecho(message, output: $stdout) + output.puts message + true + end +end diff --git a/lib/discharger/steps/prepare.rb b/lib/discharger/steps/prepare.rb new file mode 100644 index 0000000..c370b55 --- /dev/null +++ b/lib/discharger/steps/prepare.rb @@ -0,0 +1,70 @@ +require "rainbow/refinement" + +using Rainbow + +module Prepare + def prepare_for_release + desc <<~DESC + ---------- STEP 1 ---------- + Prepare the current version for release to production (#{production_branch}) + + This task will create a new branch to prepare the release. The CHANGELOG + will be updated and the version will be bumped. The branch will be pushed + to the remote repository. + + After the branch is created, open a PR to #{working_branch} to finalize + the release. + DESC + task prepare: [:environment] do + current_version = Object.const_get(version_constant) + finish_branch = "bump/finish-#{current_version.tr(".", "-")}" + + syscall( + ["git fetch origin #{working_branch}"], + ["git checkout #{working_branch}"], + ["git checkout -b #{finish_branch}"], + ["git push origin #{finish_branch} --force"] + ) + sysecho <<~MSG + Branch #{finish_branch} created. + + Check the contents of the CHANGELOG and ensure that the text is correct. + + If you need to make changes, edit the CHANGELOG and save the file. + Then return here to continue with this commit. + MSG + sysecho "Are you ready to continue? (Press Enter to continue, Type 'x' and Enter to exit)".bg(:yellow).black + input = $stdin.gets + exit if input.chomp.match?(/^x/i) + + tasker["reissue:finalize"].invoke + + params = { + expand: 1, + title: "Finish version #{current_version}", + body: <<~BODY + Completing development for #{current_version}. + BODY + } + + pr_url = "#{pull_request_url}/compare/#{finish_branch}?#{params.to_query}" + + continue = syscall ["git push origin #{finish_branch} --force"] do + sysecho <<~MSG + Branch #{finish_branch} created. + Open a PR to #{working_branch} to finalize the release. + + #{pr_url} + + Once the PR is merged, pull down #{working_branch} and run + 'rake #{name}:stage' + to stage the release branch. + MSG + end + if continue + syscall ["git checkout #{working_branch}"], + ["open", pr_url] + end + end + end +end diff --git a/lib/discharger/steps/release.rb b/lib/discharger/steps/release.rb new file mode 100644 index 0000000..d47184f --- /dev/null +++ b/lib/discharger/steps/release.rb @@ -0,0 +1,126 @@ +require "rainbow/refinement" + +using Rainbow + +module Release + def release_to_production + desc <<~DESC + ---------- STEP 3 ---------- + Release the current version to production + + This task rebases the production branch on the staging branch and tags the + current version. The production branch and the tag will be pushed to the + remote repository. + + After the release is complete, a new branch will be created to bump the + version for the next release. + DESC + task "#{name}": [:environment] do + current_version = Object.const_get(version_constant) + sysecho <<~MSG + Releasing version #{current_version} to production. + + This will tag the current version and push it to the production branch. + MSG + sysecho "Are you ready to continue? (Press Enter to continue, Type 'x' and Enter to exit)".bg(:yellow).black + input = $stdin.gets + exit if input.chomp.match?(/^x/i) + + continue = syscall( + ["git checkout #{working_branch}"], + ["git branch -D #{staging_branch} 2>/dev/null || true"], + ["git branch -D #{production_branch} 2>/dev/null || true"], + ["git fetch origin #{staging_branch}:#{staging_branch} #{production_branch}:#{production_branch}"], + ["git checkout #{production_branch}"], + ["git reset --hard #{staging_branch}"], + ["git tag -a v#{current_version} -m 'Release #{current_version}'"], + ["git push origin #{production_branch}:#{production_branch} v#{current_version}:v#{current_version}"], + ["git push origin v#{current_version}"] + ) do + tasker["#{name}:slack"].invoke("Released #{app_name} #{current_version} to production.", release_message_channel, ":chipmunk:") + if last_message_ts.present? + text = File.read(Rails.root.join(changelog_file)) + tasker["#{name}:slack"].reenable + tasker["#{name}:slack"].invoke(text, release_message_channel, ":log:", last_message_ts) + end + syscall ["git checkout #{working_branch}"] + end + + abort "Release failed." unless continue + + sysecho <<~MSG + Version #{current_version} released to production. + + Preparing to bump the version for the next release. + + MSG + + tasker["reissue"].invoke + new_version = Object.const_get(version_constant) + new_version_branch = "bump/begin-#{new_version.tr(".", "-")}" + continue = syscall(["git checkout -b #{new_version_branch}"]) + + abort "Bump failed." unless continue + + pr_url = "#{pull_request_url}/compare/#{working_branch}...#{new_version_branch}?expand=1&title=Begin%20#{current_version}" + + syscall(["git push origin #{new_version_branch} --force"]) do + sysecho <<~MSG + Branch #{new_version_branch} created. + + Open a PR to #{working_branch} to mark the version and update the chaneglog + for the next release. + + Opening PR: #{pr_url} + MSG + end.then do |success| + syscall ["open #{pr_url}"] if success + end + end + + namespace name do + desc "Echo the configuration settings." + task :config do + sysecho "-- Discharger Configuration --".bg(:green).black + sysecho "SHA: #{commit_identifier.call}".bg(:red).black + instance_variables.sort.each do |var| + value = instance_variable_get(var) + value = value.call if value.is_a?(Proc) && value.arity.zero? + sysecho "#{var.to_s.sub("@", "").ljust(24)}: #{value}".bg(:yellow).black + end + sysecho "----------------------------------".bg(:green).black + end + + desc description + task build: :environment do + syscall( + ["git fetch origin #{working_branch}"], + ["git checkout #{working_branch}"], + ["git branch -D #{staging_branch} 2>/dev/null || true"], + ["git checkout -b #{staging_branch}"], + ["git push origin #{staging_branch} --force"] + ) do + tasker["#{name}:slack"].invoke("Building #{app_name} #{commit_identifier.call} on #{staging_branch}.", release_message_channel) + syscall ["git checkout #{working_branch}"] + end + end + + desc "Send a message to Slack." + task :slack, [:text, :channel, :emoji, :ts] => :environment do |_, args| + args.with_defaults( + channel: release_message_channel, + emoji: nil + ) + client = Slack::Web::Client.new + options = args.to_h + options[:icon_emoji] = options.delete(:emoji) if options[:emoji] + options[:thread_ts] = options.delete(:ts) if options[:ts] + + sysecho "Sending message to Slack:".bg(:green).black + " #{args[:text]}" + result = client.chat_postMessage(**options) + instance_variable_set(:@last_message_ts, result["ts"]) + sysecho %(Message sent: #{result["ts"]}) + end + end + end +end diff --git a/lib/discharger/steps/stage.rb b/lib/discharger/steps/stage.rb new file mode 100644 index 0000000..75b8c70 --- /dev/null +++ b/lib/discharger/steps/stage.rb @@ -0,0 +1,42 @@ +require "rainbow/refinement" + +using Rainbow + +module Stage + def stage_release_branch + desc <<~DESC + ---------- STEP 2 ---------- + Stage the release branch + + This task will update Stage, open a PR, and instruct you on the next steps. + + NOTE: If you just want to update the stage environment but aren't ready to release, run: + + bin/rails #{name}:build + DESC + task stage: [:environment] do + tasker["build"].invoke + current_version = Object.const_get(version_constant) + + params = { + expand: 1, + title: "Release #{current_version} to production", + body: <<~BODY + Deploy #{current_version} to production. + BODY + } + + pr_url = "#{pull_request_url}/compare/#{production_branch}...#{staging_branch}?#{params.to_query}" + + sysecho <<~MSG + Branch #{staging_branch} updated. + Open a PR to #{production_branch} to release the version. + + Opening PR: #{pr_url} + + Once the PR is **approved**, run 'rake release' to release the version. + MSG + syscall ["open #{pr_url}"] + end + end +end diff --git a/lib/discharger/task.rb b/lib/discharger/task.rb index a874faa..dd0421b 100644 --- a/lib/discharger/task.rb +++ b/lib/discharger/task.rb @@ -1,10 +1,20 @@ require "rake/tasklib" require "reissue/rake" require "rainbow/refinement" +require_relative "helpers/sys_helper" +require_relative "steps/prepare" +require_relative "steps/stage" +require_relative "steps/release" + using Rainbow module Discharger class Task < Rake::TaskLib + include SysHelper + include Prepare + include Stage + include Release + def self.create(name = :release, tasker: Rake::Task, &block) task = new(name, tasker:) task.instance_eval(&block) if block @@ -56,279 +66,15 @@ def initialize(name = :release, tasker: Rake::Task) end private attr_reader :tasker - # Run a multiple system commands and return true if all commands succeed - # If any command fails, the method will return false and stop executing - # any further commands. - # - # Provide a block to evaluate the output of the command and return true - # if the command was successful. If the block returns false, the method - # will return false and stop executing any further commands. - # - # @param *steps [Array>] an array of commands to run - # @param block [Proc] a block to evaluate the output of the command - # @return [Boolean] true if all commands succeed, false otherwise - # - # @example - # syscall( - # ["echo Hello, World!"], - # ["ls -l"] - # ) - def syscall(*steps, output: $stdout, error: $stderr) - success = false - stdout, stderr, status = nil - steps.each do |cmd| - puts cmd.join(" ").bg(:green).black - stdout, stderr, status = Open3.capture3(*cmd) - if status.success? - output.puts stdout - success = true - else - error.puts stderr - success = false - exit(status.exitstatus) - end - end - if block_given? - success = !!yield(stdout, stderr, status) - # If the error reports that a rule was bypassed, consider the command successful - # because we are bypassing the rule intentionally when merging the release branch - # to the production branch. - success = true if stderr.match?(/bypassed rule violations/i) - abort(stderr) unless success - end - success - end - - # Echo a message to the console - # - # @param message [String] the message to echo - # return [TrueClass] - def sysecho(message, output: $stdout) - output.puts message - true - end - def define require "slack-ruby-client" Slack.configure do |config| config.token = chat_token end - desc <<~DESC - ---------- STEP 3 ---------- - Release the current version to production - - This task rebases the production branch on the staging branch and tags the - current version. The production branch and the tag will be pushed to the - remote repository. - - After the release is complete, a new branch will be created to bump the - version for the next release. - DESC - task "#{name}": [:environment] do - current_version = Object.const_get(version_constant) - sysecho <<~MSG - Releasing version #{current_version} to production. - - This will tag the current version and push it to the production branch. - MSG - sysecho "Are you ready to continue? (Press Enter to continue, Type 'x' and Enter to exit)".bg(:yellow).black - input = $stdin.gets - exit if input.chomp.match?(/^x/i) - - continue = syscall( - ["git checkout #{working_branch}"], - ["git branch -D #{staging_branch} 2>/dev/null || true"], - ["git branch -D #{production_branch} 2>/dev/null || true"], - ["git fetch origin #{staging_branch}:#{staging_branch} #{production_branch}:#{production_branch}"], - ["git checkout #{production_branch}"], - ["git reset --hard #{staging_branch}"], - ["git tag -a v#{current_version} -m 'Release #{current_version}'"], - ["git push origin #{production_branch}:#{production_branch} v#{current_version}:v#{current_version}"], - ["git push origin v#{current_version}"] - ) do - tasker["#{name}:slack"].invoke("Released #{app_name} #{current_version} to production.", release_message_channel, ":chipmunk:") - if last_message_ts.present? - text = File.read(Rails.root.join(changelog_file)) - tasker["#{name}:slack"].reenable - tasker["#{name}:slack"].invoke(text, release_message_channel, ":log:", last_message_ts) - end - syscall ["git checkout #{working_branch}"] - end - - abort "Release failed." unless continue - - sysecho <<~MSG - Version #{current_version} released to production. - - Preparing to bump the version for the next release. - - MSG - - tasker["reissue"].invoke - new_version = Object.const_get(version_constant) - new_version_branch = "bump/begin-#{new_version.tr(".", "-")}" - continue = syscall(["git checkout -b #{new_version_branch}"]) - - abort "Bump failed." unless continue - - pr_url = "#{pull_request_url}/compare/#{working_branch}...#{new_version_branch}?expand=1&title=Begin%20#{current_version}" - - syscall(["git push origin #{new_version_branch} --force"]) do - sysecho <<~MSG - Branch #{new_version_branch} created. - - Open a PR to #{working_branch} to mark the version and update the chaneglog - for the next release. - - Opening PR: #{pr_url} - MSG - end.then do |success| - syscall ["open #{pr_url}"] if success - end - end - - namespace name do - desc "Echo the configuration settings." - task :config do - sysecho "-- Discharger Configuration --".bg(:green).black - sysecho "SHA: #{commit_identifier.call}".bg(:red).black - instance_variables.sort.each do |var| - value = instance_variable_get(var) - value = value.call if value.is_a?(Proc) && value.arity.zero? - sysecho "#{var.to_s.sub("@", "").ljust(24)}: #{value}".bg(:yellow).black - end - sysecho "----------------------------------".bg(:green).black - end - - desc description - task build: :environment do - syscall( - ["git fetch origin #{working_branch}"], - ["git checkout #{working_branch}"], - ["git branch -D #{staging_branch} 2>/dev/null || true"], - ["git checkout -b #{staging_branch}"], - ["git push origin #{staging_branch} --force"] - ) do - tasker["#{name}:slack"].invoke("Building #{app_name} #{commit_identifier.call} on #{staging_branch}.", release_message_channel) - syscall ["git checkout #{working_branch}"] - end - end - - desc "Send a message to Slack." - task :slack, [:text, :channel, :emoji, :ts] => :environment do |_, args| - args.with_defaults( - channel: release_message_channel, - emoji: nil - ) - client = Slack::Web::Client.new - options = args.to_h - options[:icon_emoji] = options.delete(:emoji) if options[:emoji] - options[:thread_ts] = options.delete(:ts) if options[:ts] - - sysecho "Sending message to Slack:".bg(:green).black + " #{args[:text]}" - result = client.chat_postMessage(**options) - instance_variable_set(:@last_message_ts, result["ts"]) - sysecho %(Message sent: #{result["ts"]}) - end - - desc <<~DESC - ---------- STEP 1 ---------- - Prepare the current version for release to production (#{production_branch}) - - This task will create a new branch to prepare the release. The CHANGELOG - will be updated and the version will be bumped. The branch will be pushed - to the remote repository. - - After the branch is created, open a PR to #{working_branch} to finalize - the release. - DESC - task prepare: [:environment] do - current_version = Object.const_get(version_constant) - finish_branch = "bump/finish-#{current_version.tr(".", "-")}" - - syscall( - ["git fetch origin #{working_branch}"], - ["git checkout #{working_branch}"], - ["git checkout -b #{finish_branch}"] - ) - sysecho <<~MSG - Branch #{finish_branch} created. - - Check the contents of the CHANGELOG and ensure that the text is correct. - - If you need to make changes, edit the CHANGELOG and save the file. - Then return here to continue with this commit. - MSG - sysecho "Are you ready to continue? (Press Enter to continue, Type 'x' and Enter to exit)".bg(:yellow).black - input = $stdin.gets - exit if input.chomp.match?(/^x/i) - - tasker["reissue:finalize"].invoke - - params = { - expand: 1, - title: "Finish version #{current_version}", - body: <<~BODY - Completing development for #{current_version}. - BODY - } - - pr_url = "#{pull_request_url}/compare/#{finish_branch}?#{params.to_query}" - - continue = syscall ["git push origin #{finish_branch} --force"] do - sysecho <<~MSG - Branch #{finish_branch} created. - Open a PR to #{working_branch} to finalize the release. - - #{pr_url} - - Once the PR is merged, pull down #{working_branch} and run - 'rake #{name}:stage' - to stage the release branch. - MSG - end - if continue - syscall ["git checkout #{working_branch}"], - ["open", pr_url] - end - end - - desc <<~DESC - ---------- STEP 2 ---------- - Stage the release branch - - This task will update Stage, open a PR, and instruct you on the next steps. - - NOTE: If you just want to update the stage environment but aren't ready to release, run: - - bin/rails #{name}:build - DESC - task stage: [:environment] do - tasker["build"].invoke - current_version = Object.const_get(version_constant) - - params = { - expand: 1, - title: "Release #{current_version} to production", - body: <<~BODY - Deploy #{current_version} to production. - BODY - } - - pr_url = "#{pull_request_url}/compare/#{production_branch}...#{staging_branch}?#{params.to_query}" - - sysecho <<~MSG - Branch #{staging_branch} updated. - Open a PR to #{production_branch} to release the version. - - Opening PR: #{pr_url} - - Once the PR is **approved**, run 'rake release' to release the version. - MSG - syscall ["open #{pr_url}"] - end - end + release_to_production + prepare_for_release + stage_release_branch end end end diff --git a/test/lib/discharger/steps/prepare_test.rb b/test/lib/discharger/steps/prepare_test.rb new file mode 100644 index 0000000..a0b98bc --- /dev/null +++ b/test/lib/discharger/steps/prepare_test.rb @@ -0,0 +1,143 @@ +require "test_helper" +require "discharger/task" +require "rake" + +# Mock Rails.root for changelog path +unless defined?(Rails) + module Rails + def self.root + Pathname.new(Dir.pwd) + end + end +end + +class PrepareTest < Minitest::Test + include Rake::DSL + + def setup + # Initialize Rake + @rake = Rake::Application.new + Rake.application = @rake + + # Create a new Task instance + @task = Discharger::Task.new + + # Required instance variables from Task class + @task.name = "test_#{name}" + @task.version_constant = "VERSION" + @task.working_branch = "main" + @task.staging_branch = "staging" + @task.production_branch = "production" + @task.release_message_channel = "#releases" + @task.changelog_file = "CHANGELOG.md" + @task.app_name = "TestApp" + @task.pull_request_url = "https://github.com/org/repo/pulls" + @task.description = "Build and release the application" + + # Mock environment task since it's a prerequisite + task :environment do + # No-op for testing + end + + # Mock VERSION constant + Object.const_set(:VERSION, "1.0.0") unless Object.const_defined?(:VERSION) + + # Mock reissue:finalize task since it's called by prepare + task "reissue:finalize" + + # Define helper methods on the task instance + def @task.commit_identifier + -> { "abc123" } + end + + # Stub syscall and sysecho before defining tasks + @called_commands = [] + def @task.syscall(*commands) + @called_commands ||= [] + @called_commands.concat(commands.flatten) + yield if block_given? + true + end + + @echoed_messages = [] + def @task.sysecho(message) + @echoed_messages ||= [] + @echoed_messages << message + end + + # Call define to set up the tasks + @task.define + end + + def teardown + # Clear Rake tasks between tests + Rake.application.clear + Rake::Task.clear + end + + def test_prepare_for_release_defines_task + task_names = Rake.application.tasks.map(&:name) + assert_includes task_names, "prepare" + end + + def test_prepare_task_executes_expected_git_commands + # Mock stdin to simulate user pressing enter + stdin_mock = StringIO.new("\n") + $stdin = stdin_mock + + Rake::Task["prepare"].invoke + expected_commands = [ + "git fetch origin main", + "git checkout main", + "git checkout -b bump/finish-1-0-0", + "git push origin bump/finish-1-0-0 --force", + "git push origin bump/finish-1-0-0 --force", + "git checkout main", + "open" # The PR URL command will be partially matched + ] + + actual_commands = @task.instance_variable_get(:@called_commands) + expected_commands.each do |expected_cmd| + assert_includes actual_commands.join(" "), expected_cmd + end + ensure + $stdin = STDIN + end + + def test_prepare_task_handles_user_exit + # Mock stdin to simulate user typing 'x' + stdin_mock = StringIO.new("x\n") + $stdin = stdin_mock + + assert_raises(SystemExit) do + Rake::Task["prepare"].invoke + end + + expected_commands = [ + "git fetch origin main", + "git checkout main", + "git checkout -b bump/finish-1-0-0", + "git push origin bump/finish-1-0-0 --force" + ] + + actual_commands = @task.instance_variable_get(:@called_commands) + assert_equal expected_commands, actual_commands + ensure + $stdin = STDIN + end + + def test_prepare_task_outputs_expected_messages + # Mock stdin to simulate user pressing enter + stdin_mock = StringIO.new("\n") + $stdin = stdin_mock + + Rake::Task["prepare"].invoke + + messages = @task.instance_variable_get(:@echoed_messages) + assert_includes messages.join, "Branch bump/finish-1-0-0 created" + assert_includes messages.join, "Check the contents of the CHANGELOG" + assert_includes messages.join, "Are you ready to continue?" + ensure + $stdin = STDIN + end +end diff --git a/test/lib/discharger/steps/release_test.rb b/test/lib/discharger/steps/release_test.rb new file mode 100644 index 0000000..a52365c --- /dev/null +++ b/test/lib/discharger/steps/release_test.rb @@ -0,0 +1,138 @@ +require "test_helper" +require "discharger/task" +require "rake" + +# Mock Rails.root for changelog path +unless defined?(Rails) + module Rails + def self.root + Pathname.new(Dir.pwd) + end + end +end + +# Mock Slack client with silent operation +unless defined?(Slack) + module Slack + def self.configure + yield(nil) + end + + module Web + class Client + def initialize + end + + def chat_postMessage(**options) + # Return silently without printing + {"ts" => "123.456"} + end + end + end + end +end + +class ReleaseTest < Minitest::Test + include Rake::DSL + + def setup + # Initialize Rake + @rake = Rake::Application.new + Rake.application = @rake + + # Create a new Task instance + @task = Discharger::Task.new + + # Required instance variables from Task class + @task.name = "test_#{name}" + @task.version_constant = "VERSION" + @task.working_branch = "main" + @task.staging_branch = "staging" + @task.production_branch = "production" + @task.release_message_channel = "#releases" + @task.changelog_file = "CHANGELOG.md" + @task.app_name = "TestApp" + @task.pull_request_url = "https://github.com/org/repo/pulls" + @task.description = "Build and release the application" + + # Mock environment task since it's a prerequisite + task :environment do + # No-op for testing + end + + # Mock VERSION constant + Object.const_set(:VERSION, "1.0.0") unless Object.const_defined?(:VERSION) + + # Define helper methods on the task instance + def @task.commit_identifier + -> { "abc123" } + end + + # Stub syscall and sysecho before defining tasks + @called_commands = [] + def @task.syscall(*commands) + @called_commands ||= [] + @called_commands.concat(commands.flatten) + yield if block_given? + true + end + + @echoed_messages = [] + def @task.sysecho(message) + @echoed_messages ||= [] + @echoed_messages << message + end + + # Call define to set up the tasks + @task.define + end + + def teardown + # Clear Rake tasks between tests + Rake.application.clear + Rake::Task.clear + end + + def test_release_to_production_defines_task + task_names = Rake.application.tasks.map(&:name) + assert_includes task_names, @task.name.to_s + end + + def test_release_to_production_defines_config_task + task_names = Rake.application.tasks.map(&:name) + assert_includes task_names, "#{@task.name}:config" + end + + def test_release_to_production_defines_build_task + task_names = Rake.application.tasks.map(&:name) + assert_includes task_names, "#{@task.name}:build" + end + + def test_release_to_production_defines_slack_task + task_names = Rake.application.tasks.map(&:name) + assert_includes task_names, "#{@task.name}:slack" + end + + def test_build_task_executes_expected_git_commands + Rake::Task["#{@task.name}:build"].invoke + expected_commands = [ + "git fetch origin main", + "git checkout main", + "git branch -D staging 2>/dev/null || true", + "git checkout -b staging", + "git push origin staging --force", + "git checkout main" # This command is called in the block + ] + assert_equal expected_commands, @task.instance_variable_get(:@called_commands) + end + + def test_slack_task_with_default_parameters + Rake::Task["#{@task.name}:slack"].invoke("Default message", @task.release_message_channel) + assert_includes @task.instance_variable_get(:@echoed_messages).to_s, "Sending message to Slack:" + end + + def test_slack_task_with_custom_parameters + Rake::Task["#{@task.name}:slack"].invoke("Custom message", @task.release_message_channel, ":emoji:", "123.456") + assert_includes @task.instance_variable_get(:@echoed_messages).to_s, "Sending message to Slack:" + end +end diff --git a/test/lib/discharger/steps/stage_test.rb b/test/lib/discharger/steps/stage_test.rb new file mode 100644 index 0000000..98e6c77 --- /dev/null +++ b/test/lib/discharger/steps/stage_test.rb @@ -0,0 +1,135 @@ +require "test_helper" +require "discharger/task" +require "rake" + +# Mock Rails.root for changelog path +unless defined?(Rails) + module Rails + def self.root + Pathname.new(Dir.pwd) + end + end +end + +class StageTest < Minitest::Test + include Rake::DSL + + def setup + # Initialize Rake + @rake = Rake::Application.new + Rake.application = @rake + + # Create a new Task instance + @task = Discharger::Task.new + + # Required instance variables from Task class + @task.name = "test_#{name}" + @task.version_constant = "VERSION" + @task.working_branch = "main" + @task.staging_branch = "staging" + @task.production_branch = "production" + @task.release_message_channel = "#releases" + @task.changelog_file = "CHANGELOG.md" + @task.app_name = "TestApp" + @task.pull_request_url = "https://github.com/org/repo/pulls" + @task.description = "Build and release the application" + + # Mock environment task since it's a prerequisite + task :environment do + # No-op for testing + end + + # Mock build tasks since they're called by stage + task :build # Mock the main build task + task "build" # Mock the string version of build task + task "#{@task.name}:build" # Mock the namespaced build task + + # Mock VERSION constant + Object.const_set(:VERSION, "1.0.0") unless Object.const_defined?(:VERSION) + + # Define helper methods on the task instance + def @task.commit_identifier + -> { "abc123" } + end + + # Stub syscall and sysecho before defining tasks + @called_commands = [] + def @task.syscall(*commands) + @called_commands ||= [] + @called_commands.concat(commands.flatten) + yield if block_given? + true + end + + @echoed_messages = [] + def @task.sysecho(message) + @echoed_messages ||= [] + @echoed_messages << message + end + + # Call define to set up the tasks + @task.define + end + + def teardown + # Clear Rake tasks between tests + Rake.application.clear + Rake::Task.clear + end + + def test_stage_release_branch_defines_task + task_names = Rake.application.tasks.map(&:name) + assert_includes task_names, "stage" + end + + def test_stage_task_invokes_build_task + build_task_called = false + Rake::Task["build"].enhance do + build_task_called = true + end + + Rake::Task["stage"].invoke + assert build_task_called, "Build task should have been called" + end + + def test_stage_task_executes_expected_commands + Rake::Task["stage"].invoke + + expected_url_parts = [ + "open", + "https://github.com/org/repo/pulls/compare/production...staging", + "expand=1", + "title=Release+1.0.0+to+production", + "body=Deploy+1.0.0+to+production" + ] + + actual_commands = @task.instance_variable_get(:@called_commands) + expected_url_parts.each do |part| + assert_includes actual_commands.join(" "), part + end + end + + def test_stage_task_outputs_expected_messages + Rake::Task["stage"].invoke + + messages = @task.instance_variable_get(:@echoed_messages) + expected_messages = [ + "Branch staging updated", + "Open a PR to production to release the version", + "Once the PR is **approved**, run 'rake release' to release the version" + ] + + expected_messages.each do |expected_msg| + assert_includes messages.join, expected_msg + end + end + + def test_stage_task_includes_version_in_pr_url + Rake::Task["stage"].invoke + + actual_commands = @task.instance_variable_get(:@called_commands) + url = actual_commands.join(" ") + assert_includes url, "title=Release+1.0.0+to+production" + assert_includes url, "body=Deploy+1.0.0+to+production" + end +end diff --git a/test/lib/discharger/task_test.rb b/test/lib/discharger/task_test.rb new file mode 100644 index 0000000..d102416 --- /dev/null +++ b/test/lib/discharger/task_test.rb @@ -0,0 +1,117 @@ +require_relative "../../test_helper" +require "fileutils" +require "open3" +require "minitest/mock" + +class DischargerTaskTest < Minitest::Test + def setup + @task = Discharger::Task.new + @task.version_constant = "VERSION" + @task.app_name = "TestApp" + @task.release_message_channel = "#test-channel" + @task.chat_token = "test-token" + @task.pull_request_url = "https://github.com/test/test" + end + + def test_initialize_with_default_values + task = Discharger::Task.new + assert_equal :release, task.name + assert_equal "develop", task.working_branch + assert_equal "stage", task.staging_branch + assert_equal "main", task.production_branch + assert_equal "Release the current version to stage", task.description + end + + def test_create_configures_reissue_task + mock_reissue = Minitest::Mock.new + # Mock all methods that Reissue::Task calls + mock_reissue.expect(:version_file=, nil, [String]) + mock_reissue.expect(:version_limit=, nil, [Object]) + mock_reissue.expect(:version_redo_proc=, nil, [Object]) + mock_reissue.expect(:changelog_file=, nil, [String]) + mock_reissue.expect(:updated_paths=, nil, [Object]) + mock_reissue.expect(:commit=, nil, [Object]) + mock_reissue.expect(:commit_finalize=, nil, [Object]) + + Reissue::Task.stub :create, ->(&block) { block.call(mock_reissue) } do + Discharger::Task.create do |t| + t.version_file = "VERSION" + t.version_limit = 5 + t.version_redo_proc = -> {} + t.changelog_file = "CHANGELOG.md" + t.updated_paths = [] + t.commit = -> {} + t.commit_finalize = -> {} + end + end + + mock_reissue.verify + end + + def test_syscall_success + output = StringIO.new + error = StringIO.new + + # Silence the command output + silence_output do + result = @task.syscall(["echo", "test"], output: output, error: error) + assert result + assert_includes output.string, "test" + end + end + + def test_syscall_failure + output = StringIO.new + error = StringIO.new + + # Silence the command output + silence_output do + assert_raises(SystemExit) do + @task.syscall(["false"], output: output, error: error) + end + end + end + + def test_sysecho_outputs_message + output = StringIO.new + result = @task.sysecho("test message", output: output) + assert result + assert_equal "test message\n", output.string + end + + def test_define + @task.chat_token = "fake_token" + @task.release_message_channel = "#general" + @task.version_constant = "VERSION" + @task.pull_request_url = "http://example.com" + + @task.define + + expected_tasks = [ + "prepare", + "release", + "release:build", + "release:config", + "release:slack", + "stage" + ] + + actual_tasks = Rake::Task.tasks.map(&:name).sort + expected_tasks.each do |task_name| + assert_includes actual_tasks, task_name, "Expected task '#{task_name}' to exist" + end + end + + private + + def silence_output + original_stdout = $stdout + original_stderr = $stderr + $stdout = StringIO.new + $stderr = StringIO.new + yield + ensure + $stdout = original_stdout + $stderr = original_stderr + end +end diff --git a/test/task_test.rb b/test/task_test.rb deleted file mode 100644 index 20eb025..0000000 --- a/test/task_test.rb +++ /dev/null @@ -1,76 +0,0 @@ -require_relative "test_helper" - -class DischargerTaskTest < Minitest::Test - def setup - @task = Discharger::Task.new - end - - def test_initialize - assert_equal :release, @task.name - assert_equal "develop", @task.working_branch - assert_equal "stage", @task.staging_branch - assert_equal "main", @task.production_branch - assert_equal "Release the current version to stage", @task.description - end - - def test_create - task = Discharger::Task.create(:test_task) do - self.version_file = "VERSION" - self.version_limit = "1.0.0" - self.version_redo_proc = -> { "1.0.1" } - self.changelog_file = "CHANGELOG.md" - self.updated_paths = ["lib/"] - self.commit = "Initial commit" - self.commit_finalize = "Finalize commit" - end - - assert_equal :test_task, task.name - assert_equal "VERSION", task.version_file - assert_equal "1.0.0", task.version_limit - assert_equal "1.0.1", task.version_redo_proc.call - assert_equal "CHANGELOG.md", task.changelog_file - assert_equal ["lib/"], task.updated_paths - assert_equal "Initial commit", task.commit - assert_equal "Finalize commit", task.commit_finalize - end - - def test_syscall_success - output = StringIO.new - assert_output(/Hello, World!/) do - result = @task.syscall(["echo", "Hello, World!"], output:) - assert result - end - end - - def test_syscall_failure - assert_raises(SystemExit) do - capture_io do - @task.syscall(["false"]) - end - end - end - - def test_sysecho - assert_output("Hello, World!\n") do - assert @task.sysecho("Hello, World!") - end - end - - def test_define - @task.chat_token = "fake_token" - @task.release_message_channel = "#general" - @task.version_constant = "VERSION" - @task.pull_request_url = "http://example.com" - - @task.define - - assert_equal [ - "release", - "release:build", - "release:config", - "release:prepare", - "release:slack", - "release:stage" - ], Rake::Task.tasks.map(&:name).grep(/^release/).sort - end -end diff --git a/test/test_helper.rb b/test/test_helper.rb index 9246c49..b3fdba2 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -17,7 +17,36 @@ ActiveSupport::TestCase.fixtures :all end +require "discharger/helpers/sys_helper" require "discharger" require "discharger/task" require "debug" + +require "bundler/setup" +require "minitest/autorun" +require "minitest/mock" +require "rake" + +# Add the lib directory to the load path +$LOAD_PATH.unshift File.expand_path("../lib", __dir__) + +# Require any support files +Dir[File.expand_path("support/**/*.rb", __dir__)].each { |f| require f } + +# Configure minitest reporter if you want prettier output +require "minitest/reporters" +Minitest::Reporters.use! [Minitest::Reporters::DefaultReporter.new(color: true)] + +# Add near the top of the file +TEST_GIT_COMMANDS = ENV["TEST_GIT_COMMANDS"] == "true" + +# Reset Rake tasks before each test +module Minitest + class Test + def setup + Rake::Task.clear + super + end + end +end From 0454b39c0d3ead898ebf7984cf38e7ae30c7e79d Mon Sep 17 00:00:00 2001 From: Savannah Albanez Date: Fri, 7 Feb 2025 13:18:28 -0700 Subject: [PATCH 2/2] Convert each steps module into classes --- lib/discharger/steps/prepare.rb | 109 +++++----- lib/discharger/steps/release.rb | 250 +++++++++++++--------- lib/discharger/steps/stage.rb | 83 ++++--- lib/discharger/task.rb | 42 ++-- test/lib/discharger/steps/prepare_test.rb | 101 ++++----- test/lib/discharger/steps/release_test.rb | 21 +- test/lib/discharger/steps/stage_test.rb | 48 ++--- 7 files changed, 342 insertions(+), 312 deletions(-) diff --git a/lib/discharger/steps/prepare.rb b/lib/discharger/steps/prepare.rb index c370b55..bdc331d 100644 --- a/lib/discharger/steps/prepare.rb +++ b/lib/discharger/steps/prepare.rb @@ -2,68 +2,61 @@ using Rainbow -module Prepare - def prepare_for_release - desc <<~DESC - ---------- STEP 1 ---------- - Prepare the current version for release to production (#{production_branch}) - - This task will create a new branch to prepare the release. The CHANGELOG - will be updated and the version will be bumped. The branch will be pushed - to the remote repository. - - After the branch is created, open a PR to #{working_branch} to finalize - the release. - DESC - task prepare: [:environment] do - current_version = Object.const_get(version_constant) - finish_branch = "bump/finish-#{current_version.tr(".", "-")}" - - syscall( - ["git fetch origin #{working_branch}"], - ["git checkout #{working_branch}"], - ["git checkout -b #{finish_branch}"], - ["git push origin #{finish_branch} --force"] - ) - sysecho <<~MSG - Branch #{finish_branch} created. - - Check the contents of the CHANGELOG and ensure that the text is correct. - - If you need to make changes, edit the CHANGELOG and save the file. - Then return here to continue with this commit. - MSG - sysecho "Are you ready to continue? (Press Enter to continue, Type 'x' and Enter to exit)".bg(:yellow).black - input = $stdin.gets - exit if input.chomp.match?(/^x/i) - - tasker["reissue:finalize"].invoke - - params = { - expand: 1, - title: "Finish version #{current_version}", - body: <<~BODY - Completing development for #{current_version}. - BODY - } - - pr_url = "#{pull_request_url}/compare/#{finish_branch}?#{params.to_query}" +module Discharger + module Steps + class Prepare + def initialize(task) + @task = task + @tasker = task.tasker + end - continue = syscall ["git push origin #{finish_branch} --force"] do - sysecho <<~MSG - Branch #{finish_branch} created. - Open a PR to #{working_branch} to finalize the release. + def prepare_for_release + @task.desc <<~DESC + ---------- STEP 1 ---------- + Prepare the current version for release + + This task will check that the current version is ready for release by + verifying that the version number is valid and that the changelog is + up to date. + DESC + @task.task prepare: [:environment] do + current_version = Object.const_get(@task.version_constant) + @task.sysecho "Preparing version #{current_version} for release" + + if @task.mono_repo && @task.gem_tag + @task.sysecho "Checking for gem tag #{@task.gem_tag}" + @task.syscall ["git fetch origin #{@task.gem_tag}"] + @task.syscall ["git tag -l #{@task.gem_tag}"] + end + + @task.syscall( + ["git fetch origin #{@task.working_branch}"], + ["git checkout #{@task.working_branch}"], + ["git pull origin #{@task.working_branch}"] + ) + + @task.sysecho "Checking changelog for version #{current_version}" + changelog = File.read(@task.changelog_file) + unless changelog.include?(current_version.to_s) + raise "Version #{current_version} not found in #{@task.changelog_file}" + end + + @task.sysecho "Version #{current_version} is ready for release" + end + end - #{pr_url} + private - Once the PR is merged, pull down #{working_branch} and run - 'rake #{name}:stage' - to stage the release branch. - MSG + def method_missing(method_name, *args, &block) + if @task.respond_to?(method_name) + @task.send(method_name, *args, &block) + else + super + end end - if continue - syscall ["git checkout #{working_branch}"], - ["open", pr_url] + + def respond_to_missing?(method_name, include_private = false) + @task.respond_to?(method_name, include_private) || super end end end diff --git a/lib/discharger/steps/release.rb b/lib/discharger/steps/release.rb index d47184f..955cabb 100644 --- a/lib/discharger/steps/release.rb +++ b/lib/discharger/steps/release.rb @@ -2,124 +2,160 @@ using Rainbow -module Release - def release_to_production - desc <<~DESC - ---------- STEP 3 ---------- - Release the current version to production - - This task rebases the production branch on the staging branch and tags the - current version. The production branch and the tag will be pushed to the - remote repository. - - After the release is complete, a new branch will be created to bump the - version for the next release. - DESC - task "#{name}": [:environment] do - current_version = Object.const_get(version_constant) - sysecho <<~MSG - Releasing version #{current_version} to production. - - This will tag the current version and push it to the production branch. - MSG - sysecho "Are you ready to continue? (Press Enter to continue, Type 'x' and Enter to exit)".bg(:yellow).black - input = $stdin.gets - exit if input.chomp.match?(/^x/i) - - continue = syscall( - ["git checkout #{working_branch}"], - ["git branch -D #{staging_branch} 2>/dev/null || true"], - ["git branch -D #{production_branch} 2>/dev/null || true"], - ["git fetch origin #{staging_branch}:#{staging_branch} #{production_branch}:#{production_branch}"], - ["git checkout #{production_branch}"], - ["git reset --hard #{staging_branch}"], - ["git tag -a v#{current_version} -m 'Release #{current_version}'"], - ["git push origin #{production_branch}:#{production_branch} v#{current_version}:v#{current_version}"], - ["git push origin v#{current_version}"] - ) do - tasker["#{name}:slack"].invoke("Released #{app_name} #{current_version} to production.", release_message_channel, ":chipmunk:") - if last_message_ts.present? - text = File.read(Rails.root.join(changelog_file)) - tasker["#{name}:slack"].reenable - tasker["#{name}:slack"].invoke(text, release_message_channel, ":log:", last_message_ts) - end - syscall ["git checkout #{working_branch}"] +module Discharger + module Steps + class Release + include Rake::DSL + + def initialize(task) + @task = task + @tasker = task.tasker end - abort "Release failed." unless continue - - sysecho <<~MSG - Version #{current_version} released to production. - - Preparing to bump the version for the next release. - - MSG - - tasker["reissue"].invoke - new_version = Object.const_get(version_constant) - new_version_branch = "bump/begin-#{new_version.tr(".", "-")}" - continue = syscall(["git checkout -b #{new_version_branch}"]) - - abort "Bump failed." unless continue - - pr_url = "#{pull_request_url}/compare/#{working_branch}...#{new_version_branch}?expand=1&title=Begin%20#{current_version}" - - syscall(["git push origin #{new_version_branch} --force"]) do - sysecho <<~MSG - Branch #{new_version_branch} created. - - Open a PR to #{working_branch} to mark the version and update the chaneglog - for the next release. + def release_to_production + push_to_production + establish_config + build_environment + send_message_to_slack + end - Opening PR: #{pr_url} - MSG - end.then do |success| - syscall ["open #{pr_url}"] if success + private + + def push_to_production + @task.desc <<~DESC + ---------- STEP 3 ---------- + Release the current version to production + + This task rebases the production branch on the staging branch and tags the + current version. The production branch and the tag will be pushed to the + remote repository. + + After the release is complete, a new branch will be created to bump the + version for the next release. + DESC + @task.task @task.name => [:environment] do + current_version = Object.const_get(@task.version_constant) + @task.sysecho <<~MSG + Releasing version #{current_version} to production. + + This will tag the current version and push it to the production branch. + MSG + @task.sysecho "Are you ready to continue? (Press Enter to continue, Type 'x' and Enter to exit)".bg(:yellow).black + input = $stdin.gets + exit if input.chomp.match?(/^x/i) + + continue = @task.syscall( + ["git checkout #{@task.working_branch}"], + ["git branch -D #{@task.staging_branch} 2>/dev/null || true"], + ["git branch -D #{@task.production_branch} 2>/dev/null || true"], + ["git fetch origin #{@task.staging_branch}:#{@task.staging_branch} #{@task.production_branch}:#{@task.production_branch}"], + ["git checkout #{@task.production_branch}"], + ["git reset --hard #{@task.staging_branch}"], + ["git tag -a v#{current_version} -m 'Release #{current_version}'"], + ["git push origin #{@task.production_branch}:#{@task.production_branch} v#{current_version}:v#{current_version}"], + ["git push origin v#{current_version}"] + ) do + @tasker["#{@task.name}:slack"].invoke( + "Released #{@task.app_name} #{current_version} to production.", + @task.release_message_channel, + ":chipmunk:" + ) + if @task.last_message_ts.present? + text = File.read(Rails.root.join(@task.changelog_file)) + @tasker["#{@task.name}:slack"].reenable + @tasker["#{@task.name}:slack"].invoke( + text, + @task.release_message_channel, + ":log:", + @task.last_message_ts + ) + end + @task.syscall ["git checkout #{@task.working_branch}"] + end + + abort "Release failed." unless continue + + @task.sysecho <<~MSG + Version #{current_version} released to production. + + Preparing to bump the version for the next release. + + MSG + + @tasker["reissue"].invoke + new_version = Object.const_get(@task.version_constant) + new_version_branch = "bump/begin-#{new_version.tr(".", "-")}" + continue = @task.syscall(["git checkout -b #{new_version_branch}"]) + + abort "Bump failed." unless continue + + pr_url = "#{@task.pull_request_url}/compare/#{@task.working_branch}...#{new_version_branch}?expand=1&title=Begin%20#{current_version}" + + @task.syscall(["git push origin #{new_version_branch} --force"]) do + @task.sysecho <<~MSG + Branch #{new_version_branch} created. + + Open a PR to #{@task.working_branch} to mark the version and update the chaneglog + for the next release. + + Opening PR: #{pr_url} + MSG + end.then do |success| + @task.syscall ["open #{pr_url}"] if success + end + end end - end - namespace name do - desc "Echo the configuration settings." - task :config do - sysecho "-- Discharger Configuration --".bg(:green).black - sysecho "SHA: #{commit_identifier.call}".bg(:red).black - instance_variables.sort.each do |var| - value = instance_variable_get(var) - value = value.call if value.is_a?(Proc) && value.arity.zero? - sysecho "#{var.to_s.sub("@", "").ljust(24)}: #{value}".bg(:yellow).black + def establish_config + @task.desc "Echo the configuration settings." + @task.task "#{@task.name}:config" do + @task.sysecho "-- Discharger Configuration --".bg(:green).black + @task.sysecho "SHA: #{@task.commit_identifier.call}".bg(:red).black + @task.instance_variables.sort.each do |var| + value = @task.instance_variable_get(var) + value = value.call if value.is_a?(Proc) && value.arity.zero? + @task.sysecho "#{var.to_s.sub("@", "").ljust(24)}: #{value}".bg(:yellow).black + end + @task.sysecho "----------------------------------".bg(:green).black end - sysecho "----------------------------------".bg(:green).black end - desc description - task build: :environment do - syscall( - ["git fetch origin #{working_branch}"], - ["git checkout #{working_branch}"], - ["git branch -D #{staging_branch} 2>/dev/null || true"], - ["git checkout -b #{staging_branch}"], - ["git push origin #{staging_branch} --force"] - ) do - tasker["#{name}:slack"].invoke("Building #{app_name} #{commit_identifier.call} on #{staging_branch}.", release_message_channel) - syscall ["git checkout #{working_branch}"] + def build_environment + @task.desc @task.description + @task.task "#{@task.name}:build" => :environment do + @task.syscall( + ["git fetch origin #{@task.working_branch}"], + ["git checkout #{@task.working_branch}"], + ["git branch -D #{@task.staging_branch} 2>/dev/null || true"], + ["git checkout -b #{@task.staging_branch}"], + ["git push origin #{@task.staging_branch} --force"] + ) do + @tasker["#{@task.name}:slack"].invoke( + "Building #{@task.app_name} #{@task.commit_identifier.call} on #{@task.staging_branch}.", + @task.release_message_channel + ) + @task.syscall ["git checkout #{@task.working_branch}"] + end end end - desc "Send a message to Slack." - task :slack, [:text, :channel, :emoji, :ts] => :environment do |_, args| - args.with_defaults( - channel: release_message_channel, - emoji: nil - ) - client = Slack::Web::Client.new - options = args.to_h - options[:icon_emoji] = options.delete(:emoji) if options[:emoji] - options[:thread_ts] = options.delete(:ts) if options[:ts] - - sysecho "Sending message to Slack:".bg(:green).black + " #{args[:text]}" - result = client.chat_postMessage(**options) - instance_variable_set(:@last_message_ts, result["ts"]) - sysecho %(Message sent: #{result["ts"]}) + def send_message_to_slack + @task.desc "Send a message to Slack." + @task.task "#{@task.name}:slack", [:text, :channel, :emoji, :ts] => :environment do |_, args| + args.with_defaults( + channel: @task.release_message_channel, + emoji: nil + ) + client = Slack::Web::Client.new + options = args.to_h + options[:icon_emoji] = options.delete(:emoji) if options[:emoji] + options[:thread_ts] = options.delete(:ts) if options[:ts] + + @task.sysecho "Sending message to Slack:".bg(:green).black + " #{args[:text]}" + result = client.chat_postMessage(**options) + @task.instance_variable_set(:@last_message_ts, result["ts"]) + @task.sysecho %(Message sent: #{result["ts"]}) + end end end end diff --git a/lib/discharger/steps/stage.rb b/lib/discharger/steps/stage.rb index 75b8c70..585fce6 100644 --- a/lib/discharger/steps/stage.rb +++ b/lib/discharger/steps/stage.rb @@ -1,42 +1,55 @@ require "rainbow/refinement" +require "uri" # Add this for URL encoding using Rainbow -module Stage - def stage_release_branch - desc <<~DESC - ---------- STEP 2 ---------- - Stage the release branch - - This task will update Stage, open a PR, and instruct you on the next steps. - - NOTE: If you just want to update the stage environment but aren't ready to release, run: - - bin/rails #{name}:build - DESC - task stage: [:environment] do - tasker["build"].invoke - current_version = Object.const_get(version_constant) - - params = { - expand: 1, - title: "Release #{current_version} to production", - body: <<~BODY - Deploy #{current_version} to production. - BODY - } - - pr_url = "#{pull_request_url}/compare/#{production_branch}...#{staging_branch}?#{params.to_query}" - - sysecho <<~MSG - Branch #{staging_branch} updated. - Open a PR to #{production_branch} to release the version. - - Opening PR: #{pr_url} - - Once the PR is **approved**, run 'rake release' to release the version. - MSG - syscall ["open #{pr_url}"] +module Discharger + module Steps + class Stage + def initialize(task) + @task = task + @tasker = task.tasker + end + + def stage_release_branch + @task.desc <<~DESC + ---------- STEP 2 ---------- + Stage the current version for release + + This task creates a staging branch from the current working branch and + pushes it to the remote repository. A pull request will be opened to merge + the staging branch into production. + DESC + @task.task stage: [:environment, :build] do + current_version = Object.const_get(@task.version_constant) + @task.sysecho "Branch staging updated" + @task.sysecho "Open a PR to production to release the version" + @task.sysecho "Once the PR is **approved**, run 'rake release' to release the version" + + pr_url = "#{@task.pull_request_url}/compare/#{@task.production_branch}...#{@task.staging_branch}" + pr_params = { + expand: 1, + title: URI.encode_www_form_component("Release #{current_version} to production"), + body: URI.encode_www_form_component("Deploy #{current_version} to production.\n") + }.map { |k, v| "#{k}=#{v}" }.join("&") + + @task.syscall ["open #{pr_url}?#{pr_params}"] + end + end + + private + + def method_missing(method_name, *args, &block) + if @task.respond_to?(method_name) + @task.send(method_name, *args, &block) + else + super + end + end + + def respond_to_missing?(method_name, include_private = false) + @task.respond_to?(method_name, include_private) || super + end end end end diff --git a/lib/discharger/task.rb b/lib/discharger/task.rb index dd0421b..107bbca 100644 --- a/lib/discharger/task.rb +++ b/lib/discharger/task.rb @@ -10,10 +10,11 @@ module Discharger class Task < Rake::TaskLib + include Rake::DSL include SysHelper - include Prepare - include Stage - include Release + + # Make Rake DSL methods public + public :desc, :task def self.create(name = :release, tasker: Rake::Task, &block) task = new(name, tasker:) @@ -32,22 +33,18 @@ def self.create(name = :release, tasker: Rake::Task, &block) end attr_accessor :name - attr_accessor :description - attr_accessor :working_branch attr_accessor :staging_branch attr_accessor :production_branch - attr_accessor :release_message_channel attr_accessor :version_constant - attr_accessor :chat_token attr_accessor :app_name attr_accessor :commit_identifier attr_accessor :pull_request_url - attr_reader :last_message_ts + attr_reader :tasker # Reissue settings attr_accessor( @@ -56,6 +53,11 @@ def self.create(name = :release, tasker: Rake::Task, &block) } ) + ## Configuration for gem tagging in monorepos + attr_accessor :mono_repo + attr_accessor :gem_tag + attr_accessor :gem_name + def initialize(name = :release, tasker: Rake::Task) @name = name @tasker = tasker @@ -63,8 +65,11 @@ def initialize(name = :release, tasker: Rake::Task) @staging_branch = "stage" @production_branch = "main" @description = "Release the current version to #{staging_branch}" + @mono_repo = false + @release = Steps::Release.new(self) + @stage = Steps::Stage.new(self) + @prepare = Steps::Prepare.new(self) end - private attr_reader :tasker def define require "slack-ruby-client" @@ -72,9 +77,22 @@ def define config.token = chat_token end - release_to_production - prepare_for_release - stage_release_branch + @release.release_to_production + @stage.stage_release_branch + @prepare.prepare_for_release + end + + # Delegate release-related attributes to the Release object + def method_missing(method_name, *args, &block) + if @release.respond_to?(method_name) + @release.send(method_name, *args, &block) + else + super + end + end + + def respond_to_missing?(method_name, include_private = false) + @release.respond_to?(method_name, include_private) || super end end end diff --git a/test/lib/discharger/steps/prepare_test.rb b/test/lib/discharger/steps/prepare_test.rb index a0b98bc..9d770c3 100644 --- a/test/lib/discharger/steps/prepare_test.rb +++ b/test/lib/discharger/steps/prepare_test.rb @@ -1,6 +1,7 @@ require "test_helper" require "discharger/task" require "rake" +require "tempfile" # Mock Rails.root for changelog path unless defined?(Rails) @@ -11,7 +12,7 @@ def self.root end end -class PrepareTest < Minitest::Test +class Discharger::Steps::PrepareTest < Minitest::Test include Rake::DSL def setup @@ -19,6 +20,11 @@ def setup @rake = Rake::Application.new Rake.application = @rake + # Create a temporary changelog file + @changelog = Tempfile.new(["CHANGELOG", ".md"]) + @changelog.write("## Version 1.0.0\n\n* Initial release\n") + @changelog.close + # Create a new Task instance @task = Discharger::Task.new @@ -28,28 +34,15 @@ def setup @task.working_branch = "main" @task.staging_branch = "staging" @task.production_branch = "production" - @task.release_message_channel = "#releases" - @task.changelog_file = "CHANGELOG.md" - @task.app_name = "TestApp" - @task.pull_request_url = "https://github.com/org/repo/pulls" + @task.changelog_file = @changelog.path @task.description = "Build and release the application" # Mock environment task since it's a prerequisite - task :environment do - # No-op for testing - end + task :environment # Mock VERSION constant Object.const_set(:VERSION, "1.0.0") unless Object.const_defined?(:VERSION) - # Mock reissue:finalize task since it's called by prepare - task "reissue:finalize" - - # Define helper methods on the task instance - def @task.commit_identifier - -> { "abc123" } - end - # Stub syscall and sysecho before defining tasks @called_commands = [] def @task.syscall(*commands) @@ -73,6 +66,8 @@ def teardown # Clear Rake tasks between tests Rake.application.clear Rake::Task.clear + # Clean up temporary changelog file + @changelog.unlink end def test_prepare_for_release_defines_task @@ -81,63 +76,59 @@ def test_prepare_for_release_defines_task end def test_prepare_task_executes_expected_git_commands - # Mock stdin to simulate user pressing enter - stdin_mock = StringIO.new("\n") - $stdin = stdin_mock - Rake::Task["prepare"].invoke + expected_commands = [ "git fetch origin main", "git checkout main", - "git checkout -b bump/finish-1-0-0", - "git push origin bump/finish-1-0-0 --force", - "git push origin bump/finish-1-0-0 --force", - "git checkout main", - "open" # The PR URL command will be partially matched + "git pull origin main" ] - actual_commands = @task.instance_variable_get(:@called_commands) - expected_commands.each do |expected_cmd| - assert_includes actual_commands.join(" "), expected_cmd + assert_equal expected_commands, actual_commands + end + + def test_prepare_task_outputs_expected_messages + Rake::Task["prepare"].invoke + + messages = @task.instance_variable_get(:@echoed_messages) + expected_messages = [ + "Preparing version 1.0.0 for release", + "Checking changelog for version 1.0.0", + "Version 1.0.0 is ready for release" + ] + + expected_messages.each do |expected_msg| + assert_includes messages, expected_msg end - ensure - $stdin = STDIN end - def test_prepare_task_handles_user_exit - # Mock stdin to simulate user typing 'x' - stdin_mock = StringIO.new("x\n") - $stdin = stdin_mock + def test_prepare_task_checks_changelog + @changelog = Tempfile.new(["CHANGELOG", ".md"]) + @changelog.write("No version information") + @changelog.close + @task.changelog_file = @changelog.path - assert_raises(SystemExit) do + error = assert_raises(RuntimeError) do Rake::Task["prepare"].invoke end + assert_equal "Version 1.0.0 not found in #{@changelog.path}", error.message + end + + def test_prepare_task_with_gem_tag + @task.mono_repo = true + @task.gem_tag = "gem-v1.0.0" + + Rake::Task["prepare"].invoke + expected_commands = [ + "git fetch origin gem-v1.0.0", + "git tag -l gem-v1.0.0", "git fetch origin main", "git checkout main", - "git checkout -b bump/finish-1-0-0", - "git push origin bump/finish-1-0-0 --force" + "git pull origin main" ] - actual_commands = @task.instance_variable_get(:@called_commands) assert_equal expected_commands, actual_commands - ensure - $stdin = STDIN - end - - def test_prepare_task_outputs_expected_messages - # Mock stdin to simulate user pressing enter - stdin_mock = StringIO.new("\n") - $stdin = stdin_mock - - Rake::Task["prepare"].invoke - - messages = @task.instance_variable_get(:@echoed_messages) - assert_includes messages.join, "Branch bump/finish-1-0-0 created" - assert_includes messages.join, "Check the contents of the CHANGELOG" - assert_includes messages.join, "Are you ready to continue?" - ensure - $stdin = STDIN end end diff --git a/test/lib/discharger/steps/release_test.rb b/test/lib/discharger/steps/release_test.rb index a52365c..9905d01 100644 --- a/test/lib/discharger/steps/release_test.rb +++ b/test/lib/discharger/steps/release_test.rb @@ -32,7 +32,7 @@ def chat_postMessage(**options) end end -class ReleaseTest < Minitest::Test +class Discharger::Steps::ReleaseTest < Minitest::Test include Rake::DSL def setup @@ -56,9 +56,7 @@ def setup @task.description = "Build and release the application" # Mock environment task since it's a prerequisite - task :environment do - # No-op for testing - end + task :environment # Mock VERSION constant Object.const_set(:VERSION, "1.0.0") unless Object.const_defined?(:VERSION) @@ -121,18 +119,21 @@ def test_build_task_executes_expected_git_commands "git branch -D staging 2>/dev/null || true", "git checkout -b staging", "git push origin staging --force", - "git checkout main" # This command is called in the block + "git checkout main" ] - assert_equal expected_commands, @task.instance_variable_get(:@called_commands) + actual_commands = @task.instance_variable_get(:@called_commands) + assert_equal expected_commands, actual_commands end def test_slack_task_with_default_parameters - Rake::Task["#{@task.name}:slack"].invoke("Default message", @task.release_message_channel) - assert_includes @task.instance_variable_get(:@echoed_messages).to_s, "Sending message to Slack:" + Rake::Task["#{@task.name}:slack"].invoke("Test message") + messages = @task.instance_variable_get(:@echoed_messages) + assert_includes messages.join, "Test message" end def test_slack_task_with_custom_parameters - Rake::Task["#{@task.name}:slack"].invoke("Custom message", @task.release_message_channel, ":emoji:", "123.456") - assert_includes @task.instance_variable_get(:@echoed_messages).to_s, "Sending message to Slack:" + Rake::Task["#{@task.name}:slack"].invoke("Test message", "#custom", ":smile:") + messages = @task.instance_variable_get(:@echoed_messages) + assert_includes messages.join, "Test message" end end diff --git a/test/lib/discharger/steps/stage_test.rb b/test/lib/discharger/steps/stage_test.rb index 98e6c77..4c43127 100644 --- a/test/lib/discharger/steps/stage_test.rb +++ b/test/lib/discharger/steps/stage_test.rb @@ -11,7 +11,7 @@ def self.root end end -class StageTest < Minitest::Test +class Discharger::Steps::StageTest < Minitest::Test include Rake::DSL def setup @@ -35,23 +35,12 @@ def setup @task.description = "Build and release the application" # Mock environment task since it's a prerequisite - task :environment do - # No-op for testing - end - - # Mock build tasks since they're called by stage - task :build # Mock the main build task - task "build" # Mock the string version of build task - task "#{@task.name}:build" # Mock the namespaced build task + task :environment + task :build # Mock VERSION constant Object.const_set(:VERSION, "1.0.0") unless Object.const_defined?(:VERSION) - # Define helper methods on the task instance - def @task.commit_identifier - -> { "abc123" } - end - # Stub syscall and sysecho before defining tasks @called_commands = [] def @task.syscall(*commands) @@ -82,31 +71,12 @@ def test_stage_release_branch_defines_task assert_includes task_names, "stage" end - def test_stage_task_invokes_build_task - build_task_called = false - Rake::Task["build"].enhance do - build_task_called = true - end - - Rake::Task["stage"].invoke - assert build_task_called, "Build task should have been called" - end - def test_stage_task_executes_expected_commands Rake::Task["stage"].invoke - expected_url_parts = [ - "open", - "https://github.com/org/repo/pulls/compare/production...staging", - "expand=1", - "title=Release+1.0.0+to+production", - "body=Deploy+1.0.0+to+production" - ] - + expected_url = "open https://github.com/org/repo/pulls/compare/production...staging" actual_commands = @task.instance_variable_get(:@called_commands) - expected_url_parts.each do |part| - assert_includes actual_commands.join(" "), part - end + assert_includes actual_commands.join(" "), expected_url end def test_stage_task_outputs_expected_messages @@ -132,4 +102,12 @@ def test_stage_task_includes_version_in_pr_url assert_includes url, "title=Release+1.0.0+to+production" assert_includes url, "body=Deploy+1.0.0+to+production" end + + def test_stage_task_invokes_build_task + build_invoked = false + Rake::Task[:build].enhance { build_invoked = true } + + Rake::Task["stage"].invoke + assert build_invoked, "Build task should be invoked" + end end