369 lines
8.4 KiB
Ruby
Executable File
369 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
|
|
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"
|