Index: cvsspam.conf =================================================================== --- cvsspam.conf (.../tags/RELEASE-0_2_12) (revision 277) +++ cvsspam.conf (.../trunk) (revision 277) @@ -34,11 +34,19 @@ # # When $jiraURL is given, text of the form 'project-1234' will be linked # to this issue in JIRA. +# +# When $xplannerStoryURL, $xplannerIterationURL and $xplannerProjectURL are +# given, text of the form XS1234 will be linked to XPlanner stories; text of +# the form XI1234 will be linked to XPlanner iterations; and text of the form +# XP1234 will be linked to XPlanner projects. #$bugzillaURL = "http://bugzilla.mozilla.org/show_bug.cgi?id=%s" #$jiraURL = "http://jira.atlassian.com/secure/ViewIssue.jspa?key=%s" +#$xplannerStoryURL = "http://www.example.com/xplanner/do/view/userstory?oid=%s" +#$xplannerIterationURL = "http://www.example.com/xplanner/do/view/iteration?oid=%s" +#$xplannerProjectURL = "http://www.example.com/xplanner/do/view/project?oid=%s" # Link to Wiki systems # @@ -71,6 +79,7 @@ #$cvswebURL = "http://localhost/cgi-bin/cvsweb.cgi" +#$tracURL = "http://localhost/trac/project" # Additional SMTP Headers (Optional) @@ -116,15 +125,32 @@ -# cvsdiff keyword ignoring (Default: show changes in keywords) +# cvsdiff keyword ignoring (Default: show changes in keywords) # # Changes in CVS keywords can be distracting. For instance, the -# $Revision: 1.12 $ keyword will change on each commit. Set this value to true +# $Revision$ keyword will change on each commit. Set this value to true # to exclude changes in keyword fields (adds the -kk option to cvs diff). #$diff_ignore_keywords = true +# cvsdiff whitespace ignoring (Default: show whitespace-only changes) +# +# Whitespace-only changes can distract from the rest of a diff. Set this +# value to true to exclude changes in the amount of whitespace (adds the -b +# option to cvs diff). + +$diff_ignore_whitespace = true + + +# cvs diff files ignoring (Default: empty) +# +# Make CVSspam ignore certain files. +# +# Can contain file masks, separated by whitespace. + +#$ignore_files = "*.al *.gif" + # $no_removed_file_diff and $no_added_file_diff # # Set both these options, and emails will only include diffs for files @@ -132,7 +158,7 @@ # deleted... -# Don't show diff for removed files (Default: show file's contents) +# Don't show diff for removed files (Default: show file's contents) # # If you aren't interested in seeing the contents of a file that was # removed, set this option to true. The files will still appear in the index @@ -166,14 +192,46 @@ # Allows the specification of a character set for all generated emails. # The files CVS is dealing with should already be in the character set you # specify -- no transcoding is done. +# +# Note that you can override this with --charset argument per module, etc. #$charset="ISO-8859-1" +# Users file (Default: $CVSROOT/CVSROOT/users) +# +# Specify users file to lookup From addresses for commites + +#$users_file = "/srv/svn/users" + +# Users file charset (Default: $charset) +# +# If the users file is encoded differently than $charset, You can override +# it here. Especially useful if you use --charset argument. See above. + +#$users_file_charset = "ISO-8859-1" + + # File names in Subject (Default: no filenames in Subject) # # Some people like file names to appear in the email subject. To make # them happy, you can say $files_in_subject = true here. #$files_in_subject = false + + +# Module Path email header (Default: no X-CVSspam-Module-Path header) +# +# Sets 'X-CVSspam-Module-Path' header to contain common path of files commited. +# Useful for server side mail filtering. + +#$cvsroot_email_header = true + +# Email size limit (Default: around 2MB) +# +# When large changes are committed, large CVSspam emails can result. Here +# you can set the size of email that CVSspam is not allowed to append any +# more diffs onto. Specify the number of bytes. + +#$mail_size_limit = 2097152 Property changes on: cvsspam.conf ___________________________________________________________________ Deleted: svn:executable - * Modified: svn:keywords - Author Date Id Revision + Author Date Id Index: collect_diffs.rb =================================================================== --- collect_diffs.rb (.../tags/RELEASE-0_2_12) (revision 277) +++ collect_diffs.rb (.../trunk) (revision 277) @@ -26,7 +26,18 @@ $tmpdir = ENV["TMPDIR"] || "/tmp" $dirtemplate = "#cvsspam.#{Process.getpgrp}.#{Process.uid}" +def shell_mask2regex(mask) + '^' + mask.gsub('.', '\.').gsub('?', '.').gsub('*', '.*') + '$' +end + def find_data_dir + if $from_address + safe_from = make_fromaddr_safe_for_filename($from_address) + Dir["#{$tmpdir}/#{$dirtemplate}.#{safe_from}-*"].each do |dir| + stat = File.stat(dir) + return dir if stat.owned? + end + end Dir["#{$tmpdir}/#{$dirtemplate}-*"].each do |dir| stat = File.stat(dir) return dir if stat.owned? @@ -35,6 +46,14 @@ end +# transform any special / unexpected characters appearing in the argument to +# --from so that they will not cause problems if the value is inserted into +# a file or directory name +def make_fromaddr_safe_for_filename(addr) + addr.gsub(/[^a-zA-Z0-1.,_-]/, "_") +end + + def blah(msg) if $debug $stderr.puts "collect_diffs.rb: #{msg}" @@ -81,11 +100,11 @@ File.open("#{$datadir}/commitinfo-tags") do |file| $commitinfo_tags = Hash.new file.each_line do |line| - line =~ /([^\t]+)\t(.+)/ - key = $2 - val = $1 - key.sub!(/^#{ENV['CVSROOT']}\//, '') - $commitinfo_tags[key] = val + line =~ /([^\t]+)\t(.+)/ + key = $2 + val = $1 + key.sub!(/^#{ENV['CVSROOT']}\//, '') + $commitinfo_tags[key] = val end end end @@ -129,7 +148,14 @@ changes = Array.new i = 0 while i < cvs_info.length - changes << ChangeInfo.new(cvs_info[i], cvs_info[i+=1], cvs_info[i+=1]) + change_file = cvs_info[i] + # It's been reported, + # http://lists.badgers-in-foil.co.uk/pipermail/cvsspam-devel/2005-September/000380.html + # that sometimes the second revision number that CVS gives us contains a + # trailing newline character, so we strip ws from these values before use, + change_from = cvs_info[i+=1].strip + change_to = cvs_info[i+=1].strip + changes << ChangeInfo.new(change_file, change_from, change_to) i+=1 end return changes @@ -194,6 +220,8 @@ changes.each do |change| + next if $ignore_file_regexes and $ignore_file_regexes.any?{|r| change.file =~ /#{r}/} + # record version information file.puts "#V #{change.fromVer},#{change.toVer}" @@ -202,7 +230,7 @@ # note if the file is on a branch tag = nil if change.isRemoval - tag = get_commitinfo_tag("#{$repository_path}/#{change.file}") + tag = get_commitinfo_tag("#{$repository_path}/#{change.file}") else status = nil safer_popen($cvs_prog, "-nq", "status", change.file) do |io| @@ -210,18 +238,19 @@ end fail "couldn't get cvs status: #{$!} (exited with #{$?})" unless ($?>>8)==0 - if status =~ /^\s*Sticky Tag:\s*(.+) \(branch: +/m - tag = $1 - end + if status =~ /^\s*Sticky Tag:\s*(.+) \(branch: +/m + tag = $1 + end - if status =~ /^\s*Sticky Options:\s*-kb/m - binary_file = true - end + if status =~ /^\s*Sticky Options:\s*-kb/m + binary_file = true + end end file.puts "#T #{tag}" unless tag.nil? diff_cmd = Array.new << $cvs_prog << "-nq" << "diff" << "-Nu" diff_cmd << "-kk" if $diff_ignore_keywords + diff_cmd << "-b" if $diff_ignore_whitespace if change.isAddition file.write "#A " @@ -240,24 +269,24 @@ file.puts "#{$repository_path}/#{change.file}" diff_cmd << change.file if binary_file - blah("not diffing #{change.file}; has -kb set") - # fake diff lines that will cause cvsspam.rb to consider this a binary - # file, - file.puts "#U diff x x" - file.puts "#U Binary files x and y differ" + blah("not diffing #{change.file}; has -kb set") + # fake diff lines that will cause cvsspam.rb to consider this a binary + # file, + file.puts "#U diff x x" + file.puts "#U Binary files x and y differ" else - # do a cvs diff and place the output into our temp file - blah("about to run #{diff_cmd.join(' ')}") - safer_popen(*diff_cmd) do |pipe| - # skip over cvs-diff's preamble - pipe.each do |line| - break if line =~ /^diff / - end - file.puts "#U #{line}" - pipe.each do |line| - file.puts "#U #{line}" - end - end + # do a cvs diff and place the output into our temp file + blah("about to run #{diff_cmd.join(' ')}") + safer_popen(*diff_cmd) do |pipe| + # skip over cvs-diff's preamble + pipe.each do |line| + break if line =~ /^diff / + end + file.puts "#U #{line}" + pipe.each do |line| + file.puts "#U #{line}" + end + end end # TODO: don't how to do this reliably on different systems... #fail "cvsdiff did not give exit status 1 for invocation: #{diff_cmd.join(' ')}" unless ($?>>8)==1 @@ -333,10 +362,13 @@ end $config = nil +$from_address = nil $cvs_prog = "cvs" $debug = false $diff_ignore_keywords = false +$diff_ignore_whitespace = false $task_keywords = [] +$ignore_file_regexes = nil unless ENV.has_key?('CVSROOT') fail "$CVSROOT not defined. It should be when I am invoked from CVSROOT/loginfo" @@ -387,6 +419,7 @@ end $config = arg if opt=="--config" $debug = true if opt == "--debug" + $from_address = arg if opt == "--from" end blah("CVSROOT is #{ENV['CVSROOT']}") @@ -426,6 +459,9 @@ class GUESS end load $config + if $ignore_files + $ignore_file_regexes = $ignore_files.split(/\s+/).map{|i| shell_mask2regex(i)} + end else blah("Config file '#{$config}' not found, ignoring") end @@ -447,3 +483,5 @@ process_log(ARGV[0]) end mailtest + +# vim:et:ts=2:sw=2 Index: record_lastdir.rb =================================================================== --- record_lastdir.rb (.../tags/RELEASE-0_2_12) (revision 277) +++ record_lastdir.rb (.../trunk) (revision 277) @@ -4,7 +4,6 @@ # http://www.badgers-in-foil.co.uk/projects/cvsspam/ # Copyright (c) David Holroyd -$repositorydir = ARGV.shift $tmpdir = ENV["TMPDIR"] || "/tmp" @@ -19,6 +18,36 @@ nil end + +# transform any special / unexpected characters appearing in the argument to +# --from so that they will not cause problems if the value is inserted into +# a file or directory name +def make_fromaddr_safe_for_filename(addr) + addr.gsub(/[^a-zA-Z0-1.,_-]/, "_") +end + +# Option processing doesn't use GetoptLong (for the moment) bacause arguments +# given to this script by CVS include the names of committed files. It +# seems quite possible that one of those file names could begin with a '-' +# and therefore be treated by GetoptLong as a value which requires processing. +# This would probably result in an error. +# +# [That could be worked around by placing a '--' option (which tells GetoptLong +# to stop processing option arguments) at the very end of the arguments to +# record_lastdir.rb in commitinfo, but that's very easily forgotten, and isn't +# really backwards compatable with the behaviour of older CVSspam releases.] +if ARGV.first == "--from" + # we could, of course, be tricked, if the first committed file in the list + # happened to be named '--from' :S + + # drop the "--from" + ARGV.shift + # and use the value which was given following the option, + $dirtemplate << "." << make_fromaddr_safe_for_filename(ARGV.shift) +end + +$repositorydir = ARGV.shift + $datadir = find_data_dir() if $datadir==nil @@ -78,5 +107,7 @@ # email yet. File.open("#{$datadir}/lastdir", "w") { |file| - file.write $repositorydir + file.write $repositorydir } + +# vim:et:ts=2:sw=2 Property changes on: TODO ___________________________________________________________________ Deleted: svn:executable - * Index: project.xml =================================================================== Index: svn_post_commit_hook.rb =================================================================== --- svn_post_commit_hook.rb (.../tags/RELEASE-0_2_12) (revision 0) +++ svn_post_commit_hook.rb (.../trunk) (revision 277) @@ -0,0 +1,412 @@ +#!/usr/bin/ruby -w + +$svnlook_exe = "svnlook" # default assumes the program is in $PATH + +def usage(msg) + $stderr.puts(msg) + exit(1) +end + +def blah(msg) + if $debug + $stderr.puts "svn_post_commit_hook.rb: #{msg}" + end +end + +$debug = false +$tmpdir = ENV["TMPDIR"] || "/tmp" +$dirtemplate = "#svnspam.#{Process.getpgrp}.#{Process.uid}" +# arguments to pass though to 'cvsspam.rb' +$passthrough_args = [] + +def make_data_dir + dir = "#{$tmpdir}/#{$dirtemplate}-#{rand(99999999)}" + Dir.mkdir(dir, 0700) + dir +end + +def init + $datadir = make_data_dir + + # set PWD so that svnlook can create its .svnlook directory + Dir.chdir($datadir) +end + +def cleanup + unless $debug + File.unlink("#{$datadir}/logfile") + Dir.rmdir($datadir) + end +end + +def send_email + cmd = File.dirname($0) + "/cvsspam.rb" + unless system(cmd,"--svn","#{$datadir}/logfile", *$passthrough_args) + fail "problem running '#{cmd}'" + end +end + +# Like IO.popen, but accepts multiple arguments like Kernel.exec +# (So no need to escape shell metacharacters) +def safer_popen(*args) + IO.popen("-") do |pipe| + if pipe==nil + exec(*args) + else + yield pipe + end + end +end + + +# Process the command-line arguments in the given list +def process_args + require 'getoptlong' + + opts = GetoptLong.new( + [ "--to", "-t", GetoptLong::REQUIRED_ARGUMENT ], + [ "--config", "-c", GetoptLong::REQUIRED_ARGUMENT ], + [ "--debug", "-d", GetoptLong::NO_ARGUMENT ], + [ "--from", "-u", GetoptLong::REQUIRED_ARGUMENT ], + [ "--charset", GetoptLong::REQUIRED_ARGUMENT ] + ) + + opts.each do |opt, arg| + if ["--to", "--config", "--from", "--charset"].include?(opt) + $passthrough_args << opt << arg + end + if ["--debug"].include?(opt) + $passthrough_args << opt + end + $config = arg if opt=="--config" + $debug = true if opt == "--debug" + end + + $repository = ARGV[0] + $revision = ARGV[1] + + unless $revision =~ /^\d+$/ + usage("revision must be an integer: #{revision.inspect}") + end + $revision = $revision.to_i + + unless FileTest.directory?($repository) + usage("no such directory: #{$repository.inspect}") + end +end + +# runs the given svnlook subcommand +def svnlook(cmd, revision, *args) + rev = revision.to_s + safer_popen($svnlook_exe, cmd, $repository, "-r", rev, *args) do |io| + yield io + end +end + +class Change + def initialize(filechange, propchange, path) + @filechange = filechange + @propchange = propchange + @path = path + end + + attr_accessor :filechange, :propchange, :path + + def property_change? + @propchange != " " + end + + def file_change? + @filechange != "_" + end + + def addition? + @filechange == "A" + end + + def deletion? + @filechange == "D" + end +end + + + +# Line-oriented access to an underlying IO object. Remembers 'current' line +# for lookahead during parsing. +class LineReader + def initialize(io) + @io = io + end + + def current + @line + end + + def next_line + (@line = @io.gets) != nil + end + + def assert_current(re) + raise "unexpected #{current.inspect}" unless @line =~ re + $~ + end + + def assert_next(re=nil) + raise "unexpected end of text" unless next_line + unless re.nil? + raise "unexpected #{current.inspect}" unless @line =~ re + end + $~ + end +end + + +def read_modified_diff(out, lines, path) + lines.assert_next(/^=+$/) + lines.assert_next + if lines.current =~ /\(Binary files differ\)/ + process_modified_binary_diff(out, lines, path) + else + process_modified_text_diff(out, lines, path) + end +end + + +def process_modified_binary_diff(out, lines, path) + prev_rev= $revision-1 + next_rev= $revision + out.puts "#V #{prev_rev},#{next_rev}" + out.puts "#M #{path}" + out.puts "#U diff x x" + out.puts "#U Binary files x and y differ" +end + + +def process_modified_text_diff(out, lines, path) + m = lines.assert_current(/^---.*\(rev (\d+)\)$/) + prev_rev = m[1].to_i + diff1 = lines.current + m = lines.assert_next(/^\+\+\+.*\(rev (\d+)\)$/) + next_rev = m[1].to_i + diff2 = lines.current + out.puts "#V #{prev_rev},#{next_rev}" + out.puts "#M #{path}" + out.puts "#U #{diff1}" + out.puts "#U #{diff2}" + while lines.next_line && lines.current =~ /^[-\+ @\\]/ + out.puts "#U #{lines.current}" + end +end + +def read_added_diff(out, lines, path) + lines.assert_next(/^=+$/) + lines.assert_next + if lines.current =~ /\(Binary files differ\)/ + process_added_binary_diff(out, lines, path) + else + process_added_text_diff(out, lines, path) + end +end + +def process_added_binary_diff(out, lines, path) + next_rev= $revision + out.puts "#V NONE,#{next_rev}" + out.puts "#A #{path}" + out.puts "#U diff x x" + out.puts "#U Binary file x added" +end + +def process_added_text_diff(out, lines, path) + m = lines.assert_current(/^---.*\(rev (\d+)\)$/) + prev_rev = m[1].to_i + diff1 = lines.current + m = lines.assert_next(/^\+\+\+.*\(rev (\d+)\)$/) + next_rev = m[1].to_i + diff2 = lines.current + out.puts "#V NONE,#{next_rev}" + out.puts "#A #{path}" + out.puts "#U #{diff1}" + out.puts "#U #{diff2}" + while lines.next_line && lines.current =~ /^[-\+ @\\]/ + out.puts "#U #{lines.current}" + end +end + +def read_deleted_diff(out, lines, path) + lines.assert_next(/^=+$/) + m = lines.assert_next(/^---.*\(rev (\d+)\)$/) + prev_rev = m[1].to_i + diff1 = lines.current + m = lines.assert_next(/^\+\+\+.*\(rev (\d+)\)$/) + next_rev = m[1].to_i + diff2 = lines.current + out.puts "#V #{prev_rev},NONE" + out.puts "#R #{path}" + out.puts "#U #{diff1}" + out.puts "#U #{diff2}" + while lines.next_line && lines.current =~ /^[-\+ @\\]/ + out.puts "#U #{lines.current}" + end +end + +def read_property_lines(path, prop_name, revision) + lines = [] + svnlook("propget", revision, prop_name, path) do |io| + io.each_line do |line| + lines << line.chomp + end + end + lines +end + +def assert_prop_match(a, b) + if !b.nil? && a != b + raise "property mismatch: #{a.inspect}!=#{b.inspect}" + end +end + +# We need to read the property change from the output of svnlook, but have +# a difficulty in that there's no unambiguous delimiter marking the end of +# a potentially multi-line property value. Therefore, we do a seperate +# svn propget on the given file to get the value of the property on its own, +# and then use that value as a guide as to how much data to read from the +# svnlook output. +def munch_prop_text(path, prop_name, revision, lines, line0) + prop = read_property_lines(path, prop_name, revision) + if prop.empty? + assert_prop_match(line0, "") + return + end + assert_prop_match(line0, prop.shift) + prop.each do |prop_line| + lines.assert_next + assert_prop_match(lines.current.chomp, prop_line) + end +end + +def read_properties_changed(out, lines, path) + prev_rev= $revision-1 + next_rev= $revision + lines.assert_next(/^_+$/) + return unless lines.next_line + out.puts "#V #{prev_rev},#{next_rev}" + out.puts "#P #{path}" +# The first three get consumed and not highlighted + out.puts "#U " + out.puts "#U Property changes:" + out.puts "#U " + + while true + break unless lines.current =~ /^(?:Name|Added|Deleted): (.+)$/ + + prop_name = $1 + m = lines.assert_next(/^ ([-+]) (.*)/) + op = m[1] + line0 = m[2] + if op == "-" + munch_prop_text(path, prop_name, $revision-1, lines, line0) + if lines.next_line && lines.current =~ /^ \+ (.*)/ + munch_prop_text(path, prop_name, $revision, lines, $1) + lines.next_line + end + else # op == "+" + munch_prop_text(path, prop_name, $revision, lines, line0) + lines.next_line + end + out.puts "#U #{m[1]} #{prop_name}:#{m[2]}" + end + out.puts "#U " +end + +def handle_copy(out, lines, path, from_ref, from_file) + prev_rev= $revision-1 + next_rev= $revision + out.puts "#V #{from_file}:#{prev_rev},#{next_rev}" + out.puts "#C #{path}" + if lines.next_line && lines.current =~ /^=+$/ + m = lines.assert_next(/^---.*\(rev (\d+)\)$/) + prev_rev = m[1].to_i + diff1 = lines.current + m = lines.assert_next(/^\+\+\+.*\(rev (\d+)\)$/) + next_rev = m[1].to_i + diff2 = lines.current + out.puts "#U #{diff1}" + out.puts "#U #{diff2}" + while lines.next_line && lines.current =~ /^[-\+ @\\]/ + out.puts "#U #{lines.current}" + end + else + out.puts "#U " + out.puts "#U Copied from #{from_file}:#{from_ref}" + out.puts "#U " + end +end + +def svnlook_author + svnlook("author", $revision) do |io| + return io.readline.chomp + end + nil +end + +def find_author + return if $passthrough_args.include?("--from") + author = svnlook_author + if author + blah("Author from svnlook: '#{author}'") + $passthrough_args << "--from" << author + end +end + +def process_svnlook_log(file) + svnlook("log", $revision) do |io| + io.each_line do |line| + file.puts("#> #{line}") + end + end +end + +def process_svnlook_diff(file) + svnlook("diff", $revision) do |io| + lines = LineReader.new(io) + while lines.next_line + if lines.current =~ /^Modified:\s+(.*)/ + read_modified_diff(file, lines, $1) + elsif lines.current =~ /^Added:\s+(.*)/ + read_added_diff(file, lines, $1) + elsif lines.current =~ /^Copied:\s+(.*) \(from rev (\d+), (.*)\)$/ + handle_copy(file, lines, $1, $2, $3) + elsif lines.current =~ /^Deleted:\s+(.*)/ + read_deleted_diff(file, lines, $1) + elsif lines.current =~ /^Property changes on:\s+(.*)/ + read_properties_changed(file, lines, $1) + elsif lines.current == "\n" + # ignore + else + raise "unable to parse line '#{lines.current.inspect}'" + end + end + end +end + +def process_commit() + File.open("#{$datadir}/logfile", File::WRONLY|File::CREAT) do |file| + process_svnlook_log(file) + process_svnlook_diff(file) + end +end + + +def main + init() + process_args() + find_author() + process_commit() + send_email() + cleanup() +end + + +main + +# vim:et:ts=2:sw=2 Property changes on: svn_post_commit_hook.rb ___________________________________________________________________ Added: svn:mergeinfo Added: svn:executable + * Property changes on: COPYING ___________________________________________________________________ Deleted: svn:executable - * Index: CREDITS =================================================================== --- CREDITS (.../tags/RELEASE-0_2_12) (revision 277) +++ CREDITS (.../trunk) (revision 277) @@ -29,3 +29,10 @@ Elan Ruusamäe Steve Fox Christopher Petro + Robin Getz + Glen Starrett + Jonathan Rafkind + Ryan Dlugosz + Steve Woodcock + Andy Selle + Charles Duffy Index: cvsspam-doc.xml =================================================================== --- cvsspam-doc.xml (.../tags/RELEASE-0_2_12) (revision 277) +++ cvsspam-doc.xml (.../trunk) (revision 277) @@ -452,6 +452,23 @@ + +
+ RT + + For Gforge, when a CVS log comment contains text like Fix + for Bug [#123], or Task [T456] ..., the + text "[#123]" or "[T456]" will become a hyper-link to that Gforge page in + the generated email. The format [#nnn] and + [Tnnn] is taken from the existing plugin for + Gforge called cvstracker. + + To enable, give your Gforge's URL in CVSspam's configuration file: +$gforgeBugURL = "http://gforge.org/tracker/index.php?func=detail&aid=%s" +$gforgeTaskURL = "http://gforge.org/pm/task.php?func=detailtask&project_task_id=%s" + The marker %s tells CVSspam where in the URL to put the bugId from the + log message. +
CVS Web Frontends Property changes on: cvsspam-doc.xml ___________________________________________________________________ Deleted: svn:executable - * Index: cvsspam.rb =================================================================== --- cvsspam.rb (.../tags/RELEASE-0_2_12) (revision 277) +++ cvsspam.rb (.../trunk) (revision 277) @@ -20,11 +20,14 @@ $version = "0.2.12" +require 'time' $maxSubjectLength = 200 $maxLinesPerDiff = 1000 -$maxDiffLineLength = 1000 # may be set to nil for no limit -$charset = nil # nil implies 'don't specify a charset' +# may be set to nil for no limit +$maxDiffLineLength = 1000 +# nil implies 'don't specify a charset' +$charset = nil $mailSubject = '' def blah(text) @@ -35,10 +38,6 @@ a126 || b==UNDERSCORE || b==TAB - sprintf("=%02x", b) + if b>126 || b==UNDERSCORE || b==TAB || b==HOOK || b==EQUALS + sprintf("=%02X", b) elsif b == SPACE "_" else @@ -163,8 +164,9 @@ # gives a string starting "=?", and including a charset specification, that # marks the start of a quoted-printable character sequence - def marker_start_quoted - "=?#{@charset}?#{@encoding}?" + def marker_start_quoted(charset=nil) + charset = @charset if charset.nil? + "=?#{charset}?#{@encoding}?" end # test to see of the given string contains non-ASCII characters @@ -388,6 +390,7 @@ class FileEntry def initialize(path) @path = path + @fromVer = @toVer = nil @lineAdditions = @lineRemovals = 0 @repository = Repository.get(path) @repository.merge_common_prefix(basedir()) @@ -397,7 +400,7 @@ # the full path and filename within the repository attr_accessor :path - # the type of change committed 'M'=modified, 'A'=added, 'R'=removed + # the type of change committed 'M'=modified, 'A'=added, 'R'=removed, 'P'=properties, 'C'=copied attr_accessor :type # records number of 'addition' lines in diff output, once counted attr_accessor :lineAdditions @@ -452,17 +455,28 @@ def removal? @type == "R" end - + # was this file added during the commit? def addition? @type == "A" end + # was this file copied during the commit? + def copied? + @type == "C" + end + # was this file simply modified during the commit? def modification? @type == "M" end + + # was this file simply modified during the commit? + def modifiedprops? + @type == "P" + end + # passing true, this object remembers that a diff will appear in the email, # passing false, this object remembers that no diff will appear in the email. # Once the value is set, it will not be changed @@ -533,6 +547,14 @@ # TODO: consolidate these into a nicer framework, mailSub = proc { |match| "#{match}" } urlSub = proc { |match| "#{match}" } +gforgeTaskSub = proc { |match| + match =~ /([0-9]+)/ + "#{match}" +} +gforgeBugSub = proc { |match| + match =~ /([0-9]+)/ + "#{match}" +} bugzillaSub = proc { |match| match =~ /([0-9]+)/ "#{match}" @@ -544,15 +566,31 @@ match =~ /([0-9]+)/ "#{match}" } +issueSub = proc { |match| + match =~ /([0-9]+)/ + "#{match}" +} wikiSub = proc { |match| - match =~ /\[\[(.*)\]\]/ + match =~ /\[\[(.*?)\]\]/ raw = $1 "[[#{raw}]]" } +xplannerIterationSub = proc { |match| + match =~ /([0-9]+)/ + "#{match}" +} +xplannerProjectSub = proc { |match| + match =~ /([0-9]+)/ + "#{match}" +} +xplannerStorySub = proc { |match| + match =~ /([0-9]+)/ + "#{match}" +} commentSubstitutions = { - '(?:mailto:)?[\w\.\-\+\=]+\@[\w\-]+(?:\.[\w\-]+)+\b' => mailSub, - '\b(?:http|https|ftp):[^ \t\n<>"]+[\w/]' => urlSub - } + '(?:mailto:)?[\w\.\-\+\=]+\@[\w\-]+(?:\.[\w\-]+)+\b' => mailSub, + '\b(?:http|https|ftp):[^ \t\n<>"]+[\w/]' => urlSub +} # outputs commit log comment text supplied by LogReader as preformatted HTML class CommentHandler < LineConsumer @@ -670,6 +708,12 @@ def diff(file) '->' end + + # may be overridden by subclasses that are able to make a hyperlink to a + # history log for a file + def log(file) + '' + end end # Superclass for objects that can link to CVS frontends on the web (ViewCVS, @@ -710,6 +754,14 @@ "#{super(file)}" end + def log(file) + link = log_url(file) + if link + return "(log)" + end + return nil + end + protected def add_repo(url) if @repository_name @@ -722,6 +774,10 @@ url end end + + def log_url(file) + nil + end end # Link to ViewCVS @@ -745,6 +801,15 @@ def diff_url(file) add_repo("#{@base_url}#{urlEncode(file.path)}.diff?r1=#{file.fromVer}&r2=#{file.toVer}") end + + def log_url(file) + if file.toVer + log_anchor = "#rev#{file.toVer}" + else + log_anchor = "" + end + add_repo("#{@base_url}#{urlEncode(file.path)}#{log_anchor}") + end end # Link to Chora, from the Horde framework @@ -767,9 +832,9 @@ class CVSwebFrontend < WebFrontend def path_url(path, tag) if tag == nil - add_repo(@base_url + urlEncode(path)) + add_repo(@base_url + urlEncode(path) + "/") else - add_repo("#{@base_url}#{urlEncode(path)}?only_with_tag=#{urlEncode(tag)}") + add_repo("#{@base_url}#{urlEncode(path)}/?only_with_tag=#{urlEncode(tag)}") end end @@ -780,9 +845,45 @@ def diff_url(file) add_repo("#{@base_url}#{urlEncode(file.path)}.diff?r1=text&tr1=#{file.fromVer}&r2=text&tr2=#{file.toVer}&f=h") end + + protected + + def log_url(file) + if file.toVer + log_anchor = "#rev#{file.toVer}" + else + log_anchor = "" + end + add_repo("#{@base_url}#{urlEncode(file.path)}#{log_anchor}") + end end +# Link to Trac +class TracFrontend < WebFrontend + def path_url(path, tag) + add_repo("#{@base_url}browser/#{urlEncode(path)}") + end + def version_url(path, version) + add_repo("#{@base_url}browser/#{urlEncode(path)}?rev=#{version}") + end + + def diff_url(file) + add_repo("#{@base_url}changeset/#{file.toVer}") + end + + protected + + def log_url(file) + if file.toVer + log_anchor = "?rev=#{file.toVer}" + else + log_anchor = "" + end + add_repo("#{@base_url}log/#{urlEncode(file.path)}#{log_anchor}") + end +end + # in need of refactoring... # Note when LogReader finds record of a file that was added in this commit @@ -801,6 +902,15 @@ end end +# Note when LogReader finds record of a file that was copied in this commit +class CopiedFileHandler < FileHandler + def handleFile(file) + file.type="C" + file.fromVer=$fromVer + file.toVer=$toVer + end +end + # Note when LogReader finds record of a file that was modified in this commit class ModifiedFileHandler < FileHandler def handleFile(file) @@ -810,7 +920,16 @@ end end +# Note when LogReader finds record of a file whose properties were modified in this commit +class ModifiedPropsFileHandler < FileHandler + def handleFile(file) + file.type="P" + file.fromVer=$fromVer + file.toVer=$toVer + end +end + # Used by UnifiedDiffHandler to record the number of added and removed lines # appearing in a unidiff. class UnifiedDiffStats @@ -873,7 +992,10 @@ addInfixSize = line.length - (prefixLen+suffixLen) oversize_change = deleteInfixSize*100/@lineJustDeleted.length>33 || addInfixSize*100/line.length>33 - if prefixLen==1 && suffixLen==0 || deleteInfixSize<=0 || oversize_change + # avoid doing 'within-a-line highlighting' if a multibyte encoding + # is suspected, as all the suffix/prefix stuff above is byte, not + # character based + if multibyte_encoding? || prefixLen==1 && suffixLen==0 || deleteInfixSize<=0 || oversize_change print(htmlEncode(@lineJustDeleted)) else print(htmlEncode(@lineJustDeleted[0,prefixLen])) @@ -905,7 +1027,7 @@ @lineJustDeleted = nil end shift(initial) - if prefixLen==1 && suffixLen==0 || addInfixSize<=0 || oversize_change + if multibyte_encoding? || prefixLen==1 && suffixLen==0 || addInfixSize<=0 || oversize_change encoded = htmlEncode(line) else encoded = htmlEncode(line[0,prefixLen]) + @@ -958,7 +1080,7 @@ end shift(nil) if @truncatedLineCount>0 - println("[Note: Some over-long lines of diff output only partialy shown]") + println("[Note: Some over-long lines of diff output only partially shown]") end end @@ -976,11 +1098,21 @@ print($frontend.path($file.basedir, $file.tag)) println("
") println("
#{htmlEncode($file.file)} removed after #{$frontend.version($file.path,$file.fromVer)}
") + when "C" + print("") + print($frontend.path($file.basedir, $file.tag)) + println("
") + println("
#{htmlEncode($file.file)} copied from #{$frontend.version($file.path,$file.fromVer)}
") when "M" print("") print($frontend.path($file.basedir, $file.tag)) println("
") println("
#{htmlEncode($file.file)} #{$frontend.version($file.path,$file.fromVer)} #{$frontend.diff($file)} #{$frontend.version($file.path,$file.toVer)}
") + when "P" + print("") + print($frontend.path($file.basedir, $file.tag)) + println("
") + println("
#{htmlEncode($file.file)} #{$frontend.version($file.path,$file.fromVer)} #{$frontend.diff($file)} #{$frontend.version($file.path,$file.toVer)}
") end print("
")
     lines.each do |line|
@@ -1078,7 +1210,7 @@
           @colour.teardown
         end
         println("") # end of "file" div
-	$file.has_diff = true
+        $file.has_diff = true
       end
     end
   end
@@ -1181,7 +1313,7 @@
 
 # an RFC 822 email address
 class EmailAddress
-  def initialize(text)
+  def initialize(text, charset=nil)
     if text =~ /^\s*([^<]+?)\s*<\s*([^>]+?)\s*>\s*$/
       @personal_name = $1
       @address = $2
@@ -1189,9 +1321,10 @@
       @personal_name = nil
       @address = text
     end
+    @charset=charset
   end
 
-  attr_accessor :personal_name, :address
+  attr_accessor :personal_name, :address, :charset
 
   def has_personal_name?
     return !@personal_name.nil?
@@ -1222,9 +1355,9 @@
   # rfc2047 encode the word, if it contains non-ASCII characters
   def encode_word(word)
     if $encoder.requires_rfc2047?(word)
-      encoded = $encoder.marker_start_quoted
+      encoded = $encoder.marker_start_quoted(@charset)
       $encoder.each_char_encoded(word) do |code|
-	encoded << code
+        encoded << code
       end
       encoded << $encoder.marker_end_quoted
       return encoded
@@ -1233,26 +1366,40 @@
   end
 end
 
+# guess if the users selected encoding is multibyte, since some CVSspam code
+# isn't multibyte-safe, and needs to be disabled.
+def multibyte_encoding?
+  $charset && ["utf-8", "utf-16"].include?($charset.downcase)
+end
 
 cvsroot_dir = "#{ENV['CVSROOT']}/CVSROOT"
 $config = "#{cvsroot_dir}/cvsspam.conf"
 $users_file = "#{cvsroot_dir}/users"
+$users_file_charset = nil
 
 $debug = false
+$svn = false
 $recipients = Array.new
 $sendmail_prog = "/usr/sbin/sendmail"
 $hostname = ENV['HOSTNAME'] || 'localhost'
 $no_removed_file_diff = false
 $no_added_file_diff = false
 $no_diff = false
-$task_keywords = ['TODO', 'FIXME']
+$task_keywords = ['TODO', 'FIXME', 'FIXIT', 'todo']
 $bugzillaURL = nil
+$gforgeBugURL = nil
+$gforgeTaskURL = nil
 $wikiURL = nil
 $jiraURL = nil
 $ticketURL = nil
+$issueURL = nil
 $viewcvsURL = nil
+$xplannerIterationURL = nil
+$xplannerProjectURL = nil
+$xplannerStoryURL = nil
 $choraURL = nil
 $cvswebURL = nil
+$tracURL = nil
 $from_address = nil
 $subjectPrefix = nil
 $files_in_subject = false;
@@ -1261,6 +1408,7 @@
 # 2MiB limit on attached diffs,
 $mail_size_limit = 1024 * 1024 * 2
 $arg_charset = nil
+$cvsroot_email_header = false
 
 require 'getoptlong'
 
@@ -1268,6 +1416,7 @@
   [ "--to",     "-t", GetoptLong::REQUIRED_ARGUMENT ],
   [ "--config", "-c", GetoptLong::REQUIRED_ARGUMENT ],
   [ "--debug",  "-d", GetoptLong::NO_ARGUMENT ],
+  [ "--svn",    "-s", GetoptLong::NO_ARGUMENT ],
   [ "--from",   "-u", GetoptLong::REQUIRED_ARGUMENT ],
   [ "--charset",      GetoptLong::REQUIRED_ARGUMENT ]
 )
