Skip to content

Commit

Permalink
Add rake tasks to merge and create Changelog entries
Browse files Browse the repository at this point in the history
  • Loading branch information
marcandre committed Sep 29, 2020
1 parent 210ce65 commit fe13a5f
Show file tree
Hide file tree
Showing 6 changed files with 306 additions and 4 deletions.
6 changes: 4 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
* 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,13 +54,15 @@ Here are a few examples:
* [#7542](https://github.com/rubocop-hq/rubocop-ast/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.
* 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-ast/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`.
* The rake tasks `rake changelog:new|bug|change` will create a file with the body based off the last git commit. Modifiy it and add it to your commit; maintainers will merge it automatically later.
* 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, you'll have to 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-ast/issues
[2]: https://www.gun.io/blog/how-to-github-fork-branch-and-pull-request
Expand Down
1 change: 1 addition & 0 deletions changelog/new_add_rake_tasks_to_merge_and_create.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
* [#131](https://github.com/rubocop-hq/rubocop-ast/pull/131): Add rake tasks to merge and create Changelog entries. ([@marcandre][])
85 changes: 85 additions & 0 deletions spec/tasks/changelog_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# frozen_string_literal: true

return unless RUBY_VERSION >= '2.6'

require_relative '../../tasks/changelog'

# rubocop:disable RSpec/ExampleLength
RSpec.describe Changelog do
subject(:changelog) do
list = entries.to_h { |e| [e.path, e.content] }
described_class.new(content: <<~CHANGELOG, entries: list)
# Change log
## master (unreleased)
### New features
* [#bogus] Bogus feature
* [#bogus] Other bogus feature
## 0.7.1 (2020-09-28)
### Bug fixes
* [#127](https://github.com/rubocop-hq/rubocop-ast/pull/127): Fix dependency issue for JRuby. ([@marcandre][])
## 0.7.0 (2020-09-27)
### New features
* [#105](https://github.com/rubocop-hq/rubocop-ast/pull/105): `NodePattern` stuff...
* [#109](https://github.com/rubocop-hq/rubocop-ast/pull/109): Add `NodePattern` debugging rake tasks: `test_pattern`, `compile`, `parse`. See also [this app](https://nodepattern.herokuapp.com) ([@marcandre][])
* [#110](https://github.com/rubocop-hq/rubocop-ast/pull/110): Add `NodePattern` support for multiple terms unions. ([@marcandre][])
* [#111](https://github.com/rubocop-hq/rubocop-ast/pull/111): Optimize some `NodePattern`s by using `Set`s. ([@marcandre][])
* [#112](https://github.com/rubocop-hq/rubocop-ast/pull/112): Add `NodePattern` support for Regexp literals. ([@marcandre][])
more stuf....
[@marcandre]: https://github.com/marcandre
[@johndoexx]: https://github.com/johndoexx
CHANGELOG
end

let(:entries) do
%i[fix new fix].map.with_index do |type, i|
Changelog::Entry.new(type: type, body: "Do something cool#{'x' * i}", user: "johndoe#{'x' * i}")
end
end
let(:entry) { entries.first }

describe Changelog::Entry do
it 'generates correct content' do
expect(entry.content).to eq <<~MD
* [#x](https://github.com/rubocop-hq/rubocop-ast/pull/x): Do something cool. ([@johndoe][])
MD
end
end

it 'parses correctly' do
expect(changelog.rest).to start_with('## 0.7.1 (2020-09-28)')
end

it 'merges correctly' do
expect(changelog.unreleased_content).to eq(<<~CHANGELOG)
### New features
* [#bogus] Bogus feature
* [#bogus] Other bogus feature
* [#x](https://github.com/rubocop-hq/rubocop-ast/pull/x): Do something coolx. ([@johndoex][])
### Bug fixes
* [#x](https://github.com/rubocop-hq/rubocop-ast/pull/x): Do something cool. ([@johndoe][])
* [#x](https://github.com/rubocop-hq/rubocop-ast/pull/x): Do something coolxx. ([@johndoexx][])
CHANGELOG

expect(changelog.new_contributor_lines).to eq(
[
'[@johndoe]: https://github.com/johndoe',
'[@johndoex]: https://github.com/johndoex'
]
)
end
end
# rubocop:enable RSpec/ExampleLength
33 changes: 33 additions & 0 deletions tasks/changelog.rake
Original file line number Diff line number Diff line change
@@ -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
return 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
Original file line number Diff line number Diff line change
@@ -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 # rubocop:disable Metrics/ClassLength
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
4 changes: 2 additions & 2 deletions tasks/cut_release.rake
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,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 Expand Up @@ -54,7 +54,7 @@ namespace :cut_release do
end

desc 'and restore docs/antora'
task :release do
task release: 'changelog:check_clean' do
update_file 'docs/antora.yml' do |s|
s.gsub!(/version: .*/, 'version: master')
end
Expand Down

0 comments on commit fe13a5f

Please sign in to comment.