commit-email.rb   [plain text]


#!/usr/bin/env ruby

require "optparse"
require "ostruct"
require "stringio"
require "tempfile"

SENDMAIL = "/usr/sbin/sendmail"

def parse(args)
  options = OpenStruct.new
  options.to = []
  options.error_to = []
  options.from = nil
  options.add_diff = true
  options.repository_uri = nil
  options.rss_path = nil
  options.rss_uri = nil
  options.name = nil

  opts = OptionParser.new do |opts|
    opts.separator ""

    opts.on("-I", "--include [PATH]",
            "Add [PATH] to load path") do |path|
      $LOAD_PATH.unshift(path)
    end
    
    opts.on("-t", "--to [TO]",
            "Add [TO] to to address") do |to|
      options.to << to unless to.nil?
    end
    
    opts.on("-e", "--error-to [TO]",
            "Add [TO] to to address when error is occurred") do |to|
      options.error_to << to unless to.nil?
    end
    
    opts.on("-f", "--from [FROM]",
            "Use [FROM] as from address") do |from|
      options.from = from
    end
    
    opts.on("-n", "--no-diff",
            "Don't add diffs") do |from|
      options.add_diff = false
    end
    
    opts.on("-r", "--repository-uri [URI]",
            "Use [URI] as URI of repository") do |uri|
      options.repository_uri = uri
    end
    
    opts.on("--rss-path [PATH]",
            "Use [PATH] as output RSS path") do |path|
      options.rss_path = path
    end
    
    opts.on("--rss-uri [URI]",
            "Use [URI] as output RSS URI") do |uri|
      options.rss_uri = uri
    end
    
    opts.on("--name [NAME]",
            "Use [NAME] as repository name") do |name|
      options.name = name
    end
    
    opts.on_tail("--help", "Show this message") do
      puts opts
      exit!
    end
  end

  opts.parse!(args)

  options
end

def make_body(info, params)
  body = ""
  body << "#{info.author}\t#{format_time(info.date)}\n"
  body << "\n"
  body << "  New Revision: #{info.revision}\n"
  body << "\n"
  body << added_dirs(info)
  body << added_files(info)
  body << copied_dirs(info)
  body << copied_files(info)
  body << deleted_dirs(info)
  body << deleted_files(info)
  body << modified_dirs(info)
  body << modified_files(info)
  body << "\n"
  body << "  Log:\n"
  info.log.each_line do |line|
    body << "    #{line}"
  end
  body << "\n"
  body << change_info(info, params[:repository_uri], params[:add_diff])
  body
end

def format_time(time)
  time.strftime('%Y-%m-%d %X %z (%a, %d %b %Y)')
end

def changed_items(title, type, items)
  rv = ""
  unless items.empty?
    rv << "  #{title} #{type}:\n"
    if block_given?
      yield(rv, items)
    else
      rv << items.collect {|item| "    #{item}\n"}.join('')
    end
  end
  rv
end

def changed_files(title, files, &block)
  changed_items(title, "files", files, &block)
end

def added_files(info)
  changed_files("Added", info.added_files)
end

def deleted_files(info)
  changed_files("Removed", info.deleted_files)
end

def modified_files(info)
  changed_files("Modified", info.updated_files)
end

