#!/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 < $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 < ] Generate a rough-draft changelog in Markdown format for changes since , 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"