miketheman/knife-community

View on GitHub
lib/chef/knife/community_release.rb

Summary

Maintainability
A
3 hrs
Test Coverage
require 'chef/knife'

module KnifeCommunity
  # A Knife plugin to release cookbooks to the Chef Supermarket
  class CommunityRelease < Chef::Knife
    deps do
      require 'mixlib/shellout'
      require 'chef/config'
      require 'chef/knife/cookbook_site_share'
      require 'chef/cookbook_site_streaming_uploader'
      require 'grit'
      require 'versionomy'
      require 'json'

      require 'knife-community'
    end

    banner 'knife community release COOKBOOK [VERSION] (options)'

    option :cookbook_path,
           short: '-o PATH:PATH',
           long: '--cookbook-path PATH:PATH',
           description: 'A colon-separated path to look for cookbooks in',
           proc: ->(o) { o.split(':') }

    option :remote,
           short: '-R REMOTE',
           long: '--remote REMOTE',
           default: 'origin',
           description: 'Remote Git repository to push to'

    option :branch,
           short: '-B ',
           long: '--branch BRANCH',
           default: 'master',
           description: 'Remote Git branch name to push to'

    option :devodd,
           long: '--devodd',
           boolean: true,
           description: 'Odd-numbered development cycle. Bump minor version & commit for development'

    option :git_push,
           long: '--[no-]git-push',
           boolean: true,
           default: true,
           description: 'Indicates whether the commits and tags should be pushed to pushed to the default git remote.'

    option :tag_prefix,
           long: '--tag-prefix TAGPREFIX',
           description: 'Prefix for Git tag name, followed by version'

    option :site_share,
           long: '--[no-]site-share',
           boolean: true,
           default: true,
           description: 'Indicates whether the cookbook should be pushed to the Chef Supermarket.'

    def setup
      self.config = Chef::Config.merge!(config)
      validate_args
      # Set variables for global use
      @cookbook_name = name_args.first
      @target_version = Versionomy.parse(name_args.last) if name_args.size > 1
      [@cookbook_name, @target_version, config]
    end

    def run
      setup

      ui.msg 'Starting to validate the envrionment before changing anything...'
      CookbookValidator.new(@cookbook_name, config[:cookbook_path], @target_version).validate!

      validate_repo
      validate_repo_clean

      validate_no_existing_tag(tag_string)

      # TODO: skip next step if --no-git-push is provided
      validate_target_remote_branch

      ui.msg 'All validation steps have passed, making changes...'
      set_new_cb_version
      commit_new_cb_version
      tag_new_cb_version(tag_string)

      if config[:git_push]
        git_push_commits
        git_push_tags
      end

      if config[:site_share]
        confirm_share_msg = "Shall I release version #{@target_version} of the"
        confirm_share_msg << " #{@cb_name} cookbook to the Supermarket? (Y/N) "
        if config[:yes] || (ask_question(confirm_share_msg).chomp.upcase == 'Y')
          share_new_version
          ui.msg "Version #{@target_version} of the #{@cb_name} cookbook has been released!"
          ui.msg "Check it out at http://ckbk.it/#{@cb_name}"
        end
      end

      if config[:devodd]
        if @target_version.tiny.even?
          set_odd_cb_version
          commit_odd_cb_version

          git_push_commits if config[:git_push]
        else
          puts "I'm already odd!"
        end
      end
    end # run

    private

    # Ensure argumanets are valid, assign values of arguments
    #
    # @param [Array] the global `name_args` object
    def validate_args
      if name_args.size < 1
        ui.error('No cookbook has been specified')
        show_usage
        exit 1
      end
      if name_args.size > 2
        ui.error('Too many arguments are being passed. Please verify.')
        show_usage
        exit 1
      end
    end

    # Ensure that the cookbook is in a git repo
    # @TODO: Use Grit instead of shelling out.
    # Couldn't figure out the rev_parse method invocation on a non-repo.
    #
    # @return [String] The absolute file path of the git repository's root
    # @example
    #  "/Users/miketheman/git/knife-community"
    def validate_repo
      @repo_root = shellout('git rev-parse --show-toplevel').stdout.chomp
    rescue Exception => e
      ui.error "There doesn't seem to be a git repo at #{@cb_path}\n#{e}"
      exit 3
    end

    # Inspect the cookbook directory's git status is good to push.
    # Any existing tracked files should be staged, otherwise error & exit.
    # Untracked files are warned about, but will allow continue.
    # This needs more testing.
    def validate_repo_clean
      @gitrepo = Grit::Repo.new(@repo_root)
      status = @gitrepo.status
      if !status.changed.nil? || status.changed.size != 0 # This has to be a convoluted way to determine a non-empty...
        # Test each for the magic sha_index. Ref: https://github.com/mojombo/grit/issues/142
        status.changed.each do |file|
          case file[1].sha_index
          when '0' * 40
            ui.error 'There seem to be unstaged changes in your repo. Either stash or add them.'
            exit 4
          else
            ui.msg 'There are modified files that have been staged, and will be included in the push.'
          end
        end
      elsif status.untracked.size > 0
        ui.warn 'There are untracked files in your repo. You might want to look into that.'
      end
    end

    # Ensure that there isn't already a git tag for this version.
    def validate_no_existing_tag(tag_string)
      existing_tags = []
      @gitrepo.tags.each { |tag| existing_tags << tag.name }
      if existing_tags.include?(tag_string)
        ui.error 'This version tag has already been committed to the repo.'
        ui.error "Are you sure you haven't released this already?"
        exit 6
      end
    end

    # Ensure that the remote and branch are indeed valid. We provide defaults in options.
    def validate_target_remote_branch
      remote_path = File.join(config[:remote], config[:branch])

      remotes = []
      @gitrepo.remotes.each { |remote| remotes << remote.name }
      unless remotes.include?(remote_path)
        ui.error 'The remote/branch specified does not seem to exist.'
        exit 7
      end
    end

    # Replace the existing version string with the new version
    def set_new_cb_version
      metadata_file = File.join(@cb_path, 'metadata.rb')
      fi = File.read(metadata_file)
      fi.gsub!(/version(\s+)('|")#{@cb_version.to_s}('|")/, "version\\1\\2#{@target_version}\\3")
      File.open(metadata_file, 'w') { |file| file.puts fi }
    end

    # Using shellout as needed.
    # @todo Struggled with the Grit::Repo#add for hours.
    def commit_new_cb_version
      shellout('git add metadata.rb')
      @gitrepo.commit_index("release v#{@target_version}")
    end

    # Returns the desired tag string, based on config option
    def tag_string
      config[:tag_prefix] ? "#{config[:tag_prefix]}#{@target_version}" : @target_version.to_s
    end

    def tag_new_cb_version(tag_string)
      shellout("git tag -a -m 'release v#{@target_version}' #{tag_string}")
    end

    def set_odd_cb_version
      metadata_file = File.join(@cb_path, 'metadata.rb')
      fi = File.read(metadata_file)
      fi.gsub!(/version(\s+)('|")#{@target_version.to_s}('|")/, "version\\1\\2#{@target_version.bump(:tiny)}\\3")
      File.open(metadata_file, 'w') { |file| file.puts fi }
    end

    def commit_odd_cb_version
      shellout('git add metadata.rb')
      @gitrepo.commit_index('increment version for development')
    end

    # Apparently Grit does not have any `push` semantics yet.
    def git_push_commits
      shellout("git push #{config[:remote]} #{config[:branch]}")
    end

    def git_push_tags
      shellout("git push #{config[:remote]} --tags")
    end

    def share_new_version
      # Need to find the existing cookbook's category. Thankfully, this is readily available via REST/JSON.
      response = Net::HTTP.get_response('supermarket.chef.io', "/api/v1/cookbooks/#{@cb_name}")
      category = JSON.parse(response.body)['category'] ||= 'Other'

      cb_share = Chef::Knife::CookbookSiteShare.new
      cb_share.name_args = [@cb_name, category]
      cb_share.run
    end

    def shellout(command)
      proc = Mixlib::ShellOut.new(command, cwd: @cb_path)
      proc.run_command
      proc.error!
      proc
    end
  end # class
end # module