2 ===================================================================
3 --- cvsspam.conf (.../tags/RELEASE-0_2_12) (revision 275)
4 +++ cvsspam.conf (.../trunk) (revision 275)
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)
34 # cvsdiff keyword ignoring (Default: show changes in keywords)
36 # Changes in CVS keywords can be distracting. For instance, the
37 -# $Revision$ keyword will change on each commit. Set this value to true
38 +# $Revision$ keyword will change on each commit. Set this value to true
39 # to exclude changes in keyword fields (adds the -kk option to cvs diff).
41 #$diff_ignore_keywords = true
44 +# cvsdiff whitespace ignoring (Default: show whitespace-only changes)
46 +# Whitespace-only changes can distract from the rest of a diff. Set this
47 +# value to true to exclude changes in the amount of whitespace (adds the -b
48 +# option to cvs diff).
50 +$diff_ignore_whitespace = true
53 # $no_removed_file_diff and $no_added_file_diff
55 # Set both these options, and emails will only include diffs for files
57 # Allows the specification of a character set for all generated emails.
58 # The files CVS is dealing with should already be in the character set you
59 # specify -- no transcoding is done.
61 +# Note that you can override this with --charset argument per module, etc.
63 #$charset="ISO-8859-1"
67 +# Users file (Default: $CVSROOT/CVSROOT/users)
69 +# Specify users file to lookup From addresses for commites
71 +#$users_file = "/srv/svn/users"
73 +# Users file charset (Default: $charset)
75 +# If the users file is encoded differently than $charset, You can override
76 +# it here. Especially useful if you use --charset argument. See above.
78 +#$users_file_charset = "ISO-8859-1"
81 # File names in Subject (Default: no filenames in Subject)
83 # Some people like file names to appear in the email subject. To make
84 # them happy, you can say $files_in_subject = true here.
86 #$files_in_subject = false
89 +# Module Path email header (Default: no X-CVSspam-Module-Path header)
91 +# Sets 'X-CVSspam-Module-Path' header to contain common path of files commited.
92 +# Useful for server side mail filtering.
94 +#$cvsroot_email_header = true
96 +# Email size limit (Default: around 2MB)
98 +# When large changes are committed, large CVSspam emails can result. Here
99 +# you can set the size of email that CVSspam is not allowed to append any
100 +# more diffs onto. Specify the number of bytes.
102 +#$mail_size_limit = 2097152
104 Property changes on: cvsspam.conf
105 ___________________________________________________________________
106 Deleted: svn:executable
108 Modified: svn:keywords
109 - Author Date Id Revision
112 Index: collect_diffs.rb
113 ===================================================================
114 --- collect_diffs.rb (.../tags/RELEASE-0_2_12) (revision 275)
115 +++ collect_diffs.rb (.../trunk) (revision 275)
117 $dirtemplate = "#cvsspam.#{Process.getpgrp}.#{Process.uid}"
121 + safe_from = make_fromaddr_safe_for_filename($from_address)
122 + Dir["#{$tmpdir}/#{$dirtemplate}.#{safe_from}-*"].each do |dir|
123 + stat = File.stat(dir)
124 + return dir if stat.owned?
127 Dir["#{$tmpdir}/#{$dirtemplate}-*"].each do |dir|
128 stat = File.stat(dir)
129 return dir if stat.owned?
134 +# transform any special / unexpected characters appearing in the argument to
135 +# --from so that they will not cause problems if the value is inserted into
136 +# a file or directory name
137 +def make_fromaddr_safe_for_filename(addr)
138 + addr.gsub(/[^a-zA-Z0-1.,_-]/, "_")
144 $stderr.puts "collect_diffs.rb: #{msg}"
148 while i < cvs_info.length
149 - changes << ChangeInfo.new(cvs_info[i], cvs_info[i+=1], cvs_info[i+=1])
150 + change_file = cvs_info[i]
151 + # It's been reported,
152 + # http://lists.badgers-in-foil.co.uk/pipermail/cvsspam-devel/2005-September/000380.html
153 + # that sometimes the second revision number that CVS gives us contains a
154 + # trailing newline character, so we strip ws from these values before use,
155 + change_from = cvs_info[i+=1].strip
156 + change_to = cvs_info[i+=1].strip
157 + changes << ChangeInfo.new(change_file, change_from, change_to)
163 diff_cmd = Array.new << $cvs_prog << "-nq" << "diff" << "-Nu"
164 diff_cmd << "-kk" if $diff_ignore_keywords
165 + diff_cmd << "-b" if $diff_ignore_whitespace
176 $diff_ignore_keywords = false
177 +$diff_ignore_whitespace = false
180 unless ENV.has_key?('CVSROOT')
183 $config = arg if opt=="--config"
184 $debug = true if opt == "--debug"
185 + $from_address = arg if opt == "--from"
188 blah("CVSROOT is #{ENV['CVSROOT']}")
189 Index: record_lastdir.rb
190 ===================================================================
191 --- record_lastdir.rb (.../tags/RELEASE-0_2_12) (revision 275)
192 +++ record_lastdir.rb (.../trunk) (revision 275)
194 # http://www.badgers-in-foil.co.uk/projects/cvsspam/
195 # Copyright (c) David Holroyd
197 -$repositorydir = ARGV.shift
199 $tmpdir = ENV["TMPDIR"] || "/tmp"
206 +# transform any special / unexpected characters appearing in the argument to
207 +# --from so that they will not cause problems if the value is inserted into
208 +# a file or directory name
209 +def make_fromaddr_safe_for_filename(addr)
210 + addr.gsub(/[^a-zA-Z0-1.,_-]/, "_")
213 +# Option processing doesn't use GetoptLong (for the moment) bacause arguments
214 +# given to this script by CVS include the names of committed files. It
215 +# seems quite possible that one of those file names could begin with a '-'
216 +# and therefore be treated by GetoptLong as a value which requires processing.
217 +# This would probably result in an error.
219 +# [That could be worked around by placing a '--' option (which tells GetoptLong
220 +# to stop processing option arguments) at the very end of the arguments to
221 +# record_lastdir.rb in commitinfo, but that's very easily forgotten, and isn't
222 +# really backwards compatable with the behaviour of older CVSspam releases.]
223 +if ARGV.first == "--from"
224 + # we could, of course, be tricked, if the first committed file in the list
225 + # happened to be named '--from' :S
227 + # drop the "--from"
229 + # and use the value which was given following the option,
230 + $dirtemplate << "." << make_fromaddr_safe_for_filename(ARGV.shift)
233 +$repositorydir = ARGV.shift
235 $datadir = find_data_dir()
239 Property changes on: TODO
240 ___________________________________________________________________
241 Deleted: svn:executable
245 ===================================================================
246 Index: svn_post_commit_hook.rb
247 ===================================================================
248 --- svn_post_commit_hook.rb (.../tags/RELEASE-0_2_12) (revision 0)
249 +++ svn_post_commit_hook.rb (.../trunk) (revision 275)
253 +$svnlook_exe = "svnlook" # default assumes the program is in $PATH
262 + $stderr.puts "svn_post_commit_hook.rb: #{msg}"
267 +$tmpdir = ENV["TMPDIR"] || "/tmp"
268 +$dirtemplate = "#svnspam.#{Process.getpgrp}.#{Process.uid}"
269 +# arguments to pass though to 'cvsspam.rb'
270 +$passthrough_args = []
273 + dir = "#{$tmpdir}/#{$dirtemplate}-#{rand(99999999)}"
274 + Dir.mkdir(dir, 0700)
279 + $datadir = make_data_dir
281 + # set PWD so that svnlook can create its .svnlook directory
282 + Dir.chdir($datadir)
287 + File.unlink("#{$datadir}/logfile")
288 + Dir.rmdir($datadir)
293 + cmd = File.dirname($0) + "/cvsspam.rb"
294 + unless system(cmd,"--svn","#{$datadir}/logfile", *$passthrough_args)
295 + fail "problem running '#{cmd}'"
299 +# Like IO.popen, but accepts multiple arguments like Kernel.exec
300 +# (So no need to escape shell metacharacters)
301 +def safer_popen(*args)
302 + IO.popen("-") do |pipe|
312 +# Process the command-line arguments in the given list
314 + require 'getoptlong'
316 + opts = GetoptLong.new(
317 + [ "--to", "-t", GetoptLong::REQUIRED_ARGUMENT ],
318 + [ "--config", "-c", GetoptLong::REQUIRED_ARGUMENT ],
319 + [ "--debug", "-d", GetoptLong::NO_ARGUMENT ],
320 + [ "--from", "-u", GetoptLong::REQUIRED_ARGUMENT ],
321 + [ "--charset", GetoptLong::REQUIRED_ARGUMENT ]
324 + opts.each do |opt, arg|
325 + if ["--to", "--config", "--from", "--charset"].include?(opt)
326 + $passthrough_args << opt << arg
328 + if ["--debug"].include?(opt)
329 + $passthrough_args << opt
331 + $config = arg if opt=="--config"
332 + $debug = true if opt == "--debug"
335 + $repository = ARGV[0]
336 + $revision = ARGV[1]
338 + unless $revision =~ /^\d+$/
339 + usage("revision must be an integer: #{revision.inspect}")
341 + $revision = $revision.to_i
343 + unless FileTest.directory?($repository)
344 + usage("no such directory: #{$repository.inspect}")
348 +# runs the given svnlook subcommand
349 +def svnlook(cmd, revision, *args)
350 + rev = revision.to_s
351 + safer_popen($svnlook_exe, cmd, $repository, "-r", rev, *args) do |io|
357 + def initialize(filechange, propchange, path)
358 + @filechange = filechange
359 + @propchange = propchange
363 + attr_accessor :filechange, :propchange, :path
365 + def property_change?
384 +# Line-oriented access to an underlying IO object. Remembers 'current' line
385 +# for lookahead during parsing.
396 + (@line = @io.gets) != nil
399 + def assert_current(re)
400 + raise "unexpected #{current.inspect}" unless @line =~ re
404 + def assert_next(re=nil)
405 + raise "unexpected end of text" unless next_line
407 + raise "unexpected #{current.inspect}" unless @line =~ re
414 +def read_modified_diff(out, lines, path)
415 + lines.assert_next(/^=+$/)
417 + if lines.current =~ /\(Binary files differ\)/
418 + process_modified_binary_diff(out, lines, path)
420 + process_modified_text_diff(out, lines, path)
425 +def process_modified_binary_diff(out, lines, path)
426 + prev_rev= $revision-1
427 + next_rev= $revision
428 + out.puts "#V #{prev_rev},#{next_rev}"
429 + out.puts "#M #{path}"
430 + out.puts "#U diff x x"
431 + out.puts "#U Binary files x and y differ"
435 +def process_modified_text_diff(out, lines, path)
436 + m = lines.assert_current(/^---.*\(rev (\d+)\)$/)
437 + prev_rev = m[1].to_i
438 + diff1 = lines.current
439 + m = lines.assert_next(/^\+\+\+.*\(rev (\d+)\)$/)
440 + next_rev = m[1].to_i
441 + diff2 = lines.current
442 + out.puts "#V #{prev_rev},#{next_rev}"
443 + out.puts "#M #{path}"
444 + out.puts "#U #{diff1}"
445 + out.puts "#U #{diff2}"
446 + while lines.next_line && lines.current =~ /^[-\+ @\\]/
447 + out.puts "#U #{lines.current}"
451 +def read_added_diff(out, lines, path)
452 + lines.assert_next(/^=+$/)
454 + if lines.current =~ /\(Binary files differ\)/
455 + process_added_binary_diff(out, lines, path)
457 + process_added_text_diff(out, lines, path)
461 +def process_added_binary_diff(out, lines, path)
462 + next_rev= $revision
463 + out.puts "#V NONE,#{next_rev}"
464 + out.puts "#A #{path}"
465 + out.puts "#U diff x x"
466 + out.puts "#U Binary file x added"
469 +def process_added_text_diff(out, lines, path)
470 + m = lines.assert_current(/^---.*\(rev (\d+)\)$/)
471 + prev_rev = m[1].to_i
472 + diff1 = lines.current
473 + m = lines.assert_next(/^\+\+\+.*\(rev (\d+)\)$/)
474 + next_rev = m[1].to_i
475 + diff2 = lines.current
476 + out.puts "#V NONE,#{next_rev}"
477 + out.puts "#A #{path}"
478 + out.puts "#U #{diff1}"
479 + out.puts "#U #{diff2}"
480 + while lines.next_line && lines.current =~ /^[-\+ @\\]/
481 + out.puts "#U #{lines.current}"
485 +def read_deleted_diff(out, lines, path)
486 + lines.assert_next(/^=+$/)
487 + m = lines.assert_next(/^---.*\(rev (\d+)\)$/)
488 + prev_rev = m[1].to_i
489 + diff1 = lines.current
490 + m = lines.assert_next(/^\+\+\+.*\(rev (\d+)\)$/)
491 + next_rev = m[1].to_i
492 + diff2 = lines.current
493 + out.puts "#V #{prev_rev},NONE"
494 + out.puts "#R #{path}"
495 + out.puts "#U #{diff1}"
496 + out.puts "#U #{diff2}"
497 + while lines.next_line && lines.current =~ /^[-\+ @\\]/
498 + out.puts "#U #{lines.current}"
502 +def read_property_lines(path, prop_name, revision)
504 + svnlook("propget", revision, prop_name, path) do |io|
505 + io.each_line do |line|
506 + lines << line.chomp
512 +def assert_prop_match(a, b)
513 + if !b.nil? && a != b
514 + raise "property mismatch: #{a.inspect}!=#{b.inspect}"
518 +# We need to read the property change from the output of svnlook, but have
519 +# a difficulty in that there's no unambiguous delimiter marking the end of
520 +# a potentially multi-line property value. Therefore, we do a seperate
521 +# svn propget on the given file to get the value of the property on its own,
522 +# and then use that value as a guide as to how much data to read from the
524 +def munch_prop_text(path, prop_name, revision, lines, line0)
525 + prop = read_property_lines(path, prop_name, revision)
527 + assert_prop_match(line0, "")
530 + assert_prop_match(line0, prop.shift)
531 + prop.each do |prop_line|
533 + assert_prop_match(lines.current.chomp, prop_line)
537 +def read_properties_changed(out, lines, path)
538 + prev_rev= $revision-1
539 + next_rev= $revision
540 + lines.assert_next(/^_+$/)
541 + return unless lines.next_line
542 + out.puts "#V #{prev_rev},#{next_rev}"
543 + out.puts "#P #{path}"
544 +# The first three get consumed and not highlighted
546 + out.puts "#U Property changes:"
550 + break unless lines.current =~ /^(?:Name|Added|Deleted): (.+)$/
553 + m = lines.assert_next(/^ ([-+]) (.*)/)
557 + munch_prop_text(path, prop_name, $revision-1, lines, line0)
558 + if lines.next_line && lines.current =~ /^ \+ (.*)/
559 + munch_prop_text(path, prop_name, $revision, lines, $1)
563 + munch_prop_text(path, prop_name, $revision, lines, line0)
566 + out.puts "#U #{m[1]} #{prop_name}:#{m[2]}"
571 +def handle_copy(out, lines, path, from_ref, from_file)
572 + prev_rev= $revision-1
573 + next_rev= $revision
574 + out.puts "#V #{from_file}:#{prev_rev},#{next_rev}"
575 + out.puts "#C #{path}"
576 + if lines.next_line && lines.current =~ /^=+$/
577 + m = lines.assert_next(/^---.*\(rev (\d+)\)$/)
578 + prev_rev = m[1].to_i
579 + diff1 = lines.current
580 + m = lines.assert_next(/^\+\+\+.*\(rev (\d+)\)$/)
581 + next_rev = m[1].to_i
582 + diff2 = lines.current
583 + out.puts "#U #{diff1}"
584 + out.puts "#U #{diff2}"
585 + while lines.next_line && lines.current =~ /^[-\+ @\\]/
586 + out.puts "#U #{lines.current}"
590 + out.puts "#U Copied from #{from_file}:#{from_ref}"
596 + svnlook("author", $revision) do |io|
597 + return io.readline.chomp
603 + return if $passthrough_args.include?("--from")
604 + author = svnlook_author
606 + blah("Author from svnlook: '#{author}'")
607 + $passthrough_args << "--from" << author
611 +def process_svnlook_log(file)
612 + svnlook("log", $revision) do |io|
613 + io.each_line do |line|
614 + file.puts("#> #{line}")
619 +def process_svnlook_diff(file)
620 + svnlook("diff", $revision) do |io|
621 + lines = LineReader.new(io)
622 + while lines.next_line
623 + if lines.current =~ /^Modified:\s+(.*)/
624 + read_modified_diff(file, lines, $1)
625 + elsif lines.current =~ /^Added:\s+(.*)/
626 + read_added_diff(file, lines, $1)
627 + elsif lines.current =~ /^Copied:\s+(.*) \(from rev (\d+), (.*)\)$/
628 + handle_copy(file, lines, $1, $2, $3)
629 + elsif lines.current =~ /^Deleted:\s+(.*)/
630 + read_deleted_diff(file, lines, $1)
631 + elsif lines.current =~ /^Property changes on:\s+(.*)/
632 + read_properties_changed(file, lines, $1)
633 + elsif lines.current == "\n"
636 + raise "unable to parse line '#{lines.current.inspect}'"
642 +def process_commit()
643 + File.open("#{$datadir}/logfile", File::WRONLY|File::CREAT) do |file|
644 + process_svnlook_log(file)
645 + process_svnlook_diff(file)
662 Property changes on: svn_post_commit_hook.rb
663 ___________________________________________________________________
665 Added: svn:executable
669 Property changes on: COPYING
670 ___________________________________________________________________
671 Deleted: svn:executable
675 ===================================================================
676 --- CREDITS (.../tags/RELEASE-0_2_12) (revision 275)
677 +++ CREDITS (.../trunk) (revision 275)
689 Index: cvsspam-doc.xml
690 ===================================================================
691 --- cvsspam-doc.xml (.../tags/RELEASE-0_2_12) (revision 275)
692 +++ cvsspam-doc.xml (.../trunk) (revision 275)
694 </screen></informalexample>
701 + <para>For Gforge, when a CVS log comment contains text like <userinput>Fix
702 + for Bug [#123]</userinput>, or <userinput>Task [T456] ...</userinput>, the
703 + text "[#123]" or "[T456]" will become a hyper-link to that Gforge page in
704 + the generated email. The format [#<replaceable>nnn</replaceable>] and
705 + [T<replaceable>nnn</replaceable>] is taken from the existing plugin for
706 + Gforge called cvstracker.</para>
708 + <para>To enable, give your Gforge's URL in CVSspam's configuration file:
709 +<informalexample><screen>$gforgeBugURL = "http://gforge.org/tracker/index.php?func=detail&aid=%s"
710 +$gforgeTaskURL = "http://gforge.org/pm/task.php?func=detailtask&project_task_id=%s"</screen></informalexample>
711 + The marker %s tells CVSspam where in the URL to put the bugId from the
712 + log message.</para>
716 <section><title>CVS Web Frontends</title>
718 Property changes on: cvsspam-doc.xml
719 ___________________________________________________________________
720 Deleted: svn:executable
724 ===================================================================
725 --- cvsspam.rb (.../tags/RELEASE-0_2_12) (revision 275)
726 +++ cvsspam.rb (.../trunk) (revision 275)
733 $maxSubjectLength = 200
734 $maxLinesPerDiff = 1000
739 -# NB must ensure the time is UTC
740 -# (the Ruby Time object's strftime() doesn't supply a numeric timezone)
741 -DATE_HEADER_FORMAT = "%a, %d %b %Y %H:%M:%S +0000"
743 # Perform (possibly) multiple global substitutions on a string.
744 # the regexps given as keys must not use capturing subexpressions '(...)'
749 hash.each do |key,val|
750 - if expr == nil ; expr="(" else expr<<"|(" end
751 + if expr == nil ; expr="(" else expr << "|(" end
756 UNDERSCORE = chr("_")
762 # encode a header value according to the RFC-2047 quoted-printable spec,
763 # allowing non-ASCII characters to appear in header values, and wrapping
765 # return a string representing the given character-code in quoted-printable
767 def quoted_encode_char(b)
768 - if b>126 || b==UNDERSCORE || b==TAB
769 - sprintf("=%02x", b)
770 + if b>126 || b==UNDERSCORE || b==TAB || b==HOOK || b==EQUALS
771 + sprintf("=%02X", b)
777 # gives a string starting "=?", and including a charset specification, that
778 # marks the start of a quoted-printable character sequence
779 - def marker_start_quoted
780 - "=?#{@charset}?#{@encoding}?"
781 + def marker_start_quoted(charset=nil)
782 + charset = @charset if charset.nil?
783 + "=?#{charset}?#{@encoding}?"
786 # test to see of the given string contains non-ASCII characters
791 + @fromVer = @toVer = nil
792 @lineAdditions = @lineRemovals = 0
793 @repository = Repository.get(path)
794 @repository.merge_common_prefix(basedir())
797 # the full path and filename within the repository
799 - # the type of change committed 'M'=modified, 'A'=added, 'R'=removed
800 + # the type of change committed 'M'=modified, 'A'=added, 'R'=removed, 'P'=properties, 'C'=copied
802 # records number of 'addition' lines in diff output, once counted
803 attr_accessor :lineAdditions
804 @@ -452,17 +453,28 @@
810 # was this file added during the commit?
815 + # was this file copied during the commit?
820 # was this file simply modified during the commit?
825 + # was this file simply modified during the commit?
831 # passing true, this object remembers that a diff will appear in the email,
832 # passing false, this object remembers that no diff will appear in the email.
833 # Once the value is set, it will not be changed
835 # TODO: consolidate these into a nicer framework,
836 mailSub = proc { |match| "<a href=\"mailto:#{match}\">#{match}</a>" }
837 urlSub = proc { |match| "<a href=\"#{match}\">#{match}</a>" }
838 +gforgeTaskSub = proc { |match|
839 + match =~ /([0-9]+)/
840 + "<a href=\"#{$gforgeTaskURL.sub(/%s/, $1)}\">#{match}</a>"
842 +gforgeBugSub = proc { |match|
843 + match =~ /([0-9]+)/
844 + "<a href=\"#{$gforgeBugURL.sub(/%s/, $1)}\">#{match}</a>"
846 bugzillaSub = proc { |match|
848 "<a href=\"#{$bugzillaURL.sub(/%s/, $1)}\">#{match}</a>"
849 @@ -544,11 +564,27 @@
851 "<a href=\"#{$ticketURL.sub(/%s/, $1)}\">#{match}</a>"
853 +issueSub = proc { |match|
854 + match =~ /([0-9]+)/
855 + "<a href=\"#{$issueURL.sub(/%s/, $1)}\">#{match}</a>"
857 wikiSub = proc { |match|
858 - match =~ /\[\[(.*)\]\]/
859 + match =~ /\[\[(.*?)\]\]/
861 "<a href=\"#{$wikiURL.sub(/%s/, urlEncode(raw))}\">[[#{raw}]]</a>"
863 +xplannerIterationSub = proc { |match|
864 + match =~ /([0-9]+)/
865 + "<a href=\"#{$xplannerIterationURL.sub(/%s/, $1)}\">#{match}</a>"
867 +xplannerProjectSub = proc { |match|
868 + match =~ /([0-9]+)/
869 + "<a href=\"#{$xplannerProjectURL.sub(/%s/, $1)}\">#{match}</a>"
871 +xplannerStorySub = proc { |match|
872 + match =~ /([0-9]+)/
873 + "<a href=\"#{$xplannerStoryURL.sub(/%s/, $1)}\">#{match}</a>"
875 commentSubstitutions = {
876 '(?:mailto:)?[\w\.\-\+\=]+\@[\w\-]+(?:\.[\w\-]+)+\b' => mailSub,
877 '\b(?:http|https|ftp):[^ \t\n<>"]+[\w/]' => urlSub
883 + # may be overridden by subclasses that are able to make a hyperlink to a
884 + # history log for a file
890 # Superclass for objects that can link to CVS frontends on the web (ViewCVS,
892 "<a href=\"#{diff_url(file)}\">#{super(file)}</a>"
896 + link = log_url(file)
898 + return "<span id=\"info\">(<a href=\"#{link}\">log</a>)</span>"
919 add_repo("#{@base_url}#{urlEncode(file.path)}.diff?r1=#{file.fromVer}&r2=#{file.toVer}")
924 + log_anchor = "#rev#{file.toVer}"
928 + add_repo("#{@base_url}#{urlEncode(file.path)}#{log_anchor}")
932 # Link to Chora, from the Horde framework
934 class CVSwebFrontend < WebFrontend
935 def path_url(path, tag)
937 - add_repo(@base_url + urlEncode(path))
938 + add_repo(@base_url + urlEncode(path) + "/")
940 - add_repo("#{@base_url}#{urlEncode(path)}?only_with_tag=#{urlEncode(tag)}")
941 + add_repo("#{@base_url}#{urlEncode(path)}/?only_with_tag=#{urlEncode(tag)}")
947 add_repo("#{@base_url}#{urlEncode(file.path)}.diff?r1=text&tr1=#{file.fromVer}&r2=text&tr2=#{file.toVer}&f=h")
954 + log_anchor = "#rev#{file.toVer}"
958 + add_repo("#{@base_url}#{urlEncode(file.path)}#{log_anchor}")
963 +class TracFrontend < WebFrontend
964 + def path_url(path, tag)
965 + add_repo("#{@base_url}browser/#{urlEncode(path)}")
968 + def version_url(path, version)
969 + add_repo("#{@base_url}browser/#{urlEncode(path)}?rev=#{version}")
973 + add_repo("#{@base_url}changeset/#{file.toVer}")
980 + log_anchor = "?rev=#{file.toVer}"
984 + add_repo("#{@base_url}log/#{urlEncode(file.path)}#{log_anchor}")
988 # in need of refactoring...
990 # Note when LogReader finds record of a file that was added in this commit
995 +# Note when LogReader finds record of a file that was copied in this commit
996 +class CopiedFileHandler < FileHandler
997 + def handleFile(file)
999 + file.fromVer=$fromVer
1004 # Note when LogReader finds record of a file that was modified in this commit
1005 class ModifiedFileHandler < FileHandler
1006 def handleFile(file)
1007 @@ -810,7 +918,16 @@
1011 +# Note when LogReader finds record of a file whose properties were modified in this commit
1012 +class ModifiedPropsFileHandler < FileHandler
1013 + def handleFile(file)
1015 + file.fromVer=$fromVer
1021 # Used by UnifiedDiffHandler to record the number of added and removed lines
1022 # appearing in a unidiff.
1023 class UnifiedDiffStats
1024 @@ -873,7 +990,10 @@
1025 addInfixSize = line.length - (prefixLen+suffixLen)
1026 oversize_change = deleteInfixSize*100/@lineJustDeleted.length>33 || addInfixSize*100/line.length>33
1028 - if prefixLen==1 && suffixLen==0 || deleteInfixSize<=0 || oversize_change
1029 + # avoid doing 'within-a-line highlighting' if a multibyte encoding
1030 + # is suspected, as all the suffix/prefix stuff above is byte, not
1032 + if multibyte_encoding? || prefixLen==1 && suffixLen==0 || deleteInfixSize<=0 || oversize_change
1033 print(htmlEncode(@lineJustDeleted))
1035 print(htmlEncode(@lineJustDeleted[0,prefixLen]))
1036 @@ -905,7 +1025,7 @@
1037 @lineJustDeleted = nil
1040 - if prefixLen==1 && suffixLen==0 || addInfixSize<=0 || oversize_change
1041 + if multibyte_encoding? || prefixLen==1 && suffixLen==0 || addInfixSize<=0 || oversize_change
1042 encoded = htmlEncode(line)
1044 encoded = htmlEncode(line[0,prefixLen]) +
1045 @@ -958,7 +1078,7 @@
1048 if @truncatedLineCount>0
1049 - println("<strong class=\"error\" title=\"#{@truncatedLineCount} lines truncated at column #{$maxDiffLineLength}\">[Note: Some over-long lines of diff output only partialy shown]</strong>")
1050 + println("<strong class=\"error\" title=\"#{@truncatedLineCount} lines truncated at column #{$maxDiffLineLength}\">[Note: Some over-long lines of diff output only partially shown]</strong>")
1054 @@ -976,11 +1096,21 @@
1055 print($frontend.path($file.basedir, $file.tag))
1056 println("</span><br />")
1057 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>")
1059 + print("<span class=\"pathname\" id=\"copied\">")
1060 + print($frontend.path($file.basedir, $file.tag))
1061 + println("</span><br />")
1062 + 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>")
1064 print("<span class=\"pathname\">")
1065 print($frontend.path($file.basedir, $file.tag))
1066 println("</span><br />")
1067 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>")
1069 + print("<span class=\"pathname\">")
1070 + print($frontend.path($file.basedir, $file.tag))
1071 + println("</span><br />")
1072 + 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>")
1074 print("<pre class=\"diff\"><small id=\"info\">")
1075 lines.each do |line|
1076 @@ -1181,7 +1311,7 @@
1078 # an RFC 822 email address
1080 - def initialize(text)
1081 + def initialize(text, charset=nil)
1082 if text =~ /^\s*([^<]+?)\s*<\s*([^>]+?)\s*>\s*$/
1085 @@ -1189,9 +1319,10 @@
1086 @personal_name = nil
1092 - attr_accessor :personal_name, :address
1093 + attr_accessor :personal_name, :address, :charset
1095 def has_personal_name?
1096 return !@personal_name.nil?
1097 @@ -1222,7 +1353,7 @@
1098 # rfc2047 encode the word, if it contains non-ASCII characters
1099 def encode_word(word)
1100 if $encoder.requires_rfc2047?(word)
1101 - encoded = $encoder.marker_start_quoted
1102 + encoded = $encoder.marker_start_quoted(@charset)
1103 $encoder.each_char_encoded(word) do |code|
1106 @@ -1233,26 +1364,40 @@
1110 +# guess if the users selected encoding is multibyte, since some CVSspam code
1111 +# isn't multibyte-safe, and needs to be disabled.
1112 +def multibyte_encoding?
1113 + $charset && ["utf-8", "utf-16"].include?($charset.downcase)
1116 cvsroot_dir = "#{ENV['CVSROOT']}/CVSROOT"
1117 $config = "#{cvsroot_dir}/cvsspam.conf"
1118 $users_file = "#{cvsroot_dir}/users"
1119 +$users_file_charset = nil
1123 $recipients = Array.new
1124 $sendmail_prog = "/usr/sbin/sendmail"
1125 $hostname = ENV['HOSTNAME'] || 'localhost'
1126 $no_removed_file_diff = false
1127 $no_added_file_diff = false
1129 -$task_keywords = ['TODO', 'FIXME']
1130 +$task_keywords = ['TODO', 'FIXME', 'FIXIT', 'todo']
1132 +$gforgeBugURL = nil
1133 +$gforgeTaskURL = nil
1139 +$xplannerIterationURL = nil
1140 +$xplannerProjectURL = nil
1141 +$xplannerStoryURL = nil
1146 $subjectPrefix = nil
1147 $files_in_subject = false;
1148 @@ -1261,6 +1406,7 @@
1149 # 2MiB limit on attached diffs,
1150 $mail_size_limit = 1024 * 1024 * 2
1152 +$cvsroot_email_header = false
1154 require 'getoptlong'
1156 @@ -1268,6 +1414,7 @@
1157 [ "--to", "-t", GetoptLong::REQUIRED_ARGUMENT ],
1158 [ "--config", "-c", GetoptLong::REQUIRED_ARGUMENT ],
1159 [ "--debug", "-d", GetoptLong::NO_ARGUMENT ],
1160 + [ "--svn", "-s", GetoptLong::NO_ARGUMENT ],
1161 [ "--from", "-u", GetoptLong::REQUIRED_ARGUMENT ],
1162 [ "--charset", GetoptLong::REQUIRED_ARGUMENT ]
1164 @@ -1276,6 +1423,7 @@
1165 $recipients << EmailAddress.new(arg) if opt=="--to"
1166 $config = arg if opt=="--config"
1167 $debug = true if opt=="--debug"
1168 + $svn = true if opt=="--svn"
1169 $from_address = EmailAddress.new(arg) if opt=="--from"
1170 # must use different variable as the config is readed later.
1171 $arg_charset = arg if opt == "--charset"
1172 @@ -1288,7 +1436,7 @@
1174 $stderr.puts "missing required file argument"
1176 - puts "Usage: cvsspam.rb [ --to <email> ] [ --config <file> ] <collect_diffs file>"
1177 + puts "Usage: cvsspam.rb [ --svn ] [ --to <email> ] [ --config <file> ] <collect_diffs file>"
1181 @@ -1321,6 +1469,8 @@
1182 blah("Config file '#{$config}' not found, ignoring")
1185 +blah("Users file: '#{$users_file}'")
1187 unless $arg_charset.nil?
1188 $charset = $arg_charset
1190 @@ -1337,6 +1487,9 @@
1191 elsif $cvswebURL !=nil
1192 $cvswebURL << "/" unless $cvswebURL =~ /\/$/
1193 $frontend = CVSwebFrontend.new($cvswebURL)
1194 +elsif $tracURL !=nil
1195 + $tracURL << "/" unless $tracURL =~ /\/$/
1196 + $frontend = TracFrontend.new($tracURL)
1198 $frontend = NoFrontend.new
1200 @@ -1353,17 +1506,35 @@
1203 if $bugzillaURL != nil
1204 - commentSubstitutions['\b[Bb][Uu][Gg]\s*#?[0-9]+'] = bugzillaSub
1205 + commentSubstitutions['\b[Bb](?:[Uu][Gg])?\s*[#:]?\s*\[?[0-9]+\]?'] = bugzillaSub
1207 +if $gforgeBugURL != nil
1208 + commentSubstitutions['\B\[#[0-9]+\]'] = gforgeBugSub
1210 +if $gforgeTaskURL != nil
1211 + commentSubstitutions['\B\[[Tt][0-9]+\]'] = gforgeTaskSub
1214 commentSubstitutions['\b[a-zA-Z]+-[0-9]+\b'] = jiraSub
1216 if $ticketURL != nil
1217 commentSubstitutions['\b[Tt][Ii][Cc][Kk][Ee][Tt]\s*#?[0-9]+\b'] = ticketSub
1219 +if $issueURL != nil
1220 + commentSubstitutions['\b[Ii][Ss][Ss][Uu][Ee]\s*#?[0-9]+\b'] = issueSub
1223 commentSubstitutions['\[\[.+\]\]'] = wikiSub
1225 +if $xplannerIterationURL != nil
1226 + commentSubstitutions['\bXI\[?[0-9]+\]?'] = xplannerIterationSub
1228 +if $xplannerProjectURL != nil
1229 + commentSubstitutions['\bXP\[?[0-9]+\]?'] = xplannerProjectSub
1231 +if $xplannerStoryURL != nil
1232 + commentSubstitutions['\bXS\[?[0-9]+\]?'] = xplannerStorySub
1234 $commentEncoder = MultiSub.new(commentSubstitutions)
1237 @@ -1374,12 +1545,16 @@
1239 "A" => AddedFileHandler.new,
1240 "R" => RemovedFileHandler.new,
1241 + "C" => CopiedFileHandler.new,
1242 "M" => ModifiedFileHandler.new,
1243 + "P" => ModifiedPropsFileHandler.new,
1244 "V" => VersionHandler.new]
1246 $handlers["A"].setTagHandler(tagHandler)
1247 $handlers["R"].setTagHandler(tagHandler)
1248 +$handlers["C"].setTagHandler(tagHandler)
1249 $handlers["M"].setTagHandler(tagHandler)
1250 +$handlers["P"].setTagHandler(tagHandler)
1252 $fileEntries = Array.new
1253 $task_list = Array.new
1254 @@ -1404,7 +1579,11 @@
1257 if $subjectPrefix == nil
1258 - $subjectPrefix = "[CVS #{Repository.array.join(',')}]"
1260 + $subjectPrefix = "[SVN #{Repository.array.join(',')}]"
1262 + $subjectPrefix = "[CVS #{Repository.array.join(',')}]"
1266 if $files_in_subject
1267 @@ -1451,6 +1630,8 @@
1268 #removed {background-color:#ffdddd;}
1269 #removedchars {background-color:#ff9999;font-weight:bolder;}
1270 tr.alt #removed {background-color:#f7cccc;}
1271 + #copied {background-color:#ccccff;}
1272 + tr.alt #copied {background-color:#bbbbf7;}
1273 #info {color:#888888;}
1274 #context {background-color:#eeeeee;}
1275 td {padding-left:.3em;padding-right:.3em;}
1276 @@ -1483,7 +1664,9 @@
1282 + filesModifiedProps = 0
1284 totalLinesRemoved = 0
1286 @@ -1492,24 +1675,26 @@
1287 $fileEntries.each do |file|
1288 unless file.repository == last_repository
1289 last_repository = file.repository
1290 - mail.print("<tr class=\"head\"><td colspan=\"#{last_repository.has_multiple_tags ? 5 : 4}\">")
1291 + mail.print("<tr class=\"head\"><td colspan=\"#{last_repository.has_multiple_tags ? 6 : 5}\">")
1292 if last_repository.has_multiple_tags
1293 mail.print("Mixed-tag commit")
1295 mail.print("Commit")
1297 mail.print(" in <b><tt>#{htmlEncode(last_repository.common_prefix)}</tt></b>")
1298 - if last_repository.trunk_only?
1299 - mail.print("<span id=\"info\"> on MAIN</span>")
1301 - mail.print(" on ")
1303 - last_repository.each_tag do |tag|
1306 - mail.print tagCount<last_repository.tag_count ? ", " : " & "
1308 + if last_repository.trunk_only?
1309 + mail.print("<span id=\"info\"> on MAIN</span>")
1311 + mail.print(" on ")
1313 + last_repository.each_tag do |tag|
1316 + mail.print tagCount<last_repository.tag_count ? ", " : " & "
1318 + mail.print tag ? htmlEncode(tag) : "<span id=\"info\">MAIN</span>"
1320 - mail.print tag ? htmlEncode(tag) : "<span id=\"info\">MAIN</span>"
1323 mail.puts("</td></tr>")
1324 @@ -1524,8 +1709,12 @@
1328 + elsif file.copied?
1330 elsif file.modification?
1332 + elsif file.modifiedprops?
1333 + filesModifiedProps += 1
1335 name = htmlEncode(file.name_after_common_prefix)
1336 slashPos = name.rindex("/")
1337 @@ -1545,17 +1734,29 @@
1338 name = "<span id=\"added\">#{name}</span>"
1340 name = "<span id=\"removed\">#{name}</span>"
1341 + elsif file.copied?
1342 + name = "<span id=\"copied\">#{name}</span>"
1344 + mail.print("<td>")
1346 - mail.print("<td><tt>#{prefix}<a href=\"#file#{file_count}\">#{name}</a></tt></td>")
1347 + mail.print("<tt>#{prefix}<a href=\"#file#{file_count}\">#{name}</a></tt>")
1349 - mail.print("<td><tt>#{prefix}#{name}</tt></td>")
1350 + mail.print("<tt>#{prefix}#{name}</tt>")
1353 - mail.print("<td colspan=\"2\" align=\"center\"><small id=\"info\">[empty]</small></td>")
1354 + mail.print(" #{$frontend.log(file)}")
1355 + mail.print("</td>")
1357 + mail.print("<td colspan=\"3\" align=\"center\"><small id=\"info\">[copied]</small></td>")
1358 + elsif file.isEmpty
1359 + mail.print("<td colspan=\"3\" align=\"center\"><small id=\"info\">[empty]</small></td>")
1361 - mail.print("<td colspan=\"2\" align=\"center\"><small id=\"info\">[binary]</small></td>")
1362 + mail.print("<td colspan=\"3\" align=\"center\"><small id=\"info\">[binary]</small></td>")
1364 + if file.modifiedprops?
1365 + mail.print("<td align=\"right\"><small id=\"info\">[props]</small></td>")
1367 + mail.print("<td></td>")
1369 if file.lineAdditions>0
1370 totalLinesAdded += file.lineAdditions
1371 mail.print("<td align=\"right\" id=\"added\">+#{file.lineAdditions}</td>")
1372 @@ -1582,15 +1783,19 @@
1373 mail.print("<td nowrap=\"nowrap\" align=\"right\">added #{$frontend.version(file.path,file.toVer)}</td>")
1375 mail.print("<td nowrap=\"nowrap\">#{$frontend.version(file.path,file.fromVer)} removed</td>")
1376 + elsif file.copied?
1377 + mail.print("<td nowrap=\"nowrap\" align=\"center\">#{$frontend.version(file.path,file.fromVer)} #{$frontend.diff(file)} #{$frontend.version(file.path,file.toVer)}</td>")
1378 elsif file.modification?
1379 mail.print("<td nowrap=\"nowrap\" align=\"center\">#{$frontend.version(file.path,file.fromVer)} #{$frontend.diff(file)} #{$frontend.version(file.path,file.toVer)}</td>")
1380 + elsif file.modifiedprops?
1381 + mail.print("<td nowrap=\"nowrap\" align=\"center\">#{$frontend.version(file.path,file.fromVer)} #{$frontend.diff(file)} #{$frontend.version(file.path,file.toVer)}</td>")
1386 if $fileEntries.size>1 && (totalLinesAdded+totalLinesRemoved)>0
1387 # give total number of lines added/removed accross all files
1388 - mail.print("<tr><td></td>")
1389 + mail.print("<tr><td></td><td></td>")
1390 if totalLinesAdded>0
1391 mail.print("<td align=\"right\" id=\"added\">+#{totalLinesAdded}</td>")
1393 @@ -1607,7 +1812,7 @@
1395 mail.puts("</table>")
1397 - totalFilesChanged = filesAdded+filesRemoved+filesModified
1398 + totalFilesChanged = filesAdded+filesRemoved+filesCopied+filesModified+filesModifiedProps
1399 if totalFilesChanged > 1
1400 mail.print("<small id=\"info\">")
1402 @@ -1620,11 +1825,21 @@
1403 mail.print("#{filesRemoved} removed")
1407 + mail.print(" + ") if changeKind>0
1408 + mail.print("#{filesCopied} copied")
1412 mail.print(" + ") if changeKind>0
1413 mail.print("#{filesModified} modified")
1416 + if filesModifiedProps>0
1417 + mail.print(" + ") if changeKind>0
1418 + mail.print("#{filesModifiedProps} modified properties")
1421 mail.print(", total #{totalFilesChanged}") if changeKind > 1
1422 mail.puts(" files</small><br />")
1424 @@ -1667,12 +1882,13 @@
1425 # CVSROOT/users file, if the file exists. The argument is returned unchanged
1426 # if no alias is found.
1427 def sender_alias(email)
1428 + blah("Lookup '#{email}' from users file")
1429 if File.exists?($users_file)
1430 File.open($users_file) do |io|
1431 io.each_line do |line|
1432 if line =~ /^([^:]+)\s*:\s*(['"]?)([^\n\r]+)(\2)/
1433 if email.address == $1
1434 - return EmailAddress.new($3)
1435 + return EmailAddress.new($3, $users_file_charset)
1439 @@ -1686,6 +1902,8 @@
1440 # sensible header formatting, and for ensuring that the body is seperated
1441 # from the message headers by a blank line (as it is required to be).
1443 + ENCODE_HEADERS = ["Subject", "X-CVSspam-Module-Path"]
1446 @done_headers = false
1448 @@ -1695,8 +1913,8 @@
1450 def header(name, value)
1451 raise "headers already commited" if @done_headers
1452 - if name == "Subject"
1453 - $encoder.encode_header(@io, "Subject", value)
1454 + if ENCODE_HEADERS.include?(name)
1455 + $encoder.encode_header(@io, name, value)
1457 @io.puts("#{name}: #{value}")
1459 @@ -1769,7 +1987,7 @@
1460 ctx.header("To", recipients.map{|addr| addr.encoded}.join(','))
1461 blah("Mail From: <#{from}>")
1462 ctx.header("From", from.encoded) if from
1463 - ctx.header("Date", Time.now.utc.strftime(DATE_HEADER_FORMAT))
1464 + ctx.header("Date", Time.now.rfc2822)
1468 @@ -1800,10 +2018,10 @@
1469 return unless $fileEntries.length == 1
1470 file = $fileEntries[0]
1471 name = zap_header_special_chars(file.path)
1472 - unless file.fromVer == "NONE"
1474 mail.header("References", make_msg_id("#{name}.#{file.fromVer}", $hostname))
1476 - unless file.toVer == "NONE"
1478 mail.header("Message-ID", make_msg_id("#{name}.#{file.toVer}", $hostname))
1481 @@ -1834,6 +2052,14 @@
1484 mail.header("X-Mailer", "CVSspam #{$version} <http://www.badgers-in-foil.co.uk/projects/cvsspam/>")
1485 + if $cvsroot_email_header
1487 + if Repository.count == 1
1488 + rep = Repository.array.first
1489 + mod << rep.common_prefix
1491 + mail.header("X-CVSspam-Module-Path", mod)
1495 make_html_email(body)
1497 Property changes on: testcases/data/remove.png
1498 ___________________________________________________________________
1499 Deleted: svn:executable
1503 Property changes on: testcases/data/fiddlyedits.after
1504 ___________________________________________________________________
1505 Deleted: svn:executable
1509 Property changes on: testcases/data/fiddlyedits.before
1510 ___________________________________________________________________
1511 Deleted: svn:executable
1515 Property changes on: testcases/data/add.png
1516 ___________________________________________________________________
1517 Deleted: svn:executable
1521 Property changes on: testcases/README
1522 ___________________________________________________________________
1523 Deleted: svn:executable
1527 Property changes on: .
1528 ___________________________________________________________________