diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c1dcce6..0315478 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,7 +5,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - ruby-version: ['2.5', '2.7'] + ruby-version: ['2.7'] steps: - uses: actions/checkout@v2 - uses: ruby/setup-ruby@v1 diff --git a/README.md b/README.md index e5c42c7..dc8645b 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Options: - generate changelogs for some supermarket hosted cookbooks - generate changelogs for all git located cookbooks -This plugin works in policyfile style repositories or classical repositories with a Berksfile +This plugin works in policyfile style repositories ## Todos diff --git a/knife-changelog.gemspec b/knife-changelog.gemspec index 81fb16d..5ca1cc0 100644 --- a/knife-changelog.gemspec +++ b/knife-changelog.gemspec @@ -5,7 +5,7 @@ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) Gem::Specification.new do |spec| spec.name = 'knife-changelog' - spec.version = '2.0.0' + spec.version = '3.0.0' spec.authors = ['Gregoire Seux'] spec.email = ['kamaradclimber@gmail.com'] spec.summary = 'Facilitate access to cookbooks changelog' @@ -13,6 +13,8 @@ Gem::Specification.new do |spec| spec.homepage = 'https://github.com/kamaradclimber/knife-changelog' spec.license = 'MIT' + spec.required_ruby_version = '>= 2.7' + spec.files = `git ls-files -z`.split("\x0") spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) @@ -24,7 +26,6 @@ Gem::Specification.new do |spec| spec.add_development_dependency 'rubocop' spec.add_development_dependency 'webmock' - spec.add_dependency 'berkshelf' spec.add_dependency 'chef' spec.add_dependency 'chef-cli' spec.add_dependency 'deep_merge' diff --git a/lib/chef/knife/changelog.rb b/lib/chef/knife/changelog.rb index ee08fc2..c6b301a 100644 --- a/lib/chef/knife/changelog.rb +++ b/lib/chef/knife/changelog.rb @@ -9,39 +9,9 @@ class Changelog < Knife banner 'knife changelog COOKBOOK [COOKBOOK ...]' deps do - require 'knife/changelog/changelog' - require 'knife/changelog/berksfile' - require 'berkshelf' require 'knife/changelog/policyfile' end - option :linkify, - short: '-l', - long: '--linkify', - description: 'add markdown links where relevant', - boolean: true - - option :markdown, - short: '-m', - long: '--markdown', - description: 'use markdown syntax', - boolean: true - - option :ignore_changelog_file, - long: '--ignore-changelog-file', - description: 'Ignore changelog file presence, use git history instead', - boolean: true - - option :allow_update_all, - long: '--allow-update-all', - description: 'If no cookbook given, check all Berksfile', - boolean: true, - default: true - - option :submodules, - long: '--submodules SUBMODULE[,SUBMODULE]', - description: 'Submoduless to check for changes as well (comma separated)' - option :prevent_downgrade, long: '--prevent-downgrade', description: 'Fail if knife-changelog detect a cookbook downgrade', @@ -59,24 +29,13 @@ class Changelog < Knife boolean: true, default: false - option :update, - long: '--update', - description: 'Update Berksfile' - def run Log.info config.to_s - if config[:policyfile] && File.exist?(config[:policyfile]) - puts PolicyChangelog.new( - @name_args, - config[:policyfile], - config[:with_dependencies] - ).generate_changelog - else - berksfile = Berkshelf::Berksfile.from_options({}) - puts KnifeChangelog::Changelog::Berksfile - .new(berksfile, config) - .run(@name_args) - end + puts PolicyChangelog.new( + @name_args, + config[:policyfile], + config[:with_dependencies] + ).generate_changelog(config[:prevent_downgrade]) end end end diff --git a/lib/knife/changelog/berksfile.rb b/lib/knife/changelog/berksfile.rb deleted file mode 100644 index 39c651f..0000000 --- a/lib/knife/changelog/berksfile.rb +++ /dev/null @@ -1,68 +0,0 @@ -# frozen_string_literal: true - -require_relative 'changelog' - -class KnifeChangelog - class Changelog - class Berksfile < Changelog - def initialize(berksfile, config) - require 'berkshelf' - @locked_versions = berksfile.lockfile.locks - @sources = berksfile.sources - @berksfile = berksfile - super(config) - end - - def all_cookbooks - @locked_versions.keys - end - - def new_cookbook?(name) - ck_dep(name).nil? - end - - # return true if cookbook is downloaded from supermarket - def supermarket?(name) - # here is berkshelf "expressive" way to say cookbook - # comes from supermarket - ck_dep(name).location.is_a?(NilClass) - end - - # return true if cookbook is downloaded from git - def git?(name) - ck_dep(name).location.is_a?(Berkshelf::GitLocation) - end - - # return true if cookbook is downloaded from local path - def local?(name) - ck_dep(name).location.is_a?(Berkshelf::PathLocation) - end - - # return a Changelog::Location for this cookbook - def git_location(name) - raise "#{name} has not a git location" unless git?(name) - Location.from_berk_git_location(ck_dep(name).location) - end - - # return a list of supermarket uri for a given cookbook - # example: [ 'https://supermarket.chef.io' ] - def supermarkets_for(_name) - @sources.map(&:uri) - end - - def guess_version_for(name) - @locked_versions[name].locked_version.to_s - end - - def update(cookbooks) - @berksfile.update(*cookbooks) - end - - private - - def ck_dep(name) - @locked_versions[name] - end - end - end -end diff --git a/lib/knife/changelog/changelog.rb b/lib/knife/changelog/changelog.rb deleted file mode 100644 index c30e701..0000000 --- a/lib/knife/changelog/changelog.rb +++ /dev/null @@ -1,267 +0,0 @@ -# coding: utf-8 -require 'chef/log' -require 'chef/knife' -require 'chef/version_class' -require 'rest-client' -require 'json' -require_relative 'git' - -class KnifeChangelog - class Changelog - - Location = Struct.new(:uri, :revision, :rev_parse) do - # todo move this method to Changelog::Berkshelf - def self.from_berk_git_location(location) - Location.new(location.uri, - location.revision.strip, - location.instance_variable_get(:@rev_parse)) - end - end - - def initialize(config = {}) - @tmp_prefix = 'knife-changelog' - @config = config - @tmp_dirs = [] - end - - # returns a list of all cookbooks names - def all_cookbooks - raise NotImplementedError - end - - # return true if cookbook is not already listed as dependency - def new_cookbook?(name) - raise NotImplementedError - end - - # return true if cookbook is downloaded from supermarket - def supermarket?(name) - raise NotImplementedError - end - - # return true if cookbook is downloaded from git - def git?(name) - raise NotImplementedError - end - - # return true if cookbook is downloaded from local path - def local?(name) - raise NotImplementedError - end - - # return a Changelog::Location for a given cookbook - def git_location(name) - raise NotImplementedError - end - - # return a list of supermarket uri for a given cookbook - # example: [ 'https://supermarket.chef.io' ] - def supermarkets_for(name) - raise NotImplementedError - end - - # return current locked version for a given cookbook - def guess_version_for(name) - raise NotImplementedError - end - - - - def run(cookbooks) - changelog = [] - begin - if cookbooks.empty? and @config[:allow_update_all] - cks = all_cookbooks - else - cks = cookbooks - end - changelog += cks.map do |cookbook| - Chef::Log.debug "Checking changelog for #{cookbook} (cookbook)" - execute cookbook - end - subs = @config[:submodules] || [] - subs = subs.split(',') if subs.is_a? String - changelog += subs.map do |submodule| - Chef::Log.debug "Checking changelog for #{submodule} (submodule)" - execute(submodule, true) - end - update(cks) if @config[:update] - ensure - clean - end - changelog.compact.join("\n") - end - - def clean - @tmp_dirs.each do |dir| - FileUtils.rm_rf dir - end - end - - def handle_new_cookbook - stars = '**' if @config[:markdown] - ["#{stars}Cookbook was not in the berksfile#{stars}"] - end - - def execute(name, submodule = false) - version_change, changelog = if submodule - handle_submodule(name) - elsif new_cookbook?(name) - ['', handle_new_cookbook] - else - case true - when supermarket?(name) - handle_source(name) - when git?(name) - handle_git(name, git_location(name)) - when local?(name) - Chef::Log.debug "path location are always at the last version" - ['', ''] - else - raise "Cannot handle #{loc.class} yet" - end - end - format_changelog(name, version_change, changelog) - end - - def format_changelog(name, version_change, changelog) - if changelog.empty? - nil - else - full = ["Changelog for #{name}: #{version_change}"] - full << '=' * full.first.size - full << changelog - full << '' - full.compact.join("\n") - end - end - - def get_from_supermarket_sources(name) - supermarkets_for(name).map do |uri| - begin - # TODO: we could call /universe endpoint once - # instead of calling /api/v1/cookbooks/ for each cookbook - RestClient::Request.execute( - url: "#{uri}/api/v1/cookbooks/#{name}", - method: :get, - verify_ssl: false # TODO make this configurable - ) - rescue => e - Chef::Log.debug "Error fetching package from supermarket #{e.class.name} #{e.message}" - nil - end - end - .compact - .map { |json| JSON.parse(json) } - .sort_by { |ck| cookbook_highest_version(ck) } - .map { |ck| ck['source_url'] || ck ['external_url'] } - .last - .tap do |source| - raise "Cannot find any changelog source for #{name}" unless source - end - end - - def cookbook_highest_version(json) - json['versions'] - .map { |version_url| Chef::Version.new(version_url.gsub(/.*\//, '')) } - .sort - .last - end - - def handle_source(name) - url = get_from_supermarket_sources(name) - raise "No source found in supermarket for cookbook '#{name}'" unless url - Chef::Log.debug("Using #{url} as source url") - # Workaround source_url not being written in a clonable way. - # github.com/blah/cookbook works but git clone requires github.com/blah/cookbook.git - if !url.end_with?('.git') - url = "#{url}.git" - end - location = Location.new(url, guess_version_for(name), 'HEAD') - handle_git(name, location) - end - - def detect_cur_revision(name, rev, git) - unless git.revision_exists?(rev) - prefixed_rev = 'v' + rev - return prefixed_rev if git.revision_exists?(prefixed_rev) - fail "#{rev} is not an existing revision (#{name}), not a tag/commit/branch name." - end - rev - end - - def handle_submodule(name) - subm_url = Mixlib::ShellOut.new("git config --list| grep ^submodule | grep ^submodule.#{name}.url") - subm_url.run_command - subm_url.error! - url = subm_url.stdout.lines.first.split('=')[1].chomp - subm_revision = Mixlib::ShellOut.new("git submodule status #{name}") - subm_revision.run_command - subm_revision.error! - revision = subm_revision.stdout.strip.split(' ').first - revision.gsub!(/^\+/, '') - loc = Location.new(url, revision, 'HEAD') - handle_git(name, loc) - end - - # take cookbook name and Changelog::Location instance - def handle_git(name, location) - # todo: remove this compat check - raise "should be a location" unless location.is_a?(Changelog::Location) - git = Git.new(@tmp_prefix, location.uri) - @tmp_dirs << git.shallow_clone - - rev_parse = location.rev_parse - cur_rev = detect_cur_revision(name, location.revision, git) - changelog_file = git.files(rev_parse).find { |line| line =~ /\s(changelog.*$)/i } - changelog = if changelog_file and !@config[:ignore_changelog_file] - Chef::Log.info "Found changelog file : " + $1 - generate_from_changelog_file($1, cur_rev, rev_parse, git) - end - changelog ||= generate_from_git_history(git, location, cur_rev, rev_parse) - ["#{cur_rev}->#{rev_parse}", changelog] - end - - def generate_from_changelog_file(filename, current_rev, rev_parse, git) - ch = git.diff(filename, current_rev, rev_parse) - .collect { |line| $1.strip if line =~ /^{\+(.*)\+}$/ }.compact - .map { |line| line.gsub(/^#+(.*)$/, "\\1\n---")} # replace section by smaller header - .select { |line| !(line =~ /^===+/)}.compact # remove header lines - ch.empty? ? nil : ch - end - - def generate_from_git_history(git, location, current_rev, rev_parse) - c = git.log(current_rev, rev_parse) - n = https_url(location) - c = linkify(n, c) if @config[:linkify] and n - c = c.map { |line| "* " + line } if @config[:markdown] - c = c.map { |line| line.strip } # clean end of line - c - end - - GERRIT_REGEXP = %r{^(.*)/[^/]+/[^/]+(?:\.git)$} - def linkify(url, changelog) - format = case url - when /gitlab/, /github/ - "\\2 (#{url.chomp('.git')}/commit/\\1)" - when GERRIT_REGEXP - "\\2 (#{::Regexp.last_match(1)}/#/q/\\1)" - end - format ? changelog.map { |line| line.sub(/^([a-f0-9]+) (.*)$/, format) } : changelog - end - - def https_url(location) - if location.uri =~ /^\w+:\/\/(.*@)?(.*)(\.git)?/ - "https://%s" % [ $2 ] - else - fail "Cannot guess http url from git remote url: #{location.uri}" - end - end - - def short(location) - if location.uri =~ /([\w-]+)\/([\w-]+)(\.git)?$/ - "%s/%s" % [$1,$2] - end - end - end -end diff --git a/lib/knife/changelog/git.rb b/lib/knife/changelog/git.rb deleted file mode 100644 index 7c780a7..0000000 --- a/lib/knife/changelog/git.rb +++ /dev/null @@ -1,46 +0,0 @@ -class KnifeChangelog - class Git - attr_accessor :tmp_prefix, :uri - - def initialize(tmp_prefix, uri) - @tmp_prefix = tmp_prefix - @uri = uri - end - - def shallow_clone - Chef::Log.debug "Cloning #{uri} in #{tmp_prefix}" - dir = Dir.mktmpdir(tmp_prefix) - clone = Mixlib::ShellOut.new("git clone --bare #{uri} bare-clone", cwd: dir) - clone.run_command - clone.error! - @clone_dir = ::File.join(dir, 'bare-clone') - @clone_dir - end - - def files(rev_parse) - ls_tree = Mixlib::ShellOut.new("git ls-tree -r #{rev_parse}", cwd: @clone_dir) - ls_tree.run_command - ls_tree.error! - ls_tree.stdout.lines.map(&:strip) - end - - def diff(filename, current_rev, rev_parse) - diff = Mixlib::ShellOut.new("git diff #{current_rev}..#{rev_parse} --word-diff -- #{filename}", cwd: @clone_dir) - diff.run_command - diff.stdout.lines - end - - def log(current_rev, rev_parse) - log = Mixlib::ShellOut.new("git log --no-merges --abbrev-commit --pretty=oneline #{current_rev}..#{rev_parse}", cwd: @clone_dir) - log.run_command - log.stdout.lines - end - - def revision_exists?(revision) - Chef::Log.debug "Testing existence of #{revision}" - revlist = Mixlib::ShellOut.new("git rev-list --quiet #{revision}", cwd: @clone_dir) - revlist.run_command - !revlist.error? - end - end -end diff --git a/lib/knife/changelog/git_submodule.rb b/lib/knife/changelog/git_submodule.rb deleted file mode 100644 index d39b917..0000000 --- a/lib/knife/changelog/git_submodule.rb +++ /dev/null @@ -1,18 +0,0 @@ -# coding: utf-8 -require 'chef/log' -require_relative 'changelog' - -class KnifeChangelog - class GitSubmodule < Changelog - - def run(submodules) - raise ::ArgumentError, "Submodules must be an Array instead of #{submodules.inspect}" unless submodules.is_a?(::Array) - submodules.map do |submodule| - Chef::Log.debug "Checking changelog for #{submodule} (submodule)" - format_changelog(submodule, *handle_submodule(submodule)) - end.compact.join("\n") - ensure - clean - end - end -end diff --git a/spec/data/Berksfile b/spec/data/Berksfile deleted file mode 100644 index 3770a2e..0000000 --- a/spec/data/Berksfile +++ /dev/null @@ -1,9 +0,0 @@ -# frozen_string_literal: true - -source 'https://mysupermarket.io' -source 'https://mysupermarket2.io' - -cookbook 'uptodate' -cookbook 'outdated1' -cookbook 'second_out_of_date' -cookbook 'othercookbook' diff --git a/spec/data/Berksfile.lock b/spec/data/Berksfile.lock deleted file mode 100644 index dbabd49..0000000 --- a/spec/data/Berksfile.lock +++ /dev/null @@ -1,11 +0,0 @@ -DEPENDENCIES - othercookbook - outdated1 - second_out_of_date - uptodate - -GRAPH - othercookbook (1.0.0) - outdated1 (1.0.0) - second_out_of_date (1.0.0) - uptodate (1.0.0) diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 4e2e4ff..6f16615 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require 'knife/changelog/changelog' -require 'knife/changelog/berksfile' require 'knife/changelog/policyfile' require 'webmock/rspec' diff --git a/spec/unit/changelog_spec.rb b/spec/unit/changelog_spec.rb deleted file mode 100644 index 0748e5d..0000000 --- a/spec/unit/changelog_spec.rb +++ /dev/null @@ -1,125 +0,0 @@ -# frozen_string_literal: true - -require 'berkshelf' -require 'spec_helper' - -RSpec.shared_examples 'changelog generation' do - # this supposes that "changelog" is an instance of KnifeChangelog::Changelog - it 'detects basic changelog' do - mock_git('second_out_of_date', <<-EOH) - aaaaaa commit in second_out_of_date - bbbbbb bugfix in second_out_of_date - EOH - mock_git('outdated1', <<-EOH) - aaaaaa commit in outdated1 - bbbbbb bugfix in outdated1 - EOH - mock_git('uptodate', '') - - changelog_txt = changelog.run(%w[new_cookbook uptodate outdated1 second_out_of_date]) - expect(changelog_txt).to match(/commit in outdated1/) - expect(changelog_txt).to match(/commit in second_out_of_date/) - expect(changelog_txt).not_to match(/uptodate/) - expect(changelog_txt).to match(/new_cookbook: \n.*\nCookbook was not/) - end -end - -describe KnifeChangelog::Changelog do - before(:each) do - stub_request(:get, %r{https://mysupermarket.io/api/v1/cookbooks/}) - .to_return(status: 404, body: '{}') - - mock_supermarket('uptodate', %w[1.0.0]) - mock_supermarket('outdated1', %w[1.0.0 1.1.0]) - # TODO: we should make second_out_of_date a git location - mock_supermarket('second_out_of_date', %w[1.0.0 1.2.0]) - - mock_universe('https://mysupermarket2.io', uptodate: %w[1.0.0], outdated1: %w[1.0.0 1.1.0], second_out_of_date: %w[1.0.0 1.2.0]) - mock_universe('https://mysupermarket.io', {}) - end - - def mock_git(name, changelog) - expect(KnifeChangelog::Git).to receive(:new) - .with(anything, /#{name}(.git|$)/) - .and_return(double(name, - shallow_clone: '/tmp/randomdir12345', - revision_exists?: true, - files: [], - log: changelog.split("\n"))) - end - - def mock_supermarket(name, versions) - stub_request(:get, %r{https://mysupermarket2.io/api/v1/cookbooks/#{name}}) - .to_return(status: 200, body: supermarket_versions(name, versions)) - end - - def supermarket_versions(name, versions) - { - name: name, - maintainer: 'Linus', - description: 'small project on the side', - category: 'Operating System', - source_url: "https://github.com/chef-cookbooks/#{name}", - versions: [] - }.tap do |json| - versions.each do |v| - json[:versions] << "https://source.io/#{name}/#{v}" - end - end.to_json - end - - def mock_universe(supermarket_url, cookbooks) - universe = cookbooks.transform_values do |versions| - versions.map do |v| - [v, { - location_type: 'opscode', - location_path: "#{supermarket_url}/api/v1", - download_url: "#{supermarket_url}/api/v1/cookbooks/insertnamehere/versions/#{v}/download", - dependencies: {} - }] - end.to_h - end - stub_request(:get, "#{supermarket_url}/universe") - .to_return(status: 200, body: universe.to_json) - end - - context 'in Berksfile mode' do - let(:berksfile) do - Berkshelf::Berksfile.from_options( - berksfile: File.join(File.dirname(__FILE__), '../data/Berksfile') - ) - end - - let(:options) do - {} - end - - let(:changelog) do - KnifeChangelog::Changelog::Berksfile.new(berksfile, options) - end - - include_examples 'changelog generation' - - context 'with --update' do - let(:options) do - { update: true } - end - it 'updates Berksfile' do - mock_git('outdated1', <<-EOH) - aaaaaa commit in outdated1 - bbbbbb bugfix in outdated1 - EOH - expect(berksfile).to receive(:update).with('outdated1') - changelog.run(%w[outdated1]) - end - end - end -end - -class Hash - unless Chef::Version.new(RUBY_VERSION) >= Chef::Version.new('2.4') - def transform_values - map { |k, v| [k, (yield v)] }.to_h - end - end -end diff --git a/spec/unit/policyfile_spec.rb b/spec/unit/policyfile_spec.rb index 8462be0..19f210c 100644 --- a/spec/unit/policyfile_spec.rb +++ b/spec/unit/policyfile_spec.rb @@ -92,35 +92,6 @@ end end - describe '#linkify' do - subject { KnifeChangelog::Changelog.new(config) } - let(:config) { double('config') } - let(:changelog) do - ['9363423 Leverage criteo-flavor 3.11 to benefit from labels'] - end - context 'when url is gitlab style' do - let(:url) { 'https://gitlab.com/chef-cookbooks/criteo-rackguru.git' } - - it 'creates a gitlab style link' do - expect(subject.linkify(url, changelog).first).to match(%r{https://gitlab.com/chef-cookbooks/criteo-rackguru/commit/9363423}) - end - end - - context 'when url is github style' do - let(:url) { 'https://github.com/chef-cookbooks/criteo-rackguru.git' } - it 'creates a github style link' do - expect(subject.linkify(url, changelog).first).to match(%r{https://github.com/chef-cookbooks/criteo-rackguru/commit/9363423}) - end - end - - context 'when url has no known style' do - let(:url) { 'https://review.mycompany.com/chef-cookbooks/criteo-rackguru.git' } - it 'creates a gerrit style link' do - expect(subject.linkify(url, changelog).first).to match(%r{https://review.mycompany.com/#/q/9363423}) - end - end - end - describe '#versions' do context 'when type is current' do it 'returns correct current versions' do