Package buildbot :: Package status :: Module mail
[hide private]
[frames] | no frames]

Source Code for Module buildbot.status.mail

  1  # -*- test-case-name: buildbot.test.test_status -*- 
  2   
  3  # the email.MIMEMultipart module is only available in python-2.2.2 and later 
  4  import re 
  5   
  6  from email.Message import Message 
  7  from email.Utils import formatdate 
  8  from email.MIMEText import MIMEText 
  9  try: 
 10      from email.MIMEMultipart import MIMEMultipart 
 11      canDoAttachments = True 
 12  except ImportError: 
 13      canDoAttachments = False 
 14  import urllib 
 15   
 16  from zope.interface import implements 
 17  from twisted.internet import defer 
 18  from twisted.mail.smtp import sendmail 
 19  from twisted.python import log as twlog 
 20   
 21  from buildbot import interfaces, util 
 22  from buildbot.status import base 
 23  from buildbot.status.builder import FAILURE, SUCCESS, WARNINGS, Results 
 24   
 25  import sys 
 26  if sys.version_info[:3] < (2,4,0): 
 27      from sets import Set as set 
 28   
 29  VALID_EMAIL = re.compile("[a-zA-Z0-9\.\_\%\-\+]+@[a-zA-Z0-9\.\_\%\-]+.[a-zA-Z]{2,6}") 
 30   