@@ -1276,6 +1425,7 @@
   $recipients << EmailAddress.new(arg) if opt=="--to"
   $config = arg if opt=="--config"
   $debug = true if opt=="--debug"
+  $svn = true if opt=="--svn"
   $from_address = EmailAddress.new(arg) if opt=="--from"
   # must use different variable as the config is readed later.
   $arg_charset = arg if opt == "--charset"
@@ -1288,7 +1438,7 @@
   else
     $stderr.puts "missing required file argument"
   end
-  puts "Usage: cvsspam.rb [ --to  ] [ --config  ] "
+  puts "Usage: cvsspam.rb [ --svn ] [ --to  ] [ --config  ] "
   exit(-1)
 end
 
@@ -1321,6 +1471,8 @@
   blah("Config file '#{$config}' not found, ignoring")
 end
 
+blah("Users file: '#{$users_file}'")
+
 unless $arg_charset.nil?
   $charset = $arg_charset
 end
@@ -1337,6 +1489,9 @@
 elsif $cvswebURL !=nil
   $cvswebURL << "/" unless $cvswebURL =~ /\/$/
   $frontend = CVSwebFrontend.new($cvswebURL)
+elsif $tracURL !=nil
+  $tracURL << "/" unless $tracURL =~ /\/$/
+  $frontend = TracFrontend.new($tracURL)
 else
   $frontend = NoFrontend.new
 end
