From 929dfdee0c01b0aef4b86483d12050f2dde40949 Mon Sep 17 00:00:00 2001 From: Rena Watson Date: Fri, 28 May 2021 19:17:59 +0900 Subject: [PATCH] Add rake tasks for alternative way to specify Changelog entries Follow https://github.com/rubocop/rubocop/pull/8930 and https://github.com/rubocop/rubocop/commit/3fc75c4 --- .github/PULL_REQUEST_TEMPLATE.md | 2 +- CONTRIBUTING.md | 5 +- Rakefile | 2 + spec/project_spec.rb | 90 ++++++++++------ tasks/changelog.rake | 34 ++++++ tasks/changelog.rb | 171 +++++++++++++++++++++++++++++++ tasks/cut_release.rake | 5 +- 7 files changed, 270 insertions(+), 39 deletions(-) create mode 100644 tasks/changelog.rake create mode 100644 tasks/changelog.rb diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 4b029d0..aa0a678 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -10,7 +10,7 @@ Before submitting the PR make sure the following are checked: * [ ] Feature branch is up-to-date with `master` (if not - rebase it). * [ ] Squashed related commits together. * [ ] Added tests. -* [ ] Added an entry to the [Changelog](https://github.com/rubocop-hq/rubocop-performance/blob/master/CHANGELOG.md) if the new code introduces user-observable changes. See [changelog entry format](https://github.com/rubocop-hq/rubocop-performance/blob/master/CONTRIBUTING.md#changelog-entry-format). +* [ ] Added an entry (file) to the [changelog folder](https://github.com/rubocop/rubocop-rails/blob/master/changelog/) named `{change_type}_{change_description}.md` if the new code introduces user-observable changes. See [changelog entry format](https://github.com/rubocop/rubocop/blob/master/CONTRIBUTING.md#changelog-entry-format) for details. * [ ] The PR relates to *only* one subject with a clear title and description in grammatically correct, complete sentences. * [ ] Run `bundle exec rake default`. It executes all tests and RuboCop for itself, and generates the documentation. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8e2435e..ad7b69f 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) for details. * 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 @@ -53,12 +53,13 @@ Here are a few examples: * New cop `ElseLayout` checks for odd arrangement of code in the `else` branch of a conditional expression. ([@bbatsov][]) ``` +* 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/rubocop-performance/issues/123): `. * Describe the brief of the change. The sentence should end with a punctuation. * 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/rubocop-performance/issues [2]: https://www.gun.io/blog/how-to-github-fork-branch-and-pull-request diff --git a/Rakefile b/Rakefile index 41ba7ec..172aee2 100644 --- a/Rakefile +++ b/Rakefile @@ -1,5 +1,7 @@ # frozen_string_literal: true +task release: 'changelog:check_clean' # Before task is required + require 'bundler' require 'bundler/gem_tasks' diff --git a/spec/project_spec.rb b/spec/project_spec.rb index e30eed8..6898203 100644 --- a/spec/project_spec.rb +++ b/spec/project_spec.rb @@ -44,12 +44,10 @@ end end - it 'has a SupportedStyles for all EnforcedStyle ' \ - 'and EnforcedStyle is valid' do + it 'has a SupportedStyles for all EnforcedStyle and EnforcedStyle is valid' do errors = [] cop_names.each do |name| - enforced_styles = config[name] - .select { |key, _| key.start_with?('Enforced') } + enforced_styles = config[name].select { |key, _| key.start_with?('Enforced') } enforced_styles.each do |style_name, style| supported_key = RuboCop::Cop::Util.to_supported_styles(style_name) valid = config[name][supported_key] @@ -76,12 +74,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 @@ -96,34 +89,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| @@ -184,4 +154,58 @@ end 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' + + it 'has a link to the contributors at the end' do + expect(entries).to all(match(/\(\[@\S+\]\[\](?:, \[@\S+\]\[\])*\)$/)) + end + + it 'starts with `new_`, `fix_`, or `change_`' do + expect(File.basename(path)).to(match(/\A(new|fix|change)_.+/)) + end + 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 end diff --git a/tasks/changelog.rake b/tasks/changelog.rake new file mode 100644 index 0000000..0004e38 --- /dev/null +++ b/tasks/changelog.rake @@ -0,0 +1,34 @@ +# 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, [:id] do |_task, args| + ref_type = :pull if args[:id] + path = Changelog::Entry.new(type: type, ref_id: args[:id], ref_type: ref_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 0000000..696fb4c --- /dev/null +++ b/tasks/changelog.rb @@ -0,0 +1,171 @@ +# 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/rubocop' + MAX_LENGTH = 40 + CONTRIBUTOR = '[@%s]: https://github.com/%s' + SIGNATURE = Regexp.new(format(Regexp.escape('[@%s][]'), user: '([\w-]+)')) + EOF = "\n" + + # 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: nil, ref_id: nil, user: github_user) + id, body = extract_id(body) + ref_id ||= id || 'x' + ref_type ||= id ? :issues : :pull + 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) + /^\[Fix(?:es)? #(\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 + warn 'Set your username with `git config --global credential.username "myusernamehere"`' if user.empty? + + 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 { |path| File.delete(path) } + 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 { |header, things| ["### #{header}\n", *things, ''] }.join("\n") + end + + def merge_content + merged_content = [@header, unreleased_content, @rest.chomp, *new_contributor_lines].join("\n") + + merged_content << EOF + end + + def self.pending? + entry_paths.any? + end + + def self.entry_paths + Dir["#{ENTRIES_PATH}*"] + end + + def self.read_entries + entry_paths.to_h { |path| [path, File.read(path)] } + end + + def new_contributor_lines + contributors + .map { |user| format(CONTRIBUTOR, user: user) } + .reject { |line| @rest.include?(line) } + end + + def contributors + contributors = @entries.values.flat_map do |entry| + entry.match(/\. \((?.+)\)\n/)[:contributors].split(',') + end + + contributors.join.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 7b1357f..9baf22d 100644 --- a/tasks/cut_release.rake +++ b/tasks/cut_release.rake @@ -4,9 +4,8 @@ require 'bump' 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 + desc "Cut a new #{release_type} release, create release notes and update documents." + task release_type => 'changelog:check_clean' do run(release_type) end end