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
  • Loading branch information
koic committed May 24, 2021
1 parent ad4fb01 commit 0af324c
Show file tree
Hide file tree
Showing 7 changed files with 270 additions and 39 deletions.
2 changes: 1 addition & 1 deletion .github/PULL_REQUEST_TEMPLATE.md
Expand Up @@ -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-rails/blob/master/CHANGELOG.md) if the new code introduces user-observable changes. See [changelog entry format](https://github.com/rubocop-hq/rubocop-rails/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.
Expand Down
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) 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
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions Rakefile
@@ -1,5 +1,7 @@
# frozen_string_literal: true

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

require 'bundler'
require 'bundler/gem_tasks'

Expand Down
90 changes: 57 additions & 33 deletions spec/project_spec.rb
Expand Up @@ -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]
Expand All @@ -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
Expand All @@ -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|
Expand Down Expand Up @@ -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
34 changes: 34 additions & 0 deletions 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
171 changes: 171 additions & 0 deletions 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}%<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/rubocop'
MAX_LENGTH = 40
CONTRIBUTOR = '[@%<user>s]: https://github.com/%<user>s'
SIGNATURE = Regexp.new(format(Regexp.escape('[@%<user>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(/\. \((?<contributors>.+)\)\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<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
5 changes: 2 additions & 3 deletions tasks/cut_release.rake
Expand Up @@ -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
Expand Down

0 comments on commit 0af324c

Please sign in to comment.