2 ===================================================================
3 --- cvsspam.conf (.../tags/RELEASE-0_2_12) (revision 277)
4 +++ cvsspam.conf (.../trunk) (revision 277)
7 # When $jiraURL is given, text of the form 'project-1234' will be linked
8 # to this issue in JIRA.
10 +# When $xplannerStoryURL, $xplannerIterationURL and $xplannerProjectURL are
11 +# given, text of the form XS1234 will be linked to XPlanner stories; text of
12 +# the form XI1234 will be linked to XPlanner iterations; and text of the form
13 +# XP1234 will be linked to XPlanner projects.
15 #$bugzillaURL = "http://bugzilla.mozilla.org/show_bug.cgi?id=%s"
17 #$jiraURL = "http://jira.atlassian.com/secure/ViewIssue.jspa?key=%s"
19 +#$xplannerStoryURL = "http://www.example.com/xplanner/do/view/userstory?oid=%s"
20 +#$xplannerIterationURL = "http://www.example.com/xplanner/do/view/iteration?oid=%s"
21 +#$xplannerProjectURL = "http://www.example.com/xplanner/do/view/project?oid=%s"
23 # Link to Wiki systems
27 #$cvswebURL = "http://localhost/cgi-bin/cvsweb.cgi"
29 +#$tracURL = "http://localhost/trac/project"
32 # Additional SMTP Headers (Optional)
37 -# cvsdiff keyword ignoring (Default: show changes in keywords)
38 +# cvsdiff keyword ignoring (Default: show changes in keywords)
40 # Changes in CVS keywords can be distracting. For instance, the
41 -# $Revision: 1.12 $ keyword will change on each commit. Set this value to true
42 +# $Revision$ keyword will change on each commit. Set this value to true
43 # to exclude changes in keyword fields (adds the -kk option to cvs diff).
45 #$diff_ignore_keywords = true
48 +# cvsdiff whitespace ignoring (Default: show whitespace-only changes)
50 +# Whitespace-only changes can distract from the rest of a diff. Set this
51 +# value to true to exclude changes in the amount of whitespace (adds the -b
52 +# option to cvs diff).
54 +$diff_ignore_whitespace = true
57 +# cvs diff files ignoring (Default: empty)
59 +# Make CVSspam ignore certain files.
61 +# Can contain file masks, separated by whitespace.
63 +#$ignore_files = "*.al *.gif"
65 # $no_removed_file_diff and $no_added_file_diff
67 # Set both these options, and emails will only include diffs for files
72 -# Don't show diff for removed files (Default: show file's contents)
73 +# Don't show diff for removed files (Default: show file's contents)
75 # If you aren't interested in seeing the contents of a file that was
76 # removed, set this option to true. The files will still appear in the index
78 # Allows the specification of a character set for all generated emails.
79 # The files CVS is dealing with should already be in the character set you
80 # specify -- no transcoding is done.
82 +# Note that you can override this with --charset argument per module, etc.
84 #$charset="ISO-8859-1"
88 +# Users file (Default: $CVSROOT/CVSROOT/users)
90 +# Specify users file to lookup From addresses for commites
92 +#$users_file = "/srv/svn/users"
94 +# Users file charset (Default: $charset)
96 +# If the users file is encoded differently than $charset, You can override
97 +# it here. Especially useful if you use --charset argument. See above.
99 +#$users_file_charset = "ISO-8859-1"
102 # File names in Subject (Default: no filenames in Subject)
104 # Some people like file names to appear in the email subject. To make
105 # them happy, you can say $files_in_subject = true here.
107 #$files_in_subject = false
110 +# Module Path email header (Default: no X-CVSspam-Module-Path header)
112 +# Sets 'X-CVSspam-Module-Path' header to contain common path of files commited.
113 +# Useful for server side mail filtering.
115 +#$cvsroot_email_header = true
117 +# Email size limit (Default: around 2MB)
119 +# When large changes are committed, large CVSspam emails can result. Here
120 +# you can set the size of email that CVSspam is not allowed to append any
121 +# more diffs onto. Specify the number of bytes.
123 +#$mail_size_limit = 2097152
125 Property changes on: cvsspam.conf
126 ___________________________________________________________________
127 Deleted: svn:executable
129 Modified: svn:keywords
130 - Author Date Id Revision
133 Index: collect_diffs.rb
134 ===================================================================
135 --- collect_diffs.rb (.../tags/RELEASE-0_2_12) (revision 277)
136 +++ collect_diffs.rb (.../trunk) (revision 277)
138 $tmpdir = ENV["TMPDIR"] || "/tmp"
139 $dirtemplate = "#cvsspam.#{Process.getpgrp}.#{Process.uid}"
141 +def shell_mask2regex(mask)
142 + '^' + mask.gsub('.', '\.').gsub('?', '.').gsub('*', '.*') + '$'
147 + safe_from = make_fromaddr_safe_for_filename($from_address)
148 + Dir["#{$tmpdir}/#{$dirtemplate}.#{safe_from}-*"].each do |dir|
149 + stat = File.stat(dir)
150 + return dir if stat.owned?
153 Dir["#{$tmpdir}/#{$dirtemplate}-*"].each do |dir|
154 stat = File.stat(dir)
155 return dir if stat.owned?
160 +# transform any special / unexpected characters appearing in the argument to
161 +# --from so that they will not cause problems if the value is inserted into
162 +# a file or directory name
163 +def make_fromaddr_safe_for_filename(addr)
164 + addr.gsub(/[^a-zA-Z0-1.,_-]/, "_")
170 $stderr.puts "collect_diffs.rb: #{msg}"
172 File.open("#{$datadir}/commitinfo-tags") do |file|
173 $commitinfo_tags = Hash.new
174 file.each_line do |line|
175 - line =~ /([^\t]+)\t(.+)/
178 - key.sub!(/^#{ENV['CVSROOT']}\//, '')
179 - $commitinfo_tags[key] = val
180 + line =~ /([^\t]+)\t(.+)/
183 + key.sub!(/^#{ENV['CVSROOT']}\//, '')
184 + $commitinfo_tags[key] = val
191 while i < cvs_info.length
192 - changes << ChangeInfo.new(cvs_info[i], cvs_info[i+=1], cvs_info[i+=1])
193 + change_file = cvs_info[i]
194 + # It's been reported,
195 + # http://lists.badgers-in-foil.co.uk/pipermail/cvsspam-devel/2005-September/000380.html
196 + # that sometimes the second revision number that CVS gives us contains a
197 + # trailing newline character, so we strip ws from these values before use,
198 + change_from = cvs_info[i+=1].strip
199 + change_to = cvs_info[i+=1].strip
200 + changes << ChangeInfo.new(change_file, change_from, change_to)
206 changes.each do |change|
208 + next if $ignore_file_regexes and $ignore_file_regexes.any?{|r| change.file =~ /#{r}/}
210 # record version information
211 file.puts "#V #{change.fromVer},#{change.toVer}"
214 # note if the file is on a branch
217 - tag = get_commitinfo_tag("#{$repository_path}/#{change.file}")
218 + tag = get_commitinfo_tag("#{$repository_path}/#{change.file}")
221 safer_popen($cvs_prog, "-nq", "status", change.file) do |io|
222 @@ -210,18 +238,19 @@
224 fail "couldn't get cvs status: #{$!} (exited with #{$?})" unless ($?>>8)==0
226 - if status =~ /^\s*Sticky Tag:\s*(.+) \(branch: +/m
229 + if status =~ /^\s*Sticky Tag:\s*(.+) \(branch: +/m
233 - if status =~ /^\s*Sticky Options:\s*-kb/m
236 + if status =~ /^\s*Sticky Options:\s*-kb/m
240 file.puts "#T #{tag}" unless tag.nil?
242 diff_cmd = Array.new << $cvs_prog << "-nq" << "diff" << "-Nu"
243 diff_cmd << "-kk" if $diff_ignore_keywords
244 + diff_cmd << "-b" if $diff_ignore_whitespace
248 @@ -240,24 +269,24 @@
249 file.puts "#{$repository_path}/#{change.file}"
250 diff_cmd << change.file
252 - blah("not diffing #{change.file}; has -kb set")
253 - # fake diff lines that will cause cvsspam.rb to consider this a binary
255 - file.puts "#U diff x x"
256 - file.puts "#U Binary files x and y differ"
257 + blah("not diffing #{change.file}; has -kb set")
258 + # fake diff lines that will cause cvsspam.rb to consider this a binary
260 + file.puts "#U diff x x"
261 + file.puts "#U Binary files x and y differ"
263 - # do a cvs diff and place the output into our temp file
264 - blah("about to run #{diff_cmd.join(' ')}")
265 - safer_popen(*diff_cmd) do |pipe|
266 - # skip over cvs-diff's preamble
267 - pipe.each do |line|
268 - break if line =~ /^diff /
270 - file.puts "#U #{line}"
271 - pipe.each do |line|
272 - file.puts "#U #{line}"
275 + # do a cvs diff and place the output into our temp file
276 + blah("about to run #{diff_cmd.join(' ')}")
277 + safer_popen(*diff_cmd) do |pipe|
278 + # skip over cvs-diff's preamble
279 + pipe.each do |line|
280 + break if line =~ /^diff /
282 + file.puts "#U #{line}"
283 + pipe.each do |line|
284 + file.puts "#U #{line}"
288 # TODO: don't how to do this reliably on different systems...
289 #fail "cvsdiff did not give exit status 1 for invocation: #{diff_cmd.join(' ')}" unless ($?>>8)==1
290 @@ -333,10 +362,13 @@
297 $diff_ignore_keywords = false
298 +$diff_ignore_whitespace = false
300 +$ignore_file_regexes = nil
302 unless ENV.has_key?('CVSROOT')
303 fail "$CVSROOT not defined. It should be when I am invoked from CVSROOT/loginfo"
306 $config = arg if opt=="--config"
307 $debug = true if opt == "--debug"
308 + $from_address = arg if opt == "--from"
311 blah("CVSROOT is #{ENV['CVSROOT']}")
317 + $ignore_file_regexes = $ignore_files.split(/\s+/).map{|i| shell_mask2regex(i)}
320 blah("Config file '#{$config}' not found, ignoring")
328 Index: record_lastdir.rb
329 ===================================================================
330 --- record_lastdir.rb (.../tags/RELEASE-0_2_12) (revision 277)
331 +++ record_lastdir.rb (.../trunk) (revision 277)
333 # http://www.badgers-in-foil.co.uk/projects/cvsspam/
334 # Copyright (c) David Holroyd
336 -$repositorydir = ARGV.shift
338 $tmpdir = ENV["TMPDIR"] || "/tmp"
345 +# transform any special / unexpected characters appearing in the argument to
346 +# --from so that they will not cause problems if the value is inserted into
347 +# a file or directory name
348 +def make_fromaddr_safe_for_filename(addr)
349 + addr.gsub(/[^a-zA-Z0-1.,_-]/, "_")
352 +# Option processing doesn't use GetoptLong (for the moment) bacause arguments
353 +# given to this script by CVS include the names of committed files. It
354 +# seems quite possible that one of those file names could begin with a '-'
355 +# and therefore be treated by GetoptLong as a value which requires processing.
356 +# This would probably result in an error.
358 +# [That could be worked around by placing a '--' option (which tells GetoptLong
359 +# to stop processing option arguments) at the very end of the arguments to
360 +# record_lastdir.rb in commitinfo, but that's very easily forgotten, and isn't
361 +# really backwards compatable with the behaviour of older CVSspam releases.]
362 +if ARGV.first == "--from"
363 + # we could, of course, be tricked, if the first committed file in the list
364 + # happened to be named '--from' :S
366 + # drop the "--from"
368 + # and use the value which was given following the option,
369 + $dirtemplate << "." << make_fromaddr_safe_for_filename(ARGV.shift)
372 +$repositorydir = ARGV.shift
374 $datadir = find_data_dir()
380 File.open("#{$datadir}/lastdir", "w") { |file|
381 - file.write $repositorydir
382 + file.write $repositorydir
387 Property changes on: TODO
388 ___________________________________________________________________
389 Deleted: svn:executable
393 ===================================================================
394 Index: svn_post_commit_hook.rb
395 ===================================================================
396 --- svn_post_commit_hook.rb (.../tags/RELEASE-0_2_12) (revision 0)
397 +++ svn_post_commit_hook.rb (.../trunk) (revision 277)
401 +$svnlook_exe = "svnlook" # default assumes the program is in $PATH
410 + $stderr.puts "svn_post_commit_hook.rb: #{msg}"
415 +$tmpdir = ENV["TMPDIR"] || "/tmp"
416 +$dirtemplate = "#svnspam.#{Process.getpgrp}.#{Process.uid}"
417 +# arguments to pass though to 'cvsspam.rb'
418 +$passthrough_args = []
421 + dir = "#{$tmpdir}/#{$dirtemplate}-#{rand(99999999)}"
422 + Dir.mkdir(dir, 0700)
427 + $datadir = make_data_dir
429 + # set PWD so that svnlook can create its .svnlook directory
430 + Dir.chdir($datadir)
435 + File.unlink("#{$datadir}/logfile")
436 + Dir.rmdir($datadir)
441 + cmd = File.dirname($0) + "/cvsspam.rb"
442 + unless system(cmd,"--svn","#{$datadir}/logfile", *$passthrough_args)
443 + fail "problem running '#{cmd}'"
447 +# Like IO.popen, but accepts multiple arguments like Kernel.exec
448 +# (So no need to escape shell metacharacters)
449 +def safer_popen(*args)
450 + IO.popen("-") do |pipe|
460 +# Process the command-line arguments in the given list
462 + require 'getoptlong'
464 + opts = GetoptLong.new(
465 + [ "--to", "-t", GetoptLong::REQUIRED_ARGUMENT ],
466 + [ "--config", "-c", GetoptLong::REQUIRED_ARGUMENT ],
467 + [ "--debug", "-d", GetoptLong::NO_ARGUMENT ],
468 + [ "--from", "-u", GetoptLong::REQUIRED_ARGUMENT ],
469 + [ "--charset", GetoptLong::REQUIRED_ARGUMENT ]
472 + opts.each do |opt, arg|
473 + if ["--to", "--config", "--from", "--charset"].include?(opt)
474 + $passthrough_args << opt << arg
476 + if ["--debug"].include?(opt)
477 + $passthrough_args << opt
479 + $config = arg if opt=="--config"
480 + $debug = true if opt == "--debug"
483 + $repository = ARGV[0]
484 + $revision = ARGV[1]
486 + unless $revision =~ /^\d+$/
487 + usage("revision must be an integer: #{revision.inspect}")
489 + $revision = $revision.to_i
491 + unless FileTest.directory?($repository)
492 + usage("no such directory: #{$repository.inspect}")
496 +# runs the given svnlook subcommand
497 +def svnlook(cmd, revision, *args)
498 + rev = revision.to_s
499 + safer_popen($svnlook_exe, cmd, $repository, "-r", rev, *args) do |io|
505 + def initialize(filechange, propchange, path)
506 + @filechange = filechange
507 + @propchange = propchange
511 + attr_accessor :filechange, :propchange, :path
513 + def property_change?
532 +# Line-oriented access to an underlying IO object. Remembers 'current' line
533 +# for lookahead during parsing.
544 + (@line = @io.gets) != nil
547 + def assert_current(re)
548 + raise "unexpected #{current.inspect}" unless @line =~ re
552 + def assert_next(re=nil)
553 + raise "unexpected end of text" unless next_line
555 + raise "unexpected #{current.inspect}" unless @line =~ re
562 +def read_modified_diff(out, lines, path)
563 + lines.assert_next(/^=+$/)
565 + if lines.current =~ /\(Binary files differ\)/
566 + process_modified_binary_diff(out, lines, path)
568 + process_modified_text_diff(out, lines, path)
573 +def process_modified_binary_diff(out, lines, path)
574 + prev_rev= $revision-1
575 + next_rev= $revision
576 + out.puts "#V #{prev_rev},#{next_rev}"
577 + out.puts "#M #{path}"
578 + out.puts "#U diff x x"
579 + out.puts "#U Binary files x and y differ"
583 +def process_modified_text_diff(out, lines, path)
584 + m = lines.assert_current(/^---.*\(rev (\d+)\)$/)
585 + prev_rev = m[1].to_i
586 + diff1 = lines.current
587 + m = lines.assert_next(/^\+\+\+.*\(rev (\d+)\)$/)
588 + next_rev = m[1].to_i
589 + diff2 = lines.current
590 + out.puts "#V #{prev_rev},#{next_rev}"
591 + out.puts "#M #{path}"
592 + out.puts "#U #{diff1}"
593 + out.puts "#U #{diff2}"
594 + while lines.next_line && lines.current =~ /^[-\+ @\\]/
595 + out.puts "#U #{lines.current}"
599 +def read_added_diff(out, lines, path)
600 + lines.assert_next(/^=+$/)
602 + if lines.current =~ /\(Binary files differ\)/
603 + process_added_binary_diff(out, lines, path)
605 + process_added_text_diff(out, lines, path)
609 +def process_added_binary_diff(out, lines, path)
610 + next_rev= $revision
611 + out.puts "#V NONE,#{next_rev}"
612 + out.puts "#A #{path}"
613 + out.puts "#U diff x x"
614 + out.puts "#U Binary file x added"
617 +def process_added_text_diff(out, lines, path)
618 + m = lines.assert_current(/^---.*\(rev (\d+)\)$/)
619 + prev_rev = m[1].to_i
620 + diff1 = lines.current
621 + m = lines.assert_next(/^\+\+\+.*\(rev (\d+)\)$/)
622 + next_rev = m[1].to_i
623 + diff2 = lines.current
624 + out.puts "#V NONE,#{next_rev}"
625 + out.puts "#A #{path}"
626 + out.puts "#U #{diff1}"
627 + out.puts "#U #{diff2}"
628 + while lines.next_line && lines.current =~ /^[-\+ @\\]/
629 + out.puts "#U #{lines.current}"
633 +def read_deleted_diff(out, lines, path)
634 + lines.assert_next(/^=+$/)
635 + m = lines.assert_next(/^---.*\(rev (\d+)\)$/)
636 + prev_rev = m[1].to_i
637 + diff1 = lines.current
638 + m = lines.assert_next(/^\+\+\+.*\(rev (\d+)\)$/)
639 + next_rev = m[1].to_i
640 + diff2 = lines.current
641 + out.puts "#V #{prev_rev},NONE"
642 + out.puts "#R #{path}"
643 + out.puts "#U #{diff1}"
644 + out.puts "#U #{diff2}"
645 + while lines.next_line && lines.current =~ /^[-\+ @\\]/
646 + out.puts "#U #{lines.current}"
650 +def read_property_lines(path, prop_name, revision)
652 + svnlook("propget", revision, prop_name, path) do |io|
653 + io.each_line do |line|
654 + lines << line.chomp
660 +def assert_prop_match(a, b)
661 + if !b.nil? && a != b
662 + raise "property mismatch: #{a.inspect}!=#{b.inspect}"
666 +# We need to read the property change from the output of svnlook, but have
667 +# a difficulty in that there's no unambiguous delimiter marking the end of
668 +# a potentially multi-line property value. Therefore, we do a seperate
669 +# svn propget on the given file to get the value of the property on its own,
670 +# and then use that value as a guide as to how much data to read from the
672 +def munch_prop_text(path, prop_name, revision, lines, line0)
673 + prop = read_property_lines(path, prop_name, revision)
675 + assert_prop_match(line0, "")
678 + assert_prop_match(line0, prop.shift)
679 + prop.each do |prop_line|
681 + assert_prop_match(lines.current.chomp, prop_line)
685 +def read_properties_changed(out, lines, path)
686 + prev_rev= $revision-1
687 + next_rev= $revision
688 + lines.assert_next(/^_+$/)
689 + return unless lines.next_line
690 + out.puts "#V #{prev_rev},#{next_rev}"
691 + out.puts "#P #{path}"
692 +# The first three get consumed and not highlighted
694 + out.puts "#U Property changes:"
698 + break unless lines.current =~ /^(?:Name|Added|Deleted): (.+)$/
701 + m = lines.assert_next(/^ ([-+]) (.*)/)
705 + munch_prop_text(path, prop_name, $revision-1, lines, line0)
706 + if lines.next_line && lines.current =~ /^ \+ (.*)/
707 + munch_prop_text(path, prop_name, $revision, lines, $1)
711 + munch_prop_text(path, prop_name, $revision, lines, line0)
714 + out.puts "#U #{m[1]} #{prop_name}:#{m[2]}"
719 +def handle_copy(out, lines, path, from_ref, from_file)
720 + prev_rev= $revision-1
721 + next_rev= $revision
722 + out.puts "#V #{from_file}:#{prev_rev},#{next_rev}"
723 + out.puts "#C #{path}"
724 + if lines.next_line && lines.current =~ /^=+$/
725 + m = lines.assert_next(/^---.*\(rev (\d+)\)$/)
726 + prev_rev = m[1].to_i
727 + diff1 = lines.current
728 + m = lines.assert_next(/^\+\+\+.*\(rev (\d+)\)$/)
729 + next_rev = m[1].to_i
730 + diff2 = lines.current
731 + out.puts "#U #{diff1}"
732 + out.puts "#U #{diff2}"
733 + while lines.next_line && lines.current =~ /^[-\+ @\\]/
734 + out.puts "#U #{lines.current}"
738 + out.puts "#U Copied from #{from_file}:#{from_ref}"
744 + svnlook("author", $revision) do |io|
745 + return io.readline.chomp
751 + return if $passthrough_args.include?("--from")
752 + author = svnlook_author
754 + blah("Author from svnlook: '#{author}'")
755 + $passthrough_args << "--from" << author
759 +def process_svnlook_log(file)
760 + svnlook("log", $revision) do |io|
761 + io.each_line do |line|
762 + file.puts("#> #{line}")
767 +def process_svnlook_diff(file)
768 + svnlook("diff", $revision) do |io|
769 + lines = LineReader.new(io)
770 + while lines.next_line
771 + if lines.current =~ /^Modified:\s+(.*)/
772 + read_modified_diff(file, lines, $1)
773 + elsif lines.current =~ /^Added:\s+(.*)/
774 + read_added_diff(file, lines, $1)
775 + elsif lines.current =~ /^Copied:\s+(.*) \(from rev (\d+), (.*)\)$/
776 + handle_copy(file, lines, $1, $2, $3)
777 + elsif lines.current =~ /^Deleted:\s+(.*)/
778 + read_deleted_diff(file, lines, $1)
779 + elsif lines.current =~ /^Property changes on:\s+(.*)/
780 + read_properties_changed(file, lines, $1)
781 + elsif lines.current == "\n"
784 + raise "unable to parse line '#{lines.current.inspect}'"
790 +def process_commit()
791 + File.open("#{$datadir}/logfile", File::WRONLY|File::CREAT) do |file|
792 + process_svnlook_log(file)
793 + process_svnlook_diff(file)
812 Property changes on: svn_post_commit_hook.rb
813 ___________________________________________________________________
815 Added: svn:executable
819 Property changes on: COPYING
820 ___________________________________________________________________
821 Deleted: svn:executable
825 ===================================================================
826 --- CREDITS (.../tags/RELEASE-0_2_12) (revision 277)
827 +++ CREDITS (.../trunk) (revision 277)
839 Index: cvsspam-doc.xml
840 ===================================================================
841 --- cvsspam-doc.xml (.../tags/RELEASE-0_2_12) (revision 277)
842 +++ cvsspam-doc.xml (.../trunk) (revision 277)
844 </screen></informalexample>
851 + <para>For Gforge, when a CVS log comment contains text like <userinput>Fix
852 + for Bug [#123]</userinput>, or <userinput>Task [T456] ...</userinput>, the
853 + text "[#123]" or "[T456]" will become a hyper-link to that Gforge page in
854 + the generated email. The format [#<replaceable>nnn</replaceable>] and
855 + [T<replaceable>nnn</replaceable>] is taken from the existing plugin for
856 + Gforge called cvstracker.</para>
858 + <para>To enable, give your Gforge's URL in CVSspam's configuration file:
859 +<informalexample><screen>$gforgeBugURL = "http://gforge.org/tracker/index.php?func=detail&aid=%s"
860 +$gforgeTaskURL = "http://gforge.org/pm/task.php?func=detailtask&project_task_id=%s"</screen></informalexample>
861 + The marker %s tells CVSspam where in the URL to put the bugId from the
862 + log message.</para>
866 <section><title>CVS Web Frontends</title>
868 Property changes on: cvsspam-doc.xml
869 ___________________________________________________________________
870 Deleted: svn:executable
874 ===================================================================
875 --- cvsspam.rb (.../tags/RELEASE-0_2_12) (revision 277)
876 +++ cvsspam.rb (.../trunk) (revision 277)
883 $maxSubjectLength = 200
884 $maxLinesPerDiff = 1000
885 -$maxDiffLineLength = 1000 # may be set to nil for no limit
886 -$charset = nil # nil implies 'don't specify a charset'
887 +# may be set to nil for no limit
888 +$maxDiffLineLength = 1000
889 +# nil implies 'don't specify a charset'
898 -# NB must ensure the time is UTC
899 -# (the Ruby Time object's strftime() doesn't supply a numeric timezone)
900 -DATE_HEADER_FORMAT = "%a, %d %b %Y %H:%M:%S +0000"
902 # Perform (possibly) multiple global substitutions on a string.
903 # the regexps given as keys must not use capturing subexpressions '(...)'
908 hash.each do |key,val|
909 - if expr == nil ; expr="(" else expr<<"|(" end
910 + if expr == nil ; expr="(" else expr << "|(" end
915 UNDERSCORE = chr("_")
921 # encode a header value according to the RFC-2047 quoted-printable spec,
922 # allowing non-ASCII characters to appear in header values, and wrapping
924 # return a string representing the given character-code in quoted-printable
926 def quoted_encode_char(b)
927 - if b>126 || b==UNDERSCORE || b==TAB
928 - sprintf("=%02x", b)
929 + if b>126 || b==UNDERSCORE || b==TAB || b==HOOK || b==EQUALS
930 + sprintf("=%02X", b)
936 # gives a string starting "=?", and including a charset specification, that
937 # marks the start of a quoted-printable character sequence
938 - def marker_start_quoted
939 - "=?#{@charset}?#{@encoding}?"
940 + def marker_start_quoted(charset=nil)
941 + charset = @charset if charset.nil?
942 + "=?#{charset}?#{@encoding}?"
945 # test to see of the given string contains non-ASCII characters
950 + @fromVer = @toVer = nil
951 @lineAdditions = @lineRemovals = 0
952 @repository = Repository.get(path)
953 @repository.merge_common_prefix(basedir())
956 # the full path and filename within the repository
958 - # the type of change committed 'M'=modified, 'A'=added, 'R'=removed
959 + # the type of change committed 'M'=modified, 'A'=added, 'R'=removed, 'P'=properties, 'C'=copied
961 # records number of 'addition' lines in diff output, once counted
962 attr_accessor :lineAdditions
963 @@ -452,17 +455,28 @@
969 # was this file added during the commit?
974 + # was this file copied during the commit?
979 # was this file simply modified during the commit?
984 + # was this file simply modified during the commit?
990 # passing true, this object remembers that a diff will appear in the email,
991 # passing false, this object remembers that no diff will appear in the email.
992 # Once the value is set, it will not be changed
994 # TODO: consolidate these into a nicer framework,
995 mailSub = proc { |match| "<a href=\"mailto:#{match}\">#{match}</a>" }
996 urlSub = proc { |match| "<a href=\"#{match}\">#{match}</a>" }
997 +gforgeTaskSub = proc { |match|
998 + match =~ /([0-9]+)/
999 + "<a href=\"#{$gforgeTaskURL.sub(/%s/, $1)}\">#{match}</a>"
1001 +gforgeBugSub = proc { |match|
1002 + match =~ /([0-9]+)/
1003 + "<a href=\"#{$gforgeBugURL.sub(/%s/, $1)}\">#{match}</a>"
1005 bugzillaSub = proc { |match|
1007 "<a href=\"#{$bugzillaURL.sub(/%s/, $1)}\">#{match}</a>"
1008 @@ -544,15 +566,31 @@
1010 "<a href=\"#{$ticketURL.sub(/%s/, $1)}\">#{match}</a>"
1012 +issueSub = proc { |match|
1013 + match =~ /([0-9]+)/
1014 + "<a href=\"#{$issueURL.sub(/%s/, $1)}\">#{match}</a>"
1016 wikiSub = proc { |match|
1017 - match =~ /\[\[(.*)\]\]/
1018 + match =~ /\[\[(.*?)\]\]/
1020 "<a href=\"#{$wikiURL.sub(/%s/, urlEncode(raw))}\">[[#{raw}]]</a>"
1022 +xplannerIterationSub = proc { |match|
1023 + match =~ /([0-9]+)/
1024 + "<a href=\"#{$xplannerIterationURL.sub(/%s/, $1)}\">#{match}</a>"
1026 +xplannerProjectSub = proc { |match|
1027 + match =~ /([0-9]+)/
1028 + "<a href=\"#{$xplannerProjectURL.sub(/%s/, $1)}\">#{match}</a>"
1030 +xplannerStorySub = proc { |match|
1031 + match =~ /([0-9]+)/
1032 + "<a href=\"#{$xplannerStoryURL.sub(/%s/, $1)}\">#{match}</a>"
1034 commentSubstitutions = {
1035 - '(?:mailto:)?[\w\.\-\+\=]+\@[\w\-]+(?:\.[\w\-]+)+\b' => mailSub,
1036 - '\b(?:http|https|ftp):[^ \t\n<>"]+[\w/]' => urlSub
1038 + '(?:mailto:)?[\w\.\-\+\=]+\@[\w\-]+(?:\.[\w\-]+)+\b' => mailSub,
1039 + '\b(?:http|https|ftp):[^ \t\n<>"]+[\w/]' => urlSub
1042 # outputs commit log comment text supplied by LogReader as preformatted HTML
1043 class CommentHandler < LineConsumer
1044 @@ -670,6 +708,12 @@
1049 + # may be overridden by subclasses that are able to make a hyperlink to a
1050 + # history log for a file
1056 # Superclass for objects that can link to CVS frontends on the web (ViewCVS,
1057 @@ -710,6 +754,14 @@
1058 "<a href=\"#{diff_url(file)}\">#{super(file)}</a>"
1062 + link = log_url(file)
1064 + return "<span id=\"info\">(<a href=\"#{link}\">log</a>)</span>"
1072 @@ -722,6 +774,10 @@
1083 @@ -745,6 +801,15 @@
1085 add_repo("#{@base_url}#{urlEncode(file.path)}.diff?r1=#{file.fromVer}&r2=#{file.toVer}")
1090 + log_anchor = "#rev#{file.toVer}"
1094 + add_repo("#{@base_url}#{urlEncode(file.path)}#{log_anchor}")
1098 # Link to Chora, from the Horde framework
1100 class CVSwebFrontend < WebFrontend
1101 def path_url(path, tag)
1103 - add_repo(@base_url + urlEncode(path))
1104 + add_repo(@base_url + urlEncode(path) + "/")
1106 - add_repo("#{@base_url}#{urlEncode(path)}?only_with_tag=#{urlEncode(tag)}")
1107 + add_repo("#{@base_url}#{urlEncode(path)}/?only_with_tag=#{urlEncode(tag)}")
1111 @@ -780,9 +845,45 @@
1113 add_repo("#{@base_url}#{urlEncode(file.path)}.diff?r1=text&tr1=#{file.fromVer}&r2=text&tr2=#{file.toVer}&f=h")
1120 + log_anchor = "#rev#{file.toVer}"
1124 + add_repo("#{@base_url}#{urlEncode(file.path)}#{log_anchor}")
1129 +class TracFrontend < WebFrontend
1130 + def path_url(path, tag)
1131 + add_repo("#{@base_url}browser/#{urlEncode(path)}")
1134 + def version_url(path, version)
1135 + add_repo("#{@base_url}browser/#{urlEncode(path)}?rev=#{version}")
1138 + def diff_url(file)
1139 + add_repo("#{@base_url}changeset/#{file.toVer}")
1146 + log_anchor = "?rev=#{file.toVer}"
1150 + add_repo("#{@base_url}log/#{urlEncode(file.path)}#{log_anchor}")
1154 # in need of refactoring...
1156 # Note when LogReader finds record of a file that was added in this commit
1157 @@ -801,6 +902,15 @@
1161 +# Note when LogReader finds record of a file that was copied in this commit
1162 +class CopiedFileHandler < FileHandler
1163 + def handleFile(file)
1165 + file.fromVer=$fromVer
1170 # Note when LogReader finds record of a file that was modified in this commit
1171 class ModifiedFileHandler < FileHandler
1172 def handleFile(file)
1173 @@ -810,7 +920,16 @@
1177 +# Note when LogReader finds record of a file whose properties were modified in this commit
1178 +class ModifiedPropsFileHandler < FileHandler
1179 + def handleFile(file)
1181 + file.fromVer=$fromVer
1187 # Used by UnifiedDiffHandler to record the number of added and removed lines
1188 # appearing in a unidiff.
1189 class UnifiedDiffStats
1190 @@ -873,7 +992,10 @@
1191 addInfixSize = line.length - (prefixLen+suffixLen)
1192 oversize_change = deleteInfixSize*100/@lineJustDeleted.length>33 || addInfixSize*100/line.length>33
1194 - if prefixLen==1 && suffixLen==0 || deleteInfixSize<=0 || oversize_change
1195 + # avoid doing 'within-a-line highlighting' if a multibyte encoding
1196 + # is suspected, as all the suffix/prefix stuff above is byte, not
1198 + if multibyte_encoding? || prefixLen==1 && suffixLen==0 || deleteInfixSize<=0 || oversize_change
1199 print(htmlEncode(@lineJustDeleted))
1201 print(htmlEncode(@lineJustDeleted[0,prefixLen]))
1202 @@ -905,7 +1027,7 @@
1203 @lineJustDeleted = nil
1206 - if prefixLen==1 && suffixLen==0 || addInfixSize<=0 || oversize_change
1207 + if multibyte_encoding? || prefixLen==1 && suffixLen==0 || addInfixSize<=0 || oversize_change
1208 encoded = htmlEncode(line)
1210 encoded = htmlEncode(line[0,prefixLen]) +
1211 @@ -958,7 +1080,7 @@
1214 if @truncatedLineCount>0
1215 - println("<strong class=\"error\" title=\"#{@truncatedLineCount} lines truncated at column #{$maxDiffLineLength}\">[Note: Some over-long lines of diff output only partialy shown]</strong>")
1216 + println("<strong class=\"error\" title=\"#{@truncatedLineCount} lines truncated at column #{$maxDiffLineLength}\">[Note: Some over-long lines of diff output only partially shown]</strong>")
1220 @@ -976,11 +1098,21 @@
1221 print($frontend.path($file.basedir, $file.tag))
1222 println("</span><br />")
1223 println("<div class=\"fileheader\" id=\"removed\"><big><b>#{htmlEncode($file.file)}</b></big> <small id=\"info\">removed after #{$frontend.version($file.path,$file.fromVer)}</small></div>")
1225 + print("<span class=\"pathname\" id=\"copied\">")
1226 + print($frontend.path($file.basedir, $file.tag))
1227 + println("</span><br />")
1228 + println("<div class=\"fileheader\" id=\"copied\"><big><b>#{htmlEncode($file.file)}</b></big> <small id=\"info\">copied from #{$frontend.version($file.path,$file.fromVer)}</small></div>")
1230 print("<span class=\"pathname\">")
1231 print($frontend.path($file.basedir, $file.tag))
1232 println("</span><br />")
1233 println("<div class=\"fileheader\"><big><b>#{htmlEncode($file.file)}</b></big> <small id=\"info\">#{$frontend.version($file.path,$file.fromVer)} #{$frontend.diff($file)} #{$frontend.version($file.path,$file.toVer)}</small></div>")
1235 + print("<span class=\"pathname\">")
1236 + print($frontend.path($file.basedir, $file.tag))
1237 + println("</span><br />")
1238 + println("<div class=\"fileheader\"><big><b>#{htmlEncode($file.file)}</b></big> <small id=\"info\">#{$frontend.version($file.path,$file.fromVer)} #{$frontend.diff($file)} #{$frontend.version($file.path,$file.toVer)}</small></div>")
1240 print("<pre class=\"diff\"><small id=\"info\">")
1241 lines.each do |line|
1242 @@ -1078,7 +1210,7 @@
1245 println("</div>") # end of "file" div
1246 - $file.has_diff = true
1247 + $file.has_diff = true
1251 @@ -1181,7 +1313,7 @@
1253 # an RFC 822 email address
1255 - def initialize(text)
1256 + def initialize(text, charset=nil)
1257 if text =~ /^\s*([^<]+?)\s*<\s*([^>]+?)\s*>\s*$/
1260 @@ -1189,9 +1321,10 @@
1261 @personal_name = nil
1267 - attr_accessor :personal_name, :address
1268 + attr_accessor :personal_name, :address, :charset
1270 def has_personal_name?
1271 return !@personal_name.nil?
1272 @@ -1222,9 +1355,9 @@
1273 # rfc2047 encode the word, if it contains non-ASCII characters
1274 def encode_word(word)
1275 if $encoder.requires_rfc2047?(word)
1276 - encoded = $encoder.marker_start_quoted
1277 + encoded = $encoder.marker_start_quoted(@charset)
1278 $encoder.each_char_encoded(word) do |code|
1282 encoded << $encoder.marker_end_quoted
1284 @@ -1233,26 +1366,40 @@
1288 +# guess if the users selected encoding is multibyte, since some CVSspam code
1289 +# isn't multibyte-safe, and needs to be disabled.
1290 +def multibyte_encoding?
1291 + $charset && ["utf-8", "utf-16"].include?($charset.downcase)
1294 cvsroot_dir = "#{ENV['CVSROOT']}/CVSROOT"
1295 $config = "#{cvsroot_dir}/cvsspam.conf"
1296 $users_file = "#{cvsroot_dir}/users"
1297 +$users_file_charset = nil
1301 $recipients = Array.new
1302 $sendmail_prog = "/usr/sbin/sendmail"
1303 $hostname = ENV['HOSTNAME'] || 'localhost'
1304 $no_removed_file_diff = false
1305 $no_added_file_diff = false
1307 -$task_keywords = ['TODO', 'FIXME']
1308 +$task_keywords = ['TODO', 'FIXME', 'FIXIT', 'todo']
1310 +$gforgeBugURL = nil
1311 +$gforgeTaskURL = nil
1317 +$xplannerIterationURL = nil
1318 +$xplannerProjectURL = nil
1319 +$xplannerStoryURL = nil
1324 $subjectPrefix = nil
1325 $files_in_subject = false;
1326 @@ -1261,6 +1408,7 @@
1327 # 2MiB limit on attached diffs,
1328 $mail_size_limit = 1024 * 1024 * 2
1330 +$cvsroot_email_header = false
1332 require 'getoptlong'
1334 @@ -1268,6 +1416,7 @@
1335 [ "--to", "-t", GetoptLong::REQUIRED_ARGUMENT ],
1336 [ "--config", "-c", GetoptLong::REQUIRED_ARGUMENT ],
1337 [ "--debug", "-d", GetoptLong::NO_ARGUMENT ],
1338 + [ "--svn", "-s", GetoptLong::NO_ARGUMENT ],
1339 [ "--from", "-u", GetoptLong::REQUIRED_ARGUMENT ],
1340 [ "--charset", GetoptLong::REQUIRED_ARGUMENT ]
1342 @@ -1276,6 +1425,7 @@
1343 $recipients << EmailAddress.new(arg) if opt=="--to"
1344 $config = arg if opt=="--config"
1345 $debug = true if opt=="--debug"
1346 + $svn = true if opt=="--svn"
1347 $from_address = EmailAddress.new(arg) if opt=="--from"
1348 # must use different variable as the config is readed later.
1349 $arg_charset = arg if opt == "--charset"
1350 @@ -1288,7 +1438,7 @@
1352 $stderr.puts "missing required file argument"
1354 - puts "Usage: cvsspam.rb [ --to <email> ] [ --config <file> ] <collect_diffs file>"
1355 + puts "Usage: cvsspam.rb [ --svn ] [ --to <email> ] [ --config <file> ] <collect_diffs file>"
1359 @@ -1321,6 +1471,8 @@
1360 blah("Config file '#{$config}' not found, ignoring")
1363 +blah("Users file: '#{$users_file}'")
1365 unless $arg_charset.nil?
1366 $charset = $arg_charset
1368 @@ -1337,6 +1489,9 @@
1369 elsif $cvswebURL !=nil
1370 $cvswebURL << "/" unless $cvswebURL =~ /\/$/
1371 $frontend = CVSwebFrontend.new($cvswebURL)
1372 +elsif $tracURL !=nil
1373 + $tracURL << "/" unless $tracURL =~ /\/$/
1374 + $frontend = TracFrontend.new($tracURL)
1376 $frontend = NoFrontend.new
1378 @@ -1353,33 +1508,57 @@
1381 if $bugzillaURL != nil
1382 - commentSubstitutions['\b[Bb][Uu][Gg]\s*#?[0-9]+'] = bugzillaSub
1383 + commentSubstitutions['\b[Bb](?:[Uu][Gg])?\s*[#:]?\s*\[?[0-9]+\]?'] = bugzillaSub
1385 +if $gforgeBugURL != nil
1386 + commentSubstitutions['\B\[#[0-9]+\]'] = gforgeBugSub
1388 +if $gforgeTaskURL != nil
1389 + commentSubstitutions['\B\[[Tt][0-9]+\]'] = gforgeTaskSub
1392 commentSubstitutions['\b[a-zA-Z]+-[0-9]+\b'] = jiraSub
1394 if $ticketURL != nil
1395 commentSubstitutions['\b[Tt][Ii][Cc][Kk][Ee][Tt]\s*#?[0-9]+\b'] = ticketSub
1397 +if $issueURL != nil
1398 + commentSubstitutions['\b[Ii][Ss][Ss][Uu][Ee]\s*#?[0-9]+\b'] = issueSub
1401 commentSubstitutions['\[\[.+\]\]'] = wikiSub
1403 +if $xplannerIterationURL != nil
1404 + commentSubstitutions['\bXI\[?[0-9]+\]?'] = xplannerIterationSub
1406 +if $xplannerProjectURL != nil
1407 + commentSubstitutions['\bXP\[?[0-9]+\]?'] = xplannerProjectSub
1409 +if $xplannerStoryURL != nil
1410 + commentSubstitutions['\bXS\[?[0-9]+\]?'] = xplannerStorySub
1412 $commentEncoder = MultiSub.new(commentSubstitutions)
1415 tagHandler = TagHandler.new
1417 -$handlers = Hash[">" => CommentHandler.new,
1418 - "U" => UnifiedDiffHandler.new,
1419 - "T" => tagHandler,
1420 - "A" => AddedFileHandler.new,
1421 - "R" => RemovedFileHandler.new,
1422 - "M" => ModifiedFileHandler.new,
1423 - "V" => VersionHandler.new]
1425 + ">" => CommentHandler.new,
1426 + "U" => UnifiedDiffHandler.new,
1427 + "T" => tagHandler,
1428 + "A" => AddedFileHandler.new,
1429 + "R" => RemovedFileHandler.new,
1430 + "C" => CopiedFileHandler.new,
1431 + "M" => ModifiedFileHandler.new,
1432 + "P" => ModifiedPropsFileHandler.new,
1433 + "V" => VersionHandler.new
1436 $handlers["A"].setTagHandler(tagHandler)
1437 $handlers["R"].setTagHandler(tagHandler)
1438 +$handlers["C"].setTagHandler(tagHandler)
1439 $handlers["M"].setTagHandler(tagHandler)
1440 +$handlers["P"].setTagHandler(tagHandler)
1442 $fileEntries = Array.new
1443 $task_list = Array.new
1444 @@ -1404,7 +1583,11 @@
1447 if $subjectPrefix == nil
1448 - $subjectPrefix = "[CVS #{Repository.array.join(',')}]"
1450 + $subjectPrefix = "[SVN #{Repository.array.join(',')}]"
1452 + $subjectPrefix = "[CVS #{Repository.array.join(',')}]"
1456 if $files_in_subject
1457 @@ -1451,6 +1634,8 @@
1458 #removed {background-color:#ffdddd;}
1459 #removedchars {background-color:#ff9999;font-weight:bolder;}
1460 tr.alt #removed {background-color:#f7cccc;}
1461 + #copied {background-color:#ccccff;}
1462 + tr.alt #copied {background-color:#bbbbf7;}
1463 #info {color:#888888;}
1464 #context {background-color:#eeeeee;}
1465 td {padding-left:.3em;padding-right:.3em;}
1466 @@ -1483,7 +1668,9 @@
1472 + filesModifiedProps = 0
1474 totalLinesRemoved = 0
1476 @@ -1492,24 +1679,26 @@
1477 $fileEntries.each do |file|
1478 unless file.repository == last_repository
1479 last_repository = file.repository
1480 - mail.print("<tr class=\"head\"><td colspan=\"#{last_repository.has_multiple_tags ? 5 : 4}\">")
1481 + mail.print("<tr class=\"head\"><td colspan=\"#{last_repository.has_multiple_tags ? 6 : 5}\">")
1482 if last_repository.has_multiple_tags
1483 mail.print("Mixed-tag commit")
1485 mail.print("Commit")
1487 mail.print(" in <b><tt>#{htmlEncode(last_repository.common_prefix)}</tt></b>")
1488 - if last_repository.trunk_only?
1489 - mail.print("<span id=\"info\"> on MAIN</span>")
1491 - mail.print(" on ")
1493 - last_repository.each_tag do |tag|
1496 - mail.print tagCount<last_repository.tag_count ? ", " : " & "
1498 + if last_repository.trunk_only?
1499 + mail.print("<span id=\"info\"> on MAIN</span>")
1501 + mail.print(" on ")
1503 + last_repository.each_tag do |tag|
1506 + mail.print tagCount<last_repository.tag_count ? ", " : " & "
1508 + mail.print tag ? htmlEncode(tag) : "<span id=\"info\">MAIN</span>"
1510 - mail.print tag ? htmlEncode(tag) : "<span id=\"info\">MAIN</span>"
1513 mail.puts("</td></tr>")
1514 @@ -1524,8 +1713,12 @@
1518 + elsif file.copied?
1520 elsif file.modification?
1522 + elsif file.modifiedprops?
1523 + filesModifiedProps += 1
1525 name = htmlEncode(file.name_after_common_prefix)
1526 slashPos = name.rindex("/")
1527 @@ -1545,17 +1738,29 @@
1528 name = "<span id=\"added\">#{name}</span>"
1530 name = "<span id=\"removed\">#{name}</span>"
1531 + elsif file.copied?
1532 + name = "<span id=\"copied\">#{name}</span>"
1534 + mail.print("<td>")
1536 - mail.print("<td><tt>#{prefix}<a href=\"#file#{file_count}\">#{name}</a></tt></td>")
1537 + mail.print("<tt>#{prefix}<a href=\"#file#{file_count}\">#{name}</a></tt>")
1539 - mail.print("<td><tt>#{prefix}#{name}</tt></td>")
1540 + mail.print("<tt>#{prefix}#{name}</tt>")
1543 - mail.print("<td colspan=\"2\" align=\"center\"><small id=\"info\">[empty]</small></td>")
1544 + mail.print(" #{$frontend.log(file)}")
1545 + mail.print("</td>")
1547 + mail.print("<td colspan=\"3\" align=\"center\"><small id=\"info\">[copied]</small></td>")
1548 + elsif file.isEmpty
1549 + mail.print("<td colspan=\"3\" align=\"center\"><small id=\"info\">[empty]</small></td>")
1551 - mail.print("<td colspan=\"2\" align=\"center\"><small id=\"info\">[binary]</small></td>")
1552 + mail.print("<td colspan=\"3\" align=\"center\"><small id=\"info\">[binary]</small></td>")
1554 + if file.modifiedprops?
1555 + mail.print("<td align=\"right\"><small id=\"info\">[props]</small></td>")
1557 + mail.print("<td></td>")
1559 if file.lineAdditions>0
1560 totalLinesAdded += file.lineAdditions
1561 mail.print("<td align=\"right\" id=\"added\">+#{file.lineAdditions}</td>")
1562 @@ -1582,15 +1787,19 @@
1563 mail.print("<td nowrap=\"nowrap\" align=\"right\">added #{$frontend.version(file.path,file.toVer)}</td>")
1565 mail.print("<td nowrap=\"nowrap\">#{$frontend.version(file.path,file.fromVer)} removed</td>")
1566 + elsif file.copied?
1567 + mail.print("<td nowrap=\"nowrap\" align=\"center\">#{$frontend.version(file.path,file.fromVer)} #{$frontend.diff(file)} #{$frontend.version(file.path,file.toVer)}</td>")
1568 elsif file.modification?
1569 mail.print("<td nowrap=\"nowrap\" align=\"center\">#{$frontend.version(file.path,file.fromVer)} #{$frontend.diff(file)} #{$frontend.version(file.path,file.toVer)}</td>")
1570 + elsif file.modifiedprops?
1571 + mail.print("<td nowrap=\"nowrap\" align=\"center\">#{$frontend.version(file.path,file.fromVer)} #{$frontend.diff(file)} #{$frontend.version(file.path,file.toVer)}</td>")
1576 if $fileEntries.size>1 && (totalLinesAdded+totalLinesRemoved)>0
1577 # give total number of lines added/removed accross all files
1578 - mail.print("<tr><td></td>")
1579 + mail.print("<tr><td></td><td></td>")
1580 if totalLinesAdded>0
1581 mail.print("<td align=\"right\" id=\"added\">+#{totalLinesAdded}</td>")
1583 @@ -1607,7 +1816,7 @@
1585 mail.puts("</table>")
1587 - totalFilesChanged = filesAdded+filesRemoved+filesModified
1588 + totalFilesChanged = filesAdded+filesRemoved+filesCopied+filesModified+filesModifiedProps
1589 if totalFilesChanged > 1
1590 mail.print("<small id=\"info\">")
1592 @@ -1620,11 +1829,21 @@
1593 mail.print("#{filesRemoved} removed")
1597 + mail.print(" + ") if changeKind>0
1598 + mail.print("#{filesCopied} copied")
1602 mail.print(" + ") if changeKind>0
1603 mail.print("#{filesModified} modified")
1606 + if filesModifiedProps>0
1607 + mail.print(" + ") if changeKind>0
1608 + mail.print("#{filesModifiedProps} modified properties")
1611 mail.print(", total #{totalFilesChanged}") if changeKind > 1
1612 mail.puts(" files</small><br />")
1614 @@ -1667,12 +1886,13 @@
1615 # CVSROOT/users file, if the file exists. The argument is returned unchanged
1616 # if no alias is found.
1617 def sender_alias(email)
1618 + blah("Lookup '#{email}' from users file")
1619 if File.exists?($users_file)
1620 File.open($users_file) do |io|
1621 io.each_line do |line|
1622 if line =~ /^([^:]+)\s*:\s*(['"]?)([^\n\r]+)(\2)/
1623 if email.address == $1
1624 - return EmailAddress.new($3)
1625 + return EmailAddress.new($3, $users_file_charset)
1629 @@ -1686,6 +1906,8 @@
1630 # sensible header formatting, and for ensuring that the body is seperated
1631 # from the message headers by a blank line (as it is required to be).
1633 + ENCODE_HEADERS = ["Subject", "X-CVSspam-Module-Path"]
1636 @done_headers = false
1638 @@ -1695,8 +1917,8 @@
1640 def header(name, value)
1641 raise "headers already commited" if @done_headers
1642 - if name == "Subject"
1643 - $encoder.encode_header(@io, "Subject", value)
1644 + if ENCODE_HEADERS.include?(name)
1645 + $encoder.encode_header(@io, name, value)
1647 @io.puts("#{name}: #{value}")
1649 @@ -1769,7 +1991,7 @@
1650 ctx.header("To", recipients.map{|addr| addr.encoded}.join(','))
1651 blah("Mail From: <#{from}>")
1652 ctx.header("From", from.encoded) if from
1653 - ctx.header("Date", Time.now.utc.strftime(DATE_HEADER_FORMAT))
1654 + ctx.header("Date", Time.now.rfc2822)
1658 @@ -1800,10 +2022,10 @@
1659 return unless $fileEntries.length == 1
1660 file = $fileEntries[0]
1661 name = zap_header_special_chars(file.path)
1662 - unless file.fromVer == "NONE"
1664 mail.header("References", make_msg_id("#{name}.#{file.fromVer}", $hostname))
1666 - unless file.toVer == "NONE"
1668 mail.header("Message-ID", make_msg_id("#{name}.#{file.toVer}", $hostname))
1671 @@ -1834,8 +2056,18 @@
1674 mail.header("X-Mailer", "CVSspam #{$version} <http://www.badgers-in-foil.co.uk/projects/cvsspam/>")
1675 + if $cvsroot_email_header
1677 + if Repository.count == 1
1678 + rep = Repository.array.first
1679 + mod << rep.common_prefix
1681 + mail.header("X-CVSspam-Module-Path", mod)
1685 make_html_email(body)
1691 Property changes on: testcases/data/remove.png
1692 ___________________________________________________________________
1693 Deleted: svn:executable
1697 Property changes on: testcases/data/fiddlyedits.after
1698 ___________________________________________________________________
1699 Deleted: svn:executable
1703 Property changes on: testcases/data/fiddlyedits.before
1704 ___________________________________________________________________
1705 Deleted: svn:executable
1709 Property changes on: testcases/data/add.png
1710 ___________________________________________________________________
1711 Deleted: svn:executable
1715 Property changes on: testcases/README
1716 ___________________________________________________________________
1717 Deleted: svn:executable
1721 Property changes on: .
1722 ___________________________________________________________________