From 50abd66e3e40d001c24ba47bcc037b5ad54b3599 Mon Sep 17 00:00:00 2001 From: Marc-Andre Lafortune Date: Mon, 28 Sep 2020 23:29:35 -0400 Subject: [PATCH] Add rake tasks to merge and create Changelog entries --- .../new_add_rake_tasks_to_merge_and_create.md | 1 + CONTRIBUTING.md | 1 + spec/tasks/changelog_spec.rb | 73 ++++++++ tasks/changelog.rake | 33 ++++ tasks/changelog.rb | 169 ++++++++++++++++++ tasks/cut_release.rake | 4 +- 6 files changed, 279 insertions(+), 2 deletions(-) create mode 100644 CHANGELOG/new_add_rake_tasks_to_merge_and_create.md create mode 100644 spec/tasks/changelog_spec.rb create mode 100644 tasks/changelog.rake create mode 100644 tasks/changelog.rb diff --git a/CHANGELOG/new_add_rake_tasks_to_merge_and_create.md b/CHANGELOG/new_add_rake_tasks_to_merge_and_create.md new file mode 100644 index 000000000..af5082418 --- /dev/null +++ b/CHANGELOG/new_add_rake_tasks_to_merge_and_create.md @@ -0,0 +1 @@ +* [#131](https://github.com/rubocop-hq/rubocop-ast/pull/131): Add rake tasks to merge and create Changelog entries. ([@marcandre][]) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9f60c230a..8e7985b68 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -61,6 +61,7 @@ Here are a few examples: * 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`. +* If it is not your first contribution, you may prefer using `rake cl:new|bug|change` that creates a file with the body of your changelog entry, to be merged later. [1]: https://github.com/rubocop-hq/rubocop-ast/issues [2]: https://www.gun.io/blog/how-to-github-fork-branch-and-pull-request diff --git a/spec/tasks/changelog_spec.rb b/spec/tasks/changelog_spec.rb new file mode 100644 index 000000000..dbcfe5b96 --- /dev/null +++ b/spec/tasks/changelog_spec.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +returng unless RUBY_VERSION >= '2.6' + +require_relative '../../tasks/changelog' + +# rubocop:disable RSpec/ExampleLength +RSpec.describe Changelog do + subject(:changelog) do + list = entries.to_h { |e| [e.path, e.content] } + described_class.new(content: <<~CHANGELOG, entries: list) + # Change log + + ## master (unreleased) + + ### New features + + * [#bogus] Bogus feature + * [#bogus] Other bogus feature + + ## 0.7.1 (2020-09-28) + + ### Bug fixes + + * [#127](https://github.com/rubocop-hq/rubocop-ast/pull/127): Fix dependency issue for JRuby. ([@marcandre][]) + + ## 0.7.0 (2020-09-27) + + ### New features + + * [#105](https://github.com/rubocop-hq/rubocop-ast/pull/105): `NodePattern` compiler [complete rewrite](https://docs.rubocop.org/rubocop-ast/node_pattern_compiler.html). Add support for multiple variadic terms. ([@marcandre][]) + * [#109](https://github.com/rubocop-hq/rubocop-ast/pull/109): Add `NodePattern` debugging rake tasks: `test_pattern`, `compile`, `parse`. See also [this app](https://nodepattern.herokuapp.com) ([@marcandre][]) + * [#110](https://github.com/rubocop-hq/rubocop-ast/pull/110): Add `NodePattern` support for multiple terms unions. ([@marcandre][]) + * [#111](https://github.com/rubocop-hq/rubocop-ast/pull/111): Optimize some `NodePattern`s by using `Set`s. ([@marcandre][]) + * [#112](https://github.com/rubocop-hq/rubocop-ast/pull/112): Add `NodePattern` support for Regexp literals. ([@marcandre][]) + CHANGELOG + end + + let(:entries) do + %i[fix new fix].map.with_index do |type, i| + Changelog::Entry.new(type: type, body: "Do something cool#{'x' * i}", user: 'johndoe') + end + end + let(:entry) { entries.first } + + describe Changelog::Entry do + it 'generates correct content' do + expect(entry.content).to eq <<~MD + * [#x](https://github.com/rubocop-hq/rubocop-ast/pull/x): Do something cool. ([@johndoe][]) + MD + end + end + + it 'parses correctly' do + expect(changelog.rest).to start_with('## 0.7.1 (2020-09-28)') + end + + it 'merges correctly' do + expect(changelog.unreleased_content).to eq(<<~CHANGELOG) + ### New features + + * [#bogus] Bogus feature + * [#bogus] Other bogus feature + * [#x](https://github.com/rubocop-hq/rubocop-ast/pull/x): Do something coolx. ([@johndoe][]) + + ### Bug fixes + + * [#x](https://github.com/rubocop-hq/rubocop-ast/pull/x): Do something cool. ([@johndoe][]) + * [#x](https://github.com/rubocop-hq/rubocop-ast/pull/x): Do something coolxx. ([@johndoe][]) + CHANGELOG + end +end +# rubocop:enable RSpec/ExampleLength diff --git a/tasks/changelog.rake b/tasks/changelog.rake new file mode 100644 index 000000000..586b2657a --- /dev/null +++ b/tasks/changelog.rake @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +autoload :Changelog, "#{__dir__}/changelog" + +namespace :cl 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 + return unless Changelog.pending? + + puts '*** Pending changelog entries!' + puts 'Do `bundle exec rake cl:merge`' + exit(1) + end +end diff --git a/tasks/changelog.rb b/tasks/changelog.rb new file mode 100644 index 000000000..52c42c370 --- /dev/null +++ b/tasks/changelog.rb @@ -0,0 +1,169 @@ +# 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 + + # New entry + # rubocop:disable Metrics/BlockLength + 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 + 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 + # rubocop:enable Metrics/BlockLength + + 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 + ].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 + + 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 edb3b37a5..f32601511 100644 --- a/tasks/cut_release.rake +++ b/tasks/cut_release.rake @@ -11,7 +11,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 => 'cl:check_clean' do run(release_type) end end @@ -54,7 +54,7 @@ namespace :cut_release do end desc 'and restore docs/antora' -task :release do +task release: 'cl:check_clean' do update_file 'docs/antora.yml' do |s| s.gsub!(/version: .*/, 'version: master') end