def copied_files(info)
  changed_files("Copied", info.copied_files) do |rv, files|
    rv << files.collect do |file, from_file, from_rev|
      <<-INFO
    #{file}
      (from rev #{from_rev}, #{from_file})
INFO
    end.join("")
  end
end

def changed_dirs(title, files, &block)
  changed_items(title, "directories", files, &block)
end

def added_dirs(info)
  changed_dirs("Added", info.added_dirs)
end

def deleted_dirs(info)
  changed_dirs("Removed", info.deleted_dirs)
end

def modified_dirs(info)
  changed_dirs("Modified", info.updated_dirs)
end

def copied_dirs(info)
  changed_dirs("Copied", info.copied_dirs) do |rv, dirs|
    rv << dirs.collect do |dir, from_dir, from_rev|
      "    #{dir} (from rev #{from_rev}, #{from_dir})\n"
    end.join("")
  end
end


CHANGED_TYPE = {
  :added => "Added",
  :modified => "Modified",
  :deleted => "Deleted",
  :copied => "Copied",
  :property_changed => "Property changed",
}

CHANGED_MARK = Hash.new("=")
CHANGED_MARK[:property_changed] = "_"

def change_info(info, uri, add_diff)
  result = changed_dirs_info(info, uri)
  result = "\n#{result}" unless result.empty?
  result << "\n"
  diff_info(info, uri, add_diff).each do |key, infos|
    infos.each do |desc, link|
      result << "#{desc}\n"
    end
  end
  result
end

def changed_dirs_info(info, uri)
  rev = info.revision
  (info.added_dirs.collect do |dir|
     "  Added: #{dir}\n"
   end + info.copied_dirs.collect do |dir, from_dir, from_rev|
     <<-INFO
  Copied: #{dir}
    (from rev #{from_rev}, #{from_dir})
INFO
   end + info.deleted_dirs.collect do |dir|
     <<-INFO
  Deleted: #{dir}
    % svn ls #{[uri, dir].compact.join("/")}@#{rev - 1}
INFO
   end + info.updated_dirs.collect do |dir|
     "  Modified: #{dir}\n"
   end).join("\n")
end

def diff_info(info, uri, add_diff)
  info.diffs.collect do |key, values|
    [
      key,
      values.collect do |type, value|
        args = []
        rev = info.revision
        case type
        when :added
          command = "cat"
        when :modified, :property_changed
          command = "diff"
          args.concat(["-r", "#{info.revision - 1}:#{info.revision}"])
        when :deleted
          command = "cat"
          rev -= 1
        when :copied
          command = "cat"
        else
          raise "unknown diff type: #{value.type}"
        end

        command += " #{args.join(' ')}" unless args.empty?

        link = [uri, key].compact.join("/")

        line_info = "+#{value.added_line} -#{value.deleted_line}"
        desc = <<-HEADER
  #{CHANGED_TYPE[value.type]}: #{key} (#{line_info})
#{CHANGED_MARK[value.type] * 67}
HEADER

        if add_diff
          desc << value.body
        else
          desc << <<-CONTENT
    % svn #{command} #{link}@#{rev}
CONTENT
        end
      
        [desc, link]
      end
    ]
  end
end

def make_header(to, from, info, params)
  headers = []
  headers << x_author(info)
  headers << x_repository(info)
  headers << x_id(info)
  headers << x_sha256(info)
  headers << "Content-Type: text/plain; charset=UTF-8"
  headers << "Content-Transfer-Encoding: 8bit"
  headers << "From: #{from}"
  headers << "To: #{to.join(' ')}"
  headers << "Subject: #{make_subject(params[:name], info)}"
  headers.find_all do |header|
    /\A\s*\z/ !~ header
  end.join("\n")
end

def make_subject(name, info)
  subject = ""
  subject << "#{name}:" if name
  subject << "r#{info.revision}: "
  subject << info.log.lstrip.to_a.first.to_s.chomp
  NKF.nkf("-WM", subject)
end

def x_author(info)
  "X-SVN-Author: #{info.author}"
end

def x_repository(info)
  # "X-SVN-Repository: #{info.path}"
  "X-SVN-Repository: XXX"
end

def x_id(info)
  "X-SVN-Commit-Id: #{info.entire_sha256}"
end

def x_sha256(info)
  info.sha256.collect do |name, inf|
    "X-SVN-SHA256-Info: #{name}, #{inf[:revision]}, #{inf[:sha256]}"
  end.join("\n")
end

def make_mail(to, from, info, params)
  make_header(to, from, info, params) + "\n" + make_body(info, params)
end

def sendmail(to, from, mail)
  args = to.collect {|address| address.dump}.join(' ')
  open("| #{SENDMAIL} #{args}", "w") do |f|
    f.print(mail)
  end
end

def output_rss(name, file, rss_uri, repos_uri, info)
  prev_rss = nil
  begin
    if File.exist?(file)
      File.open(file) do |f|
        prev_rss = RSS::Parser.parse(f)
      end
    end
  rescue RSS::Error
  end

  File.open(file, "w") do |f|
    f.print(make_rss(prev_rss, name, rss_uri, repos_uri, info).to_s)
  end
end

def make_rss(base_rss, name, rss_uri, repos_uri, info)
  RSS::Maker.make("1.0") do |maker|
    maker.encoding = "UTF-8"

    maker.channel.about = rss_uri
    maker.channel.title = rss_title(name || repos_uri)
    maker.channel.link = repos_uri
    maker.channel.description = rss_title(name || repos_uri)
    maker.channel.dc_date = info.date

    if base_rss
      base_rss.items.each do |item|
        item.setup_maker(maker) 
      end
    end
    
    diff_info(info, repos_uri, true).each do |name, infos|
      infos.each do |desc, link|
        item = maker.items.new_item
        item.title = name
        item.description = info.log
        item.content_encoded = "<pre>#{h(desc)}</pre>"
        item.link = link
        item.dc_date = info.date
        item.dc_creator = info.author
      end
    end

    maker.items.do_sort = true
    maker.items.max_size = 15
  end
end

def rss_title(name)
  "Repository of #{name}"
end

def rss_items(items, info, repos_uri)
  diff_info(info, repos_uri).each do |name, infos|
    infos.each do |desc, link|
      items << [link, name, desc, info.date]
    end
  end
  
  items.sort_by do |uri, title, desc, date|
    date
  end.reverse
end

def main
  if ARGV.find {|arg| arg == "--help"}
    parse(ARGV)
  else
    repos, revision, to, *rest = ARGV
    options = parse(rest)
  end
  
  require "svn/info"
  info = Svn::Info.new(repos, revision)
  from = options.from || info.author
  to = [to, *options.to]
  params = {
    :repository_uri => options.repository_uri,
    :name => options.name,
    :add_diff => options.add_diff,
  }
  sendmail(to, from, make_mail(to, from, info, params))

  if options.repository_uri and
      options.rss_path and
      options.rss_uri
    require "rss/1.0"
    require "rss/dublincore"
    require "rss/content"
    require "rss/maker"
    include RSS::Utils
    output_rss(options.name,
               options.rss_path,
               options.rss_uri,
               options.repository_uri,
               info)
  end
end

begin
  main
rescue Exception
  _, _, to, *rest = ARGV
  to = [to]
  from = ENV["USER"]
  begin
    options = parse(rest)
    to = options.error_to unless options.error_to.empty?
    from = options.from
  rescue Exception
  end
  sendmail(to, from, <<-MAIL)
From: #{from}
To: #{to.join(', ')}
Subject: Error

#{$!.class}: #{$!.message}
#{$@.join("\n")}
MAIL
end