@@ -1353,33 +1508,57 @@
 
 
 if $bugzillaURL != nil
-  commentSubstitutions['\b[Bb][Uu][Gg]\s*#?[0-9]+'] = bugzillaSub
+  commentSubstitutions['\b[Bb](?:[Uu][Gg])?\s*[#:]?\s*\[?[0-9]+\]?'] = bugzillaSub
 end
+if $gforgeBugURL != nil
+  commentSubstitutions['\B\[#[0-9]+\]'] = gforgeBugSub
+end
+if $gforgeTaskURL != nil
+  commentSubstitutions['\B\[[Tt][0-9]+\]'] = gforgeTaskSub
+end
 if $jiraURL != nil
   commentSubstitutions['\b[a-zA-Z]+-[0-9]+\b'] = jiraSub
 end
 if $ticketURL != nil
   commentSubstitutions['\b[Tt][Ii][Cc][Kk][Ee][Tt]\s*#?[0-9]+\b'] = ticketSub
 end
+if $issueURL != nil
+  commentSubstitutions['\b[Ii][Ss][Ss][Uu][Ee]\s*#?[0-9]+\b'] = issueSub
+end
 if $wikiURL != nil
   commentSubstitutions['\[\[.+\]\]'] = wikiSub
 end
+if $xplannerIterationURL != nil
+  commentSubstitutions['\bXI\[?[0-9]+\]?'] = xplannerIterationSub
+end
+if $xplannerProjectURL != nil
+  commentSubstitutions['\bXP\[?[0-9]+\]?'] = xplannerProjectSub
+end
+if $xplannerStoryURL != nil
+  commentSubstitutions['\bXS\[?[0-9]+\]?'] = xplannerStorySub
+end
 $commentEncoder = MultiSub.new(commentSubstitutions)
 
 
 tagHandler = TagHandler.new
 
