Skip to content

Commit

Permalink
Add rake tasks for alternative way to specify Changelog entries
Browse files Browse the repository at this point in the history
Specs also check `changelog` entries.
  • Loading branch information
marcandre committed Oct 23, 2020
1 parent cb9912f commit 623b6bb
Show file tree
Hide file tree
Showing 7 changed files with 268 additions and 32 deletions.
5 changes: 3 additions & 2 deletions CONTRIBUTING.md
Expand Up @@ -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
Expand All @@ -55,13 +55,14 @@ Here are a few examples:
* [#7542](https://github.com/rubocop-hq/rubocop/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. 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-hq/rubocop/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`.
* 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-hq/rubocop/issues
[2]: https://www.gun.io/blog/how-to-github-fork-branch-and-pull-request
Expand Down
2 changes: 2 additions & 0 deletions Rakefile
Expand Up @@ -4,6 +4,8 @@
# and started before any application code is loaded.
require 'simplecov' if ENV['COVERAGE']

task release: 'changelog:check_clean' # Before task is required

require 'bundler'
require 'bundler/gem_tasks'
begin
Expand Down
1 change: 1 addition & 0 deletions changelog/new_add_rake_tasks_for_alternative_way_to.md
@@ -0,0 +1 @@
* [#8930](https://github.com/rubocop-hq/rubocop/pull/8930): Add rake tasks for alternative way to specify Changelog entries. ([@marcandre][])
76 changes: 47 additions & 29 deletions spec/project_spec.rb
Expand Up @@ -99,12 +99,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
Expand All @@ -119,34 +114,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|
Expand Down Expand Up @@ -208,6 +180,52 @@
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'
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

describe 'requiring all of `lib` with verbose warnings enabled' do
it 'emits no warnings' do
warnings = `ruby -Ilib -w -W2 lib/rubocop.rb 2>&1`
Expand Down
33 changes: 33 additions & 0 deletions 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
next unless Changelog.pending?

puts '*** Pending changelog entries!'
puts 'Do `bundle exec rake changelog:merge`'
exit(1)
end
end
181 changes: 181 additions & 0 deletions tasks/changelog.rb
@@ -0,0 +1,181 @@
# 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}%<type>s_%<name>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 = '[@%<user>s]: https://github.com/%<user>s'
SIGNATURE = Regexp.new(format(Regexp.escape("([@%<user>s][])\n"), user: '(\w+)'))

# 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: :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
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<type, Array<String>]]
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
2 changes: 1 addition & 1 deletion tasks/cut_release.rake
Expand Up @@ -6,7 +6,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
Expand Down

0 comments on commit 623b6bb

Please sign in to comment.