#! /usr/bin/ruby -w
#
# Copyright (C) 2005 Yoshinori K. Okuji <okuji@enbug.org>
#
# This script is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This script is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with cvsdigest; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA

require 'getoptlong'
require 'time'
require 'socket'

PROGNAME = 'cvsdigest_send'
PROGVER = '1.2'

# XXX hardcoded
SIGNATURE = <<EOS
Generated by cvsdigest #{PROGVER} http://www.nongnu.org/cvsdigest/
EOS

def usage(status = 0)
  print <<EOS
Usage: #{PROGNAME} [OPTION...] ADDR
Send log information from Maildir-like format.

  -h, --help           print this message and exit
  -m, --mailer=PROG    use the mail transfer agent PROG instead of
                         "/usr/lib/sendmail"
  -f, --from=FROM      specify FROM as the From: address
  -r, --reply-to=ADDR  specify ADDR as the Reply-To: address
  -u, --url=URL        specify the cvsweb url URL
  -d, --directory=DIR  use the directory DIR instead of "/var/lib/loginfo"
  -p, --project=NAME   use the project NAME instead of "default"

ADDR is the destination mail address for the delivery of a notification
message.

Report bugs to <okuji@enbug.org>.
EOS
  exit(status)
end

def parse()
  opts = GetoptLong.new(
    [ "--help",      "-h",    GetoptLong::NO_ARGUMENT ],
    [ "--mailer",    "-m",    GetoptLong::REQUIRED_ARGUMENT ],
    [ "--from",      "-f",    GetoptLong::REQUIRED_ARGUMENT ],
    [ "--reply-to",  "-r",    GetoptLong::REQUIRED_ARGUMENT ],
    [ "--url",       "-u",    GetoptLong::REQUIRED_ARGUMENT ],
    [ "--directory", "-d",    GetoptLong::REQUIRED_ARGUMENT ],
    [ "--project",   "-p",    GetoptLong::REQUIRED_ARGUMENT ]
  )
  opts.quiet = true

  project = 'default'
  directory = '/var/lib/cvsdigest'
  mailer = '/usr/lib/sendmail'
  from = 'nobody@' + Socket.gethostname
  reply_to = nil
  url = nil

  begin
    opts.each do |opt, arg|
      case opt
      when '--help'
        usage(0)
      when '--project'
        project = arg
      when '--directory'
        directory = arg
      when '--mailer'
        mailer = arg
      when '--from'
        from = arg
      when '--reply-to'
        reply_to = arg
      when '--url'
        url = arg
      end
    end
  rescue
    STDERR.puts('#{PROGNAME}: ' + opts.error_message)
    STDERR.puts('Try `#{PROGNAME} --help` for more information.')
    exit 1
  end

  if ARGV.size == 0
    STDERR.puts('#{PROGNAME}: no argument specified')
    STDERR.puts('Try `#{PROGNAME} --help` for more information.')
    exit 1
  elsif ARGV.size > 1
    STDERR.puts('#{PROGNAME}: too many arguments specified')
    STDERR.puts('Try `#{PROGNAME} --help` for more information.')
    exit 1
  end

  if reply_to.nil?
    reply_to = ARGV[0]
  end

  yield directory, project, mailer, from, reply_to, ARGV[0], url
end

def rename_path(path)
  parts = path.split(File::Separator)
  parts[-2] = 'old'
  new_path = File.join(parts)
  File.rename(path, new_path)
end

def log_files(directory, project)
  base = File.join(directory, project, 'new')
  Dir.open(base) do |d|
    return d.select {|entry|
      /^\./ !~ entry
    }.collect {|entry|
      File.join(base, entry)
    }.select {|path|
      File.file?(path)
    }
  end 
end

