Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add rake tasks for alternative way to specify Changelog entries #1542

Closed
wants to merge 2 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
19 changes: 18 additions & 1 deletion .github/CONTRIBUTING.md
Expand Up @@ -18,11 +18,26 @@ If you encounter problems or have ideas for improvements or new features, please
2. Create a feature branch.
3. Make sure to add tests.
4. Make sure the test suite passes (run `rake`).
5. Add a [changelog](https://github.com/rubocop/rubocop-rspec/blob/master/CHANGELOG.md) entry.
5. 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.
6. Commit your changes.
7. Push to the branch.
8. Create new Pull Request.

### Changelog entry format

Here are a few examples:

```
- [#1514](https://github.com/rubocop/rubocop-rspec/issue/1514): Fix a false positive for `RSpec/PendingWithoutReason` when not inside example. ([@ydah])
```

- 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][1].
- The entry line should start with `- ` (an hyphen 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 `[#1514](https://github.com/rubocop/rubocop-rspec/issues/1514): `.
- 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])`.

### Spell Checking

We are running [codespell](https://github.com/codespell-project/codespell) with [GitHub Actions](https://github.com/rubocop/rubocop-rspec/blob/master/.github/workflows/codespell.yml) to check spelling and
Expand Down Expand Up @@ -60,3 +75,5 @@ $ mdformat . --number
- Common pitfalls:
- If your cop inspects code outside of an example, check for false positives when similarly named variables are used inside of the example.
- If your cop inspects code inside of an example, check that it works when the example is empty (empty `describe`, `it`, etc.).

[1]: https://daringfireball.net/projects/markdown/syntax
2 changes: 1 addition & 1 deletion .github/PULL_REQUEST_TEMPLATE.md
Expand Up @@ -8,7 +8,7 @@ Before submitting the PR make sure the following are checked:
- [ ] Squashed related commits together.
- [ ] Added tests.
- [ ] Updated documentation.
- [ ] Added an entry to the `CHANGELOG.md` if the new code introduces user-observable changes.
- [ ] Added an entry (file) to the [changelog folder](https://github.com/rubocop/rubocop-committee/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 build (`bundle exec rake`) passes (be sure to run this locally, since it may produce updated documentation that you will need to commit).

If you have created a new cop:
Expand Down
6 changes: 0 additions & 6 deletions CHANGELOG.md
Expand Up @@ -2,12 +2,6 @@

## Master (Unreleased)

- Fix a false positive for `RSpec/PendingWithoutReason` when pending/skip is argument of methods. ([@ydah])
- Add new `RSpec/Capybara/MatchStyle` cop. ([@ydah])
- Add new `RSpec/Rails/MinitestAssertions` cop. ([@ydah])
- Fix a false positive for `RSpec/PendingWithoutReason` when not inside example. ([@ydah])
- Fix a false negative for `RSpec/PredicateMatcher` when using `include` and `respond_to`. ([@ydah])

## 2.16.0 (2022-12-13)

- Add new `RSpec/FactoryBot/FactoryNameStyle` cop. ([@ydah])
Expand Down
@@ -0,0 +1 @@
- [#1532](https://github.com/rubocop/rubocop-rspec/pull/1532): Fix a false negative for `RSpec/PredicateMatcher` when using `include` and `respond_to`. ([@ydah])
@@ -0,0 +1 @@
- [#1516](https://github.com/rubocop/rubocop-rspec/pull/1516): Fix a false positive for `RSpec/PendingWithoutReason` when pending/skip is argument of methods. ([@ydah])
@@ -0,0 +1 @@
- [#1514](https://github.com/rubocop/rubocop-rspec/issue/1514): Fix a false positive for `RSpec/PendingWithoutReason` when not inside example. ([@ydah])
1 change: 1 addition & 0 deletions changelog/new_add_new_rspec_capybara_match_style_cop.md
@@ -0,0 +1 @@
- [#1456](https://github.com/rubocop/rubocop-rspec/pull/1456): Add new `RSpec/Capybara/MatchStyle` cop. ([@ydah])
@@ -0,0 +1 @@
- [#1485](https://github.com/rubocop/rubocop-rspec/issue/1485): Add new `RSpec/Rails/MinitestAssertions` cop. ([@ydah])
36 changes: 36 additions & 0 deletions tasks/changelog.rake
@@ -0,0 +1,36 @@
# 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

desc 'Check to see if there are any entries left'
task :check_clean do
next unless Changelog.pending?

puts '*** Pending changelog entries!'
puts 'Do `bundle exec rake changelog:merge`'
exit(1)
end
end
204 changes: 204 additions & 0 deletions tasks/changelog.rb
@@ -0,0 +1,204 @@
# 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
CONTRIBUTORS_HEADER =
/#{Regexp.escape("<!-- Contributors (alphabetically) -->\n\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-rspec'
MAX_LENGTH = 40
CONTRIBUTOR = '[@%<link>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
FileUtils.mkdir_p(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
.split
.reject(&:empty?)
.map { |s| prettify(s) }
.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

private

def prettify(str)
str.gsub!(/\W/, '_')

# Separate word boundaries by `_`.
str.gsub!(/([A-Z]+)(?=[A-Z][a-z])|([a-z\d])(?=[A-Z])/) do
(Regexp.last_match(1) || Regexp.last_match(2)) << '_'
end

str.gsub!(/\A_+|_+\z/, '')
str.downcase!
str
end
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

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 do |header, things|
["### #{header}\n", *things, '']
end.join("\n")
end

def merge_content
merged_content = [@header, unreleased_content, @changes.chomp,
*all_contributors].join("\n")

merged_content << EOF
end

def all_contributors
(
@contributors.split(/\R/) +
new_contributors
).uniq.sort
end

def new_contributors
contributors
.map { |user| format(CONTRIBUTOR, link: user.downcase, user: user) }
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))
@changes = ss.scan_until(CONTRIBUTORS_HEADER)
@contributors = 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
50 changes: 36 additions & 14 deletions tasks/cut_release.rake
Expand Up @@ -3,18 +3,29 @@
require 'bump'

namespace :cut_release do
def update_file(path)
content = File.read(path)
File.write(path, yield(content))
end

%w[major minor patch pre].each do |release_type|
desc "Cut a new #{release_type} release and update documents."
task release_type do
desc "Cut a new #{release_type} release and create release notes."
task release_type => 'changelog:check_clean' do
run(release_type)
end
end

def add_header_to_changelog(version)
update_file('CHANGELOG.md') do |changelog|
changelog.sub("## Master (Unreleased)\n\n",
'\0' "## #{version} (#{Time.now.strftime('%F')})\n\n")
end
end

def update_antora_yml(new_version)
antora_metadata = File.read('docs/antora.yml')

File.open('docs/antora.yml', 'w') do |f|
f << antora_metadata.sub('version: ~',
"version: '#{version_sans_patch(new_version)}'")
end
end

def version_sans_patch(version)
version.split('.').take(2).join('.')
end
Expand All @@ -28,20 +39,29 @@ namespace :cut_release do
RuboCop::ConfigLoader.default_configuration = nil # invalidate loaded conf
end

def new_version_changes
changelog = File.read('CHANGELOG.md')
_, _, new_changes, _older_changes = changelog.split(/^## .*$/, 4)
new_changes
end

def update_file(path)
content = File.read(path)
File.write(path, yield(content))
end

def user_links(text)
names = text.scan(/\[@(\S+)\]/).map(&:first).uniq
names.map { |name| "[@#{name}]: https://github.com/#{name}" }.join("\n")
end

def update_docs(version)
update_file('docs/antora.yml') do |antora_metadata|
antora_metadata.sub('version: ~',
"version: '#{version_sans_patch(version)}'")
end
end

def add_header_to_changelog(version)
update_file('CHANGELOG.md') do |changelog|
changelog.sub("## Master (Unreleased)\n\n",
'\0' "## #{version} (#{Time.now.strftime('%F')})\n\n")
end
end

def run(release_type)
old_version = Bump::Bump.current
Bump::Bump.run(release_type, commit: false, bundle: false, tag: false)
Expand All @@ -50,7 +70,9 @@ namespace :cut_release do
update_cop_versions(new_version)
`bundle exec rake generate_cops_documentation`
update_docs(new_version) if %w[major minor].include?(release_type)

add_header_to_changelog(new_version)
update_antora_yml(new_version)

puts "Changed version from #{old_version} to #{new_version}."
end
Expand Down