diff options
Diffstat (limited to 'buildbot/buildbot/status/mail.py')
-rw-r--r-- | buildbot/buildbot/status/mail.py | 524 |
1 files changed, 524 insertions, 0 deletions
diff --git a/buildbot/buildbot/status/mail.py b/buildbot/buildbot/status/mail.py new file mode 100644 index 0000000..e32cfa9 --- /dev/null +++ b/buildbot/buildbot/status/mail.py @@ -0,0 +1,524 @@ +# -*- test-case-name: buildbot.test.test_status -*- + +# the email.MIMEMultipart module is only available in python-2.2.2 and later +import re + +from email.Message import Message +from email.Utils import formatdate +from email.MIMEText import MIMEText +try: + from email.MIMEMultipart import MIMEMultipart + canDoAttachments = True +except ImportError: + canDoAttachments = False +import urllib + +from zope.interface import implements +from twisted.internet import defer +from twisted.mail.smtp import sendmail +from twisted.python import log as twlog + +from buildbot import interfaces, util +from buildbot.status import base +from buildbot.status.builder import FAILURE, SUCCESS, WARNINGS, Results + +VALID_EMAIL = re.compile("[a-zA-Z0-9\.\_\%\-\+]+@[a-zA-Z0-9\.\_\%\-]+.[a-zA-Z]{2,6}") + +def message(attrs): + """Generate a buildbot mail message and return a tuple of message text + and type. + + This function can be replaced using the customMesg variable in MailNotifier. + A message function will *always* get a dictionary of attributes with + the following values: + + builderName - (str) Name of the builder that generated this event. + + projectName - (str) Name of the project. + + mode - (str) Mode set in MailNotifier. (failing, passing, problem). + + result - (str) Builder result as a string. 'success', 'warnings', + 'failure', 'skipped', or 'exception' + + buildURL - (str) URL to build page. + + buildbotURL - (str) URL to buildbot main page. + + buildText - (str) Build text from build.getText(). + + slavename - (str) Slavename. + + reason - (str) Build reason from build.getReason(). + + responsibleUsers - (List of str) List of responsible users. + + branch - (str) Name of branch used. If no SourceStamp exists branch + is an empty string. + + revision - (str) Name of revision used. If no SourceStamp exists revision + is an empty string. + + patch - (str) Name of patch used. If no SourceStamp exists patch + is an empty string. + + changes - (list of objs) List of change objects from SourceStamp. A change + object has the following useful information: + + who - who made this change + revision - what VC revision is this change + branch - on what branch did this change occur + when - when did this change occur + files - what files were affected in this change + comments - comments reguarding the change. + + The functions asText and asHTML return a list of strings with + the above information formatted. + + logs - (List of Tuples) List of tuples that contain the log name, log url + and log contents as a list of strings. + """ + text = "" + if attrs['mode'] == "all": + text += "The Buildbot has finished a build" + elif attrs['mode'] == "failing": + text += "The Buildbot has detected a failed build" + elif attrs['mode'] == "passing": + text += "The Buildbot has detected a passing build" + else: + text += "The Buildbot has detected a new failure" + text += " of %s on %s.\n" % (attrs['builderName'], attrs['projectName']) + if attrs['buildURL']: + text += "Full details are available at:\n %s\n" % attrs['buildURL'] + text += "\n" + + if attrs['buildbotURL']: + text += "Buildbot URL: %s\n\n" % urllib.quote(attrs['buildbotURL'], '/:') + + text += "Buildslave for this Build: %s\n\n" % attrs['slavename'] + text += "Build Reason: %s\n" % attrs['reason'] + + # + # No source stamp + # + if attrs['branch']: + source = "unavailable" + else: + source = "" + if attrs['branch']: + source += "[branch %s] " % attrs['branch'] + if attrs['revision']: + source += attrs['revision'] + else: + source += "HEAD" + if attrs['patch']: + source += " (plus patch)" + text += "Build Source Stamp: %s\n" % source + + text += "Blamelist: %s\n" % ",".join(attrs['responsibleUsers']) + + text += "\n" + + t = attrs['buildText'] + if t: + t = ": " + " ".join(t) + else: + t = "" + + if attrs['result'] == 'success': + text += "Build succeeded!\n" + elif attrs['result'] == 'warnings': + text += "Build Had Warnings%s\n" % t + else: + text += "BUILD FAILED%s\n" % t + + text += "\n" + text += "sincerely,\n" + text += " -The Buildbot\n" + text += "\n" + return (text, 'plain') + +class Domain(util.ComparableMixin): + implements(interfaces.IEmailLookup) + compare_attrs = ["domain"] + + def __init__(self, domain): + assert "@" not in domain + self.domain = domain + + def getAddress(self, name): + """If name is already an email address, pass it through.""" + if '@' in name: + return name + return name + "@" + self.domain + + +class MailNotifier(base.StatusReceiverMultiService): + """This is a status notifier which sends email to a list of recipients + upon the completion of each build. It can be configured to only send out + mail for certain builds, and only send messages when the build fails, or + when it transitions from success to failure. It can also be configured to + include various build logs in each message. + + By default, the message will be sent to the Interested Users list, which + includes all developers who made changes in the build. You can add + additional recipients with the extraRecipients argument. + + To get a simple one-message-per-build (say, for a mailing list), use + sendToInterestedUsers=False, extraRecipients=['listaddr@example.org'] + + Each MailNotifier sends mail to a single set of recipients. To send + different kinds of mail to different recipients, use multiple + MailNotifiers. + """ + + implements(interfaces.IEmailSender) + + compare_attrs = ["extraRecipients", "lookup", "fromaddr", "mode", + "categories", "builders", "addLogs", "relayhost", + "subject", "sendToInterestedUsers", "customMesg"] + + def __init__(self, fromaddr, mode="all", categories=None, builders=None, + addLogs=False, relayhost="localhost", + subject="buildbot %(result)s in %(projectName)s on %(builder)s", + lookup=None, extraRecipients=[], + sendToInterestedUsers=True, customMesg=message): + """ + @type fromaddr: string + @param fromaddr: the email address to be used in the 'From' header. + @type sendToInterestedUsers: boolean + @param sendToInterestedUsers: if True (the default), send mail to all + of the Interested Users. If False, only + send mail to the extraRecipients list. + + @type extraRecipients: tuple of string + @param extraRecipients: a list of email addresses to which messages + should be sent (in addition to the + InterestedUsers list, which includes any + developers who made Changes that went into this + build). It is a good idea to create a small + mailing list and deliver to that, then let + subscribers come and go as they please. + + @type subject: string + @param subject: a string to be used as the subject line of the message. + %(builder)s will be replaced with the name of the + builder which provoked the message. + + @type mode: string (defaults to all) + @param mode: one of: + - 'all': send mail about all builds, passing and failing + - 'failing': only send mail about builds which fail + - 'passing': only send mail about builds which succeed + - 'problem': only send mail about a build which failed + when the previous build passed + + @type builders: list of strings + @param builders: a list of builder names for which mail should be + sent. Defaults to None (send mail for all builds). + Use either builders or categories, but not both. + + @type categories: list of strings + @param categories: a list of category names to serve status + information for. Defaults to None (all + categories). Use either builders or categories, + but not both. + + @type addLogs: boolean. + @param addLogs: if True, include all build logs as attachments to the + messages. These can be quite large. This can also be + set to a list of log names, to send a subset of the + logs. Defaults to False. + + @type relayhost: string + @param relayhost: the host to which the outbound SMTP connection + should be made. Defaults to 'localhost' + + @type lookup: implementor of {IEmailLookup} + @param lookup: object which provides IEmailLookup, which is + responsible for mapping User names (which come from + the VC system) into valid email addresses. If not + provided, the notifier will only be able to send mail + to the addresses in the extraRecipients list. Most of + the time you can use a simple Domain instance. As a + shortcut, you can pass as string: this will be + treated as if you had provided Domain(str). For + example, lookup='twistedmatrix.com' will allow mail + to be sent to all developers whose SVN usernames + match their twistedmatrix.com account names. + + @type customMesg: func + @param customMesg: A function that returns a tuple containing the text of + a custom message and its type. This function takes + the dict attrs which has the following values: + + builderName - (str) Name of the builder that generated this event. + + projectName - (str) Name of the project. + + mode - (str) Mode set in MailNotifier. (failing, passing, problem). + + result - (str) Builder result as a string. 'success', 'warnings', + 'failure', 'skipped', or 'exception' + + buildURL - (str) URL to build page. + + buildbotURL - (str) URL to buildbot main page. + + buildText - (str) Build text from build.getText(). + + slavename - (str) Slavename. + + reason - (str) Build reason from build.getReason(). + + responsibleUsers - (List of str) List of responsible users. + + branch - (str) Name of branch used. If no SourceStamp exists branch + is an empty string. + + revision - (str) Name of revision used. If no SourceStamp exists revision + is an empty string. + + patch - (str) Name of patch used. If no SourceStamp exists patch + is an empty string. + + changes - (list of objs) List of change objects from SourceStamp. A change + object has the following useful information: + + who - who made this change + revision - what VC revision is this change + branch - on what branch did this change occur + when - when did this change occur + files - what files were affected in this change + comments - comments reguarding the change. + + The functions asText and asHTML return a list of strings with + the above information formatted. + + logs - (List of Tuples) List of tuples that contain the log name, log url, + and log contents as a list of strings. + + """ + + base.StatusReceiverMultiService.__init__(self) + assert isinstance(extraRecipients, (list, tuple)) + for r in extraRecipients: + assert isinstance(r, str) + assert VALID_EMAIL.search(r) # require full email addresses, not User names + self.extraRecipients = extraRecipients + self.sendToInterestedUsers = sendToInterestedUsers + self.fromaddr = fromaddr + assert mode in ('all', 'failing', 'problem') + self.mode = mode + self.categories = categories + self.builders = builders + self.addLogs = addLogs + self.relayhost = relayhost + self.subject = subject + if lookup is not None: + if type(lookup) is str: + lookup = Domain(lookup) + assert interfaces.IEmailLookup.providedBy(lookup) + self.lookup = lookup + self.customMesg = customMesg + self.watched = [] + self.status = None + + # you should either limit on builders or categories, not both + if self.builders != None and self.categories != None: + twlog.err("Please specify only builders to ignore or categories to include") + raise # FIXME: the asserts above do not raise some Exception either + + def setServiceParent(self, parent): + """ + @type parent: L{buildbot.master.BuildMaster} + """ + base.StatusReceiverMultiService.setServiceParent(self, parent) + self.setup() + + def setup(self): + self.status = self.parent.getStatus() + self.status.subscribe(self) + + def disownServiceParent(self): + self.status.unsubscribe(self) + for w in self.watched: + w.unsubscribe(self) + return base.StatusReceiverMultiService.disownServiceParent(self) + + def builderAdded(self, name, builder): + # only subscribe to builders we are interested in + if self.categories != None and builder.category not in self.categories: + return None + + self.watched.append(builder) + return self # subscribe to this builder + + def builderRemoved(self, name): + pass + + def builderChangedState(self, name, state): + pass + def buildStarted(self, name, build): + pass + def buildFinished(self, name, build, results): + # here is where we actually do something. + builder = build.getBuilder() + if self.builders is not None and name not in self.builders: + return # ignore this build + if self.categories is not None and \ + builder.category not in self.categories: + return # ignore this build + + if self.mode == "failing" and results != FAILURE: + return + if self.mode == "passing" and results != SUCCESS: + return + if self.mode == "problem": + if results != FAILURE: + return + prev = build.getPreviousBuild() + if prev and prev.getResults() == FAILURE: + return + # for testing purposes, buildMessage returns a Deferred that fires + # when the mail has been sent. To help unit tests, we return that + # Deferred here even though the normal IStatusReceiver.buildFinished + # signature doesn't do anything with it. If that changes (if + # .buildFinished's return value becomes significant), we need to + # rearrange this. + return self.buildMessage(name, build, results) + + def buildMessage(self, name, build, results): + # + # logs is a list of tuples that contain the log + # name, log url, and the log contents as a list of strings. + # + logs = list() + for log in build.getLogs(): + stepName = log.getStep().getName() + logName = log.getName() + logs.append(('%s.%s' % (stepName, logName), + '%s/steps/%s/logs/%s' % (self.status.getURLForThing(build), stepName, logName), + log.getText().splitlines())) + + attrs = {'builderName': name, + 'projectName': self.status.getProjectName(), + 'mode': self.mode, + 'result': Results[results], + 'buildURL': self.status.getURLForThing(build), + 'buildbotURL': self.status.getBuildbotURL(), + 'buildText': build.getText(), + 'slavename': build.getSlavename(), + 'reason': build.getReason(), + 'responsibleUsers': build.getResponsibleUsers(), + 'branch': "", + 'revision': "", + 'patch': "", + 'changes': [], + 'logs': logs} + + ss = build.getSourceStamp() + if ss: + attrs['branch'] = ss.branch + attrs['revision'] = ss.revision + attrs['patch'] = ss.patch + attrs['changes'] = ss.changes[:] + + text, type = self.customMesg(attrs) + assert type in ('plain', 'html'), "'%s' message type must be 'plain' or 'html'." % type + + haveAttachments = False + if attrs['patch'] or self.addLogs: + haveAttachments = True + if not canDoAttachments: + twlog.msg("warning: I want to send mail with attachments, " + "but this python is too old to have " + "email.MIMEMultipart . Please upgrade to python-2.3 " + "or newer to enable addLogs=True") + + if haveAttachments and canDoAttachments: + m = MIMEMultipart() + m.attach(MIMEText(text, type)) + else: + m = Message() + m.set_payload(text) + m.set_type("text/%s" % type) + + m['Date'] = formatdate(localtime=True) + m['Subject'] = self.subject % { 'result': attrs['result'], + 'projectName': attrs['projectName'], + 'builder': attrs['builderName'], + } + m['From'] = self.fromaddr + # m['To'] is added later + + if attrs['patch']: + a = MIMEText(attrs['patch'][1]) + a.add_header('Content-Disposition', "attachment", + filename="source patch") + m.attach(a) + if self.addLogs: + for log in build.getLogs(): + name = "%s.%s" % (log.getStep().getName(), + log.getName()) + if self._shouldAttachLog(log.getName()) or self._shouldAttachLog(name): + a = MIMEText(log.getText()) + a.add_header('Content-Disposition', "attachment", + filename=name) + m.attach(a) + + # now, who is this message going to? + dl = [] + recipients = [] + if self.sendToInterestedUsers and self.lookup: + for u in build.getInterestedUsers(): + d = defer.maybeDeferred(self.lookup.getAddress, u) + d.addCallback(recipients.append) + dl.append(d) + d = defer.DeferredList(dl) + d.addCallback(self._gotRecipients, recipients, m) + return d + + def _shouldAttachLog(self, logname): + if type(self.addLogs) is bool: + return self.addLogs + return logname in self.addLogs + + def _gotRecipients(self, res, rlist, m): + recipients = set() + + for r in rlist: + if r is None: # getAddress didn't like this address + continue + + # Git can give emails like 'User' <user@foo.com>@foo.com so check + # for two @ and chop the last + if r.count('@') > 1: + r = r[:r.rindex('@')] + + if VALID_EMAIL.search(r): + recipients.add(r) + else: + twlog.msg("INVALID EMAIL: %r" + r) + + # if we're sending to interested users move the extra's to the CC + # list so they can tell if they are also interested in the change + # unless there are no interested users + if self.sendToInterestedUsers and len(recipients): + m['CC'] = ", ".join(sorted(self.extraRecipients[:])) + else: + [recipients.add(r) for r in self.extraRecipients[:]] + + m['To'] = ", ".join(sorted(recipients)) + + # The extras weren't part of the TO list so add them now + if self.sendToInterestedUsers: + for r in self.extraRecipients: + recipients.add(r) + + return self.sendMessage(m, list(recipients)) + + def sendMessage(self, m, recipients): + s = m.as_string() + twlog.msg("sending mail (%d bytes) to" % len(s), recipients) + return sendmail(self.relayhost, self.fromaddr, recipients, s) |