diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 419e32ee..fbb91aa8 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-minitest/blob/master/CHANGELOG.md) if the new code introduces user-observable changes. See [changelog entry format](https://github.com/rubocop-hq/rubocop-minitest/blob/master/CONTRIBUTING.md#changelog-entry-format). +* [ ] Added an entry (file) to the [changelog folder](https://github.com/rubocop/rubocop-minitest/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/.rubocop.yml b/.rubocop.yml index 20453104..d2b69288 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -42,6 +42,7 @@ Style/FormatStringToken: Metrics/ClassLength: Exclude: - test/**/* + - tasks/changelog.rb Layout/EndOfLine: EnforcedStyle: lf diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2047e0cd..c2529627 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-minitest/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 Minitest 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-minitest/issues [2]: https://www.gun.io/blog/how-to-github-fork-branch-and-pull-request diff --git a/Rakefile b/Rakefile index 7170d765..d25ef633 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/tasks/changelog.rake b/tasks/changelog.rake new file mode 100644 index 00000000..0004e389 --- /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 00000000..a5b330e1 --- /dev/null +++ b/tasks/changelog.rb @@ -0,0 +1,166 @@ +# 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-minitest' + 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 fb0daa96..53a90533 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