-$handlers = Hash[">" => CommentHandler.new,
-		 "U" => UnifiedDiffHandler.new,
-		 "T" => tagHandler,
-		 "A" => AddedFileHandler.new,
-		 "R" => RemovedFileHandler.new,
-		 "M" => ModifiedFileHandler.new,
-		 "V" => VersionHandler.new]
+$handlers = Hash[
+  ">" => CommentHandler.new,
+  "U" => UnifiedDiffHandler.new,
+  "T" => tagHandler,
+  "A" => AddedFileHandler.new,
+  "R" => RemovedFileHandler.new,
+  "C" => CopiedFileHandler.new,
+  "M" => ModifiedFileHandler.new,
+  "P" => ModifiedPropsFileHandler.new,
+  "V" => VersionHandler.new
+]
 
 $handlers["A"].setTagHandler(tagHandler)
 $handlers["R"].setTagHandler(tagHandler)
+$handlers["C"].setTagHandler(tagHandler)
 $handlers["M"].setTagHandler(tagHandler)
+$handlers["P"].setTagHandler(tagHandler)
 
 $fileEntries = Array.new
 $task_list = Array.new
@@ -1404,7 +1583,11 @@
 end
 
 if $subjectPrefix == nil
-  $subjectPrefix = "[CVS #{Repository.array.join(',')}]"
+  if $svn
+    $subjectPrefix = "[SVN #{Repository.array.join(',')}]"
+  else
+    $subjectPrefix = "[CVS #{Repository.array.join(',')}]"
+  end
 end
 
 if $files_in_subject