31 -def message(attrs):
32 """Generate a buildbot mail message and return a tuple of message text 33 and type. 34 35 This function can be replaced using the customMesg variable in MailNotifier. 36 A message function will *always* get a dictionary of attributes with 37 the following values: 38 39 builderName - (str) Name of the builder that generated this event. 40 41 projectName - (str) Name of the project. 42 43 mode - (str) Mode set in MailNotifier. (failing, passing, problem, change). 44 45 result - (str) Builder result as a string. 'success', 'warnings', 46 'failure', 'skipped', or 'exception' 47 48 buildURL - (str) URL to build page. 49 50 buildbotURL - (str) URL to buildbot main page. 51 52 buildText - (str) Build text from build.getText(). 53 54 buildProperties - (Properties) Mapping of property names to values 55 56 slavename - (str) Slavename. 57 58 reason - (str) Build reason from build.getReason(). 59 60 responsibleUsers - (List of str) List of responsible users. 61 62 branch - (str) Name of branch used. If no SourceStamp exists branch 63 is an empty string. 64 65 revision - (str) Name of revision used. If no SourceStamp exists revision 66 is an empty string. 67 68 patch - (str) Name of patch used. If no SourceStamp exists patch 69 is an empty string. 70 71 changes - (list of objs) List of change objects from SourceStamp. A change 72 object has the following useful information: 73 74 who - who made this change 75 revision - what VC revision is this change 76 branch - on what branch did this change occur 77 when - when did this change occur 78 files - what files were affected in this change 79 comments - comments reguarding the change. 80 81 The functions asText and asHTML return a list of strings with 82 the above information formatted. 83 84 logs - (List of Tuples) List of tuples that contain the log name, log url 85 and log contents as a list of strings. 86 """ 87 text = "" 88 if attrs['mode'] == "all": 89 text += "The Buildbot has finished a build" 90 elif attrs['mode'] == "failing": 91 text += "The Buildbot has detected a failed build" 92 elif attrs['mode'] == "passing": 93 text += "The Buildbot has detected a passing build" 94 elif attrs['mode'] == "change" and attrs['result'] == 'success': 95 text += "The Buildbot has detected a restored build" 96 else: 97 text += "The Buildbot has detected a new failure" 98 text += " of %s on %s.\n" % (attrs['builderName'], attrs['projectName']) 99 if attrs['buildURL']: 100 text += "Full details are available at:\n %s\n" % attrs['buildURL'] 101 text += "\n" 102 103 if attrs['buildbotURL']: 104 text += "Buildbot URL: %s\n\n" % urllib.quote(attrs['buildbotURL'], '/:') 105 106 text += "Buildslave for this Build: %s\n\n" % attrs['slavename'] 107 text += "Build Reason: %s\n" % attrs['reason'] 108 109 # 110 # No source stamp 111 # 112 source = "" 113 if attrs['branch']: 114 source += "[branch %s] " % attrs['branch'] 115 if attrs['revision']: 116 source += attrs['revision'] 117 else: 118 source += "HEAD" 119 if attrs['patch']: 120 source += " (plus patch)" 121 122 text += "Build Source Stamp: %s\n" % source 123 124 text += "Blamelist: %s\n" % ",".join(attrs['responsibleUsers']) 125 126 text += "\n" 127 128 t = attrs['buildText'] 129 if t: 130 t = ": " + " ".join(t) 131 else: 132 t = "" 133 134 if attrs['result'] == 'success': 135 text += "Build succeeded!\n" 136 elif attrs['result'] == 'warnings': 137 text += "Build Had Warnings%s\n" % t 138 else: 139 text += "BUILD FAILED%s\n" % t 140 141 text += "\n" 142 text += "sincerely,\n" 143 text += " -The Buildbot\n" 144 text += "\n" 145 return (text, 'plain')
146
147 -class Domain(util.ComparableMixin):
148 implements(interfaces.IEmailLookup) 149 compare_attrs = ["domain"] 150
151 - def __init__(self, domain):
152 assert "@" not in domain 153 self.domain = domain
154
155 - def getAddress(self, name):
156 """If name is already an email address, pass it through.""" 157 if '@' in name: 158 return name 159 return name + "@" + self.domain
160 161
162 -class MailNotifier(base.StatusReceiverMultiService):
163 """This is a status notifier which sends email to a list of recipients 164 upon the completion of each build. It can be configured to only send out 165 mail for certain builds, and only send messages when the build fails, or 166 when it transitions from success to failure. It can also be configured to 167 include various build logs in each message. 168 169 By default, the message will be sent to the Interested Users list, which 170 includes all developers who made changes in the build. You can add 171 additional recipients with the extraRecipients argument. 172 173 To get a simple one-message-per-build (say, for a mailing list), use 174 sendToInterestedUsers=False, extraRecipients=['listaddr@example.org'] 175 176 Each MailNotifier sends mail to a single set of recipients. To send 177 different kinds of mail to different recipients, use multiple 178 MailNotifiers. 179 """ 180 181 implements(interfaces.IEmailSender) 182 183 compare_attrs = ["extraRecipients", "lookup", "fromaddr", "mode", 184 "categories", "builders", "addLogs", "relayhost", 185 "subject", "sendToInterestedUsers", "customMesg", 186 "extraHeaders"] 187
188 - def __init__(self, fromaddr, mode="all", categories=None, builders=None, 189 addLogs=False, relayhost="localhost", 190 subject="buildbot %(result)s in %(projectName)s on %(builder)s", 191 lookup=None, extraRecipients=[], 192 sendToInterestedUsers=True, customMesg=message, 193 extraHeaders=None):
194 """ 195 @type fromaddr: string 196 @param fromaddr: the email address to be used in the 'From' header. 197 @type sendToInterestedUsers: boolean 198 @param sendToInterestedUsers: if True (the default), send mail to all 199 of the Interested Users. If False, only 200 send mail to the extraRecipients list. 201 202 @type extraRecipients: tuple of string 203 @param extraRecipients: a list of email addresses to which messages 204 should be sent (in addition to the 205 InterestedUsers list, which includes any 206 developers who made Changes that went into this 207 build). It is a good idea to create a small 208 mailing list and deliver to that, then let 209 subscribers come and go as they please. 210 211 @type subject: string 212 @param subject: a string to be used as the subject line of the message. 213 %(builder)s will be replaced with the name of the 214 builder which provoked the message. 215 216 @type mode: string (defaults to all) 217 @param mode: one of: 218 - 'all': send mail about all builds, passing and failing 219 - 'failing': only send mail about builds which fail 220 - 'passing': only send mail about builds which succeed 221 - 'problem': only send mail about a build which failed 222 when the previous build passed 223 - 'change': only send mail about builds who change status 224 225 @type builders: list of strings 226 @param builders: a list of builder names for which mail should be 227 sent. Defaults to None (send mail for all builds). 228 Use either builders or categories, but not both. 229 230 @type categories: list of strings 231 @param categories: a list of category names to serve status 232 information for. Defaults to None (all 233 categories). Use either builders or categories, 234 but not both. 235 236 @type addLogs: boolean. 237 @param addLogs: if True, include all build logs as attachments to the 238 messages. These can be quite large. This can also be 239 set to a list of log names, to send a subset of the 240 logs. Defaults to False. 241 242 @type relayhost: string 243 @param relayhost: the host to which the outbound SMTP connection 244 should be made. Defaults to 'localhost' 245 246 @type lookup: implementor of {IEmailLookup} 247 @param lookup: object which provides IEmailLookup, which is 248 responsible for mapping User names (which come from 249 the VC system) into valid email addresses. If not 250 provided, the notifier will only be able to send mail 251 to the addresses in the extraRecipients list. Most of 252 the time you can use a simple Domain instance. As a 253 shortcut, you can pass as string: this will be 254 treated as if you had provided Domain(str). For 255 example, lookup='twistedmatrix.com' will allow mail 256 to be sent to all developers whose SVN usernames 257 match their twistedmatrix.com account names. 258 259 @type customMesg: func 260 @param customMesg: A function that returns a tuple containing the text of 261 a custom message and its type. This function takes 262 the dict attrs which has the following values: 263 264 builderName - (str) Name of the builder that generated this event. 265 266 projectName - (str) Name of the project. 267 268 mode - (str) Mode set in MailNotifier. (failing, passing, problem, change). 269 270 result - (str) Builder result as a string. 'success', 'warnings', 271 'failure', 'skipped', or 'exception' 272 273 buildURL - (str) URL to build page. 274 275 buildbotURL - (str) URL to buildbot main page. 276 277 buildText - (str) Build text from build.getText(). 278 279 buildProperties - (Properties instance) Mapping of 280 property names to values 281 282 slavename - (str) Slavename. 283 284 reason - (str) Build reason from build.getReason(). 285 286 responsibleUsers - (List of str) List of responsible users. 287 288 branch - (str) Name of branch used. If no SourceStamp exists branch 289 is an empty string. 290 291 revision - (str) Name of revision used. If no SourceStamp exists revision 292 is an empty string. 293 294 patch - (str) Name of patch used. If no SourceStamp exists patch 295 is an empty string. 296 297 changes - (list of objs) List of change objects from SourceStamp. A change 298 object has the following useful information: 299 300 who - who made this change 301 revision - what VC revision is this change 302 branch - on what branch did this change occur 303 when - when did this change occur 304 files - what files were affected in this change 305 comments - comments reguarding the change. 306 307 The functions asText and asHTML return a list of strings with 308 the above information formatted. 309 310 logs - (List of Tuples) List of tuples that contain the log name, log url, 311 and log contents as a list of strings. 312 @type extraHeaders: dict 313 @param extraHeaders: A dict of extra headers to add to the mail. It's 314 best to avoid putting 'To', 'From', 'Date', 315 'Subject', or 'CC' in here. Both the names and 316 values may be WithProperties instances. 317 """ 318 319 base.StatusReceiverMultiService.__init__(self) 320 assert isinstance(extraRecipients, (list, tuple)) 321 for r in extraRecipients: 322 assert isinstance(r, str) 323 assert VALID_EMAIL.search(r) # require full email addresses, not User names 324 self.extraRecipients = extraRecipients 325 self.sendToInterestedUsers = sendToInterestedUsers 326 self.fromaddr = fromaddr 327 assert mode in ('all', 'failing', 'problem', 'change') 328 self.mode = mode 329 self.categories = categories 330 self.builders = builders 331 self.addLogs = addLogs 332 self.relayhost = relayhost 333 self.subject = subject 334 if lookup is not None: 335 if type(lookup) is str: 336 lookup = Domain(lookup) 337 assert interfaces.IEmailLookup.providedBy(lookup) 338 self.lookup = lookup 339 self.customMesg = customMesg 340 if extraHeaders: 341 assert isinstance(extraHeaders, dict) 342 self.extraHeaders = extraHeaders 343 self.watched = [] 344 self.status = None 345 346 # you should either limit on builders or categories, not both 347 if self.builders != None and self.categories != None: 348 twlog.err("Please specify only builders to ignore or categories to include") 349 raise # FIXME: the asserts above do not raise some Exception either
350
351 - def setServiceParent(self, parent):
352 """ 353 @type parent: L{buildbot.master.BuildMaster} 354 """ 355 base.StatusReceiverMultiService.setServiceParent(self, parent) 356 self.setup()
357
358 - def setup(self):
359 self.status = self.parent.getStatus() 360 self.status.subscribe(self)
361
362 - def disownServiceParent(self):
363 self.status.unsubscribe(self) 364 for w in self.watched: 365 w.unsubscribe(self) 366 return base.StatusReceiverMultiService.disownServiceParent(self)
367
368 - def builderAdded(self, name, builder):
369 # only subscribe to builders we are interested in 370 if self.categories != None and builder.category not in self.categories: 371 return None 372 373 self.watched.append(builder) 374 return self # subscribe to this builder
375
376 - def builderRemoved(self, name):
377 pass
378
379 - def builderChangedState(self, name, state):
380 pass
381 - def buildStarted(self, name, build):
382 pass
383 - def buildFinished(self, name, build, results):
384 # here is where we actually do something. 385 builder = build.getBuilder() 386 if self.builders is not None and name not in self.builders: 387 return # ignore this build 388 if self.categories is not None and \ 389 builder.category not in self.categories: 390 return # ignore this build 391 392 if self.mode == "failing" and results != FAILURE: 393 return 394 if self.mode == "passing" and results != SUCCESS: 395 return 396 if self.mode == "problem": 397 if results != FAILURE: 398 return 399 prev = build.getPreviousBuild() 400 if prev and prev.getResults() == FAILURE: 401 return 402 if self.mode == "change": 403 prev = build.getPreviousBuild() 404 if not prev or prev.getResults() == results: 405 if prev: 406 print prev.getResults() 407 else: 408 print "no prev" 409 return 410 # for testing purposes, buildMessage returns a Deferred that fires 411 # when the mail has been sent. To help unit tests, we return that 412 # Deferred here even though the normal IStatusReceiver.buildFinished 413 # signature doesn't do anything with it. If that changes (if 414 # .buildFinished's return value becomes significant), we need to 415 # rearrange this. 416 return self.buildMessage(name, build, results)
417
418 - def buildMessage(self, name, build, results):
419 # 420 # logs is a list of tuples that contain the log 421 # name, log url, and the log contents as a list of strings. 422 # 423 logs = list() 424 for logf in build.getLogs(): 425 logStep = logf.getStep() 426 stepName = logStep.getName() 427 logStatus, dummy = logStep.getResults() 428 logName = logf.getName() 429 logs.append(('%s.%s' % (stepName, logName), 430 '%s/steps/%s/logs/%s' % (self.status.getURLForThing(build), stepName, logName), 431 logf.getText().splitlines(), 432 logStatus)) 433 434 properties = build.getProperties() 435 436 attrs = {'builderName': name, 437 'projectName': self.status.getProjectName(), 438 'mode': self.mode, 439 'result': Results[results], 440 'buildURL': self.status.getURLForThing(build), 441 'buildbotURL': self.status.getBuildbotURL(), 442 'buildText': build.getText(), 443 'buildProperties': properties, 444 'slavename': build.getSlavename(), 445 'reason': build.getReason(), 446 'responsibleUsers': build.getResponsibleUsers(), 447 'branch': "", 448 'revision': "", 449 'patch': "", 450 'changes': [], 451 'logs': logs} 452 453 ss = build.getSourceStamp() 454 if ss: 455 attrs['branch'] = ss.branch 456 attrs['revision'] = ss.revision 457 attrs['patch'] = ss.patch 458 attrs['changes'] = ss.changes[:] 459 460 text, type = self.customMesg(attrs) 461 assert type in ('plain', 'html'), "'%s' message type must be 'plain' or 'html'." % type 462 463 haveAttachments = False 464 if attrs['patch'] or self.addLogs: 465 haveAttachments = True 466 if not canDoAttachments: 467 twlog.msg("warning: I want to send mail with attachments, " 468 "but this python is too old to have " 469 "email.MIMEMultipart . Please upgrade to python-2.3 " 470 "or newer to enable addLogs=True") 471 472 if haveAttachments and canDoAttachments: 473 m = MIMEMultipart() 474 m.attach(MIMEText(text, type)) 475 else: 476 m = Message() 477 m.set_payload(text) 478 m.set_type("text/%s" % type) 479 480 m['Date'] = formatdate(localtime=True) 481 m['Subject'] = self.subject % { 'result': attrs['result'], 482 'projectName': attrs['projectName'], 483 'builder': attrs['builderName'], 484 } 485 m['From'] = self.fromaddr 486 # m['To'] is added later 487 488 if attrs['patch']: 489 a = MIMEText(attrs['patch'][1]) 490 a.add_header('Content-Disposition', "attachment", 491 filename="source patch") 492 m.attach(a) 493 if self.addLogs: 494 for log in build.getLogs(): 495 name = "%s.%s" % (log.getStep().getName(), 496 log.getName()) 497 if self._shouldAttachLog(log.getName()) or self._shouldAttachLog(name): 498 a = MIMEText(log.getText()) 499 a.add_header('Content-Disposition', "attachment", 500 filename=name) 501 m.attach(a) 502 503 # Add any extra headers that were requested, doing WithProperties 504 # interpolation if necessary 505 if self.extraHeaders: 506 for k,v in self.extraHeaders.items(): 507 k = properties.render(k) 508 if k in m: 509 twlog("Warning: Got header " + k + " in self.extraHeaders " 510 "but it already exists in the Message - " 511 "not adding it.") 512 continue 513 m[k] = properties.render(v) 514 515 # now, who is this message going to? 516 dl = [] 517 recipients = [] 518 if self.sendToInterestedUsers and self.lookup: 519 for u in build.getInterestedUsers(): 520 d = defer.maybeDeferred(self.lookup.getAddress, u) 521 d.addCallback(recipients.append) 522 dl.append(d) 523 d = defer.DeferredList(dl) 524 d.addCallback(self._gotRecipients, recipients, m) 525 return d
526
527 - def _shouldAttachLog(self, logname):
528 if type(self.addLogs) is bool: 529 return self.addLogs 530 return logname in self.addLogs
531
532 - def _gotRecipients(self, res, rlist, m):
533 recipients = set() 534 535 for r in rlist: 536 if r is None: # getAddress didn't like this address 537 continue 538 539 # Git can give emails like 'User' <user@foo.com>@foo.com so check 540 # for two @ and chop the last 541 if r.count('@') > 1: 542 r = r[:r.rindex('@')] 543 544 if VALID_EMAIL.search(r): 545 recipients.add(r) 546 else: 547 twlog.msg("INVALID EMAIL: %r" + r) 548 549 # if we're sending to interested users move the extra's to the CC 550 # list so they can tell if they are also interested in the change 551 # unless there are no interested users 552 if self.sendToInterestedUsers and len(recipients): 553 extra_recips = self.extraRecipients[:] 554 extra_recips.sort() 555 m['CC'] = ", ".join(extra_recips) 556 else: 557 [recipients.add(r) for r in self.extraRecipients[:]] 558 559 rlist = list(recipients) 560 rlist.sort() 561 m['To'] = ", ".join(rlist) 562 563 # The extras weren't part of the TO list so add them now 564 if self.sendToInterestedUsers: 565 for r in self.extraRecipients: 566 recipients.add(r) 567 568 return self.sendMessage(m, list(recipients))
569
570 - def sendMessage(self, m, recipients):
571 s = m.as_string() 572 twlog.msg("sending mail (%d bytes) to" % len(s), recipients) 573 return sendmail(self.relayhost, self.fromaddr, recipients, s)
574