homebrew-cask/developer/bin/generate_changelog

370 lines
8.4 KiB
Ruby
Executable File

#!/usr/bin/env ruby
#
# generate_changelog
#
###
### dependencies
###
require 'open3'
require 'set'
###
### configurable constants
###
PROJECT_URL = 'https://github.com/caskroom/homebrew-cask'
MAINTAINERS = %w[
phinze
fanquake
leoj3n
NanoXD
rolandwalker
vitorgalvao
alebcay
ndr-qef
goxberry
jimbojsb
radeksimko
federicobond
claui
caskroom
]
CODE_PATHS = %w[
bin
developer
lib
spec
test
brew-cask.rb
Rakefile
Gemfile
Gemfile.lock
.travis.yml
.gitignore
]
SHA_PAT = '[\da-f]{40}'
###
### monkeypatching
###
class Array
def to_h
Hash[*self.flatten]
end
end
###
### git methods
###
def end_object
'HEAD'
end
def matches_sha(sha)
%r{\A#{SHA_PAT}\Z}.match(sha)
end
def cd_to_project_root
Dir.chdir File.dirname(File.expand_path(__FILE__))
@git_root ||= Open3.popen3(*%w[
git rev-parse --show-toplevel
]) do |stdin, stdout, stderr|
begin
stdout.gets.chomp
rescue
end
end
Dir.chdir @git_root
@git_root
end
def warn_if_off_branch(wanted_branch='master')
current_branch = Open3.popen3(*%w[
git rev-parse --abbrev-ref HEAD
]) do |stdin, stdout, stderr|
begin
stdout.gets.chomp
rescue
end
end
unless current_branch == wanted_branch
$stderr.puts "\nWARNING: you are running from branch '#{current_branch}', not '#{wanted_branch}'\n\n"
end
end
def last_release
@last_release ||= Open3.popen3(
'./developer/bin/get_release_tag'
) do |stdin, stdout, stderr|
begin
stdout.gets.chomp
rescue
end
end
end
def next_release
if @next_release.nil?
if ENV.key?('NEW_RELEASE_TAG')
@next_release = ENV['NEW_RELEASE_TAG']
else
@next_release = Open3.popen3(
'./developer/bin/get_release_tag', '-next'
) do |stdin, stdout, stderr|
begin
stdout.gets.chomp
rescue
end
end
end
else
@next_release
end
end
def verify_git_object(object)
sha = Open3.popen3(*%w[
git rev-parse -q --verify
],
object, '--'
) do |stdin, stdout, stderr|
begin
stdout.gets.chomp
rescue
end
end
raise "'#{object}' is not a git object" unless matches_sha sha
sha
end
# constrained to last_release..HEAD
def all_shas
@all_shas ||= Open3.popen3(*%w[
git rev-list --topo-order
],
"#{last_release}..#{end_object}"
) do |stdin, stdout, stderr|
stdout.each_line.map(&:chomp)
end
end
# not constrained
def tag_commits
@tag_commits ||= Open3.popen3(*%w[
git show-ref -s --tags --dereference
]) do |stdin, stdout, stderr|
stdout.each_line.collect do |line|
line.chomp!
if ! %r{\^\{\}\Z}.match(line)
nil
elsif %r{\A(#{SHA_PAT}) }.match(line)
[$1, true]
else
raise "'#{line}' does not contain an SHA"
end
end.compact.to_h
end
end
# Collect merge commits separately because "git log" does not always
# return related merge commits when using a path constraint. There
# might be a clever way to do this in a single step already built into
# git. In any case (in the opinion of the author) git's default
# behavior is a bug.
#
# constrained to last_release..HEAD
def merge_commits
@merge_commits ||= Open3.popen3(*%w[
git rev-list --pretty=oneline --topo-order --min-parents=2 --max-parents=2 --parents
],
"#{last_release}..#{end_object}"
) do |stdin, stdout, stderr|
stdout.each_line.collect do |line|
line.chomp!
# intentionally limited to the simple case of two parents
if %r{\A(#{SHA_PAT}) (#{SHA_PAT}) (#{SHA_PAT}) (.*)}.match(line)
[$1, {
:trunk_parent => $2,
:branch_parent => $3,
:log => $4,
}]
else
raise "could not parse '#{line}'"
end
end.compact.to_h
end
end
# constrained to last_release..HEAD
# also constrained to CODE_PATHS
def ordinary_code_commits
@ordinary_code_commits ||= Open3.popen3(*%w[
git rev-list --pretty=oneline --topo-order --max-parents=1 --parents
],
"#{last_release}..#{end_object}",
'--', *CODE_PATHS
) do |stdin, stdout, stderr|
stdout.each_line.collect do |line|
line.chomp!
if %r{\A(#{SHA_PAT}) (#{SHA_PAT}) (.*)}.match(line)
[$1, {
:trunk_parent => $2,
:log => $3,
}]
else
raise "could not parse '#{line}'"
end
end.compact.to_h
end
end
###
### report/analysis methods
###
# todo: read the release date from the tag
def header
<<EOT
## #{next_release.sub(/^v/,'')}
* __Casks__
- N Casks added ...
- N total Casks
* __Features__
- none
* __Breaking Changes__
- none
* __Fixes__
- none
* __Internal Changes__
- none
* __Documentation__
- N doc commits since ...
* __Contributors__
- N new contributors since ...
- N total contributors
* __Release Date__
- YYYY-MM-DD HH:MM:SS UTC
EOT
end
def footer
@footer ||= Set.new
@footer.to_a.sort
end
def add_to_footer(line)
@footer ||= Set.new
@footer.add line
end
def seen(sha)
@seen ||= {}
if @seen[sha]
return true
else
@seen[sha] = true
return nil
end
end
def log_ordinary_commit(sha)
# indent ordinary commits
" - #{ordinary_code_commits[sha][:log]}"
end
def read_pull_request(sha)
branch_parent = merge_commits[sha][:branch_parent]
if ordinary_code_commits[branch_parent] and
%r{\AMerge pull request \#(\d+) from ([^\s/]+)}.match(merge_commits[sha][:log]) then
{
:num => $1,
:gh_user => MAINTAINERS.include?($2) ? '' : $2
}
end
end
def log_merge_commit(sha)
branch_parent = merge_commits[sha][:branch_parent]
pr = read_pull_request sha
if pr then
# munge a GitHub PR commit log entry into Markdown links
# plus log content from the first parent commit
log = "[##{pr[:num]}][]"
log.concat " #{ordinary_code_commits[branch_parent][:log]}"
add_to_footer "[##{pr[:num]}]: #{PROJECT_URL}/issues/#{pr[:num]}"
seen branch_parent
if pr[:gh_user].length > 0
log.concat " <3 [@#{pr[:gh_user]}][]"
add_to_footer "[@#{pr[:gh_user]}]: https://github.com/#{pr[:gh_user]}"
end
" - #{log}"
elsif ordinary_code_commits[branch_parent]
# non-PR merge, just pass the log msg unmodified
" - #{merge_commits[sha][:log]}"
else
# drop this merge, it does not relate to CODE_PATHS
end
end
def changelog
# follows topological order
all_shas.collect do |sha|
if tag_commits[sha]
nil
elsif seen sha
nil
elsif ordinary_code_commits[sha]
log_ordinary_commit sha
elsif merge_commits[sha]
log_merge_commit sha
end
end.compact
end
###
### main
###
# process args
if %r{\A-+h(?:elp)?}i.match(ARGV.first)
puts <<EOT
generate_changelog [ <release-tag> ]
Generate a rough-draft changelog in Markdown format for changes since
<release-tag>, which defaults to the most recent release.
The output is only a draft. Changelog items still need to be edited,
removed, and/or added.
All changelog items must also be moved to within one of the given
category sections.
EOT
exit
end
if ARGV.length
@last_release = ARGV.shift
end
# initialize
cd_to_project_root
verify_git_object last_release
warn_if_off_branch 'master'
# report
puts header
puts "\n"
puts changelog
puts "\n"
puts footer
puts "\n"