@@ -1451,6 +1634,8 @@
   #removed {background-color:#ffdddd;}
   #removedchars {background-color:#ff9999;font-weight:bolder;}
   tr.alt #removed {background-color:#f7cccc;}
+  #copied {background-color:#ccccff;}
+  tr.alt #copied {background-color:#bbbbf7;}
   #info {color:#888888;}
   #context {background-color:#eeeeee;}
   td {padding-left:.3em;padding-right:.3em;}
@@ -1483,7 +1668,9 @@
 
   filesAdded = 0
   filesRemoved = 0
+  filesCopied = 0
   filesModified  = 0
+  filesModifiedProps  = 0
   totalLinesAdded = 0
   totalLinesRemoved = 0
   file_count = 0
@@ -1492,24 +1679,26 @@
   $fileEntries.each do |file|
     unless file.repository == last_repository
       last_repository = file.repository
-      mail.print("")
+      mail.print("")
       if last_repository.has_multiple_tags
         mail.print("Mixed-tag commit")
       else
         mail.print("Commit")
       end
       mail.print(" in #{htmlEncode(last_repository.common_prefix)}")
-      if last_repository.trunk_only?
-        mail.print(" on MAIN")
-      else
-        mail.print(" on ")
-        tagCount = 0
-        last_repository.each_tag do |tag|
-          tagCount += 1
-          if tagCount > 1
-            mail.print tagCount on MAIN")
+        else
+          mail.print(" on ")
+          tagCount = 0
+          last_repository.each_tag do |tag|
+            tagCount += 1
+            if tagCount > 1
+              mail.print tagCountMAIN"
           end
