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 ab160bd
Show file tree
Hide file tree
Showing 6 changed files with 279 additions and 2 deletions.
1 change: 1 addition & 0 deletions CHANGELOG/new_add_rake_tasks_to_merge_and_create.md
@@ -0,0 +1 @@
* [#131](https://github.com/rubocop-hq/rubocop-ast/pull/131): Add rake tasks to merge and create Changelog entries. ([@marcandre][])
1 change: 1 addition & 0 deletions CONTRIBUTING.md
Expand Up @@ -61,6 +61,7 @@ Here are a few examples:
* 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`.
* If it is not your first contribution, you may prefer using `rake cl:new|bug|change` that creates a file with the body of your changelog entry, to be merged later.

[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
73 changes: 73 additions & 0 deletions spec/tasks/changelog_spec.rb
@@ -0,0 +1,73 @@
# 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` compiler [complete rewrite](https://docs.rubocop.org/rubocop-ast/node_pattern_compiler.html). Add support for multiple variadic terms. ([@marcandre][])
* [#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][])
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')
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. ([@johndoe][])
### 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. ([@johndoe][])
CHANGELOG
end
end
# rubocop:enable RSpec/ExampleLength
33 changes: 33 additions & 0 deletions tasks/changelog.rake
@@ -0,0 +1,33 @@
# frozen_string_literal: true

autoload :Changelog, "#{__dir__}/changelog"

namespace :cl 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 cl:merge`'
exit(1)
end
end
169 changes: 169 additions & 0 deletions tasks/changelog.rb
@@ -0,0 +1,169 @@
# 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

# New entry
# rubocop:disable Metrics/BlockLength
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
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
# rubocop:enable Metrics/BlockLength

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
].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

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
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 => 'cl: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: 'cl:check_clean' do
update_file 'docs/antora.yml' do |s|
s.gsub!(/version: .*/, 'version: master')
end
Expand Down

0 comments on commit ab160bd

Please sign in to comment.