From 623b6bb7f53382cfb547cb65796cde53f2364559 Mon Sep 17 00:00:00 2001 From: Marc-Andre Lafortune Date: Thu, 22 Oct 2020 14:56:37 -0400 Subject: [PATCH] Add rake tasks for alternative way to specify Changelog entries Specs also check `changelog` entries. --- CONTRIBUTING.md | 5 +- Rakefile | 2 + ...w_add_rake_tasks_for_alternative_way_to.md | 1 + spec/project_spec.rb | 76 +++++--- tasks/changelog.rake | 33 ++++ tasks/changelog.rb | 181 ++++++++++++++++++ tasks/cut_release.rake | 2 +- 7 files changed, 268 insertions(+), 32 deletions(-) create mode 100644 changelog/new_add_rake_tasks_for_alternative_way_to.md create mode 100644 tasks/changelog.rake create mode 100644 tasks/changelog.rb diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dfa0cf072cb..aba67d2cd65 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -34,7 +34,7 @@ $ rubocop -V * If your change has a corresponding open GitHub issue, prefix the commit message with `[Fix #github-issue-number]`. * Make sure to add tests for it. This is important so I don't break it in a future version unintentionally. -* Add an entry to the [Changelog](CHANGELOG.md) accordingly. See [changelog entry format](#changelog-entry-format). +* Add an entry to the [Changelog](CHANGELOG.md) by creating a file `changelog/{type}_{some_description}.md`. See [changelog entry format](#changelog-entry-format). * Please try not to mess with the Rakefile, version, or history. If you want to have your own version, or is otherwise necessary, that is fine, but please isolate to its own commit so I can cherry-pick @@ -55,13 +55,14 @@ Here are a few examples: * [#7542](https://github.com/rubocop-hq/rubocop/pull/7542): **(Breaking)** Move `LineLength` cop from `Metrics` department to `Layout` department. ([@koic][]) ``` +* Create one file `changelog/{type}_{some_description}.md`, where `type` is `new` (New feature), `fix` or `change`, and `some_description` is unique to avoid conflicts. Task `changelog:fix` (or `:new` or `:change`) can help you. * Mark it up in [Markdown syntax][6]. * The entry line should start with `* ` (an asterisk and a space). * If the change has a related GitHub issue (e.g. a bug fix for a reported issue), put a link to the issue as `[#123](https://github.com/rubocop-hq/rubocop/issues/123): `. * Describe the brief of the change. The sentence should end with a punctuation. * If this is a breaking change, mark it with `**(Breaking)**`. * At the end of the entry, add an implicit link to your GitHub user page as `([@username][])`. -* If this is your first contribution to RuboCop project, add a link definition for the implicit link to the bottom of the changelog as `[@username]: https://github.com/username`. +* Alternatively, you may modify the CHANGELOG file directly, but this may result in conflicts later on. Also, if this is your first contribution to RuboCop project, add a link definition for the implicit link to the bottom of the changelog as `[@username]: https://github.com/username`. [1]: https://github.com/rubocop-hq/rubocop/issues [2]: https://www.gun.io/blog/how-to-github-fork-branch-and-pull-request diff --git a/Rakefile b/Rakefile index a6c5ef81b30..5a37fc6d125 100644 --- a/Rakefile +++ b/Rakefile @@ -4,6 +4,8 @@ # and started before any application code is loaded. require 'simplecov' if ENV['COVERAGE'] +task release: 'changelog:check_clean' # Before task is required + require 'bundler' require 'bundler/gem_tasks' begin diff --git a/changelog/new_add_rake_tasks_for_alternative_way_to.md b/changelog/new_add_rake_tasks_for_alternative_way_to.md new file mode 100644 index 00000000000..eb0b9b27ad1 --- /dev/null +++ b/changelog/new_add_rake_tasks_for_alternative_way_to.md @@ -0,0 +1 @@ +* [#8930](https://github.com/rubocop-hq/rubocop/pull/8930): Add rake tasks for alternative way to specify Changelog entries. ([@marcandre][]) diff --git a/spec/project_spec.rb b/spec/project_spec.rb index d946902a93c..c2900439df1 100644 --- a/spec/project_spec.rb +++ b/spec/project_spec.rb @@ -99,12 +99,7 @@ end end - describe 'changelog' do - subject(:changelog) do - path = File.join(File.dirname(__FILE__), '..', 'CHANGELOG.md') - File.read(path) - end - + shared_examples 'has Changelog format' do let(:lines) { changelog.each_line } let(:non_reference_lines) do @@ -119,34 +114,11 @@ expect(non_reference_lines).to all(match(/^(\*|#|$)/)) end - it 'has link definitions for all implicit links' do - implicit_link_names = changelog.scan(/\[([^\]]+)\]\[\]/).flatten.uniq - implicit_link_names.each do |name| - expect(changelog.include?("[#{name}]: http")) - .to be(true), "CHANGELOG.md is missing a link for #{name}. " \ - 'Please add this link to the bottom of the file.' - end - end - describe 'entry' do - subject(:entries) { lines.grep(/^\*/).map(&:chomp) } - it 'has a whitespace between the * and the body' do expect(entries).to all(match(/^\* \S/)) end - context 'after version 0.14.0' do - let(:lines) do - changelog.each_line.take_while do |line| - !line.start_with?('## 0.14.0') - end - end - - it 'has a link to the contributors at the end' do - expect(entries).to all(match(/\(\[@\S+\]\[\](?:, \[@\S+\]\[\])*\)$/)) - end - end - describe 'link to related issue' do let(:issues) do entries.map do |entry| @@ -208,6 +180,52 @@ end end + describe 'Changelog' do + subject(:changelog) do + File.read(path) + end + + let(:path) do + File.join(File.dirname(__FILE__), '..', 'CHANGELOG.md') + end + let(:entries) { lines.grep(/^\*/).map(&:chomp) } + + include_examples 'has Changelog format' + + context 'future entries' do + dir = File.join(File.dirname(__FILE__), '..', 'changelog') + + Dir["#{dir}/*.md"].each do |path| + context "For #{path}" do + let(:path) { path } + + include_examples 'has Changelog format' + end + end + end + + it 'has link definitions for all implicit links' do + implicit_link_names = changelog.scan(/\[([^\]]+)\]\[\]/).flatten.uniq + implicit_link_names.each do |name| + expect(changelog.include?("[#{name}]: http")) + .to be(true), "missing a link for #{name}. " \ + 'Please add this link to the bottom of the file.' + end + end + + context 'after version 0.14.0' do + let(:lines) do + changelog.each_line.take_while do |line| + !line.start_with?('## 0.14.0') + end + end + + it 'has a link to the contributors at the end' do + expect(entries).to all(match(/\(\[@\S+\]\[\](?:, \[@\S+\]\[\])*\)$/)) + end + end + end + describe 'requiring all of `lib` with verbose warnings enabled' do it 'emits no warnings' do warnings = `ruby -Ilib -w -W2 lib/rubocop.rb 2>&1` diff --git a/tasks/changelog.rake b/tasks/changelog.rake new file mode 100644 index 00000000000..3d70ab94787 --- /dev/null +++ b/tasks/changelog.rake @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +autoload :Changelog, "#{__dir__}/changelog" + +namespace :changelog do + %i[new fix change].each do |type| + desc "Create a Changelog entry (#{type})" + task type do + path = Changelog::Entry.new(type: type).write + cmd = "git add #{path}" + system cmd + puts "Entry '#{path}' created and added to git index" + end + end + + desc 'Merge entries and delete them' + task :merge do + raise 'No entries!' unless Changelog.pending? + + Changelog.new.merge!.and_delete! + cmd = "git commit -a -m 'Update Changelog'" + puts cmd + system cmd + end + + task :check_clean do + next unless Changelog.pending? + + puts '*** Pending changelog entries!' + puts 'Do `bundle exec rake changelog:merge`' + exit(1) + end +end diff --git a/tasks/changelog.rb b/tasks/changelog.rb new file mode 100644 index 00000000000..00fd9b8d41f --- /dev/null +++ b/tasks/changelog.rb @@ -0,0 +1,181 @@ +# frozen_string_literal: true + +if RUBY_VERSION < '2.6' + puts 'Changelog utilities available only for Ruby 2.6+' + exit(1) +end + +# Changelog utility +class Changelog + ENTRIES_PATH = 'changelog/' + FIRST_HEADER = /#{Regexp.escape("## master (unreleased)\n")}/m.freeze + ENTRIES_PATH_TEMPLATE = "#{ENTRIES_PATH}%s_%s.md" + TYPE_REGEXP = /#{Regexp.escape(ENTRIES_PATH)}([a-z]+)_/.freeze + TYPE_TO_HEADER = { new: 'New features', fix: 'Bug fixes', change: 'Changes' }.freeze + HEADER = /### (.*)/.freeze + PATH = 'CHANGELOG.md' + REF_URL = 'https://github.com/rubocop-hq/rubocop-ast' + MAX_LENGTH = 40 + CONTRIBUTOR = '[@%s]: https://github.com/%s' + SIGNATURE = Regexp.new(format(Regexp.escape("([@%s][])\n"), user: '(\w+)')) + + # New entry + Entry = Struct.new(:type, :body, :ref_type, :ref_id, :user, keyword_init: true) do + def initialize(type:, body: last_commit_title, ref_type: :pull, ref_id: nil, user: github_user) + id, body = extract_id(body) + ref_id ||= id || 'x' + super + end + + def write + Dir.mkdir(ENTRIES_PATH) unless Dir.exist?(ENTRIES_PATH) + File.write(path, content) + path + end + + def path + format(ENTRIES_PATH_TEMPLATE, type: type, name: str_to_filename(body)) + end + + def content + period = '.' unless body.end_with? '.' + "* #{ref}: #{body}#{period} ([@#{user}][])\n" + end + + def ref + "[##{ref_id}](#{REF_URL}/#{ref_type}/#{ref_id})" + end + + def last_commit_title + `git log -1 --pretty=%B`.lines.first.chomp + end + + def extract_id(body) + /^\[Fixes #(\d+)\] (.*)/.match(body)&.captures || [nil, body] + end + + def str_to_filename(str) + str + .downcase + .split + .each { |s| s.gsub!(/\W/, '') } + .reject(&:empty?) + .inject do |result, word| + s = "#{result}_#{word}" + return result if s.length > MAX_LENGTH + + s + end + end + + def github_user + user = `git config --global credential.username`.chomp + if user.empty? + warn 'Set your username with `git config --global credential.username "myusernamehere"`' + end + + user + end + end + attr_reader :header, :rest + + def initialize(content: File.read(PATH), entries: Changelog.read_entries) + require 'strscan' + + parse(content) + @entries = entries + end + + def and_delete! + @entries.each_key do |path| + File.delete(path) + end + end + + def merge! + File.write(PATH, merge_content) + self + end + + def unreleased_content + entry_map = parse_entries(@entries) + merged_map = merge_entries(entry_map) + merged_map.flat_map do |header, things| + [ + "### #{header}\n", + *things, + '' + ] + end.join("\n") + end + + def merge_content + [ + @header, + unreleased_content, + @rest, + *new_contributor_lines + ].join("\n") + end + + def self.pending? + entry_paths.any? + end + + def self.entry_paths + Dir["#{ENTRIES_PATH}*"] + end + + def self.read_entries + entry_paths.to_h do |path| + [path, File.read(path)] + end + end + + def new_contributor_lines + contributors + .map { |user| format(CONTRIBUTOR, user: user) } + .reject { |line| @rest.include?(line) } + end + + def contributors + @entries.values.join("\n") + .scan(SIGNATURE).flatten + end + + private + + def merge_entries(entry_map) + all = @unreleased.merge(entry_map) { |_k, v1, v2| v1.concat(v2) } + canonical = TYPE_TO_HEADER.values.to_h { |v| [v, nil] } + canonical.merge(all).compact + end + + def parse(content) + ss = StringScanner.new(content) + @header = ss.scan_until(FIRST_HEADER) + @unreleased = parse_release(ss.scan_until(/\n(?=## )/m)) + @rest = ss.rest + end + + # @return [Hash]] + def parse_release(unreleased) + unreleased + .lines + .map(&:chomp) + .reject(&:empty?) + .slice_before(HEADER) + .to_h do |header, *entries| + [HEADER.match(header)[1], entries] + end + end + + def parse_entries(path_content_map) + changes = Hash.new { |h, k| h[k] = [] } + path_content_map.each do |path, content| + header = TYPE_TO_HEADER.fetch(TYPE_REGEXP.match(path)[1].to_sym) + changes[header].concat(content.lines.map(&:chomp)) + end + changes + end +end diff --git a/tasks/cut_release.rake b/tasks/cut_release.rake index e4264d0e6c0..c9f8ecfd4e5 100644 --- a/tasks/cut_release.rake +++ b/tasks/cut_release.rake @@ -6,7 +6,7 @@ namespace :cut_release do %w[major minor patch pre].each do |release_type| desc "Cut a new #{release_type} release, create release notes " \ 'and update documents.' - task release_type do + task release_type => 'changelog:check_clean' do run(release_type) end end