MailNotifier: add mode=passing. Closes #169
[buildbot.git] / buildbot / status / mail.py
bloba23a84ccc9c067b187e26405e783875e284a4e4b
1 # -*- test-case-name: buildbot.test.test_status -*-
3 # the email.MIMEMultipart module is only available in python-2.2.2 and later
5 from email.Message import Message
6 from email.Utils import formatdate
7 from email.MIMEText import MIMEText
8 try:
9 from email.MIMEMultipart import MIMEMultipart
10 canDoAttachments = True
11 except ImportError:
12 canDoAttachments = False
13 import urllib
15 from zope.interface import implements
16 from twisted.internet import defer
17 from twisted.mail.smtp import sendmail
18 from twisted.python import log as twlog
20 from buildbot import interfaces, util
21 from buildbot.status import base
22 from buildbot.status.builder import FAILURE, SUCCESS, WARNINGS
25 class Domain(util.ComparableMixin):
26 implements(interfaces.IEmailLookup)
27 compare_attrs = ["domain"]
29 def __init__(self, domain):
30 assert "@" not in domain
31 self.domain = domain
33 def getAddress(self, name):
34 return name + "@" + self.domain
37 class MailNotifier(base.StatusReceiverMultiService):
38 """This is a status notifier which sends email to a list of recipients
39 upon the completion of each build. It can be configured to only send out
40 mail for certain builds, and only send messages when the build fails, or
41 when it transitions from success to failure. It can also be configured to
42 include various build logs in each message.
44 By default, the message will be sent to the Interested Users list, which
45 includes all developers who made changes in the build. You can add
46 additional recipients with the extraRecipients argument.
48 To get a simple one-message-per-build (say, for a mailing list), use
49 sendToInterestedUsers=False, extraRecipients=['listaddr@example.org']
51 Each MailNotifier sends mail to a single set of recipients. To send
52 different kinds of mail to different recipients, use multiple
53 MailNotifiers.
54 """
56 implements(interfaces.IEmailSender)
58 compare_attrs = ["extraRecipients", "lookup", "fromaddr", "mode",
59 "categories", "builders", "addLogs", "relayhost",
60 "subject", "sendToInterestedUsers"]
62 def __init__(self, fromaddr, mode="all", categories=None, builders=None,
63 addLogs=False, relayhost="localhost",
64 subject="buildbot %(result)s in %(projectName)s on %(builder)s",
65 lookup=None, extraRecipients=[],
66 sendToInterestedUsers=True):
67 """
68 @type fromaddr: string
69 @param fromaddr: the email address to be used in the 'From' header.
70 @type sendToInterestedUsers: boolean
71 @param sendToInterestedUsers: if True (the default), send mail to all
72 of the Interested Users. If False, only
73 send mail to the extraRecipients list.
75 @type extraRecipients: tuple of string
76 @param extraRecipients: a list of email addresses to which messages
77 should be sent (in addition to the
78 InterestedUsers list, which includes any
79 developers who made Changes that went into this
80 build). It is a good idea to create a small
81 mailing list and deliver to that, then let
82 subscribers come and go as they please.
84 @type subject: string
85 @param subject: a string to be used as the subject line of the message.
86 %(builder)s will be replaced with the name of the
87 builder which provoked the message.
89 @type mode: string (defaults to all)
90 @param mode: one of:
91 - 'all': send mail about all builds, passing and failing
92 - 'failing': only send mail about builds which fail
93 - 'passing': only send mail about builds which succeed
94 - 'problem': only send mail about a build which failed
95 when the previous build passed
97 @type builders: list of strings
98 @param builders: a list of builder names for which mail should be
99 sent. Defaults to None (send mail for all builds).
100 Use either builders or categories, but not both.
102 @type categories: list of strings
103 @param categories: a list of category names to serve status
104 information for. Defaults to None (all
105 categories). Use either builders or categories,
106 but not both.
108 @type addLogs: boolean.
109 @param addLogs: if True, include all build logs as attachments to the
110 messages. These can be quite large. This can also be
111 set to a list of log names, to send a subset of the
112 logs. Defaults to False.
114 @type relayhost: string
115 @param relayhost: the host to which the outbound SMTP connection
116 should be made. Defaults to 'localhost'
118 @type lookup: implementor of {IEmailLookup}
119 @param lookup: object which provides IEmailLookup, which is
120 responsible for mapping User names (which come from
121 the VC system) into valid email addresses. If not
122 provided, the notifier will only be able to send mail
123 to the addresses in the extraRecipients list. Most of
124 the time you can use a simple Domain instance. As a
125 shortcut, you can pass as string: this will be
126 treated as if you had provided Domain(str). For
127 example, lookup='twistedmatrix.com' will allow mail
128 to be sent to all developers whose SVN usernames
129 match their twistedmatrix.com account names.
132 base.StatusReceiverMultiService.__init__(self)
133 assert isinstance(extraRecipients, (list, tuple))
134 for r in extraRecipients:
135 assert isinstance(r, str)
136 assert "@" in r # require full email addresses, not User names
137 self.extraRecipients = extraRecipients
138 self.sendToInterestedUsers = sendToInterestedUsers
139 self.fromaddr = fromaddr
140 assert mode in ('all', 'failing', 'problem')
141 self.mode = mode
142 self.categories = categories
143 self.builders = builders
144 self.addLogs = addLogs
145 self.relayhost = relayhost
146 self.subject = subject
147 if lookup is not None:
148 if type(lookup) is str:
149 lookup = Domain(lookup)
150 assert interfaces.IEmailLookup.providedBy(lookup)
151 self.lookup = lookup
152 self.watched = []
153 self.status = None
155 # you should either limit on builders or categories, not both
156 if self.builders != None and self.categories != None:
157 twlog.err("Please specify only builders to ignore or categories to include")
158 raise # FIXME: the asserts above do not raise some Exception either
160 def setServiceParent(self, parent):
162 @type parent: L{buildbot.master.BuildMaster}
164 base.StatusReceiverMultiService.setServiceParent(self, parent)
165 self.setup()
167 def setup(self):
168 self.status = self.parent.getStatus()
169 self.status.subscribe(self)
171 def disownServiceParent(self):
172 self.status.unsubscribe(self)
173 for w in self.watched:
174 w.unsubscribe(self)
175 return base.StatusReceiverMultiService.disownServiceParent(self)
177 def builderAdded(self, name, builder):
178 # only subscribe to builders we are interested in
179 if self.categories != None and builder.category not in self.categories:
180 return None
182 self.watched.append(builder)
183 return self # subscribe to this builder
185 def builderRemoved(self, name):
186 pass
188 def builderChangedState(self, name, state):
189 pass
190 def buildStarted(self, name, build):
191 pass
192 def buildFinished(self, name, build, results):
193 # here is where we actually do something.
194 builder = build.getBuilder()
195 if self.builders is not None and name not in self.builders:
196 return # ignore this build
197 if self.categories is not None and \
198 builder.category not in self.categories:
199 return # ignore this build
201 if self.mode == "failing" and results != FAILURE:
202 return
203 if self.mode == "passing" and results != SUCCESS:
204 return
205 if self.mode == "problem":
206 if results != FAILURE:
207 return
208 prev = build.getPreviousBuild()
209 if prev and prev.getResults() == FAILURE:
210 return
211 # for testing purposes, buildMessage returns a Deferred that fires
212 # when the mail has been sent. To help unit tests, we return that
213 # Deferred here even though the normal IStatusReceiver.buildFinished
214 # signature doesn't do anything with it. If that changes (if
215 # .buildFinished's return value becomes significant), we need to
216 # rearrange this.
217 return self.buildMessage(name, build, results)
219 def buildMessage(self, name, build, results):
220 projectName = self.status.getProjectName()
221 text = ""
222 if self.mode == "all":
223 text += "The Buildbot has finished a build"
224 elif self.mode == "failing":
225 text += "The Buildbot has detected a failed build"
226 elif self.mode == "passing":
227 text += "The Buildbot has detected a passing build"
228 else:
229 text += "The Buildbot has detected a new failure"
230 text += " of %s on %s.\n" % (name, projectName)
231 buildurl = self.status.getURLForThing(build)
232 if buildurl:
233 text += "Full details are available at:\n %s\n" % buildurl
234 text += "\n"
236 url = self.status.getBuildbotURL()
237 if url:
238 text += "Buildbot URL: %s\n\n" % urllib.quote(url, '/:')
240 text += "Buildslave for this Build: %s\n\n" % build.getSlavename()
241 text += "Build Reason: %s\n" % build.getReason()
243 patch = None
244 ss = build.getSourceStamp()
245 if ss is None:
246 source = "unavailable"
247 else:
248 source = ""
249 if ss.branch:
250 source += "[branch %s] " % ss.branch
251 if ss.revision:
252 source += ss.revision
253 else:
254 source += "HEAD"
255 if ss.patch is not None:
256 source += " (plus patch)"
257 patch = ss.patch
258 text += "Build Source Stamp: %s\n" % source
260 text += "Blamelist: %s\n" % ",".join(build.getResponsibleUsers())
262 # TODO: maybe display changes here? or in an attachment?
263 text += "\n"
265 t = build.getText()
266 if t:
267 t = ": " + " ".join(t)
268 else:
269 t = ""
271 if results == SUCCESS:
272 text += "Build succeeded!\n"
273 res = "success"
274 elif results == WARNINGS:
275 text += "Build Had Warnings%s\n" % t
276 res = "warnings"
277 else:
278 text += "BUILD FAILED%s\n" % t
279 res = "failure"
281 if self.addLogs and build.getLogs():
282 text += "Logs are attached.\n"
284 # TODO: it would be nice to provide a URL for the specific build
285 # here. That involves some coordination with html.Waterfall .
286 # Ideally we could do:
287 # helper = self.parent.getServiceNamed("html")
288 # if helper:
289 # url = helper.getURLForBuild(build)
291 text += "\n"
292 text += "sincerely,\n"
293 text += " -The Buildbot\n"
294 text += "\n"
296 haveAttachments = False
297 if patch or self.addLogs:
298 haveAttachments = True
299 if not canDoAttachments:
300 twlog.msg("warning: I want to send mail with attachments, "
301 "but this python is too old to have "
302 "email.MIMEMultipart . Please upgrade to python-2.3 "
303 "or newer to enable addLogs=True")
305 if haveAttachments and canDoAttachments:
306 m = MIMEMultipart()
307 m.attach(MIMEText(text))
308 else:
309 m = Message()
310 m.set_payload(text)
312 m['Date'] = formatdate(localtime=True)
313 m['Subject'] = self.subject % { 'result': res,
314 'projectName': projectName,
315 'builder': name,
317 m['From'] = self.fromaddr
318 # m['To'] is added later
320 if patch:
321 a = MIMEText(patch)
322 a.add_header('Content-Disposition', "attachment",
323 filename="source patch")
324 m.attach(a)
325 if self.addLogs:
326 for log in build.getLogs():
327 name = "%s.%s" % (log.getStep().getName(),
328 log.getName())
329 a = MIMEText(log.getText())
330 a.add_header('Content-Disposition', "attachment",
331 filename=name)
332 m.attach(a)
334 # now, who is this message going to?
335 dl = []
336 recipients = self.extraRecipients[:]
337 if self.sendToInterestedUsers and self.lookup:
338 for u in build.getInterestedUsers():
339 d = defer.maybeDeferred(self.lookup.getAddress, u)
340 d.addCallback(recipients.append)
341 dl.append(d)
342 d = defer.DeferredList(dl)
343 d.addCallback(self._gotRecipients, recipients, m)
344 return d
346 def _gotRecipients(self, res, rlist, m):
347 recipients = []
348 for r in rlist:
349 if r is not None and r not in recipients:
350 recipients.append(r)
351 recipients.sort()
352 m['To'] = ", ".join(recipients)
353 return self.sendMessage(m, recipients)
355 def sendMessage(self, m, recipients):
356 s = m.as_string()
357 ds = []
358 twlog.msg("sending mail (%d bytes) to" % len(s), recipients)
359 for recip in recipients:
360 ds.append(sendmail(self.relayhost, self.fromaddr, recip, s))
361 return defer.DeferredList(ds)