#!/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"
