| # 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 << " Log:\n" |
| @info.log.rstrip.each_line do |line| |
| body << " #{line}" |
| end |
| 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 << 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 = "<pre>#{RSS::Utils.html_escape(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 |
| end |
| end |