# experimental require "optparse" require "ostruct" require "time" require "net/smtp" require "socket" require "svn/info" class OptionParser class CannotCoexistOption < ParseError const_set(:Reason, 'cannot coexist option'.freeze) end end module Svn class CommitMailer KILO_SIZE = 1000 DEFAULT_MAX_SIZE = "100M" class << self def run(argv=nil) argv ||= ARGV repository_path, revision, to, options = parse(argv) to = [to, *options.to].compact mailer = CommitMailer.new(repository_path, revision, to) apply_options(mailer, options) mailer.run end def parse(argv) options = make_options parser = make_parser(options) argv = argv.dup parser.parse!(argv) repository_path, revision, to, *rest = argv [repository_path, revision, to, options] end def format_size(size) return "no limit" if size.nil? return "#{size}B" if size < KILO_SIZE size /= KILO_SIZE.to_f return "#{size}KB" if size < KILO_SIZE size /= KILO_SIZE.to_f return "#{size}MB" if size < KILO_SIZE size /= KILO_SIZE.to_f "#{size}GB" end private def apply_options(mailer, options) mailer.from = options.from mailer.from_domain = options.from_domain mailer.add_diff = options.add_diff mailer.max_size = options.max_size mailer.repository_uri = options.repository_uri mailer.rss_path = options.rss_path mailer.rss_uri = options.rss_uri mailer.multi_project = options.multi_project mailer.show_path = options.show_path mailer.trunk_path = options.trunk_path mailer.branches_path = options.branches_path mailer.tags_path = options.tags_path mailer.name = options.name mailer.use_utf7 = options.use_utf7 mailer.server = options.server mailer.port = options.port end def parse_size(size) case size when /\A(.+?)GB?\z/i Float($1) * KILO_SIZE ** 3 when /\A(.+?)MB?\z/i Float($1) * KILO_SIZE ** 2 when /\A(.+?)KB?\z/i Float($1) * KILO_SIZE when /\A(.+?)B?\z/i Float($1) else raise ArgumentError, "invalid size: #{size.inspect}" end end def make_options options = OpenStruct.new options.to = [] options.error_to = [] options.from = nil options.from_domain = nil options.add_diff = true options.max_size = parse_size(DEFAULT_MAX_SIZE) options.repository_uri = nil options.rss_path = nil options.rss_uri = nil options.multi_project = false options.show_path = false options.trunk_path = "trunk" options.branches_path = "branches" options.tags_path = "tags" options.name = nil options.use_utf7 = false options.server = "localhost" options.port = Net::SMTP.default_port options end def make_parser(options) OptionParser.new do |opts| opts.banner += " REPOSITORY_PATH REVISION TO" add_email_options(opts, options) add_input_options(opts, options) add_rss_options(opts, options) add_other_options(opts, options) opts.on_tail("--help", "Show this message") do puts opts exit! end end end def add_email_options(opts, options) opts.separator "" opts.separator "E-mail related options:" opts.on("-sSERVER", "--server=SERVER", "Use SERVER as SMTP server (#{options.server})") do |server| options.server = server end opts.on("-pPORT", "--port=PORT", Integer, "Use PORT as SMTP port (#{options.port})") do |port| options.port = port end opts.on("-tTO", "--to=TO", "Add TO to To: address") do |to| options.to << to unless to.nil? end opts.on("-eTO", "--error-to=TO", "Add TO to To: address when an error occurs") do |to| options.error_to << to unless to.nil? end opts.on("-fFROM", "--from=FROM", "Use FROM as from address") do |from| if options.from_domain raise OptionParser::CannotCoexistOption, "cannot coexist with --from-domain" end options.from = from end opts.on("--from-domain=DOMAIN", "Use author@DOMAIN as from address") do |domain| if options.from raise OptionParser::CannotCoexistOption, "cannot coexist with --from" end options.from_domain = domain end end def add_input_options(opts, options) opts.separator "" opts.separator "Output related options:" opts.on("--[no-]multi-project", "Treat as multi-project hosting repository") do |bool| options.multi_project = bool end opts.on("--name=NAME", "Use NAME as repository name") do |name| options.name = name end opts.on("--[no-]show-path", "Show commit target path") do |bool| options.show_path = bool end opts.on("--trunk-path=PATH", "Treat PATH as trunk path (#{options.trunk_path})") do |path| options.trunk_path = path end opts.on("--branches-path=PATH", "Treat PATH as branches path", "(#{options.branches_path})") do |path| options.branches_path = path end opts.on("--tags-path=PATH", "Treat PATH as tags path (#{options.tags_path})") do |path| options.tags_path = path end opts.on("-rURI", "--repository-uri=URI", "Use URI as URI of repository") do |uri| options.repository_uri = uri end opts.on("-n", "--no-diff", "Don't add diffs") do |diff| options.add_diff = false end opts.on("--max-size=SIZE", "Limit mail body size to SIZE", "G/GB/M/MB/K/KB/B units are available", "(#{format_size(options.max_size)})") do |max_size| begin options.max_size = parse_size(max_size) rescue ArgumentError raise OptionParser::InvalidArgument, max_size end end opts.on("--no-limit-size", "Don't limit mail body size", "(#{options.max_size.nil?})") do |not_limit_size| options.max_size = nil end opts.on("--[no-]utf7", "Use UTF-7 encoding for mail body instead", "of UTF-8 (#{options.use_utf7})") do |use_utf7| options.use_utf7 = use_utf7 end end def add_rss_options(opts, options) opts.separator "" opts.separator "RSS related options:" 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 end def add_other_options(opts, options) opts.separator "" opts.separator "Other options:" return opts.on("-IPATH", "--include=PATH", "Add PATH to load path") do |path| $LOAD_PATH.unshift(path) end end end attr_reader :to attr_writer :from, :add_diff, :multi_project, :show_path, :use_utf7 attr_accessor :from_domain, :max_size, :repository_uri attr_accessor :rss_path, :rss_uri, :trunk_path, :branches_path attr_accessor :tags_path, :name, :server, :port def initialize(repository_path, revision, to) @info = Svn::Info.new(repository_path, revision) @to = to end def from @from || "#{@info.author}@#{@from_domain}".sub(/@\z/, '') end def run send_mail(make_mail) output_rss end def use_utf7? @use_utf7 end def add_diff? @add_diff end def multi_project? @multi_project end def show_path? @show_path end private def extract_email_address(address) if /<(.+?)>/ =~ address $1 else address end end def send_mail(mail) _from = extract_email_address(from) to = @to.collect {|address| extract_email_address(address)} Net::SMTP.start(@server || "localhost", @port) do |smtp| smtp.open_message_stream(_from, to) do |f| f.print(mail) end end end def output_rss return unless rss_output_available? prev_rss = nil begin if File.exist?(@rss_path) File.open(@rss_path) do |f| prev_rss = RSS::Parser.parse(f) end end rescue RSS::Error end rss = make_rss(prev_rss).to_s File.open(@rss_path, "w") do |f| f.print(rss) end end def rss_output_available? if @repository_uri and @rss_path and @rss_uri begin require 'rss' true rescue LoadError false end else false end end def make_mail utf8_body = make_body utf7_body = nil utf7_body = utf8_to_utf7(utf8_body) if use_utf7? if utf7_body body = utf7_body encoding = "utf-7" bit = "7bit" else body = utf8_body encoding = "utf-8" bit = "8bit" end unless @max_size.nil? body = truncate_body(body, !utf7_body.nil?) end make_header(encoding, bit) + "\n" + body end def make_body body = "" body << "#{@info.author}\t#{format_time(@info.date)}\n" body << "\n" body << " New Revision: #{@info.revision}\n" body << "\n" body << added_dirs body << added_files body << copied_dirs body << copied_files body << deleted_dirs body << deleted_files body << modified_dirs body << modified_files body << "\n" body << " Log:\n" @info.log.each_line do |line| body << " #{line}" end body << "\n" body << change_info 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 changed_files("Added", @info.added_files) end def deleted_files changed_files("Removed", @info.deleted_files) end def modified_files changed_files("Modified", @info.updated_files) end def copied_files 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 changed_dirs("Added", @info.added_dirs) end def deleted_dirs changed_dirs("Removed", @info.deleted_dirs) end def modified_dirs changed_dirs("Modified", @info.updated_dirs) end def copied_dirs 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 result = changed_dirs_info result = "\n#{result}" unless result.empty? result << "\n" diff_info.each do |key, infos| infos.each do |desc, link| result << "#{desc}\n" end end result end def changed_dirs_info 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 #{[@repository_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.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 = [@repository_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(body_encoding, body_encoding_bit) headers = [] headers << x_author headers << x_revision headers << x_repository headers << x_id headers << "MIME-Version: 1.0" headers << "Content-Type: text/plain; charset=#{body_encoding}" headers << "Content-Transfer-Encoding: #{body_encoding_bit}" headers << "From: #{from}" headers << "To: #{to.join(', ')}" headers << "Subject: #{make_subject}" headers << "Date: #{Time.now.rfc2822}" headers.find_all do |header| /\A\s*\z/ !~ header end.join("\n") end def detect_project return nil unless multi_project? project = nil @info.paths.each do |path, from_path,| [path, from_path].compact.each do |target_path| first_component = target_path.split("/", 2)[0] project ||= first_component return nil if project != first_component end end project end def affected_paths(project) paths = [] [nil, :branches_path, :tags_path].each do |target| prefix = [project] prefix << send(target) if target prefix = prefix.compact.join("/") sub_paths = @info.sub_paths(prefix) if target.nil? sub_paths = sub_paths.find_all do |sub_path| sub_path == trunk_path end end paths.concat(sub_paths) end paths.uniq end def make_subject subject = "" project = detect_project subject << "#{@name} " if @name revision_info = "r#{@info.revision}" if show_path? _affected_paths = affected_paths(project) unless _affected_paths.empty? revision_info = "(#{_affected_paths.join(',')}) #{revision_info}" end end if project subject << "[#{project} #{revision_info}] " else subject << "#{revision_info}: " end subject << @info.log.lstrip.to_a.first.to_s.chomp NKF.nkf("-WM", subject) end def x_author "X-SVN-Author: #{@info.author}" end def x_revision "X-SVN-Revision: #{@info.revision}" end def x_repository # "X-SVN-Repository: #{@info.path}" "X-SVN-Repository: XXX" end def x_id "X-SVN-Commit-Id: #{@info.entire_sha256}" end def utf8_to_utf7(utf8) require 'iconv' Iconv.conv("UTF-7", "UTF-8", utf8) rescue InvalidEncoding begin Iconv.conv("UTF7", "UTF8", utf8) rescue Exception nil end rescue Exception nil end def truncate_body(body, use_utf7) return body if body.size < @max_size truncated_body = body[0, @max_size] formatted_size = self.class.format_size(@max_size) truncated_message = "... truncated to #{formatted_size}\n" truncated_message = utf8_to_utf7(truncated_message) if use_utf7 truncated_message_size = truncated_message.size lf_index = truncated_body.rindex(/(?:\r|\r\n|\n)/) while lf_index if lf_index + truncated_message_size < @max_size truncated_body[lf_index, @max_size] = "\n#{truncated_message}" break else lf_index = truncated_body.rindex(/(?:\r|\r\n|\n)/, lf_index - 1) end end truncated_body end def make_rss(base_rss) RSS::Maker.make("1.0") do |maker| maker.encoding = "UTF-8" maker.channel.about = @rss_uri maker.channel.title = rss_title(@name || @repository_uri) maker.channel.link = @repository_uri maker.channel.description = rss_title(@name || @repository_uri) maker.channel.dc_date = @info.date if base_rss base_rss.items.each do |item| item.setup_maker(maker) end end diff_info.each do |name, infos| infos.each do |desc, link| item = maker.items.new_item item.title = name item.description = @info.log item.content_encoded = "
#{RSS::Utils.html_escape(desc)}"
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
end
end