class Log

  class Change
    def initialize()
      @old_revision = nil
      @new_revision = nil
      @path = nil
    end
    attr_accessor :old_revision, :new_revision, :path
  end

  def initialize()
    @user = nil
    @time = nil
    @module = nil
    @branch = nil
    @changes = []
    @message = ''
  end
  attr_accessor :user, :time, :module, :branch, :changes, :message

  def similar?(log)
    # heuristic
    if @user != log.user or @module != log.module or @branch != log.branch
      return false
    end
    if @message != log.message
      return false
    end
    paths1 = @changes.collect {|c| c.path}
    paths2 = log.changes.collect {|c| c.path}
    if (paths1 & paths2).size > 0
      return false
    end
    if (Time.parse(@time) - Time.parse(log.time)).abs > 60
      return false
    end
    true
  end

  def +(log)
    new_log = Log.new
    new_log.user = @user
    new_log.time = @time
    new_log.module = @module
    new_log.branch = @branch
    new_log.changes = @changes + log.changes
    new_log.message = @message
    new_log
  end

end

def scan(file)
  log = Log.new

  log.user = file.gets().chomp()
  log.time = file.gets().chomp()

  directory, *tokens = file.gets().chomp().split(/ /)

  index = directory.index(File::Separator)
  if index.nil?
    log.module = directory
    directory = ''
  else
    log.module = directory[0...index]
    directory = directory[index+1..-1] + '/'
  end

  if tokens[0] != '!'
    tokens.each do |token|
      change = Log::Change.new
      path, old_revision, new_revision = token.split(/,/)
      change.path = directory + path
      change.old_revision = old_revision if old_revision != 'NONE'
      change.new_revision = new_revision if new_revision != 'NONE'
      log.changes << change
    end
    log.changes.sort! {|a, b| a.path <=> b.path}
  end

  in_log_message = false
  message = ''
  file.each do |line|
    if in_log_message
      message << line.strip << "\n"
    else
      if /^\s*Tag:\s*(\S+)/ =~ line
        log.branch = $1
      elsif /^Log Message:/ =~ line
        in_log_message = true
      end
    end
  end
  log.message = message.strip
  log
end

def merge_logs(logs)
  # Very heuristic.
  new_logs = []
  current = logs.shift
  logs.each do |log|
    if current.similar? log
      current += log
    else
      new_logs << current
      current = log
    end
  end
  new_logs << current
end

def send_mail(logs, mailer, from, reply_to, to, url)
  IO.popen("#{mailer} -t", 'w') do |io|
    io << 'From: cvsdigest <' << from << ">\n"
    io << 'To: ' << to << "\n"
    io << 'Reply-To: ' << reply_to << "\n"
    io << 'Subject: '
    io << logs.collect {|log| log.module}.sort.uniq.join(', ')
    io << "\n"
    io << "\n" # The end of the header

    logs.each do |log|
      io << "User:   #{log.user}\n"
      io << "Date:   #{log.time}\n"
      io << "Module: #{log.module}\n"
      unless log.branch.nil?
        io << "Branch: #{log.branch}"
      end
      io << "\n"
      log.message.split(/\n/).each do |line|
        io << '    ' << line << "\n"
      end
      io << "\n"
      log.changes.each do |change|
        if change.old_revision.nil?
          status = 'A'
        elsif change.new_revision.nil?
          status = 'R'
        else
          status = 'M'
        end
        io << "    #{status} " << change.path << "\n"
        unless url.nil? 
          io << "      #{url}#{log.module}/#{change.path}"
          case status
          when 'A'
            io << "?rev=#{change.new_revision}&content-type=text/vnd.viewcvs-markup\n"
          when 'R'
            io << "?rev=#{change.old_revision}&content-type=text/vnd.viewcvs-markup\n"
          when 'M'
            io << ".diff?r1=#{change.old_revision}&r2=#{change.new_revision}\n"
          end
        end
      end
      io << "\n"
    end

    io << "--\n"
    io << SIGNATURE
  end
end

parse() do |directory, project, mailer, from, reply_to, to, url|
  paths = log_files(directory, project)
  return if paths.empty?
  paths.sort!

  logs = paths.collect do |path|
    File.open(path, 'r') do |file|
      scan(file)
    end
  end

  logs = merge_logs(logs)
  send_mail(logs, mailer, from, reply_to, to, url)

  paths.each do |path|
    rename_path(path)
  end
end
