Fix compiler warning due to missing function prototype.
[svn.git] / subversion / bindings / swig / ruby / svn / commit-mailer.rb
blobd1c750e3ce655a4803ab0e01c35f788ae8e9c7bf
1 # experimental
3 require "optparse"
4 require "ostruct"
5 require "time"
6 require "net/smtp"
7 require "socket"
9 require "svn/info"
11 class OptionParser
12   class CannotCoexistOption < ParseError
13     const_set(:Reason, 'cannot coexist option'.freeze)
14   end
15 end
17 module Svn
18   class CommitMailer
19     KILO_SIZE = 1000
20     DEFAULT_MAX_SIZE = "100M"
22     class << self
23       def run(argv=nil)
24         argv ||= ARGV
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)
29         mailer.run
30       end
32       def parse(argv)
33         options = make_options
35         parser = make_parser(options)
36         argv = argv.dup
37         parser.parse!(argv)
38         repository_path, revision, to, *rest = argv
40         [repository_path, revision, to, options]
41       end
43       def format_size(size)
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
51         "#{size}GB"
52       end
54       private
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
72       end
74       def parse_size(size)
75         case size
76         when /\A(.+?)GB?\z/i
77           Float($1) * KILO_SIZE ** 3
78         when /\A(.+?)MB?\z/i
79           Float($1) * KILO_SIZE ** 2
80         when /\A(.+?)KB?\z/i
81           Float($1) * KILO_SIZE
82         when /\A(.+?)B?\z/i
83           Float($1)
84         else
85           raise ArgumentError, "invalid size: #{size.inspect}"
86         end
87       end
89       def make_options
90         options = OpenStruct.new
91         options.to = []
92         options.error_to = []
93         options.from = nil
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
99         options.rss_uri = 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"
105         options.name = nil
106         options.use_utf7 = false
107         options.server = "localhost"
108         options.port = Net::SMTP.default_port
109         options
110       end
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
122             puts opts
123             exit!
124           end
125         end
126       end
128       def add_email_options(opts, options)
129         opts.separator ""
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
135         end
137         opts.on("-pPORT", "--port=PORT", Integer,
138                 "Use PORT as SMTP port (#{options.port})") do |port|
139           options.port = port
140         end
142         opts.on("-tTO", "--to=TO", "Add TO to To: address") do |to|
143           options.to << to unless to.nil?
144         end
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?
149         end
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"
155           end
156           options.from = from
157         end
159         opts.on("--from-domain=DOMAIN",
160                 "Use author@DOMAIN as from address") do |domain|
161           if options.from
162             raise OptionParser::CannotCoexistOption,
163                     "cannot coexist with --from"
164           end
165           options.from_domain = domain
166         end
167       end
169       def add_input_options(opts, options)
170         opts.separator ""
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
176         end
178         opts.on("--name=NAME", "Use NAME as repository name") do |name|
179           options.name = name
180         end
182         opts.on("--[no-]show-path",
183                 "Show commit target path") do |bool|
184           options.show_path = bool
185         end
187         opts.on("--trunk-path=PATH",
188                 "Treat PATH as trunk path (#{options.trunk_path})") do |path|
189           options.trunk_path = path
190         end
192         opts.on("--branches-path=PATH",
193                 "Treat PATH as branches path",
194                 "(#{options.branches_path})") do |path|
195           options.branches_path = path
196         end
198         opts.on("--tags-path=PATH",
199                 "Treat PATH as tags path (#{options.tags_path})") do |path|
200           options.tags_path = path
201         end
203         opts.on("-rURI", "--repository-uri=URI",
204                 "Use URI as URI of repository") do |uri|
205           options.repository_uri = uri
206         end
208         opts.on("-n", "--no-diff", "Don't add diffs") do |diff|
209           options.add_diff = false
210         end
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|
216           begin
217             options.max_size = parse_size(max_size)
218           rescue ArgumentError
219             raise OptionParser::InvalidArgument, max_size
220           end
221         end
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
227         end
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
233         end
234       end
236       def add_rss_options(opts, options)
237         opts.separator ""
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
242         end
244         opts.on("--rss-uri=URI", "Use URI as output RSS URI") do |uri|
245           options.rss_uri = uri
246         end
247       end
249       def add_other_options(opts, options)
250         opts.separator ""
251         opts.separator "Other options:"
253         return
254         opts.on("-IPATH", "--include=PATH", "Add PATH to load path") do |path|
255           $LOAD_PATH.unshift(path)
256         end
257       end
258     end
260     attr_reader :to
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)
268       @to = to
269     end
271     def from
272       @from || "#{@info.author}@#{@from_domain}".sub(/@\z/, '')
273     end
275     def run
276       send_mail(make_mail)
277       output_rss
278     end
280     def use_utf7?
281       @use_utf7
282     end
284     def add_diff?
285       @add_diff
286     end
288     def multi_project?
289       @multi_project
290     end
292     def show_path?
293       @show_path
294     end
296     private
297     def extract_email_address(address)
298       if /<(.+?)>/ =~ address
299         $1
300       else
301         address
302       end
303     end
305     def send_mail(mail)
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|
310           f.print(mail)
311         end
312       end
313     end
315     def output_rss
316       return unless rss_output_available?
317       prev_rss = nil
318       begin
319         if File.exist?(@rss_path)
320           File.open(@rss_path) do |f|
321             prev_rss = RSS::Parser.parse(f)
322           end
323         end
324       rescue RSS::Error
325       end
327       rss = make_rss(prev_rss).to_s
328       File.open(@rss_path, "w") do |f|
329         f.print(rss)
330       end
331     end
333     def rss_output_available?
334       if @repository_uri and @rss_path and @rss_uri
335         begin
336           require 'rss'
337           true
338         rescue LoadError
339           false
340         end
341       else
342         false
343       end
344     end
346     def make_mail
347       utf8_body = make_body
348       utf7_body = nil
349       utf7_body = utf8_to_utf7(utf8_body) if use_utf7?
350       if utf7_body
351         body = utf7_body
352         encoding = "utf-7"
353         bit = "7bit"
354       else
355         body = utf8_body
356         encoding = "utf-8"
357         bit = "8bit"
358       end
360       unless @max_size.nil?
361         body = truncate_body(body, !utf7_body.nil?)
362       end
364       make_header(encoding, bit) + "\n" + body
365     end
367     def make_body
368       body = ""
369       body << "#{@info.author}\t#{format_time(@info.date)}\n"
370       body << "\n"
371       body << "  New Revision: #{@info.revision}\n"
372       body << "\n"
373       body << added_dirs
374       body << added_files
375       body << copied_dirs
376       body << copied_files
377       body << deleted_dirs
378       body << deleted_files
379       body << modified_dirs
380       body << modified_files
381       body << "\n"
382       body << "  Log:\n"
383       @info.log.each_line do |line|
384         body << "    #{line}"
385       end
386       body << "\n"
387       body << change_info
388       body
389     end
391     def format_time(time)
392       time.strftime('%Y-%m-%d %X %z (%a, %d %b %Y)')
393     end
395     def changed_items(title, type, items)
396       rv = ""
397       unless items.empty?
398         rv << "  #{title} #{type}:\n"
399         if block_given?
400           yield(rv, items)
401         else
402           rv << items.collect {|item| "    #{item}\n"}.join('')
403         end
404       end
405       rv
406     end
408     def changed_files(title, files, &block)
409       changed_items(title, "files", files, &block)
410     end
412     def added_files
413       changed_files("Added", @info.added_files)
414     end
416     def deleted_files
417       changed_files("Removed", @info.deleted_files)
418     end
420     def modified_files
421       changed_files("Modified", @info.updated_files)
422     end
424     def copied_files
425       changed_files("Copied", @info.copied_files) do |rv, files|
426         rv << files.collect do |file, from_file, from_rev|
427           <<-INFO
428     #{file}
429       (from rev #{from_rev}, #{from_file})
430 INFO
431         end.join("")
432       end
433     end
435     def changed_dirs(title, files, &block)
436       changed_items(title, "directories", files, &block)
437     end
439     def added_dirs
440       changed_dirs("Added", @info.added_dirs)
441     end
443     def deleted_dirs
444       changed_dirs("Removed", @info.deleted_dirs)
445     end
447     def modified_dirs
448       changed_dirs("Modified", @info.updated_dirs)
449     end
451     def copied_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"
455         end.join("")
456       end
457     end
460     CHANGED_TYPE = {
461       :added => "Added",
462       :modified => "Modified",
463       :deleted => "Deleted",
464       :copied => "Copied",
465       :property_changed => "Property changed",
466     }
468     CHANGED_MARK = Hash.new("=")
469     CHANGED_MARK[:property_changed] = "_"
471     def change_info
472       result = changed_dirs_info
473       result = "\n#{result}" unless result.empty?
474       result << "\n"
475       diff_info.each do |key, infos|
476         infos.each do |desc, link|
477           result << "#{desc}\n"
478         end
479       end
480       result
481     end
483     def changed_dirs_info
484       rev = @info.revision
485       (@info.added_dirs.collect do |dir|
486          "  Added: #{dir}\n"
487        end + @info.copied_dirs.collect do |dir, from_dir, from_rev|
488          <<-INFO
489   Copied: #{dir}
490     (from rev #{from_rev}, #{from_dir})
491 INFO
492        end + @info.deleted_dirs.collect do |dir|
493      <<-INFO
494   Deleted: #{dir}
495     % svn ls #{[@repository_uri, dir].compact.join("/")}@#{rev - 1}
496 INFO
497        end + @info.updated_dirs.collect do |dir|
498          "  Modified: #{dir}\n"
499        end).join("\n")
500     end
502     def diff_info
503       @info.diffs.collect do |key, values|
504         [
505          key,
506          values.collect do |type, value|
507            args = []
508            rev = @info.revision
509            case type
510            when :added
511              command = "cat"
512            when :modified, :property_changed
513              command = "diff"
514              args.concat(["-r", "#{@info.revision - 1}:#{@info.revision}"])
515            when :deleted
516              command = "cat"
517              rev -= 1
518            when :copied
519              command = "cat"
520            else
521              raise "unknown diff type: #{value.type}"
522            end
524            command += " #{args.join(' ')}" unless args.empty?
526            link = [@repository_uri, key].compact.join("/")
528            line_info = "+#{value.added_line} -#{value.deleted_line}"
529            desc = <<-HEADER
530   #{CHANGED_TYPE[value.type]}: #{key} (#{line_info})
531 #{CHANGED_MARK[value.type] * 67}
532 HEADER
534            if add_diff?
535              desc << value.body
536            else
537              desc << <<-CONTENT
538     % svn #{command} #{link}@#{rev}
539 CONTENT
540            end
542            [desc, link]
543          end
544         ]
545       end
546     end
548     def make_header(body_encoding, body_encoding_bit)
549       headers = []
550       headers << x_author
551       headers << x_revision
552       headers << x_repository
553       headers << x_id
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|
562         /\A\s*\z/ !~ header
563       end.join("\n")
564     end
566     def detect_project
567       return nil unless multi_project?
568       project = nil
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
574         end
575       end
576       project
577     end
579     def affected_paths(project)
580       paths = []
581       [nil, :branches_path, :tags_path].each do |target|
582         prefix = [project]
583         prefix << send(target) if target
584         prefix = prefix.compact.join("/")
585         sub_paths = @info.sub_paths(prefix)
586         if target.nil?
587           sub_paths = sub_paths.find_all do |sub_path|
588             sub_path == trunk_path
589           end
590         end
591         paths.concat(sub_paths)
592       end
593       paths.uniq
594     end
596     def make_subject
597       subject = ""
598       project = detect_project
599       subject << "#{@name} " if @name
600       revision_info = "r#{@info.revision}"
601       if show_path?
602         _affected_paths = affected_paths(project)
603         unless _affected_paths.empty?
604           revision_info = "(#{_affected_paths.join(',')}) #{revision_info}"
605         end
606       end
607       if project
608         subject << "[#{project} #{revision_info}] "
609       else
610         subject << "#{revision_info}: "
611       end
612       subject << @info.log.lstrip.to_a.first.to_s.chomp
613       NKF.nkf("-WM", subject)
614     end
616     def x_author
617       "X-SVN-Author: #{@info.author}"
618     end
620     def x_revision
621       "X-SVN-Revision: #{@info.revision}"
622     end
624     def x_repository
625       # "X-SVN-Repository: #{@info.path}"
626       "X-SVN-Repository: XXX"
627     end
629     def x_id
630       "X-SVN-Commit-Id: #{@info.entire_sha256}"
631     end
633     def utf8_to_utf7(utf8)
634       require 'iconv'
635       Iconv.conv("UTF-7", "UTF-8", utf8)
636     rescue InvalidEncoding
637       begin
638         Iconv.conv("UTF7", "UTF8", utf8)
639       rescue Exception
640         nil
641       end
642     rescue Exception
643       nil
644     end
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)/)
656       while lf_index
657         if lf_index + truncated_message_size < @max_size
658           truncated_body[lf_index, @max_size] = "\n#{truncated_message}"
659           break
660         else
661           lf_index = truncated_body.rindex(/(?:\r|\r\n|\n)/, lf_index - 1)
662         end
663       end
665       truncated_body
666     end
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
678         if base_rss
679           base_rss.items.each do |item|
680             item.setup_maker(maker)
681           end
682         end
684         diff_info.each do |name, infos|
685           infos.each do |desc, link|
686             item = maker.items.new_item
687             item.title = name
688             item.description = @info.log
689             item.content_encoded = "<pre>#{RSS::Utils.html_escape(desc)}</pre>"
690             item.link = link
691             item.dc_date = @info.date
692             item.dc_creator = @info.author
693           end
694         end
696         maker.items.do_sort = true
697         maker.items.max_size = 15
698       end
699     end
701     def rss_title(name)
702       "Repository of #{name}"
703     end
704   end