-          mail.print tag ? htmlEncode(tag) : "MAIN"
         end
       end
       mail.puts("")
@@ -1524,8 +1713,12 @@
       filesAdded += 1
     elsif file.removal?
       filesRemoved += 1
+    elsif file.copied?
+      filesCopied += 1
     elsif file.modification?
       filesModified += 1
+    elsif file.modifiedprops?
+      filesModifiedProps += 1
     end
     name = htmlEncode(file.name_after_common_prefix)
     slashPos = name.rindex("/")
@@ -1545,17 +1738,29 @@
       name = "#{name}"
     elsif file.removal?
       name = "#{name}"
+    elsif file.copied?
+      name = "#{name}"
     end
+    mail.print("")
     if file.has_diff?
-      mail.print("#{prefix}#{name}")
+      mail.print("#{prefix}#{name}")
     else
-      mail.print("#{prefix}#{name}")
+      mail.print("#{prefix}#{name}")
     end
-    if file.isEmpty
-      mail.print("[empty]")
+    mail.print(" #{$frontend.log(file)}")
+    mail.print("")
+    if file.copied?
+      mail.print("[copied]")
+    elsif file.isEmpty
+      mail.print("[empty]")
     elsif file.isBinary
-      mail.print("[binary]")
+      mail.print("[binary]")
     else
