12 class CannotCoexistOption < ParseError
13 const_set(:Reason, 'cannot coexist option'.freeze)
20 DEFAULT_MAX_SIZE = "100M"
25 repository_path, revision, to, options = parse(argv)
26 to = [to, *options.to].compact
27 mailer = CommitMailer.new(repository_path, revision, to)
28 apply_options(mailer, options)
33 options = make_options
35 parser = make_parser(options)
38 repository_path, revision, to, *rest = argv
40 [repository_path, revision, to, options]
44 return "no limit" if size.nil?
45 return "#{size}B" if size < KILO_SIZE
46 size /= KILO_SIZE.to_f
47 return "#{size}KB" if size < KILO_SIZE
48 size /= KILO_SIZE.to_f
49 return "#{size}MB" if size < KILO_SIZE
50 size /= KILO_SIZE.to_f
55 def apply_options(mailer, options)
56 mailer.from = options.from
57 mailer.from_domain = options.from_domain
58 mailer.add_diff = options.add_diff
59 mailer.max_size = options.max_size
60 mailer.repository_uri = options.repository_uri
61 mailer.rss_path = options.rss_path
62 mailer.rss_uri = options.rss_uri
63 mailer.multi_project = options.multi_project
64 mailer.show_path = options.show_path
65 mailer.trunk_path = options.trunk_path
66 mailer.branches_path = options.branches_path
67 mailer.tags_path = options.tags_path
68 mailer.name = options.name
69 mailer.use_utf7 = options.use_utf7
70 mailer.server = options.server
71 mailer.port = options.port
77 Float($1) * KILO_SIZE ** 3
79 Float($1) * KILO_SIZE ** 2
85 raise ArgumentError, "invalid size: #{size.inspect}"
90 options = OpenStruct.new
94 options.from_domain = nil
95 options.add_diff = true
96 options.max_size = parse_size(DEFAULT_MAX_SIZE)
97 options.repository_uri = nil
98 options.rss_path = nil
100 options.multi_project = false
101 options.show_path = false
102 options.trunk_path = "trunk"
103 options.branches_path = "branches"
104 options.tags_path = "tags"
106 options.use_utf7 = false
107 options.server = "localhost"
108 options.port = Net::SMTP.default_port
112 def make_parser(options)
113 OptionParser.new do |opts|
114 opts.banner += " REPOSITORY_PATH REVISION TO"
116 add_email_options(opts, options)
117 add_input_options(opts, options)
118 add_rss_options(opts, options)
119 add_other_options(opts, options)
121 opts.on_tail("--help", "Show this message") do
128 def add_email_options(opts, options)
130 opts.separator "E-mail related options:"
132 opts.on("-sSERVER", "--server=SERVER",
133 "Use SERVER as SMTP server (#{options.server})") do |server|
134 options.server = server
137 opts.on("-pPORT", "--port=PORT", Integer,
138 "Use PORT as SMTP port (#{options.port})") do |port|
142 opts.on("-tTO", "--to=TO", "Add TO to To: address") do |to|
143 options.to << to unless to.nil?
146 opts.on("-eTO", "--error-to=TO",
147 "Add TO to To: address when an error occurs") do |to|
148 options.error_to << to unless to.nil?
151 opts.on("-fFROM", "--from=FROM", "Use FROM as from address") do |from|
152 if options.from_domain
153 raise OptionParser::CannotCoexistOption,
154 "cannot coexist with --from-domain"
159 opts.on("--from-domain=DOMAIN",
160 "Use author@DOMAIN as from address") do |domain|
162 raise OptionParser::CannotCoexistOption,
163 "cannot coexist with --from"
165 options.from_domain = domain
169 def add_input_options(opts, options)
171 opts.separator "Output related options:"
173 opts.on("--[no-]multi-project",
174 "Treat as multi-project hosting repository") do |bool|
175 options.multi_project = bool
178 opts.on("--name=NAME", "Use NAME as repository name") do |name|
182 opts.on("--[no-]show-path",
183 "Show commit target path") do |bool|
184 options.show_path = bool
187 opts.on("--trunk-path=PATH",
188 "Treat PATH as trunk path (#{options.trunk_path})") do |path|
189 options.trunk_path = path
192 opts.on("--branches-path=PATH",
193 "Treat PATH as branches path",
194 "(#{options.branches_path})") do |path|
195 options.branches_path = path
198 opts.on("--tags-path=PATH",
199 "Treat PATH as tags path (#{options.tags_path})") do |path|
200 options.tags_path = path
203 opts.on("-rURI", "--repository-uri=URI",
204 "Use URI as URI of repository") do |uri|
205 options.repository_uri = uri
208 opts.on("-n", "--no-diff", "Don't add diffs") do |diff|
209 options.add_diff = false
212 opts.on("--max-size=SIZE",
213 "Limit mail body size to SIZE",
214 "G/GB/M/MB/K/KB/B units are available",
215 "(#{format_size(options.max_size)})") do |max_size|
217 options.max_size = parse_size(max_size)
219 raise OptionParser::InvalidArgument, max_size
223 opts.on("--no-limit-size",
224 "Don't limit mail body size",
225 "(#{options.max_size.nil?})") do |not_limit_size|
226 options.max_size = nil
229 opts.on("--[no-]utf7",
230 "Use UTF-7 encoding for mail body instead",
231 "of UTF-8 (#{options.use_utf7})") do |use_utf7|
232 options.use_utf7 = use_utf7
236 def add_rss_options(opts, options)
238 opts.separator "RSS related options:"
240 opts.on("--rss-path=PATH", "Use PATH as output RSS path") do |path|
241 options.rss_path = path
244 opts.on("--rss-uri=URI", "Use URI as output RSS URI") do |uri|
245 options.rss_uri = uri
249 def add_other_options(opts, options)
251 opts.separator "Other options:"
254 opts.on("-IPATH", "--include=PATH", "Add PATH to load path") do |path|
255 $LOAD_PATH.unshift(path)
261 attr_writer :from, :add_diff, :multi_project, :show_path, :use_utf7
262 attr_accessor :from_domain, :max_size, :repository_uri
263 attr_accessor :rss_path, :rss_uri, :trunk_path, :branches_path
264 attr_accessor :tags_path, :name, :server, :port
266 def initialize(repository_path, revision, to)
267 @info = Svn::Info.new(repository_path, revision)
272 @from || "#{@info.author}@#{@from_domain}".sub(/@\z/, '')
297 def extract_email_address(address)
298 if /<(.+?)>/ =~ address
306 _from = extract_email_address(from)
307 to = @to.collect {|address| extract_email_address(address)}
308 Net::SMTP.start(@server || "localhost", @port) do |smtp|
309 smtp.open_message_stream(_from, to) do |f|
316 return unless rss_output_available?
319 if File.exist?(@rss_path)
320 File.open(@rss_path) do |f|
321 prev_rss = RSS::Parser.parse(f)
327 rss = make_rss(prev_rss).to_s
328 File.open(@rss_path, "w") do |f|
333 def rss_output_available?
334 if @repository_uri and @rss_path and @rss_uri
347 utf8_body = make_body
349 utf7_body = utf8_to_utf7(utf8_body) if use_utf7?
360 unless @max_size.nil?
361 body = truncate_body(body, !utf7_body.nil?)
364 make_header(encoding, bit) + "\n" + body
369 body << "#{@info.author}\t#{format_time(@info.date)}\n"
371 body << " New Revision: #{@info.revision}\n"
378 body << deleted_files
379 body << modified_dirs
380 body << modified_files
383 @info.log.each_line do |line|
391 def format_time(time)
392 time.strftime('%Y-%m-%d %X %z (%a, %d %b %Y)')
395 def changed_items(title, type, items)
398 rv << " #{title} #{type}:\n"
402 rv << items.collect {|item| " #{item}\n"}.join('')
408 def changed_files(title, files, &block)
409 changed_items(title, "files", files, &block)
413 changed_files("Added", @info.added_files)
417 changed_files("Removed", @info.deleted_files)
421 changed_files("Modified", @info.updated_files)
425 changed_files("Copied", @info.copied_files) do |rv, files|
426 rv << files.collect do |file, from_file, from_rev|
429 (from rev #{from_rev}, #{from_file})
435 def changed_dirs(title, files, &block)
436 changed_items(title, "directories", files, &block)
440 changed_dirs("Added", @info.added_dirs)
444 changed_dirs("Removed", @info.deleted_dirs)
448 changed_dirs("Modified", @info.updated_dirs)
452 changed_dirs("Copied", @info.copied_dirs) do |rv, dirs|
453 rv << dirs.collect do |dir, from_dir, from_rev|
454 " #{dir} (from rev #{from_rev}, #{from_dir})\n"
462 :modified => "Modified",
463 :deleted => "Deleted",
465 :property_changed => "Property changed",
468 CHANGED_MARK = Hash.new("=")
469 CHANGED_MARK[:property_changed] = "_"
472 result = changed_dirs_info
473 result = "\n#{result}" unless result.empty?
475 diff_info.each do |key, infos|
476 infos.each do |desc, link|
477 result << "#{desc}\n"
483 def changed_dirs_info
485 (@info.added_dirs.collect do |dir|
487 end + @info.copied_dirs.collect do |dir, from_dir, from_rev|
490 (from rev #{from_rev}, #{from_dir})
492 end + @info.deleted_dirs.collect do |dir|
495 % svn ls #{[@repository_uri, dir].compact.join("/")}@#{rev - 1}
497 end + @info.updated_dirs.collect do |dir|
498 " Modified: #{dir}\n"
503 @info.diffs.collect do |key, values|
506 values.collect do |type, value|
512 when :modified, :property_changed
514 args.concat(["-r", "#{@info.revision - 1}:#{@info.revision}"])
521 raise "unknown diff type: #{value.type}"
524 command += " #{args.join(' ')}" unless args.empty?
526 link = [@repository_uri, key].compact.join("/")
528 line_info = "+#{value.added_line} -#{value.deleted_line}"
530 #{CHANGED_TYPE[value.type]}: #{key} (#{line_info})
531 #{CHANGED_MARK[value.type] * 67}
538 % svn #{command} #{link}@#{rev}
548 def make_header(body_encoding, body_encoding_bit)
551 headers << x_revision
552 headers << x_repository
554 headers << "MIME-Version: 1.0"
555 headers << "Content-Type: text/plain; charset=#{body_encoding}"
556 headers << "Content-Transfer-Encoding: #{body_encoding_bit}"
557 headers << "From: #{from}"
558 headers << "To: #{to.join(', ')}"
559 headers << "Subject: #{make_subject}"
560 headers << "Date: #{Time.now.rfc2822}"
561 headers.find_all do |header|
567 return nil unless multi_project?
569 @info.paths.each do |path, from_path,|
570 [path, from_path].compact.each do |target_path|
571 first_component = target_path.split("/", 2)[0]
572 project ||= first_component
573 return nil if project != first_component
579 def affected_paths(project)
581 [nil, :branches_path, :tags_path].each do |target|
583 prefix << send(target) if target
584 prefix = prefix.compact.join("/")
585 sub_paths = @info.sub_paths(prefix)
587 sub_paths = sub_paths.find_all do |sub_path|
588 sub_path == trunk_path
591 paths.concat(sub_paths)
598 project = detect_project
599 subject << "#{@name} " if @name
600 revision_info = "r#{@info.revision}"
602 _affected_paths = affected_paths(project)
603 unless _affected_paths.empty?
604 revision_info = "(#{_affected_paths.join(',')}) #{revision_info}"
608 subject << "[#{project} #{revision_info}] "
610 subject << "#{revision_info}: "
612 subject << @info.log.lstrip.to_a.first.to_s.chomp
613 NKF.nkf("-WM", subject)
617 "X-SVN-Author: #{@info.author}"
621 "X-SVN-Revision: #{@info.revision}"
625 # "X-SVN-Repository: #{@info.path}"
626 "X-SVN-Repository: XXX"
630 "X-SVN-Commit-Id: #{@info.entire_sha256}"
633 def utf8_to_utf7(utf8)
635 Iconv.conv("UTF-7", "UTF-8", utf8)
636 rescue InvalidEncoding
638 Iconv.conv("UTF7", "UTF8", utf8)
646 def truncate_body(body, use_utf7)
647 return body if body.size < @max_size
649 truncated_body = body[0, @max_size]
650 formatted_size = self.class.format_size(@max_size)
651 truncated_message = "... truncated to #{formatted_size}\n"
652 truncated_message = utf8_to_utf7(truncated_message) if use_utf7
653 truncated_message_size = truncated_message.size
655 lf_index = truncated_body.rindex(/(?:\r|\r\n|\n)/)
657 if lf_index + truncated_message_size < @max_size
658 truncated_body[lf_index, @max_size] = "\n#{truncated_message}"
661 lf_index = truncated_body.rindex(/(?:\r|\r\n|\n)/, lf_index - 1)
668 def make_rss(base_rss)
669 RSS::Maker.make("1.0") do |maker|
670 maker.encoding = "UTF-8"
672 maker.channel.about = @rss_uri
673 maker.channel.title = rss_title(@name || @repository_uri)
674 maker.channel.link = @repository_uri
675 maker.channel.description = rss_title(@name || @repository_uri)
676 maker.channel.dc_date = @info.date
679 base_rss.items.each do |item|
680 item.setup_maker(maker)
684 diff_info.each do |name, infos|
685 infos.each do |desc, link|
686 item = maker.items.new_item
688 item.description = @info.log
689 item.content_encoded = "<pre>#{RSS::Utils.html_escape(desc)}</pre>"
691 item.dc_date = @info.date
692 item.dc_creator = @info.author
696 maker.items.do_sort = true
697 maker.items.max_size = 15
702 "Repository of #{name}"