From 2676918c9d0cfb6cc33e3c30f190ac8c420babb7 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 --- CONTRIBUTING.md | 6 +- .../new_add_rake_tasks_to_merge_and_create.md | 1 + spec/tasks/changelog_spec.rb | 83 ++++++++ tasks/changelog.rake | 33 ++++ tasks/changelog.rb | 184 ++++++++++++++++++ tasks/cut_release.rake | 4 +- 6 files changed, 307 insertions(+), 4 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/CONTRIBUTING.md b/CONTRIBUTING.md index 9f60c230a..8c4ddd003 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -33,7 +33,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 @@ -54,13 +54,15 @@ Here are a few examples: * [#7542](https://github.com/rubocop-hq/rubocop-ast/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. * 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-ast/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`. +* The rake tasks `rake changelog:new|bug|change` will create a file with the body based off the last git commit. Modifiy it and add it to your commit; maintainers will merge it automatically later. +* 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, you'll have to 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-ast/issues [2]: https://www.gun.io/blog/how-to-github-fork-branch-and-pull-request 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/spec/tasks/changelog_spec.rb b/spec/tasks/changelog_spec.rb new file mode 100644 index 000000000..14f0a643b --- /dev/null +++ b/spec/tasks/changelog_spec.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +return 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` stuff... + * [#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][]) + + more stuf.... + + [@marcandre]: https://github.com/marcandre + [@johndoexx]: https://github.com/johndoexx + 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#{'x' * i}") + 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. ([@johndoex][]) + + ### 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. ([@johndoexx][]) + CHANGELOG + + expect(changelog.new_contributor_lines).to eq([ + '[@johndoe]: https://github.com/johndoe', + '[@johndoex]: https://github.com/johndoex' + ]) + end +end +# rubocop:enable RSpec/ExampleLength diff --git a/tasks/changelog.rake b/tasks/changelog.rake new file mode 100644 index 000000000..cded3bd89 --- /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 + return 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 000000000..efe97e42a --- /dev/null +++ b/tasks/changelog.rb @@ -0,0 +1,184 @@ +# 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 + # 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 + 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 + # 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, + *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 edb3b37a5..adb0e7b11 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 => 'changelog: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: 'changelog:check_clean' do update_file 'docs/antora.yml' do |s| s.gsub!(/version: .*/, 'version: master') end