+      if file.modifiedprops?
+        mail.print("[props]")
+      else
+        mail.print("")
+      end
       if file.lineAdditions>0
         totalLinesAdded += file.lineAdditions
         mail.print("+#{file.lineAdditions}")
@@ -1582,15 +1787,19 @@
       mail.print("added #{$frontend.version(file.path,file.toVer)}")
     elsif file.removal?
       mail.print("#{$frontend.version(file.path,file.fromVer)} removed")
+    elsif file.copied?
+      mail.print("#{$frontend.version(file.path,file.fromVer)} #{$frontend.diff(file)} #{$frontend.version(file.path,file.toVer)}")
     elsif file.modification?
       mail.print("#{$frontend.version(file.path,file.fromVer)} #{$frontend.diff(file)} #{$frontend.version(file.path,file.toVer)}")
+    elsif file.modifiedprops?
+      mail.print("#{$frontend.version(file.path,file.fromVer)} #{$frontend.diff(file)} #{$frontend.version(file.path,file.toVer)}")
     end
 
     mail.puts("")
   end
   if $fileEntries.size>1 && (totalLinesAdded+totalLinesRemoved)>0
     # give total number of lines added/removed accross all files
-    mail.print("")
+    mail.print("")
     if totalLinesAdded>0
       mail.print("+#{totalLinesAdded}")
     else
