From fc16ca4068f8bcf78d9881a01ab527fafa719148 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Sun, 23 May 2021 18:03:40 +0900 Subject: [PATCH] Add rake tasks for alternative way to specify Changelog entries Follow https://github.com/rubocop/rubocop/pull/8930. --- CONTRIBUTING.md | 5 +- Rakefile | 2 + spec/project_spec.rb | 90 +++++++++++++-------- tasks/changelog.rake | 34 ++++++++ tasks/changelog.rb | 173 +++++++++++++++++++++++++++++++++++++++++ tasks/cut_release.rake | 5 +- 6 files changed, 271 insertions(+), 38 deletions(-) create mode 100644 tasks/changelog.rake create mode 100644 tasks/changelog.rb diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f1dff68d3d..e85675e305 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 @@ -54,12 +54,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-rails/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-rails/issues [2]: https://www.gun.io/blog/how-to-github-fork-branch-and-pull-request diff --git a/Rakefile b/Rakefile index 9f68034c47..d0ebba31a9 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 59ab5cd056..1e74a78595 100644 --- a/spec/project_spec.rb +++ b/spec/project_spec.rb @@ -46,12 +46,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] @@ -78,12 +76,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 @@ -98,34 +91,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| @@ -186,4 +156,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 0000000000..0004e389bd --- /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 0000000000..b018dfe774 --- /dev/null +++ b/tasks/changelog.rb @@ -0,0 +1,173 @@ +# 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 + 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 { |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 fb0daa966a..53a90533e2 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