Annotation of CVSROOT/cvsspam.rb, revision 1.1
1.1 ! uid12904 1: #!/usr/bin/ruby -w
! 2:
! 3: # Part of CVSspam
! 4: # http://www.badgers-in-foil.co.uk/projects/cvsspam/
! 5: # Copyright (c) David Holroyd
! 6:
! 7: # collect_diffs.rb expects to find this script in the same directory as it
! 8: #
! 9:
! 10: # TODO: exemplify syntax for 'cvs admin -m' when log message is missing
! 11: # TODO: make max-line limit on diff output configurable
! 12: # TODO: put more exact max size limit on whole email
! 13: # TODO: support non-html mail too (text/plain, multipart/alternative)
! 14:
! 15: # If you want another 'todo keyword' (TODO & FIXME are highlighted by default)
! 16: # you could add
! 17: # $task_keywords << "KEYWORD" << "MAYBEANOTHERWORD"
! 18: # to your cvssppam.conf
! 19:
! 20:
! 21: $version = "0.2.12"
! 22:
! 23:
! 24: $maxSubjectLength = 200
! 25: $maxLinesPerDiff = 1000
! 26: $maxDiffLineLength = 1000 # may be set to nil for no limit
! 27: $charset = nil # nil implies 'don't specify a charset'
! 28: $mailSubject = ''
! 29:
! 30: def blah(text)
! 31: $stderr.puts("cvsspam.rb: #{text}") if $debug
! 32: end
! 33:
! 34: def min(a, b)
! 35: a<b ? a : b
! 36: end
! 37:
! 38: # NB must ensure the time is UTC
! 39: # (the Ruby Time object's strftime() doesn't supply a numeric timezone)
! 40: DATE_HEADER_FORMAT = "%a, %d %b %Y %H:%M:%S +0000"
! 41:
! 42: # Perform (possibly) multiple global substitutions on a string.
! 43: # the regexps given as keys must not use capturing subexpressions '(...)'
! 44: class MultiSub
! 45: # hash has regular expression fragments (as strings) as keys, mapped to
! 46: # Procs that will generate replacement text, given the matched value.
! 47: def initialize(hash)
! 48: @mash = Array.new
! 49: expr = nil
! 50: hash.each do |key,val|
! 51: if expr == nil ; expr="(" else expr<<"|(" end
! 52: expr << key << ")"
! 53: @mash << val
! 54: end
! 55: @re = Regexp.new(expr)
! 56: end
! 57:
! 58: # perform a global multi-sub on the given text, modifiying the passed string
! 59: # 'in place'
! 60: def gsub!(text)
! 61: text.gsub!(@re) { |match|
! 62: idx = -1
! 63: $~.to_a.each { |subexp|
! 64: break unless idx==-1 || subexp==nil
! 65: idx += 1
! 66: }
! 67: idx==-1 ? match : @mash[idx].call(match)
! 68: }
! 69: end
! 70: end
! 71:
! 72: # returns the character-code of the given character
! 73: def chr(txt)
! 74: txt[0]
! 75: end
! 76:
! 77: # Limited support for encoding non-US_ASCII characters in mail headers
! 78: class HeaderEncoder
! 79: def initialize
! 80: @right_margin = 78
! 81: @encoding = 'q' # quoted-printable, base64 not supported
! 82: @charset = nil # TODO: some better default?
! 83: end
! 84:
! 85: # character set to be used if any encoding is required. defaults to nil,
! 86: # which will cause an exception if encoding is attempted without another
! 87: # value being specified
! 88: attr_accessor :charset
! 89:
! 90: # write an encoded version of the header name/value to the given io
! 91: def encode_header(io, name, value)
! 92: name = name + ": "
! 93: if requires_rfc2047?(value)
! 94: rfc2047_encode_quoted(io, name, value)
! 95: else
! 96: wrap_basic_header(io, name, value)
! 97: end
! 98: end
! 99:
! 100:
! 101: private
! 102: # word wrap long headers, putting a space at the begining of wraped lines
! 103: # (i.e. SMTP header continuations)
! 104: def wrap_basic_header(io, start, rest)
! 105: rest.scan(/\s*\S+/) do |match|
! 106: if start.length>0 && start.length+match.length>@right_margin
! 107: io.puts(start)
! 108: start = " "
! 109: match.sub!(/^\s+/, "") # strip existing leading-whitespace
! 110: end
! 111: start << match
! 112: end
! 113: io.puts(start)
! 114: end
! 115:
! 116: UNDERSCORE = chr("_")
! 117: SPACE = chr(" ")
! 118: TAB = chr("\t")
! 119:
! 120: # encode a header value according to the RFC-2047 quoted-printable spec,
! 121: # allowing non-ASCII characters to appear in header values, and wrapping
! 122: # long values with header continuation lines as needed
! 123: def rfc2047_encode_quoted(io, start, rest)
! 124: raise "no charset" if @charset.nil?
! 125: code_begin = marker_start_quoted
! 126: start << code_begin
! 127: each_char_encoded(rest) do |code|
! 128: if start.length+code.length+2 > @right_margin
! 129: io.puts(start + marker_end_quoted)
! 130: start = " " + code_begin
! 131: end
! 132: start << code
! 133: end
! 134: io.puts(start + marker_end_quoted)
! 135: end
! 136:
! 137: # return a string representing the given character-code in quoted-printable
! 138: # format
! 139: def quoted_encode_char(b)
! 140: if b>126 || b==UNDERSCORE || b==TAB
! 141: sprintf("=%02x", b)
! 142: elsif b == SPACE
! 143: "_"
! 144: else
! 145: b.chr
! 146: end
! 147: end
! 148:
! 149: public
! 150:
! 151: # yields a quoted-printable version of each byte in the given string
! 152: def each_char_encoded(text)
! 153: text.each_byte do |b|
! 154: yield quoted_encode_char(b)
! 155: end
! 156: end
! 157:
! 158: # gives the string "?=",which is used to mark the end of a quoted-printable
! 159: # characte rsequence
! 160: def marker_end_quoted
! 161: "?="
! 162: end
! 163:
! 164: # gives a string starting "=?", and including a charset specification, that
! 165: # marks the start of a quoted-printable character sequence
! 166: def marker_start_quoted
! 167: "=?#{@charset}?#{@encoding}?"
! 168: end
! 169:
! 170: # test to see of the given string contains non-ASCII characters
! 171: def requires_rfc2047?(word)
! 172: (word =~ /[\177-\377]/) != nil
! 173: end
! 174: end
! 175:
! 176:
! 177: # Provides access to the datafile previously created by collect_diffs.rb.
! 178: # Each call to getLines() will return an object that will read lines of the
! 179: # same 'type' (e.g. lines of commit log comment) from the file, and stop when
! 180: # lines of a different type (e.g. line giving the next file's name) are
! 181: # encountered.
! 182: class LogReader
! 183: def initialize(logIO)
! 184: @io = logIO
! 185: advance
! 186: end
! 187:
! 188: def currentLineCode ; @line[1,1] end
! 189:
! 190:
! 191: class ConstrainedIO
! 192: def initialize(reader)
! 193: @reader = reader
! 194: @linecode = reader.currentLineCode
! 195: end
! 196:
! 197: def each
! 198: return if @reader == nil
! 199: while true
! 200: yield @reader.currentLine
! 201: break unless @reader.advance && currentValid?
! 202: end
! 203: @reader = nil
! 204: end
! 205:
! 206: def gets
! 207: return nil if @reader == nil
! 208: line = @reader.currentLine
! 209: return nil if line==nil || !currentValid?
! 210: @reader.advance
! 211: return line
! 212: end
! 213:
! 214: def currentValid?
! 215: @linecode == @reader.currentLineCode
! 216: end
! 217: end
! 218:
! 219: def getLines
! 220: ConstrainedIO.new(self)
! 221: end
! 222:
! 223: def eof ; @line==nil end
! 224:
! 225: def advance
! 226: @line = @io.gets
! 227: return false if @line == nil
! 228: unless @line[0,1] == "#"
! 229: raise "#{$logfile}:#{@io.lineno} line did not begin with '#': #{@line}"
! 230: end
! 231: return true
! 232: end
! 233:
! 234: def currentLine
! 235: @line==nil ? nil : @line[3, @line.length-4]
! 236: end
! 237: end
! 238:
! 239:
! 240: # returns a copy of the fiven string with instances of the HTML special
! 241: # characters '&', '<' and '>' encoded as their HTML entity equivalents.
! 242: def htmlEncode(text)
! 243: text.gsub(/./) do
! 244: case $&
! 245: when "&" then "&"
! 246: when "<" then "<"
! 247: when ">" then ">"
! 248: else $&
! 249: end
! 250: end
! 251: end
! 252:
! 253: # Encodes characters that would otherwise be special in a URL using the
! 254: # "%XX" syntax (where XX are hex digits).
! 255: # actually, allows '/' to appear
! 256: def urlEncode(text)
! 257: text.sub(/[^a-zA-Z0-9\-,.*_\/]/) do
! 258: "%#{sprintf('%2X', $&[0])}"
! 259: end
! 260: end
! 261:
! 262:
! 263: # Represents a top-level directory under the $CVSROOT (which is properly called
! 264: # a module -- this class is named incorrectly). Collects a list of
! 265: # all #FileEntry objects that are 'in' this repository. Class methods provide
! 266: # a list of all repositories (ick!)
! 267: class Repository
! 268: @@repositories = Hash.new
! 269:
! 270: def initialize(name)
! 271: @name = name
! 272: @common_prefix = nil
! 273: @all_tags = Hash.new
! 274: end
! 275:
! 276: # records that the given branch tag name was used for some file that was
! 277: # committed to this repository. The argument nil is taken to signify the
! 278: # MAIN branch, or 'trunk' of the project.
! 279: def add_tag(tag_name)
! 280: if @all_tags[tag_name]
! 281: @all_tags[tag_name] += 1
! 282: else
! 283: @all_tags[tag_name] = 1
! 284: end
! 285: end
! 286:
! 287: # true, if #add_tag has been passed more than one distinct value
! 288: def has_multiple_tags
! 289: @all_tags.length > 1
! 290: end
! 291:
! 292: # iterate over the tags that have been recorded against this Repository
! 293: def each_tag
! 294: @all_tags.each_key do |tag|
! 295: yield tag
! 296: end
! 297: end
! 298:
! 299: # true if the only tag that has been recorded against this repository was
! 300: # the 'trunk', i.e. no branch tags at all
! 301: def trunk_only?
! 302: @all_tags.length==1 && @all_tags[nil]!=nil
! 303: end
! 304:
! 305: # true if the files committed to this Repository have been of more than one
! 306: # branch (not a common situation, I've only seen it in real life when things
! 307: # are b0rked in someone's working directory).
! 308: def mixed_tags?
! 309: @all_tags.length>1
! 310: end
! 311:
! 312: # returns the number of tags seen during the commit to this Repository
! 313: def tag_count
! 314: @all_tags.length
! 315: end
! 316:
! 317: # calculate the path prefix shared by all files commited to this
! 318: # reposotory
! 319: def merge_common_prefix(path)
! 320: if @common_prefix == nil
! 321: @common_prefix = path.dup
! 322: else
! 323: path = path.dup
! 324: until @common_prefix == path
! 325: if @common_prefix.size>path.size
! 326: if @common_prefix.sub!(/(.*)\/.*$/, '\1').nil?
! 327: raise "unable to merge '#{path}' in to '#{@common_prefix}': prefix totally different"
! 328: end
! 329: else
! 330: if path.sub!(/(.*)\/.*$/, '\1').nil?
! 331: raise "unable to merge '#{path}' in to '#{@common_prefix}': prefix totally different"
! 332: end
! 333: end
! 334: end
! 335: end
! 336: end
! 337:
! 338: attr_reader :name, :common_prefix
! 339:
! 340: # gets the Repository object for the first component of the given path
! 341: def Repository.get(name)
! 342: # Leading './' is ignored (for peeps who have done 'cvs checkout .')
! 343: # Trailing '/' ensures no match for files in root (we just want dirs)
! 344: name =~ /^(?:\.\/)?([^\/]+)\//
! 345: name = $1
! 346: name = "/" if name.nil? # file at top-level? fake up a name for repo
! 347: rep = @@repositories[name]
! 348: if rep.nil?
! 349: rep = Repository.new(name)
! 350: @@repositories[name] = rep
! 351: end
! 352: rep
! 353: end
! 354:
! 355: # returns the total number of top-level directories seen during this commit
! 356: def Repository.count
! 357: @@repositories.size
! 358: end
! 359:
! 360: # iterate over all the Repository objects created for this commit
! 361: def Repository.each
! 362: @@repositories.each_value do |rep|
! 363: yield rep
! 364: end
! 365: end
! 366:
! 367: # returns an array of all the repository objects seen during this commit
! 368: def Repository.array
! 369: @@repositories.values
! 370: end
! 371:
! 372: # get a string representation of the repository to appear in email subjects.
! 373: # This will be the repository name, plus (possibly) the name of the branch
! 374: # on which the commit occured. If the commit was to multiple branches, the
! 375: # text '..' is used, rather than a branch name
! 376: def to_s
! 377: if trunk_only?
! 378: @name
! 379: elsif mixed_tags?
! 380: "#{@name}@.."
! 381: else
! 382: "#{@name}@#{@all_tags.keys[0]}"
! 383: end
! 384: end
! 385: end
! 386:
! 387: # Records properties of a file that was changed during this commit
! 388: class FileEntry
! 389: def initialize(path)
! 390: @path = path
! 391: @lineAdditions = @lineRemovals = 0
! 392: @repository = Repository.get(path)
! 393: @repository.merge_common_prefix(basedir())
! 394: @isEmpty = @isBinary = false
! 395: @has_diff = nil
! 396: end
! 397:
! 398: # the full path and filename within the repository
! 399: attr_accessor :path
! 400: # the type of change committed 'M'=modified, 'A'=added, 'R'=removed
! 401: attr_accessor :type
! 402: # records number of 'addition' lines in diff output, once counted
! 403: attr_accessor :lineAdditions
! 404: # records number of 'removal' lines in diff output, once counted
! 405: attr_accessor :lineRemovals
! 406: # records whether 'cvs diff' reported this as a binary file
! 407: attr_accessor :isBinary
! 408: # records if diff output (and therefore the added file) was empty
! 409: attr_accessor :isEmpty
! 410: # file version number before the commit
! 411: attr_accessor :fromVer
! 412: # file version number after the commit
! 413: attr_accessor :toVer
! 414:
! 415: # works out the filename part of #path
! 416: def file
! 417: @path =~ /.*\/(.*)/
! 418: $1
! 419: end
! 420:
! 421: # set the branch on which this change was committed, and add it to the list
! 422: # of branches for which we've seen commits (in the #Repository)
! 423: def tag=(name)
! 424: @tag = name
! 425: @repository.add_tag(name)
! 426: end
! 427:
! 428: # gives the branch on which this change was committed
! 429: def tag
! 430: @tag
! 431: end
! 432:
! 433: # works out the directory part of #path
! 434: def basedir
! 435: @path =~ /(.*)\/.*/
! 436: $1
! 437: end
! 438:
! 439: # gives the Repository object this file was automatically associated with
! 440: # on construction
! 441: def repository
! 442: @repository
! 443: end
! 444:
! 445: # gets the part of #path that comes after the prefix common to all files
! 446: # in the commit to #repository
! 447: def name_after_common_prefix
! 448: @path.slice(@repository.common_prefix.size+1,@path.size-@repository.common_prefix.size-1)
! 449: end
! 450:
! 451: # was this file removed during the commit?
! 452: def removal?
! 453: @type == "R"
! 454: end
! 455:
! 456: # was this file added during the commit?
! 457: def addition?
! 458: @type == "A"
! 459: end
! 460:
! 461: # was this file simply modified during the commit?
! 462: def modification?
! 463: @type == "M"
! 464: end
! 465:
! 466: # passing true, this object remembers that a diff will appear in the email,
! 467: # passing false, this object remembers that no diff will appear in the email.
! 468: # Once the value is set, it will not be changed
! 469: def has_diff=(diff)
! 470: # TODO: this 'if @has_diff.nil?' is counterintuitive; remove!
! 471: @has_diff = diff if @has_diff.nil?
! 472: end
! 473:
! 474: # true if this file has had a diff recorded
! 475: def has_diff?
! 476: @has_diff
! 477: end
! 478:
! 479: # true only if this file's diff (if any) should be included in the email,
! 480: # taking into account global diff-inclusion settings.
! 481: def wants_diff_in_mail?
! 482: !($no_diff ||
! 483: removal? && $no_removed_file_diff ||
! 484: addition? && $no_added_file_diff)
! 485: end
! 486: end
! 487:
! 488: # Superclass for things that eat lines of input, and turn them into output
! 489: # for our email. The 'input' will be provided by #LogReader
! 490: # Subclasses of LineConsumer will be registered in the global $handlers later
! 491: # on in this file.
! 492: class LineConsumer
! 493: # passes each line from 'lines' to the consume() method (which must be
! 494: # implemented by subclasses).
! 495: def handleLines(lines, emailIO)
! 496: @emailIO = emailIO
! 497: @lineCount = 0
! 498: setup
! 499: lines.each do |line|
! 500: @lineCount += 1
! 501: consume(line)
! 502: end
! 503: teardown
! 504: end
! 505:
! 506: # Template method called by handleLines to do any subclass-specific setup
! 507: # required. Default implementation does nothing
! 508: def setup
! 509: end
! 510:
! 511: # Template method called by handleLines to do any subclass-specific cleanup
! 512: # required. Default implementation does nothing
! 513: def teardown
! 514: end
! 515:
! 516: # Returns the number of lines handleLines() has seen so far
! 517: def lineno
! 518: @lineCount
! 519: end
! 520:
! 521: # adds a line to the output
! 522: def println(text)
! 523: @emailIO.puts(text)
! 524: end
! 525:
! 526: # adds a string to the current output line
! 527: def print(text)
! 528: @emailIO.print(text)
! 529: end
! 530: end
! 531:
! 532:
! 533: # TODO: consolidate these into a nicer framework,
! 534: mailSub = proc { |match| "<a href=\"mailto:#{match}\">#{match}</a>" }
! 535: urlSub = proc { |match| "<a href=\"#{match}\">#{match}</a>" }
! 536: bugzillaSub = proc { |match|
! 537: match =~ /([0-9]+)/
! 538: "<a href=\"#{$bugzillaURL.sub(/%s/, $1)}\">#{match}</a>"
! 539: }
! 540: jiraSub = proc { |match|
! 541: "<a href=\"#{$jiraURL.sub(/%s/, match)}\">#{match}</a>"
! 542: }
! 543: ticketSub = proc { |match|
! 544: match =~ /([0-9]+)/
! 545: "<a href=\"#{$ticketURL.sub(/%s/, $1)}\">#{match}</a>"
! 546: }
! 547: wikiSub = proc { |match|
! 548: match =~ /\[\[(.*)\]\]/
! 549: raw = $1
! 550: "<a href=\"#{$wikiURL.sub(/%s/, urlEncode(raw))}\">[[#{raw}]]</a>"
! 551: }
! 552: commentSubstitutions = {
! 553: '(?:mailto:)?[\w\.\-\+\=]+\@[\w\-]+(?:\.[\w\-]+)+\b' => mailSub,
! 554: '\b(?:http|https|ftp):[^ \t\n<>"]+[\w/]' => urlSub
! 555: }
! 556:
! 557: # outputs commit log comment text supplied by LogReader as preformatted HTML
! 558: class CommentHandler < LineConsumer
! 559: def initialize
! 560: @lastComment = nil
! 561: end
! 562:
! 563: def setup
! 564: @haveBlank = false
! 565: @comment = ""
! 566: end
! 567:
! 568: def consume(line)
! 569: if line =~ /^\s*$/
! 570: @haveBlank = true
! 571: else
! 572: if @haveBlank
! 573: @comment += "\n"
! 574: @haveBlank = false
! 575: end
! 576: # $mailSubject = line unless $mailSubject.length > 0
! 577: #
! 578: $mailSubject = "#{Repository.array.join(',')}"
! 579: @comment += line += "\n"
! 580: end
! 581: end
! 582:
! 583: def teardown
! 584: unless @comment == @lastComment
! 585: println("<pre class=\"comment\">")
! 586: encoded = htmlEncode(@comment)
! 587: $commentEncoder.gsub!(encoded)
! 588: println(encoded)
! 589: println("</pre>")
! 590: @lastComment = @comment
! 591: end
! 592: end
! 593: end
! 594:
! 595:
! 596: # Handle lines from LogReader that represent the name of the branch tag for
! 597: # the next file in the log. When files are committed to the trunk, the log
! 598: # will not contain a line specifying the branch tag name, and getLastTag
! 599: # will return nil.
! 600: class TagHandler < LineConsumer
! 601: def initialize
! 602: @tag = nil
! 603: end
! 604:
! 605: def consume(line)
! 606: # TODO: check there is only one line
! 607: @tag = line
! 608: end
! 609:
! 610: # returns the last tag name this object recorded, and resets the record, such
! 611: # that a subsequent call to this method will return nil
! 612: def getLastTag
! 613: tmp = @tag
! 614: @tag = nil
! 615: tmp
! 616: end
! 617: end
! 618:
! 619: # records, from the log file, a line specifying the old and new revision numbers
! 620: # for the next file to appear in the log. The values are recorded in the global
! 621: # variables $fromVer and $toVer
! 622: class VersionHandler < LineConsumer
! 623: def consume(line)
! 624: # TODO: check there is only one line
! 625: $fromVer,$toVer = line.split(/,/)
! 626: end
! 627: end
! 628:
! 629: # Reads a line giving the path and name of the current file being considered
! 630: # from our log of all files changed in this commit. Subclasses make different
! 631: # records depending on whether this commit adds, removes, or just modifies this
! 632: # file
! 633: class FileHandler < LineConsumer
! 634: def setTagHandler(handler)
! 635: @tagHandler = handler
! 636: end
! 637:
! 638: def consume(line)
! 639: $file = FileEntry.new(line)
! 640: if $diff_output_limiter.choose_to_limit?
! 641: $file.has_diff = false
! 642: end
! 643: $fileEntries << $file
! 644: $file.tag = getTag
! 645: handleFile($file)
! 646: end
! 647:
! 648: protected
! 649: def getTag
! 650: @tagHandler.getLastTag
! 651: end
! 652: end
! 653:
! 654: # A do-nothing superclass for objects that know how to create hyperlinks to
! 655: # web CVS interfaces (e.g. CVSweb). Subclasses overide these methods to
! 656: # wrap HTML link tags arround the text that this classes methods generate.
! 657: class NoFrontend
! 658: # Just returns an HTML-encoded version of the 'path' argument. Subclasses
! 659: # should turn this into a link to a webpage view of this CVS directory
! 660: def path(path, tag)
! 661: htmlEncode(path)
! 662: end
! 663:
! 664: # Just returns the value of the 'version' argument. Subclasses should change
! 665: # this into a link to the given version of the file.
! 666: def version(path, version)
! 667: version
! 668: end
! 669:
! 670: # Gerarates a little 'arrow' that superclasses may turn into links that will
! 671: # give an alternative 'diff' view of a change.
! 672: def diff(file)
! 673: '->'
! 674: end
! 675: end
! 676:
! 677: # Superclass for objects that can link to CVS frontends on the web (ViewCVS,
! 678: # Chora, etc.).
! 679: class WebFrontend < NoFrontend
! 680:
! 681: attr_accessor :repository_name
! 682:
! 683: def initialize(base_url)
! 684: @base_url = base_url
! 685: @repository_name = nil
! 686: end
! 687:
! 688: def path(path, tag)
! 689: path_for_href = ""
! 690: result = ""
! 691: path.split("/").each do |component|
! 692: unless result == ""
! 693: result << "/"
! 694: path_for_href << "/"
! 695: end
! 696: path_for_href << component
! 697: # The link is split over two lines so that long paths don't create
! 698: # huge HTML source-lines in the resulting email. This is an attempt to
! 699: # avoid having to prroduce a quoted-printable message (so that long lines
! 700: # can be dealt with properly),
! 701: result << "<a\n"
! 702: result << "href=\"#{path_url(path_for_href, tag)}\">#{htmlEncode(component)}</a>"
! 703: end
! 704: result
! 705: end
! 706:
! 707: def version(path, version)
! 708: "<a href=\"#{version_url(path, version)}\">#{version}</a>"
! 709: end
! 710:
! 711: def diff(file)
! 712: "<a href=\"#{diff_url(file)}\">#{super(file)}</a>"
! 713: end
! 714:
! 715: protected
! 716: def add_repo(url)
! 717: if @repository_name
! 718: if url =~ /\?/
! 719: "#{url}&cvsroot=#{urlEncode(@repository_name)}"
! 720: else
! 721: "#{url}?cvsroot=#{urlEncode(@repository_name)}"
! 722: end
! 723: else
! 724: url
! 725: end
! 726: end
! 727: end
! 728:
! 729: # Link to ViewCVS
! 730: class ViewCVSFrontend < WebFrontend
! 731: def initialize(base_url)
! 732: super(base_url)
! 733: end
! 734:
! 735: def path_url(path, tag)
! 736: if tag == nil
! 737: add_repo(@base_url + urlEncode(path))
! 738: else
! 739: add_repo("#{@base_url}#{urlEncode(path)}?only_with_tag=#{urlEncode(tag)}")
! 740: end
! 741: end
! 742:
! 743: def version_url(path, version)
! 744: add_repo("#{@base_url}#{urlEncode(path)}?rev=#{version}&content-type=text/vnd.viewcvs-markup")
! 745: end
! 746:
! 747: def diff_url(file)
! 748: add_repo("#{@base_url}#{urlEncode(file.path)}.diff?r1=#{file.fromVer}&r2=#{file.toVer}")
! 749: end
! 750: end
! 751:
! 752: # Link to Chora, from the Horde framework
! 753: class ChoraFrontend < WebFrontend
! 754: def path_url(path, tag)
! 755: # TODO: can we pass the tag somehow?
! 756: "#{@base_url}/cvs.php/#{urlEncode(path)}"
! 757: end
! 758:
! 759: def version_url(path, version)
! 760: "#{@base_url}/co.php/#{urlEncode(path)}?r=#{version}"
! 761: end
! 762:
! 763: def diff_url(file)
! 764: "#{@base_url}/diff.php/#{urlEncode(file.path)}?r1=#{file.fromVer}&r2=#{file.toVer}"
! 765: end
! 766: end
! 767:
! 768: # Link to CVSweb
! 769: class CVSwebFrontend < WebFrontend
! 770: def path_url(path, tag)
! 771: if tag == nil
! 772: add_repo(@base_url + urlEncode(path))
! 773: else
! 774: add_repo("#{@base_url}#{urlEncode(path)}?only_with_tag=#{urlEncode(tag)}")
! 775: end
! 776: end
! 777:
! 778: def version_url(path, version)
! 779: add_repo("#{@base_url}#{urlEncode(path)}?rev=#{version}&content-type=text/x-cvsweb-markup")
! 780: end
! 781:
! 782: def diff_url(file)
! 783: add_repo("#{@base_url}#{urlEncode(file.path)}.diff?r1=text&tr1=#{file.fromVer}&r2=text&tr2=#{file.toVer}&f=h")
! 784: end
! 785: end
! 786:
! 787:
! 788: # in need of refactoring...
! 789:
! 790: # Note when LogReader finds record of a file that was added in this commit
! 791: class AddedFileHandler < FileHandler
! 792: def handleFile(file)
! 793: file.type="A"
! 794: file.toVer=$toVer
! 795: end
! 796: end
! 797:
! 798: # Note when LogReader finds record of a file that was removed in this commit
! 799: class RemovedFileHandler < FileHandler
! 800: def handleFile(file)
! 801: file.type="R"
! 802: file.fromVer=$fromVer
! 803: end
! 804: end
! 805:
! 806: # Note when LogReader finds record of a file that was modified in this commit
! 807: class ModifiedFileHandler < FileHandler
! 808: def handleFile(file)
! 809: file.type="M"
! 810: file.fromVer=$fromVer
! 811: file.toVer=$toVer
! 812: end
! 813: end
! 814:
! 815:
! 816: # Used by UnifiedDiffHandler to record the number of added and removed lines
! 817: # appearing in a unidiff.
! 818: class UnifiedDiffStats
! 819: def initialize
! 820: @diffLines=3 # the three initial lines in the unidiff
! 821: end
! 822:
! 823: def diffLines
! 824: @diffLines
! 825: end
! 826:
! 827: def consume(line)
! 828: @diffLines += 1
! 829: case line[0,1]
! 830: when "+" then $file.lineAdditions += 1
! 831: when "-" then $file.lineRemovals += 1
! 832: end
! 833: end
! 834: end
! 835:
! 836: # TODO: change-within-line colourisation should really be comparing the
! 837: # set of lines just removed with the set of lines just added, but
! 838: # it currently considers just a single line
! 839:
! 840: # Used by UnifiedDiffHandler to produce an HTML, 'highlighted' version of
! 841: # the input unidiff text.
! 842: class UnifiedDiffColouriser < LineConsumer
! 843: def initialize
! 844: @currentState = "@"
! 845: @currentStyle = "info"
! 846: @lineJustDeleted = nil
! 847: @lineJustDeletedSuperlong = false
! 848: @truncatedLineCount = 0
! 849: end
! 850:
! 851: def output=(io)
! 852: @emailIO = io
! 853: end
! 854:
! 855: def consume(line)
! 856: initial = line[0,1]
! 857: superlong_line = false
! 858: if $maxDiffLineLength && line.length > $maxDiffLineLength+1
! 859: line = line[0, $maxDiffLineLength+1]
! 860: superlong_line = true
! 861: @truncatedLineCount += 1
! 862: end
! 863: if initial != @currentState
! 864: prefixLen = 1
! 865: suffixLen = 0
! 866: if initial=="+" && @currentState=="-" && @lineJustDeleted!=nil
! 867: # may be an edit, try to highlight the changes part of the line
! 868: a = line[1,line.length-1]
! 869: b = @lineJustDeleted[1,@lineJustDeleted.length-1]
! 870: prefixLen = commonPrefixLength(a, b)+1
! 871: suffixLen = commonPrefixLength(a.reverse, b.reverse)
! 872: # prevent prefix/suffux having overlap,
! 873: suffixLen = min(suffixLen, min(line.length,@lineJustDeleted.length)-prefixLen)
! 874: deleteInfixSize = @lineJustDeleted.length - (prefixLen+suffixLen)
! 875: addInfixSize = line.length - (prefixLen+suffixLen)
! 876: oversize_change = deleteInfixSize*100/@lineJustDeleted.length>33 || addInfixSize*100/line.length>33
! 877:
! 878: if prefixLen==1 && suffixLen==0 || deleteInfixSize<=0 || oversize_change
! 879: print(htmlEncode(@lineJustDeleted))
! 880: else
! 881: print(htmlEncode(@lineJustDeleted[0,prefixLen]))
! 882: print("<span id=\"removedchars\">")
! 883: print(formatChange(@lineJustDeleted[prefixLen,deleteInfixSize]))
! 884: print("</span>")
! 885: print(htmlEncode(@lineJustDeleted[@lineJustDeleted.length-suffixLen,suffixLen]))
! 886: end
! 887: if superlong_line
! 888: println("<strong class=\"error\">[...]</strong>")
! 889: else
! 890: println("")
! 891: end
! 892: @lineJustDeleted = nil
! 893: end
! 894: if initial=="-"
! 895: @lineJustDeleted=line
! 896: @lineJustDeletedSuperlong = superlong_line
! 897: shift(initial)
! 898: # we'll print it next time (fingers crossed)
! 899: return
! 900: elsif @lineJustDeleted!=nil
! 901: print(htmlEncode(@lineJustDeleted))
! 902: if @lineJustDeletedSuperlong
! 903: println("<strong class=\"error\">[...]</strong>")
! 904: else
! 905: println("")
! 906: end
! 907: @lineJustDeleted = nil
! 908: end
! 909: shift(initial)
! 910: if prefixLen==1 && suffixLen==0 || addInfixSize<=0 || oversize_change
! 911: encoded = htmlEncode(line)
! 912: else
! 913: encoded = htmlEncode(line[0,prefixLen]) +
! 914: "<span id=\"addedchars\">" +
! 915: formatChange(line[prefixLen,addInfixSize]) +
! 916: "</span>" +
! 917: htmlEncode(line[line.length-suffixLen,suffixLen])
! 918: end
! 919: else
! 920: encoded = htmlEncode(line)
! 921: end
! 922: if initial=="-"
! 923: unless @lineJustDeleted==nil
! 924: print(htmlEncode(@lineJustDeleted))
! 925: if @lineJustDeletedSuperlong
! 926: println("<strong class=\"error\">[...]</strong>")
! 927: else
! 928: println("")
! 929: end
! 930: @lineJustDeleted=nil
! 931: end
! 932: end
! 933: if initial=="+"
! 934: $task_keywords.each do |task|
! 935: if line =~ /\b(#{task}\b.*)/
! 936: $task_list << $1
! 937: encoded.sub!(/\b#{task}\b/, "<span class=\"task\">#{task}</span>")
! 938: encoded = "<a name=\"task#{$task_list.size}\" />" + encoded
! 939: break
! 940: end
! 941: end
! 942: end
! 943: print(encoded)
! 944: if superlong_line
! 945: println("<strong class=\"error\">[...]</strong>")
! 946: else
! 947: println("")
! 948: end
! 949: end
! 950:
! 951: def teardown
! 952: unless @lineJustDeleted==nil
! 953: print(htmlEncode(@lineJustDeleted))
! 954: if @lineJustDeletedSuperlong
! 955: println("<strong class=\"error\">[...]</strong>")
! 956: else
! 957: println("")
! 958: end
! 959: @lineJustDeleted = nil
! 960: end
! 961: shift(nil)
! 962: if @truncatedLineCount>0
! 963: println("<strong class=\"error\" title=\"#{@truncatedLineCount} lines truncated at column #{$maxDiffLineLength}\">[Note: Some over-long lines of diff output only partialy shown]</strong>")
! 964: end
! 965: end
! 966:
! 967: # start the diff output, using the given lines as the 'preamble' bit
! 968: def start_output(*lines)
! 969: println("<hr /><a name=\"file#{$fileEntries.size}\" /><div class=\"file\">")
! 970: case $file.type
! 971: when "A"
! 972: print("<span class=\"pathname\" id=\"added\">")
! 973: print($frontend.path($file.basedir, $file.tag))
! 974: println("</span><br />")
! 975: println("<div class=\"fileheader\" id=\"added\"><big><b>#{htmlEncode($file.file)}</b></big> <small id=\"info\">added at #{$frontend.version($file.path,$file.toVer)}</small></div>")
! 976: when "R"
! 977: print("<span class=\"pathname\" id=\"removed\">")
! 978: print($frontend.path($file.basedir, $file.tag))
! 979: println("</span><br />")
! 980: 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>")
! 981: when "M"
! 982: print("<span class=\"pathname\">")
! 983: print($frontend.path($file.basedir, $file.tag))
! 984: println("</span><br />")
! 985: 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>")
! 986: end
! 987: print("<pre class=\"diff\"><small id=\"info\">")
! 988: lines.each do |line|
! 989: println(htmlEncode(line))
! 990: end
! 991: end
! 992:
! 993: private
! 994:
! 995: def formatChange(text)
! 996: return '<small id="info">^M</small>' if text=="\r"
! 997: htmlEncode(text).gsub(/ /, ' ')
! 998: end
! 999:
! 1000: def shift(nextState)
! 1001: unless @currentState == nil
! 1002: if @currentStyle == "info"
! 1003: print("</small></pre>")
! 1004: else
! 1005: print("</pre>")
! 1006: end
! 1007: @currentStyle = case nextState
! 1008: when "\\" then "info" # as in '\ No newline at end of file'
! 1009: when "@" then "info"
! 1010: when " " then "context"
! 1011: when "+" then "added"
! 1012: when "-" then "removed"
! 1013: end
! 1014: unless nextState == nil
! 1015: if @currentStyle=='info'
! 1016: print("<pre class=\"diff\"><small id=\"info\">")
! 1017: else
! 1018: print("<pre class=\"diff\" id=\"#{@currentStyle}\">")
! 1019: end
! 1020: end
! 1021: end
! 1022: @currentState = nextState
! 1023: end
! 1024:
! 1025: def commonPrefixLength(a, b)
! 1026: length = 0
! 1027: a.each_byte do |char|
! 1028: break unless b[length]==char
! 1029: length = length + 1
! 1030: end
! 1031: return length
! 1032: end
! 1033: end
! 1034:
! 1035:
! 1036: # Handle lines from LogReader that are the output from 'cvs diff -u' for the
! 1037: # particular file under consideration
! 1038: class UnifiedDiffHandler < LineConsumer
! 1039: def setup
! 1040: @stats = UnifiedDiffStats.new
! 1041: @colour = UnifiedDiffColouriser.new
! 1042: @colour.output = @emailIO
! 1043: @lookahead = nil
! 1044: end
! 1045:
! 1046: def consume(line)
! 1047: case lineno()
! 1048: when 1
! 1049: @diffline = line
! 1050: when 2
! 1051: @lookahead = line
! 1052: when 3
! 1053: if $file.wants_diff_in_mail?
! 1054: @colour.start_output(@diffline, @lookahead, line)
! 1055: end
! 1056: else
! 1057: @stats.consume(line)
! 1058: if $file.wants_diff_in_mail?
! 1059: if $maxLinesPerDiff.nil? || @stats.diffLines < $maxLinesPerDiff
! 1060: @colour.consume(line)
! 1061: elsif @stats.diffLines == $maxLinesPerDiff
! 1062: @colour.consume(line)
! 1063: @colour.teardown
! 1064: end
! 1065: end
! 1066: end
! 1067: end
! 1068:
! 1069: def teardown
! 1070: if @lookahead == nil
! 1071: $file.isEmpty = true
! 1072: elsif @lookahead =~ /Binary files .* and .* differ/
! 1073: $file.isBinary = true
! 1074: else
! 1075: if $file.wants_diff_in_mail?
! 1076: if $maxLinesPerDiff && @stats.diffLines > $maxLinesPerDiff
! 1077: println("</pre>")
! 1078: println("<strong class=\"error\">[truncated at #{$maxLinesPerDiff} lines; #{@stats.diffLines-$maxLinesPerDiff} more skipped]</strong>")
! 1079: else
! 1080: @colour.teardown
! 1081: end
! 1082: println("</div>") # end of "file" div
! 1083: $file.has_diff = true
! 1084: end
! 1085: end
! 1086: end
! 1087: end
! 1088:
! 1089:
! 1090: # a filter that counts the number of characters output to the underlying object
! 1091: class OutputCounter
! 1092: # TODO: This should probably be a subclass of IO
! 1093: # TODO: assumes unix end-of-line convention
! 1094:
! 1095: def initialize(io)
! 1096: @io = io
! 1097: # TODO: use real number of chars representing end of line (for platform)
! 1098: @eol_size = 1
! 1099: @count = 0;
! 1100: end
! 1101:
! 1102: def puts(text)
! 1103: @count += text.length
! 1104: @count += @eol_size unless text =~ /\n$/
! 1105: @io.puts(text)
! 1106: end
! 1107:
! 1108: def print(text)
! 1109: @count += text.length
! 1110: @io.print(text)
! 1111: end
! 1112:
! 1113: attr_reader :count
! 1114: end
! 1115:
! 1116:
! 1117: # a filter that can be told to stop outputing data to the underlying object
! 1118: class OutputDropper
! 1119: def initialize(io)
! 1120: @io = io
! 1121: @drop = false
! 1122: end
! 1123:
! 1124: def puts(text)
! 1125: @io.puts(text) unless @drop
! 1126: end
! 1127:
! 1128: def print(text)
! 1129: @io.print(text) unless @drop
! 1130: end
! 1131:
! 1132: attr_accessor :drop
! 1133: end
! 1134:
! 1135:
! 1136: # TODO: the current implementation of the size-limit continues to generate
! 1137: # HTML-ified diff output, but doesn't add it to the email. This means we
! 1138: # can report 'what you would have won', but is less efficient than turning
! 1139: # of the diff highlighting code. Does this matter?
! 1140:
! 1141: # Counts the amount of data written, and when choose_to_limit? is called,
! 1142: # checks this count against the configured limit, discarding any further
! 1143: # output if the limit is exceeded. We aren't strict about the limit becase
! 1144: # we don't want to chop-off the end of a tag and produce invalid HTML, etc.
! 1145: class OutputSizeLimiter
! 1146: def initialize(io, limit)
! 1147: @dropper = OutputDropper.new(io)
! 1148: @counter = OutputCounter.new(@dropper)
! 1149: @limit = limit
! 1150: @written_count = nil
! 1151: end
! 1152:
! 1153: def puts(text)
! 1154: @counter.puts(text)
! 1155: end
! 1156:
! 1157: def print(text)
! 1158: @counter.print(text)
! 1159: end
! 1160:
! 1161: def choose_to_limit?
! 1162: return true if @dropper.drop
! 1163: if @counter.count >= @limit
! 1164: @dropper.drop = true
! 1165: @written_count = @counter.count
! 1166: return true
! 1167: end
! 1168: return false
! 1169: end
! 1170:
! 1171: def total_count
! 1172: @counter.count
! 1173: end
! 1174:
! 1175: def written_count
! 1176: if @written_count.nil?
! 1177: total_count
! 1178: else
! 1179: @written_count
! 1180: end
! 1181: end
! 1182: end
! 1183:
! 1184: # an RFC 822 email address
! 1185: class EmailAddress
! 1186: def initialize(text)
! 1187: if text =~ /^\s*([^<]+?)\s*<\s*([^>]+?)\s*>\s*$/
! 1188: @personal_name = $1
! 1189: @address = $2
! 1190: else
! 1191: @personal_name = nil
! 1192: @address = text
! 1193: end
! 1194: end
! 1195:
! 1196: attr_accessor :personal_name, :address
! 1197:
! 1198: def has_personal_name?
! 1199: return !@personal_name.nil?
! 1200: end
! 1201:
! 1202: def encoded
! 1203: if has_personal_name?
! 1204: "#{encoded_personal_name} <#{address}>"
! 1205: else
! 1206: @address
! 1207: end
! 1208: end
! 1209:
! 1210: def to_s
! 1211: if has_personal_name?
! 1212: "#{personal_name} <#{address}>"
! 1213: else
! 1214: @address
! 1215: end
! 1216: end
! 1217:
! 1218: private
! 1219:
! 1220: def encoded_personal_name
! 1221: personal_name.split(" ").map{|word| encode_word(word)}.join(" ")
! 1222: end
! 1223:
! 1224: # rfc2047 encode the word, if it contains non-ASCII characters
! 1225: def encode_word(word)
! 1226: if $encoder.requires_rfc2047?(word)
! 1227: encoded = $encoder.marker_start_quoted
! 1228: $encoder.each_char_encoded(word) do |code|
! 1229: encoded << code
! 1230: end
! 1231: encoded << $encoder.marker_end_quoted
! 1232: return encoded
! 1233: end
! 1234: word
! 1235: end
! 1236: end
! 1237:
! 1238:
! 1239: cvsroot_dir = "#{ENV['CVSROOT']}/CVSROOT"
! 1240: $config = "#{cvsroot_dir}/cvsspam.conf"
! 1241: $users_file = "#{cvsroot_dir}/users"
! 1242:
! 1243: $debug = false
! 1244: $recipients = Array.new
! 1245: $sendmail_prog = "/usr/sbin/sendmail"
! 1246: $hostname = ENV['HOSTNAME'] || 'localhost'
! 1247: $no_removed_file_diff = false
! 1248: $no_added_file_diff = false
! 1249: $no_diff = false
! 1250: $task_keywords = ['TODO', 'FIXME']
! 1251: $bugzillaURL = nil
! 1252: $wikiURL = nil
! 1253: $jiraURL = nil
! 1254: $ticketURL = nil
! 1255: $viewcvsURL = nil
! 1256: $choraURL = nil
! 1257: $cvswebURL = nil
! 1258: $from_address = nil
! 1259: $subjectPrefix = nil
! 1260: $files_in_subject = false;
! 1261: $smtp_host = nil
! 1262: $repository_name = nil
! 1263: # 2MiB limit on attached diffs,
! 1264: $mail_size_limit = 1024 * 1024 * 2
! 1265: $arg_charset = nil
! 1266:
! 1267: require 'getoptlong'
! 1268:
! 1269: opts = GetoptLong.new(
! 1270: [ "--to", "-t", GetoptLong::REQUIRED_ARGUMENT ],
! 1271: [ "--config", "-c", GetoptLong::REQUIRED_ARGUMENT ],
! 1272: [ "--debug", "-d", GetoptLong::NO_ARGUMENT ],
! 1273: [ "--from", "-u", GetoptLong::REQUIRED_ARGUMENT ],
! 1274: [ "--charset", GetoptLong::REQUIRED_ARGUMENT ]
! 1275: )
! 1276:
! 1277: opts.each do |opt, arg|
! 1278: $recipients << EmailAddress.new(arg) if opt=="--to"
! 1279: $config = arg if opt=="--config"
! 1280: $debug = true if opt=="--debug"
! 1281: $from_address = EmailAddress.new(arg) if opt=="--from"
! 1282: # must use different variable as the config is readed later.
! 1283: $arg_charset = arg if opt == "--charset"
! 1284: end
! 1285:
! 1286:
! 1287: if ARGV.length != 1
! 1288: if ARGV.length > 1
! 1289: $stderr.puts "extra arguments not needed: #{ARGV[1, ARGV.length-1].join(', ')}"
! 1290: else
! 1291: $stderr.puts "missing required file argument"
! 1292: end
! 1293: puts "Usage: cvsspam.rb [ --to <email> ] [ --config <file> ] <collect_diffs file>"
! 1294: exit(-1)
! 1295: end
! 1296:
! 1297: $logfile = ARGV[0]
! 1298:
! 1299:
! 1300: $additionalHeaders = Array.new
! 1301: $problemHeaders = Array.new
! 1302:
! 1303: # helper function called from the 'config file'
! 1304: def addHeader(name, value)
! 1305: if name =~ /^[!-9;-~]+$/
! 1306: $additionalHeaders << [name, value]
! 1307: else
! 1308: $problemHeaders << [name, value]
! 1309: end
! 1310: end
! 1311: # helper function called from the 'config file'
! 1312: def addRecipient(email)
! 1313: $recipients << EmailAddress.new(email)
! 1314: end
! 1315: # 'constant' used from the 'config file'
! 1316: class GUESS
! 1317: end
! 1318:
! 1319: if FileTest.exists?($config)
! 1320: blah("Using config '#{$config}'")
! 1321: load $config
! 1322: else
! 1323: blah("Config file '#{$config}' not found, ignoring")
! 1324: end
! 1325:
! 1326: unless $arg_charset.nil?
! 1327: $charset = $arg_charset
! 1328: end
! 1329:
! 1330: if $recipients.empty?
! 1331: fail "No email recipients defined"
! 1332: end
! 1333:
! 1334: if $viewcvsURL != nil
! 1335: $viewcvsURL << "/" unless $viewcvsURL =~ /\/$/
! 1336: $frontend = ViewCVSFrontend.new($viewcvsURL)
! 1337: elsif $choraURL !=nil
! 1338: $frontend = ChoraFrontend.new($choraURL)
! 1339: elsif $cvswebURL !=nil
! 1340: $cvswebURL << "/" unless $cvswebURL =~ /\/$/
! 1341: $frontend = CVSwebFrontend.new($cvswebURL)
! 1342: else
! 1343: $frontend = NoFrontend.new
! 1344: end
! 1345:
! 1346: if $viewcvsURL != nil || $cvswebURL !=nil
! 1347: if $repository_name == GUESS
! 1348: # use the last component of the repository path as the name
! 1349: ENV['CVSROOT'] =~ /([^\/]+$)/
! 1350: $frontend.repository_name = $1
! 1351: elsif $repository_name != nil
! 1352: $frontend.repository_name = $repository_name
! 1353: end
! 1354: end
! 1355:
! 1356:
! 1357: if $bugzillaURL != nil
! 1358: commentSubstitutions['\b[Bb][Uu][Gg]\s*#?[0-9]+'] = bugzillaSub
! 1359: end
! 1360: if $jiraURL != nil
! 1361: commentSubstitutions['\b[a-zA-Z]+-[0-9]+\b'] = jiraSub
! 1362: end
! 1363: if $ticketURL != nil
! 1364: commentSubstitutions['\b[Tt][Ii][Cc][Kk][Ee][Tt]\s*#?[0-9]+\b'] = ticketSub
! 1365: end
! 1366: if $wikiURL != nil
! 1367: commentSubstitutions['\[\[.+\]\]'] = wikiSub
! 1368: end
! 1369: $commentEncoder = MultiSub.new(commentSubstitutions)
! 1370:
! 1371:
! 1372: tagHandler = TagHandler.new
! 1373:
! 1374: $handlers = Hash[">" => CommentHandler.new,
! 1375: "U" => UnifiedDiffHandler.new,
! 1376: "T" => tagHandler,
! 1377: "A" => AddedFileHandler.new,
! 1378: "R" => RemovedFileHandler.new,
! 1379: "M" => ModifiedFileHandler.new,
! 1380: "V" => VersionHandler.new]
! 1381:
! 1382: $handlers["A"].setTagHandler(tagHandler)
! 1383: $handlers["R"].setTagHandler(tagHandler)
! 1384: $handlers["M"].setTagHandler(tagHandler)
! 1385:
! 1386: $fileEntries = Array.new
! 1387: $task_list = Array.new
! 1388: $allTags = Hash.new
! 1389:
! 1390: File.open("#{$logfile}.emailtmp", File::RDWR|File::CREAT|File::TRUNC) do |mail|
! 1391:
! 1392: $diff_output_limiter = OutputSizeLimiter.new(mail, $mail_size_limit)
! 1393:
! 1394: File.open($logfile) do |log|
! 1395: reader = LogReader.new(log)
! 1396:
! 1397: until reader.eof
! 1398: handler = $handlers[reader.currentLineCode]
! 1399: if handler == nil
! 1400: raise "No handler file lines marked '##{reader.currentLineCode}'"
! 1401: end
! 1402: handler.handleLines(reader.getLines, $diff_output_limiter)
! 1403: end
! 1404: end
! 1405:
! 1406: end
! 1407:
! 1408: if $subjectPrefix == nil
! 1409: $subjectPrefix = "[CVS #{Repository.array.join(',')}]"
! 1410: # $subjectPrefix = "CVS update"
! 1411: end
! 1412:
! 1413: $mailSubject = "#{Repository.array.join(',')}"
! 1414:
! 1415: if $files_in_subject
! 1416: all_files = ""
! 1417: $fileEntries.each do |file|
! 1418: name = htmlEncode(file.name_after_common_prefix)
! 1419: if all_files != ""
! 1420: all_files = all_files + ";" + name
! 1421: else
! 1422: all_files = name
! 1423: end
! 1424: end
! 1425: $mailSubject = all_files + ":" + $mailSubject
! 1426: end
! 1427:
! 1428: mailSubject = "#{$subjectPrefix} #{$mailSubject}"
! 1429: if mailSubject.length > $maxSubjectLength
! 1430: mailSubject = mailSubject[0, $maxSubjectLength]
! 1431: end
! 1432:
! 1433: $encoder = HeaderEncoder.new
! 1434: # TODO: maybe we should use the system-default value instead of ISO Latin 1?
! 1435: $encoder.charset = $charset.nil? ? "ISO-8859-1" : $charset
! 1436:
! 1437:
! 1438: # generate the email header (and footer) having already generated the diffs
! 1439: # for the email body to a temp file (which is simply included in the middle)
! 1440: def make_html_email(mail)
! 1441: mail.puts(<<HEAD)
! 1442: <html>
! 1443: <head>
! 1444: <style><!--
! 1445: body {background-color:#ffffff;}
! 1446: .file {border:1px solid #eeeeee;margin-top:1em;margin-bottom:1em;}
! 1447: .pathname {font-family:monospace; float:right;}
! 1448: .fileheader {margin-bottom:.5em;}
! 1449: .diff {margin:0;}
! 1450: .tasklist {padding:4px;border:1px dashed #000000;margin-top:1em;}
! 1451: .tasklist ul {margin-top:0;margin-bottom:0;}
! 1452: tr.alt {background-color:#eeeeee}
! 1453: #added {background-color:#ddffdd;}
! 1454: #addedchars {background-color:#99ff99;font-weight:bolder;}
! 1455: tr.alt #added {background-color:#ccf7cc;}
! 1456: #removed {background-color:#ffdddd;}
! 1457: #removedchars {background-color:#ff9999;font-weight:bolder;}
! 1458: tr.alt #removed {background-color:#f7cccc;}
! 1459: #info {color:#888888;}
! 1460: #context {background-color:#eeeeee;}
! 1461: td {padding-left:.3em;padding-right:.3em;}
! 1462: tr.head {border-bottom-width:1px;border-bottom-style:solid;}
! 1463: tr.head td {padding:0;padding-top:.2em;}
! 1464: .task {background-color:#ffff00;}
! 1465: .comment {white-space:-moz-pre-wrap;white-space:-pre-wrap;white-space:-o-pre-wrap;white-space:pre-wrap;word-wrap:break-word;padding:4px;border:1px dashed #000000;background-color:#ffffdd}
! 1466: .error {color:red;}
! 1467: hr {border-width:0px;height:2px;background:black;}
! 1468: --></style>
! 1469: </head>
! 1470: <body>
! 1471: HEAD
! 1472:
! 1473: unless ($problemHeaders.empty?)
! 1474: mail.puts("<strong class=\"error\">Bad header format in '#{$config}':<ul>")
! 1475: $stderr.puts("Bad header format in '#{$config}':")
! 1476: $problemHeaders.each do |header|
! 1477: mail.puts("<li><pre>#{htmlEncode(header[0])}</pre></li>")
! 1478: $stderr.puts(" - #{header[0]}")
! 1479: end
! 1480: mail.puts("</ul></strong>")
! 1481: end
! 1482: mail.puts("<table cellspacing=\"0\" cellpadding=\"0\" border=\"0\" rules=\"cols\">")
! 1483:
! 1484: haveTags = false
! 1485: Repository.each do |repository|
! 1486: haveTags |= repository.has_multiple_tags
! 1487: end
! 1488:
! 1489: filesAdded = 0
! 1490: filesRemoved = 0
! 1491: filesModified = 0
! 1492: totalLinesAdded = 0
! 1493: totalLinesRemoved = 0
! 1494: file_count = 0
! 1495: lastPath = ""
! 1496: last_repository = nil
! 1497: $fileEntries.each do |file|
! 1498: unless file.repository == last_repository
! 1499: last_repository = file.repository
! 1500: mail.print("<tr class=\"head\"><td colspan=\"#{last_repository.has_multiple_tags ? 5 : 4}\">")
! 1501: if last_repository.has_multiple_tags
! 1502: mail.print("Mixed-tag commit")
! 1503: else
! 1504: mail.print("Commit")
! 1505: end
! 1506: mail.print(" in <b><tt>#{htmlEncode(last_repository.common_prefix)}</tt></b>")
! 1507: if last_repository.trunk_only?
! 1508: mail.print("<span id=\"info\"> on MAIN</span>")
! 1509: else
! 1510: mail.print(" on ")
! 1511: tagCount = 0
! 1512: last_repository.each_tag do |tag|
! 1513: tagCount += 1
! 1514: if tagCount > 1
! 1515: mail.print tagCount<last_repository.tag_count ? ", " : " & "
! 1516: end
! 1517: mail.print tag ? htmlEncode(tag) : "<span id=\"info\">MAIN</span>"
! 1518: end
! 1519: end
! 1520: mail.puts("</td></tr>")
! 1521: end
! 1522: file_count += 1
! 1523: if (file_count%2==0)
! 1524: mail.print("<tr class=\"alt\">")
! 1525: else
! 1526: mail.print("<tr>")
! 1527: end
! 1528: if file.addition?
! 1529: filesAdded += 1
! 1530: elsif file.removal?
! 1531: filesRemoved += 1
! 1532: elsif file.modification?
! 1533: filesModified += 1
! 1534: end
! 1535: name = htmlEncode(file.name_after_common_prefix)
! 1536: slashPos = name.rindex("/")
! 1537: if slashPos==nil
! 1538: prefix = ""
! 1539: else
! 1540: thisPath = name[0,slashPos]
! 1541: name = name[slashPos+1,name.length]
! 1542: if thisPath == lastPath
! 1543: prefix = " "*(slashPos) + "/"
! 1544: else
! 1545: prefix = thisPath + "/"
! 1546: end
! 1547: lastPath = thisPath
! 1548: end
! 1549: if file.addition?
! 1550: name = "<span id=\"added\">#{name}</span>"
! 1551: elsif file.removal?
! 1552: name = "<span id=\"removed\">#{name}</span>"
! 1553: end
! 1554: if file.has_diff?
! 1555: mail.print("<td><tt>#{prefix}<a href=\"#file#{file_count}\">#{name}</a></tt></td>")
! 1556: else
! 1557: mail.print("<td><tt>#{prefix}#{name}</tt></td>")
! 1558: end
! 1559: if file.isEmpty
! 1560: mail.print("<td colspan=\"2\" align=\"center\"><small id=\"info\">[empty]</small></td>")
! 1561: elsif file.isBinary
! 1562: mail.print("<td colspan=\"2\" align=\"center\"><small id=\"info\">[binary]</small></td>")
! 1563: else
! 1564: if file.lineAdditions>0
! 1565: totalLinesAdded += file.lineAdditions
! 1566: mail.print("<td align=\"right\" id=\"added\">+#{file.lineAdditions}</td>")
! 1567: else
! 1568: mail.print("<td></td>")
! 1569: end
! 1570: if file.lineRemovals>0
! 1571: totalLinesRemoved += file.lineRemovals
! 1572: mail.print("<td align=\"right\" id=\"removed\">-#{file.lineRemovals}</td>")
! 1573: else
! 1574: mail.print("<td></td>")
! 1575: end
! 1576: end
! 1577: if last_repository.has_multiple_tags
! 1578: if file.tag
! 1579: mail.print("<td>#{htmlEncode(file.tag)}</td>")
! 1580: else
! 1581: mail.print("<td><span id=\"info\">MAIN</span></td>")
! 1582: end
! 1583: elsif haveTags
! 1584: mail.print("<td></td>")
! 1585: end
! 1586: if file.addition?
! 1587: mail.print("<td nowrap=\"nowrap\" align=\"right\">added #{$frontend.version(file.path,file.toVer)}</td>")
! 1588: elsif file.removal?
! 1589: mail.print("<td nowrap=\"nowrap\">#{$frontend.version(file.path,file.fromVer)} removed</td>")
! 1590: elsif file.modification?
! 1591: mail.print("<td nowrap=\"nowrap\" align=\"center\">#{$frontend.version(file.path,file.fromVer)} #{$frontend.diff(file)} #{$frontend.version(file.path,file.toVer)}</td>")
! 1592: end
! 1593:
! 1594: mail.puts("</tr>")
! 1595: end
! 1596: if $fileEntries.size>1 && (totalLinesAdded+totalLinesRemoved)>0
! 1597: # give total number of lines added/removed accross all files
! 1598: mail.print("<tr><td></td>")
! 1599: if totalLinesAdded>0
! 1600: mail.print("<td align=\"right\" id=\"added\">+#{totalLinesAdded}</td>")
! 1601: else
! 1602: mail.print("<td></td>")
! 1603: end
! 1604: if totalLinesRemoved>0
! 1605: mail.print("<td align=\"right\" id=\"removed\">-#{totalLinesRemoved}</td>")
! 1606: else
! 1607: mail.print("<td></td>")
! 1608: end
! 1609: mail.print("<td></td>") if haveTags
! 1610: mail.puts("<td></td></tr>")
! 1611: end
! 1612:
! 1613: mail.puts("</table>")
! 1614:
! 1615: totalFilesChanged = filesAdded+filesRemoved+filesModified
! 1616: if totalFilesChanged > 1
! 1617: mail.print("<small id=\"info\">")
! 1618: changeKind = 0
! 1619: if filesAdded>0
! 1620: mail.print("#{filesAdded} added")
! 1621: changeKind += 1
! 1622: end
! 1623: if filesRemoved>0
! 1624: mail.print(" + ") if changeKind>0
! 1625: mail.print("#{filesRemoved} removed")
! 1626: changeKind += 1
! 1627: end
! 1628: if filesModified>0
! 1629: mail.print(" + ") if changeKind>0
! 1630: mail.print("#{filesModified} modified")
! 1631: changeKind += 1
! 1632: end
! 1633: mail.print(", total #{totalFilesChanged}") if changeKind > 1
! 1634: mail.puts(" files</small><br />")
! 1635: end
! 1636:
! 1637: if $task_list.size > 0
! 1638: task_count = 0
! 1639: mail.puts("<div class=\"tasklist\"><ul>")
! 1640: $task_list.each do |item|
! 1641: task_count += 1
! 1642: item = htmlEncode(item)
! 1643: mail.puts("<li><a href=\"#task#{task_count}\">#{item}</a></li>")
! 1644: end
! 1645: mail.puts("</ul></div>")
! 1646: end
! 1647:
! 1648:
! 1649: File.open("#{$logfile}.emailtmp") do |input|
! 1650: input.each do |line|
! 1651: mail.puts(line.chomp)
! 1652: end
! 1653: end
! 1654: if $diff_output_limiter.choose_to_limit?
! 1655: mail.puts("<p><strong class=\"error\">[Reached #{$diff_output_limiter.written_count} bytes of diffs.")
! 1656: mail.puts("Since the limit is about #{$mail_size_limit} bytes,")
! 1657: mail.puts("a further #{$diff_output_limiter.total_count-$diff_output_limiter.written_count} were skipped.]</strong></p>")
! 1658: end
! 1659: if $debug
! 1660: blah("leaving file #{$logfile}.emailtmp")
! 1661: else
! 1662: File.unlink("#{$logfile}.emailtmp")
! 1663: end
! 1664:
! 1665: mail.puts("<center><small><a href=\"http://www.badgers-in-foil.co.uk/projects/cvsspam/\" title=\"commit -> email\">CVSspam</a> #{$version}</small></center>")
! 1666:
! 1667: mail.puts("</body></html>")
! 1668:
! 1669: end
! 1670:
! 1671: # Tries to look up an 'alias' email address for the given string in the
! 1672: # CVSROOT/users file, if the file exists. The argument is returned unchanged
! 1673: # if no alias is found.
! 1674: def sender_alias(email)
! 1675: if File.exists?($users_file)
! 1676: File.open($users_file) do |io|
! 1677: io.each_line do |line|
! 1678: if line =~ /^([^:]+)\s*:\s*(['"]?)([^\n\r]+)(\2)/
! 1679: if email.address == $1
! 1680: return EmailAddress.new($3)
! 1681: end
! 1682: end
! 1683: end
! 1684: end
! 1685: end
! 1686: email
! 1687: end
! 1688:
! 1689: # A handle for code that needs to add headers and a body to an email being
! 1690: # sent. This wraps an underlying IO object, and is responsible for doing
! 1691: # sensible header formatting, and for ensuring that the body is seperated
! 1692: # from the message headers by a blank line (as it is required to be).
! 1693: class MailContext
! 1694: def initialize(io)
! 1695: @done_headers = false
! 1696: @io = io
! 1697: end
! 1698:
! 1699: # add a header to the email. raises an exception if #body has already been
! 1700: # called
! 1701: def header(name, value)
! 1702: raise "headers already commited" if @done_headers
! 1703: if name == "Subject"
! 1704: $encoder.encode_header(@io, "Subject", value)
! 1705: else
! 1706: @io.puts("#{name}: #{value}")
! 1707: end
! 1708: end
! 1709:
! 1710: # yields an IO that should be used to write the message body
! 1711: def body
! 1712: @done_headers = true
! 1713: @io.puts
! 1714: yield @io
! 1715: end
! 1716: end
! 1717:
! 1718: # provides a send() method for sending email by invoking the 'sendmail'
! 1719: # command-line program
! 1720: class SendmailMailer
! 1721: def send(from, recipients)
! 1722: # The -t option causes sendmail to take message headers, as well as the
! 1723: # message body, from its input. The -oi option stops a dot on a line on
! 1724: # its own from being interpreted as the end of the message body (so
! 1725: # messages that have such a line don't fail part-way though sending),
! 1726: cmd = "#{$sendmail_prog} -t -oi"
! 1727: blah("invoking '#{cmd}'")
! 1728: IO.popen(cmd, "w") do |mail|
! 1729: ctx = MailContext.new(mail)
! 1730: ctx.header("To", recipients.map{|addr| addr.encoded}.join(','))
! 1731: if from
! 1732: blah("Mail From: <#{from}>")
! 1733: else
! 1734: blah("Mail From not set")
! 1735: end
! 1736: ctx.header("From", from.encoded) if from
! 1737: yield ctx
! 1738: end
! 1739: end
! 1740: end
! 1741: #
! 1742: # provides a send() method for sending email by connecting to an SMTP server
! 1743: # using the Ruby Net::SMTP package.
! 1744: class SMTPMailer
! 1745: def initialize(smtp_host)
! 1746: @smtp_host = smtp_host
! 1747: end
! 1748:
! 1749: class IOAdapter
! 1750: def initialize(mail)
! 1751: @mail = mail
! 1752: end
! 1753: def puts(text="")
! 1754: @mail.write(text)
! 1755: @mail.write("\r\n")
! 1756: end
! 1757: def print(text)
! 1758: @mail.write(text)
! 1759: end
! 1760: end
! 1761:
! 1762: def send(from, recipients)
! 1763: if from == nil
! 1764: from = EmailAddress.new(ENV['USER'] || ENV['USERNAME'] || 'cvsspam')
! 1765: end
! 1766: unless from.address =~ /@/
! 1767: from.address = "#{from.address}@#{$hostname}"
! 1768: end
! 1769: smtp = Net::SMTP.new(@smtp_host)
! 1770: blah("connecting to '#{@smtp_host}'")
! 1771: smtp.start()
! 1772: smtp.ready(from.address, recipients.map{|addr| addr.address}) do |mail|
! 1773: ctx = MailContext.new(IOAdapter.new(mail))
! 1774: ctx.header("To", recipients.map{|addr| addr.encoded}.join(','))
! 1775: blah("Mail From: <#{from}>")
! 1776: ctx.header("From", from.encoded) if from
! 1777: ctx.header("Date", Time.now.utc.strftime(DATE_HEADER_FORMAT))
! 1778: yield ctx
! 1779: end
! 1780: end
! 1781: end
! 1782:
! 1783:
! 1784: def make_msg_id(localpart, hostpart)
! 1785: "<cvsspam-#{localpart}@#{hostpart}>"
! 1786: end
! 1787:
! 1788:
! 1789: # replaces control characters, and a selection of other characters that
! 1790: # may not appear unquoted in an RFC822 'word', with underscores. (It
! 1791: # doesn't actually zap '.' though.)
! 1792: def zap_header_special_chars(text)
! 1793: text.gsub(/<>()\[\]@,;:\\[\000-\037\177]/, "_")
! 1794: end
! 1795:
! 1796:
! 1797: # Mail clients will try to 'thread' together a conversation over
! 1798: # several email messages by inspecting the In-Reply-To and References headers,
! 1799: # which should refer to previous emails in the conversation by mentioning
! 1800: # the value of the previous message's Message-Id header. This function invents
! 1801: # values for these headers so that, in the special case where a *single* file
! 1802: # is committed to repeatedly, the emails giving notification of these commits
! 1803: # can be threaded together automatically by the mail client.
! 1804: def inject_threading_headers(mail)
! 1805: return unless $fileEntries.length == 1
! 1806: file = $fileEntries[0]
! 1807: name = zap_header_special_chars(file.path)
! 1808: unless file.fromVer == "NONE"
! 1809: mail.header("References", make_msg_id("#{name}.#{file.fromVer}", $hostname))
! 1810: end
! 1811: unless file.toVer == "NONE"
! 1812: mail.header("Message-ID", make_msg_id("#{name}.#{file.toVer}", $hostname))
! 1813: end
! 1814: end
! 1815:
! 1816:
! 1817: if $smtp_host
! 1818: require 'net/smtp'
! 1819: mailer = SMTPMailer.new($smtp_host)
! 1820: else
! 1821: mailer = SendmailMailer.new
! 1822: end
! 1823:
! 1824: $from_address = sender_alias($from_address) unless $from_address.nil?
! 1825:
! 1826: mailer.send($from_address, $recipients) do |mail|
! 1827: mail.header("Subject", mailSubject)
! 1828: inject_threading_headers(mail)
! 1829: mail.header("MIME-Version", "1.0")
! 1830: mail.header("Content-Type", "text/html" + ($charset.nil? ? "" : "; charset=\"#{$charset}\""))
! 1831: if ENV['REMOTE_HOST']
! 1832: # TODO: I think this will always be an IP address. If a hostname is
! 1833: # possible, it may need encoding of some kind,
! 1834: mail.header("X-Originating-IP", "[#{ENV['REMOTE_HOST']}]")
! 1835: end
! 1836: unless ($additionalHeaders.empty?)
! 1837: $additionalHeaders.each do |header|
! 1838: mail.header(header[0], header[1])
! 1839: end
! 1840: end
! 1841: mail.header("X-Mailer", "CVSspam #{$version} <http://www.badgers-in-foil.co.uk/projects/cvsspam/>")
! 1842:
! 1843: mail.body do |body|
! 1844: make_html_email(body)
! 1845: end
! 1846: end
FreeBSD-CVSweb <freebsd-cvsweb@FreeBSD.org>