@@ -1607,7 +1816,7 @@
   
   mail.puts("")
 
-  totalFilesChanged = filesAdded+filesRemoved+filesModified
+  totalFilesChanged = filesAdded+filesRemoved+filesCopied+filesModified+filesModifiedProps
   if totalFilesChanged > 1
     mail.print("")
     changeKind = 0
@@ -1620,11 +1829,21 @@
       mail.print("#{filesRemoved} removed")
       changeKind += 1
     end
+    if filesCopied>0
+      mail.print(" + ") if changeKind>0
+      mail.print("#{filesCopied} copied")
+      changeKind += 1
+    end
     if filesModified>0
       mail.print(" + ") if changeKind>0
       mail.print("#{filesModified} modified")
       changeKind += 1
     end
+    if filesModifiedProps>0
+      mail.print(" + ") if changeKind>0
+      mail.print("#{filesModifiedProps} modified properties")
+      changeKind += 1
+    end
     mail.print(", total #{totalFilesChanged}") if changeKind > 1
     mail.puts(" files
") end @@ -1667,12 +1886,13 @@ # CVSROOT/users file, if the file exists. The argument is returned unchanged # if no alias is found. def sender_alias(email) + blah("Lookup '#{email}' from users file") if File.exists?($users_file) File.open($users_file) do |io| io.each_line do |line| if line =~ /^([^:]+)\s*:\s*(['"]?)([^\n\r]+)(\2)/ if email.address == $1 - return EmailAddress.new($3) + return EmailAddress.new($3, $users_file_charset) end end end @@ -1686,6 +1906,8 @@ # sensible header formatting, and for ensuring that the body is seperated # from the message headers by a blank line (as it is required to be). class MailContext + ENCODE_HEADERS = ["Subject", "X-CVSspam-Module-Path"] + def initialize(io) @done_headers = false @io = io @@ -1695,8 +1917,8 @@ # called def header(name, value) raise "headers already commited" if @done_headers - if name == "Subject" - $encoder.encode_header(@io, "Subject", value) + if ENCODE_HEADERS.include?(name) + $encoder.encode_header(@io, name, value) else @io.puts("#{name}: #{value}") end @@ -1769,7 +1991,7 @@ ctx.header("To", recipients.map{|addr| addr.encoded}.join(',')) blah("Mail From: <#{from}>") ctx.header("From", from.encoded) if from - ctx.header("Date", Time.now.utc.strftime(DATE_HEADER_FORMAT)) + ctx.header("Date", Time.now.rfc2822) yield ctx end end @@ -1800,10 +2022,10 @@ return unless $fileEntries.length == 1 file = $fileEntries[0] name = zap_header_special_chars(file.path) - unless file.fromVer == "NONE" + if file.fromVer mail.header("References", make_msg_id("#{name}.#{file.fromVer}", $hostname)) end - unless file.toVer == "NONE" + if file.toVer mail.header("Message-ID", make_msg_id("#{name}.#{file.toVer}", $hostname)) end end @@ -1834,8 +2056,18 @@ end end mail.header("X-Mailer", "CVSspam #{$version} ") + if $cvsroot_email_header + mod = '/' + if Repository.count == 1 + rep = Repository.array.first + mod << rep.common_prefix + end + mail.header("X-CVSspam-Module-Path", mod) + end mail.body do |body| make_html_email(body) end end + +# vim:et:ts=2:sw=2 Property changes on: testcases/data/remove.png ___________________________________________________________________ Deleted: svn:executable - * Property changes on: testcases/data/fiddlyedits.after ___________________________________________________________________ Deleted: svn:executable - * Property changes on: testcases/data/fiddlyedits.before ___________________________________________________________________ Deleted: svn:executable - * Property changes on: testcases/data/add.png ___________________________________________________________________ Deleted: svn:executable - * Property changes on: testcases/README ___________________________________________________________________ Deleted: svn:executable - * Property changes on: . ___________________________________________________________________ Added: svn:ignore + .project