1 # -*- test-case-name: buildbot.test.test_mailparse -*-
4 Parse various kinds of 'CVS notify' email.
8 from email
import message_from_file
9 from email
.Utils
import parseaddr
10 from email
.Iterators
import body_line_iterator
12 from zope
.interface
import implements
13 from twisted
.python
import log
14 from buildbot
import util
15 from buildbot
.interfaces
import IChangeSource
16 from buildbot
.changes
import changes
17 from buildbot
.changes
.maildir
import MaildirService
19 class MaildirSource(MaildirService
, util
.ComparableMixin
):
20 """This source will watch a maildir that is subscribed to a FreshCVS
21 change-announcement mailing list.
23 implements(IChangeSource
)
25 compare_attrs
= ["basedir", "pollinterval", "prefix"]
28 def __init__(self
, maildir
, prefix
=None):
29 MaildirService
.__init
__(self
, maildir
)
31 if prefix
and not prefix
.endswith("/"):
32 log
.msg("%s: you probably want your prefix=('%s') to end with "
36 return "%s mailing list in maildir %s" % (self
.name
, self
.basedir
)
38 def messageReceived(self
, filename
):
39 path
= os
.path
.join(self
.basedir
, "new", filename
)
40 change
= self
.parse_file(open(path
, "r"), self
.prefix
)
42 self
.parent
.addChange(change
)
43 os
.rename(os
.path
.join(self
.basedir
, "new", filename
),
44 os
.path
.join(self
.basedir
, "cur", filename
))
46 def parse_file(self
, fd
, prefix
=None):
47 m
= message_from_file(fd
)
48 return self
.parse(m
, prefix
)
50 class FCMaildirSource(MaildirSource
):
53 def parse(self
, m
, prefix
=None):
54 """Parse mail sent by FreshCVS"""
56 # FreshCVS sets From: to "user CVS <user>", but the <> part may be
57 # modified by the MTA (to include a local domain)
58 name
, addr
= parseaddr(m
["from"])
60 return None # no From means this message isn't from FreshCVS
61 cvs
= name
.find(" CVS")
63 return None # this message isn't from FreshCVS
66 # we take the time of receipt as the time of checkin. Not correct,
67 # but it avoids the out-of-order-changes issue. See the comment in
68 # parseSyncmail about using the 'Date:' header
74 lines
= list(body_line_iterator(m
))
77 if line
== "Modified files:\n":
83 line
= line
.rstrip("\n")
84 linebits
= line
.split(None, 1)
87 # insist that the file start with the prefix: FreshCVS sends
88 # changes we don't care about too
89 if file.startswith(prefix
):
90 file = file[len(prefix
):]
93 if len(linebits
) == 1:
95 elif linebits
[1] == "0 0":
100 if line
== "Log message:\n":
102 # message is terminated by "ViewCVS links:" or "Index:..." (patch)
105 if line
== "ViewCVS links:\n":
107 if line
.find("Index: ") == 0:
110 comments
= comments
.rstrip() + "\n"
115 change
= changes
.Change(who
, files
, comments
, isdir
, when
=when
)
119 class SyncmailMaildirSource(MaildirSource
):
122 def parse(self
, m
, prefix
=None):
123 """Parse messages sent by the 'syncmail' program, as suggested by the
124 sourceforge.net CVS Admin documentation. Syncmail is maintained at
127 # pretty much the same as freshcvs mail, not surprising since CVS is
128 # the one creating most of the text
130 # The mail is sent from the person doing the checkin. Assume that the
131 # local username is enough to identify them (this assumes a one-server
132 # cvs-over-rsh environment rather than the server-dirs-shared-over-NFS
134 name
, addr
= parseaddr(m
["from"])
136 return None # no From means this message isn't from FreshCVS
139 who
= addr
# might still be useful
143 # we take the time of receipt as the time of checkin. Not correct (it
144 # depends upon the email latency), but it avoids the
145 # out-of-order-changes issue. Also syncmail doesn't give us anything
146 # better to work with, unless you count pulling the v1-vs-v2
147 # timestamp out of the diffs, which would be ugly. TODO: Pulling the
148 # 'Date:' header from the mail is a possibility, and
149 # email.Utils.parsedate_tz may be useful. It should be configurable,
150 # however, because there are a lot of broken clocks out there.
153 subject
= m
["subject"]
154 # syncmail puts the repository-relative directory in the subject:
155 # mprefix + "%(dir)s %(file)s,%(oldversion)s,%(newversion)s", where
156 # 'mprefix' is something that could be added by a mailing list
158 # this is the only reasonable way to determine the directory name
159 space
= subject
.find(" ")
161 directory
= subject
[:space
]
170 lines
= list(body_line_iterator(m
))
174 if (line
== "Modified Files:\n" or
175 line
== "Added Files:\n" or
176 line
== "Removed Files:\n"):
183 if line
== "Log Message:\n":
184 lines
.insert(0, line
)
188 # note: syncmail will send one email per directory involved in a
189 # commit, with multiple files if they were in the same directory.
190 # Unlike freshCVS, it makes no attempt to collect all related
191 # commits into a single message.
193 # note: syncmail will report a Tag underneath the ... Files: line
194 # e.g.: Tag: BRANCH-DEVEL
196 if line
.startswith('Tag:'):
197 branch
= line
.split(' ')[-1].rstrip()
200 thesefiles
= line
.split(" ")
202 f
= directory
+ "/" + f
204 # insist that the file start with the prefix: we may get
205 # changes we don't care about too
206 if f
.startswith(prefix
):
211 # TODO: figure out how new directories are described, set
220 if line
== "Log Message:\n":
222 # message is terminated by "Index:..." (patch) or "--- NEW FILE.."
223 # or "--- filename DELETED ---". Sigh.
226 if line
.find("Index: ") == 0:
228 if re
.search(r
"^--- NEW FILE", line
):
230 if re
.search(r
" DELETED ---$", line
):
233 comments
= comments
.rstrip() + "\n"
235 change
= changes
.Change(who
, files
, comments
, isdir
, when
=when
,
240 # Bonsai mail parser by Stephen Davis.
242 # This handles changes for CVS repositories that are watched by Bonsai
243 # (http://www.mozilla.org/bonsai.html)
245 # A Bonsai-formatted email message looks like:
247 # C|1071099907|stephend|/cvs|Sources/Scripts/buildbot|bonsai.py|1.2|||18|7
248 # A|1071099907|stephend|/cvs|Sources/Scripts/buildbot|master.cfg|1.1|||18|7
249 # R|1071099907|stephend|/cvs|Sources/Scripts/buildbot|BuildMaster.py|||
251 # Updated bonsai parser and switched master config to buildbot-0.4.1 style.
255 # In the first example line, stephend is the user, /cvs the repository,
256 # buildbot the directory, bonsai.py the file, 1.2 the revision, no sticky
257 # and branch, 18 lines added and 7 removed. All of these fields might not be
258 # present (during "removes" for example).
260 # There may be multiple "control" lines or even none (imports, directory
261 # additions) but there is one email per directory. We only care about actual
262 # changes since it is presumed directory additions don't actually affect the
263 # build. At least one file should need to change (the makefile, say) to
264 # actually make a new directory part of the build process. That's my story
265 # and I'm sticking to it.
267 class BonsaiMaildirSource(MaildirSource
):
270 def parse(self
, m
, prefix
=None):
271 """Parse mail sent by the Bonsai cvs loginfo script."""
273 # we don't care who the email came from b/c the cvs user is in the
279 lines
= list(body_line_iterator(m
))
281 # read the control lines (what/who/where/file/etc.)
284 if line
== "LOGCOMMENT\n":
286 line
= line
.rstrip("\n")
288 # we'd like to do the following but it won't work if the number of
289 # items doesn't match so...
290 # what, timestamp, user, repo, module, file = line.split( '|' )
291 items
= line
.split('|')
293 # not a valid line, assume this isn't a bonsai message
297 # just grab the bottom-most timestamp, they're probably all the
298 # same. TODO: I'm assuming this is relative to the epoch, but
299 # this needs testing.
300 timestamp
= int(items
[1])
311 path
= "%s/%s" % (module
, file)
316 # if no files changed, return nothing
324 if line
== ":ENDLOGCOMMENT\n":
327 comments
= comments
.rstrip() + "\n"
329 # return buildbot Change object
330 return changes
.Change(who
, files
, comments
, when
=timestamp
,
333 # svn "commit-email.pl" handler. The format is very similar to freshcvs mail;
336 # From: username [at] apache.org [slightly obfuscated to avoid spam here]
337 # To: commits [at] spamassassin.apache.org
338 # Subject: svn commit: r105955 - in spamassassin/trunk: . lib/Mail
342 # Date: Sat Nov 20 00:17:49 2004 [note: TZ = local tz on server!]
343 # New Revision: 105955
345 # Modified: [also Removed: and Added:]
353 # Modified: spamassassin/trunk/lib/Mail/SpamAssassin.pm
358 class SVNCommitEmailMaildirSource(MaildirSource
):
359 name
= "SVN commit-email.pl"
361 def parse(self
, m
, prefix
=None):
362 """Parse messages sent by the svn 'commit-email.pl' trigger.
365 # The mail is sent from the person doing the checkin. Assume that the
366 # local username is enough to identify them (this assumes a one-server
367 # cvs-over-rsh environment rather than the server-dirs-shared-over-NFS
369 name
, addr
= parseaddr(m
["from"])
371 return None # no From means this message isn't from FreshCVS
374 who
= addr
# might still be useful
378 # we take the time of receipt as the time of checkin. Not correct (it
379 # depends upon the email latency), but it avoids the
380 # out-of-order-changes issue. Also syncmail doesn't give us anything
381 # better to work with, unless you count pulling the v1-vs-v2
382 # timestamp out of the diffs, which would be ugly. TODO: Pulling the
383 # 'Date:' header from the mail is a possibility, and
384 # email.Utils.parsedate_tz may be useful. It should be configurable,
385 # however, because there are a lot of broken clocks out there.
391 lines
= list(body_line_iterator(m
))
397 match
= re
.search(r
"^Author: (\S+)", line
)
401 # "New Revision: 105955"
402 match
= re
.search(r
"^New Revision: (\d+)", line
)
406 # possible TODO: use "Date: ..." data here instead of time of
407 # commit message receipt, above. however, this timestamp is
408 # specified *without* a timezone, in the server's local TZ, so to
409 # be accurate buildbot would need a config setting to specify the
410 # source server's expected TZ setting! messy.
412 # this stanza ends with the "Log:"
413 if (line
== "Log:\n"):
416 # commit message is terminated by the file-listing section
419 if (line
== "Modified:\n" or
420 line
== "Added:\n" or
421 line
== "Removed:\n"):
424 comments
= comments
.rstrip() + "\n"
430 if line
.find("Modified:\n") == 0:
431 continue # ignore this line
432 if line
.find("Added:\n") == 0:
433 continue # ignore this line
434 if line
.find("Removed:\n") == 0:
435 continue # ignore this line
438 thesefiles
= line
.split(" ")
441 # insist that the file start with the prefix: we may get
442 # changes we don't care about too
443 if f
.startswith(prefix
):
446 log
.msg("ignored file from svn commit: prefix '%s' "
447 "does not match filename '%s'" % (prefix
, f
))
450 # TODO: figure out how new directories are described, set
455 log
.msg("no matching files found, ignoring commit")
458 return changes
.Change(who
, files
, comments
, when
=when
, revision
=rev
)
460 # bzr Launchpad branch subscription mails. Sample mail:
462 # From: noreply@launchpad.net
463 # Subject: [Branch ~knielsen/maria/tmp-buildbot-test] Rev 2701: test add file
464 # To: Joe <joe@acme.com>
467 # ------------------------------------------------------------
469 # committer: Joe <joe@acme.com>
471 # timestamp: Fri 2009-05-15 10:35:43 +0200
480 # https://code.launchpad.net/~knielsen/maria/tmp-buildbot-test
482 # You are subscribed to branch lp:~knielsen/maria/tmp-buildbot-test.
483 # To unsubscribe from this branch go to https://code.launchpad.net/~knielsen/maria/tmp-buildbot-test/+edit-subscription.
487 class BzrLaunchpadEmailMaildirSource(MaildirSource
):
490 compare_attrs
= MaildirSource
.compare_attrs
+ ["branchMap", "defaultBranch"]
492 def __init__(self
, maildir
, prefix
=None, branchMap
=None, defaultBranch
=None, **kwargs
):
493 self
.branchMap
= branchMap
494 self
.defaultBranch
= defaultBranch
495 MaildirSource
.__init
__(self
, maildir
, prefix
, **kwargs
)
497 def parse(self
, m
, prefix
=None):
498 """Parse branch notification messages sent by Launchpad.
501 subject
= m
["subject"]
502 match
= re
.search(r
"^\s*\[Branch\s+([^]]+)\]", subject
)
504 repository
= match
.group(1)
508 # Put these into a dictionary, otherwise we cannot assign them
509 # from nested function definitions.
510 d
= { 'files': [], 'comments': "" }
515 def gobble_comment(s
):
516 d
['comments'] += s
+ "\n"
517 def gobble_removed(s
):
518 d
['files'].append('%s REMOVED' % s
)
520 d
['files'].append('%s ADDED' % s
)
521 def gobble_modified(s
):
522 d
['files'].append('%s MODIFIED' % s
)
523 def gobble_renamed(s
):
524 match
= re
.search(r
"^(.+) => (.+)$", s
)
526 d
['files'].append('%s RENAMED %s' % (match
.group(1), match
.group(2)))
528 d
['files'].append('%s RENAMED' % s
)
530 lines
= list(body_line_iterator(m
, True))
536 match
= re
.search(r
"^revno: ([0-9.]+)", line
)
540 # committer: Joe <joe@acme.com>
541 match
= re
.search(r
"^committer: (.*)$", line
)
545 # timestamp: Fri 2009-05-15 10:35:43 +0200
546 # datetime.strptime() is supposed to support %z for time zone, but
547 # it does not seem to work. So handle the time zone manually.
548 match
= re
.search(r
"^timestamp: [a-zA-Z]{3} (\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) ([-+])(\d{2})(\d{2})$", line
)
550 datestr
= match
.group(1)
551 tz_sign
= match
.group(2)
552 tz_hours
= match
.group(3)
553 tz_minutes
= match
.group(4)
554 when
= parseLaunchpadDate(datestr
, tz_sign
, tz_hours
, tz_minutes
)
556 if re
.search(r
"^message:\s*$", line
):
557 gobbler
= gobble_comment
558 elif re
.search(r
"^removed:\s*$", line
):
559 gobbler
= gobble_removed
560 elif re
.search(r
"^added:\s*$", line
):
561 gobbler
= gobble_added
562 elif re
.search(r
"^renamed:\s*$", line
):
563 gobbler
= gobble_renamed
564 elif re
.search(r
"^modified:\s*$", line
):
565 gobbler
= gobble_modified
566 elif re
.search(r
"^ ", line
) and gobbler
:
567 gobbler(line
[2:-1]) # Use :-1 to gobble trailing newline
569 # Determine the name of the branch.
571 if self
.branchMap
and repository
:
572 if self
.branchMap
.has_key(repository
):
573 branch
= self
.branchMap
[repository
]
574 elif self
.branchMap
.has_key('lp:' + repository
):
575 branch
= self
.branchMap
['lp:' + repository
]
577 if self
.defaultBranch
:
578 branch
= self
.defaultBranch
581 branch
= 'lp:' + repository
585 #log.msg("parse(): rev=%s who=%s files=%s comments='%s' when=%s branch=%s" % (rev, who, d['files'], d['comments'], time.asctime(time.localtime(when)), branch))
587 return changes
.Change(who
, d
['files'], d
['comments'],
588 when
=when
, revision
=rev
, branch
=branch
)
592 def parseLaunchpadDate(datestr
, tz_sign
, tz_hours
, tz_minutes
):
593 time_no_tz
= calendar
.timegm(time
.strptime(datestr
, "%Y-%m-%d %H:%M:%S"))
594 tz_delta
= 60 * 60 * int(tz_sign
+ tz_hours
) + 60 * int(tz_minutes
)
595 return time_no_tz
- tz_delta