3 # code to deliver build status through twisted.words (instant messaging
8 from zope
.interface
import Interface
, implements
9 from twisted
.internet
import protocol
, reactor
10 from twisted
.words
.protocols
import irc
11 from twisted
.python
import log
, failure
12 from twisted
.application
import internet
14 from buildbot
import interfaces
, util
15 from buildbot
import version
16 from buildbot
.sourcestamp
import SourceStamp
17 from buildbot
.process
.base
import BuildRequest
18 from buildbot
.status
import base
19 from buildbot
.status
.builder
import SUCCESS
, WARNINGS
, FAILURE
, EXCEPTION
20 from buildbot
.scripts
.runner
import ForceOptions
22 class UsageError(ValueError):
23 def __init__(self
, string
= "Invalid usage", *more
):
24 ValueError.__init
__(self
, string
, *more
)
26 class IrcBuildRequest
:
30 def __init__(self
, parent
):
32 self
.timer
= reactor
.callLater(5, self
.soon
)
36 if not self
.hasStarted
:
37 self
.parent
.send("The build has been queued, I'll give a shout"
41 self
.hasStarted
= True
47 response
= "build #%d forced" % s
.getNumber()
49 response
= "build forced [ETA %s]" % self
.parent
.convertTime(eta
)
50 self
.parent
.send(response
)
51 self
.parent
.send("I'll give a shout when the build finishes")
52 d
= s
.waitUntilFinished()
53 d
.addCallback(self
.parent
.buildFinished
)
57 """I hold the state for a single user's interaction with the buildbot.
59 This base class provides all the basic behavior (the queries and
60 responses). Subclasses for each channel type (IRC, different IM
61 protocols) are expected to provide the lower-level send/receive methods.
63 There will be one instance of me for each user who interacts personally
64 with the buildbot. There will be an additional instance for each
65 'broadcast contact' (chat rooms, IRC channels as a whole).
68 def __init__(self
, channel
, username
):
69 self
.channel
= channel
70 self
.username
= username
73 "What happen ?": "Somebody set up us the bomb.",
74 "It's You !!": ["How are you gentlemen !!",
75 "All your base are belong to us.",
76 "You are on the way to destruction."],
77 "What you say !!": ["You have no chance to survive make your time.",
81 def getCommandMethod(self
, command
):
82 meth
= getattr(self
, 'command_' + command
.upper(), None)
85 def getBuilder(self
, which
):
87 b
= self
.channel
.status
.getBuilder(which
)
89 raise UsageError
, "no such builder '%s'" % which
92 def getControl(self
, which
):
93 if not self
.channel
.control
:
94 raise UsageError("builder control is not enabled")
96 bc
= self
.channel
.control
.getBuilder(which
)
98 raise UsageError("no such builder '%s'" % which
)
101 def getAllBuilders(self
):
103 @rtype: list of L{buildbot.process.builder.Builder}
105 names
= self
.channel
.status
.getBuilderNames(categories
=self
.categories
)
107 builders
= [self
.channel
.status
.getBuilder(n
) for n
in names
]
110 def convertTime(self
, seconds
):
112 return "%d seconds" % seconds
113 minutes
= int(seconds
/ 60)
114 seconds
= seconds
- 60*minutes
116 return "%dm%02ds" % (minutes
, seconds
)
117 hours
= int(minutes
/ 60)
118 minutes
= minutes
- 60*hours
119 return "%dh%02dm%02ds" % (hours
, minutes
, seconds
)
121 def doSilly(self
, message
):
122 response
= self
.silly
[message
]
123 if type(response
) != type([]):
124 response
= [response
]
127 reactor
.callLater(when
, self
.send
, r
)
130 def command_HELLO(self
, args
):
133 def command_VERSION(self
, args
):
134 self
.send("buildbot-%s at your service" % version
)
136 def command_LIST(self
, args
):
139 raise UsageError
, "try 'list builders'"
140 if args
[0] == 'builders':
141 builders
= self
.getAllBuilders()
142 str = "Configured builders: "
145 state
= b
.getState()[0]
146 if state
== 'offline':
152 command_LIST
.usage
= "list builders - List configured builders"
154 def command_STATUS(self
, args
):
161 raise UsageError
, "try 'status <builder>'"
163 builders
= self
.getAllBuilders()
165 self
.emit_status(b
.name
)
167 self
.emit_status(which
)
168 command_STATUS
.usage
= "status [<which>] - List status of a builder (or all builders)"
170 def command_WATCH(self
, args
):
173 raise UsageError("try 'watch <builder>'")
175 b
= self
.getBuilder(which
)
176 builds
= b
.getCurrentBuilds()
178 self
.send("there are no builds currently running")
181 assert not build
.isFinished()
182 d
= build
.waitUntilFinished()
183 d
.addCallback(self
.buildFinished
)
184 r
= "watching build %s #%d until it finishes" \
185 % (which
, build
.getNumber())
188 r
+= " [%s]" % self
.convertTime(eta
)
191 command_WATCH
.usage
= "watch <which> - announce the completion of an active build"
193 def buildFinished(self
, b
):
194 results
= {SUCCESS
: "Success",
195 WARNINGS
: "Warnings",
197 EXCEPTION
: "Exception",
200 # only notify about builders we are interested in
201 builder
= b
.getBuilder()
202 log
.msg('builder %r in category %s finished' % (builder
,
204 if (self
.categories
!= None and
205 builder
.category
not in self
.categories
):
208 r
= "Hey! build %s #%d is complete: %s" % \
209 (b
.getBuilder().getName(),
211 results
.get(b
.getResults(), "??"))
212 r
+= " [%s]" % " ".join(b
.getText())
214 buildurl
= self
.channel
.status
.getURLForThing(b
)
216 self
.send("Build details are at %s" % buildurl
)
218 def command_FORCE(self
, args
):
219 args
= shlex
.split(args
) # TODO: this requires python2.3 or newer
220 if args
.pop(0) != "build":
221 raise UsageError("try 'force build WHICH <REASON>'")
222 opts
= ForceOptions()
223 opts
.parseOptions(args
)
225 which
= opts
['builder']
226 branch
= opts
['branch']
227 revision
= opts
['revision']
228 reason
= opts
['reason']
230 # keep weird stuff out of the branch and revision strings. TODO:
231 # centralize this somewhere.
232 if branch
and not re
.match(r
'^[\w\.\-\/]*$', branch
):
233 log
.msg("bad branch '%s'" % branch
)
234 self
.send("sorry, bad branch '%s'" % branch
)
236 if revision
and not re
.match(r
'^[\w\.\-\/]*$', revision
):
237 log
.msg("bad revision '%s'" % revision
)
238 self
.send("sorry, bad revision '%s'" % revision
)
241 bc
= self
.getControl(which
)
243 who
= None # TODO: if we can authenticate that a particular User
244 # asked for this, use User Name instead of None so they'll
245 # be informed of the results.
246 # TODO: or, monitor this build and announce the results
247 r
= "forced: by IRC user <%s>: %s" % (self
.username
, reason
)
248 # TODO: maybe give certain users the ability to request builds of
250 s
= SourceStamp(branch
=branch
, revision
=revision
)
251 req
= BuildRequest(r
, s
, which
)
253 bc
.requestBuildSoon(req
)
254 except interfaces
.NoSlaveError
:
255 self
.send("sorry, I can't force a build: all slaves are offline")
257 ireq
= IrcBuildRequest(self
)
258 req
.subscribe(ireq
.started
)
261 command_FORCE
.usage
= "force build <which> <reason> - Force a build"
263 def command_STOP(self
, args
):
264 args
= args
.split(None, 2)
265 if len(args
) < 3 or args
[0] != 'build':
266 raise UsageError
, "try 'stop build WHICH <REASON>'"
270 buildercontrol
= self
.getControl(which
)
273 r
= "stopped: by IRC user <%s>: %s" % (self
.username
, reason
)
275 # find an in-progress build
276 builderstatus
= self
.getBuilder(which
)
277 builds
= builderstatus
.getCurrentBuilds()
279 self
.send("sorry, no build is currently running")
282 num
= build
.getNumber()
284 # obtain the BuildControl object
285 buildcontrol
= buildercontrol
.getBuild(num
)
288 buildcontrol
.stopBuild(r
)
290 self
.send("build %d interrupted" % num
)
292 command_STOP
.usage
= "stop build <which> <reason> - Stop a running build"
294 def emit_status(self
, which
):
295 b
= self
.getBuilder(which
)
297 state
, builds
= b
.getState()
300 last
= b
.getLastFinishedBuild()
302 start
,finished
= last
.getTimes()
303 str += ", last build %s secs ago: %s" % \
304 (int(util
.now() - finished
), " ".join(last
.getText()))
305 if state
== "building":
308 step
= build
.getCurrentStep()
309 s
= "(%s)" % " ".join(step
.getText())
312 s
+= " [ETA %s]" % self
.convertTime(ETA
)
317 def emit_last(self
, which
):
318 last
= self
.getBuilder(which
).getLastFinishedBuild()
320 str = "(no builds run since last restart)"
322 start
,finish
= last
.getTimes()
323 str = "%s secs ago: " % (int(util
.now() - finish
))
324 str += " ".join(last
.getText())
325 self
.send("last build [%s]: %s" % (which
, str))
327 def command_LAST(self
, args
):
334 raise UsageError
, "try 'last <builder>'"
336 builders
= self
.getAllBuilders()
338 self
.emit_last(b
.name
)
340 self
.emit_last(which
)
341 command_LAST
.usage
= "last <which> - list last build status for builder <which>"
343 def build_commands(self
):
345 for k
in self
.__class
__.__dict
__.keys():
346 if k
.startswith('command_'):
347 commands
.append(k
[8:].lower())
351 def command_HELP(self
, args
):
354 self
.send("Get help on what? (try 'help <foo>', or 'commands' for a command list)")
357 meth
= self
.getCommandMethod(command
)
359 raise UsageError
, "no such command '%s'" % command
360 usage
= getattr(meth
, 'usage', None)
362 self
.send("Usage: %s" % usage
)
364 self
.send("No usage info for '%s'" % command
)
365 command_HELP
.usage
= "help <command> - Give help for <command>"
367 def command_SOURCE(self
, args
):
368 banner
= "My source can be found at http://buildbot.sourceforge.net/"
371 def command_COMMANDS(self
, args
):
372 commands
= self
.build_commands()
373 str = "buildbot commands: " + ", ".join(commands
)
375 command_COMMANDS
.usage
= "commands - List available commands"
377 def command_DESTROY(self
, args
):
378 self
.act("readies phasers")
380 def command_DANCE(self
, args
):
381 reactor
.callLater(1.0, self
.send
, "0-<")
382 reactor
.callLater(3.0, self
.send
, "0-/")
383 reactor
.callLater(3.5, self
.send
, "0-\\")
385 def command_EXCITED(self
, args
):
386 # like 'buildbot: destroy the sun!'
387 self
.send("What you say!")
389 def handleAction(self
, data
, user
):
390 # this is sent when somebody performs an action that mentions the
391 # buildbot (like '/me kicks buildbot'). 'user' is the name/nick/id of
392 # the person who performed the action, so if their action provokes a
393 # response, they can be named.
394 if not data
.endswith("s buildbot"):
400 response
= "%s back" % verb
403 response
= "%s %s too" % (verb
, user
)
404 reactor
.callLater(timeout
, self
.act
, response
)
406 class IRCContact(Contact
):
407 # this is the IRC-specific subclass of Contact
409 def __init__(self
, channel
, dest
):
410 Contact
.__init
__(self
, channel
)
413 # userJoined(self, user, channel)
415 def send(self
, message
):
416 self
.channel
.msg(self
.dest
, message
)
417 def act(self
, action
):
418 self
.channel
.me(self
.dest
, action
)
421 def handleMessage(self
, message
):
422 message
= message
.lstrip()
423 if self
.silly
.has_key(message
):
424 return self
.doSilly(message
)
426 parts
= message
.split(' ', 1)
430 log
.msg("irc command", cmd
)
432 meth
= self
.getCommandMethod(cmd
)
433 if not meth
and message
[-1] == '!':
434 meth
= self
.command_EXCITED
440 except UsageError
, e
:
443 f
= failure
.Failure()
445 error
= "Something bad happened (see logs): %s" % f
.type
453 #self.say(channel, "count %d" % self.counter)
454 self
.channel
.counter
+= 1
456 class IChannel(Interface
):
457 """I represent the buildbot's presence in a particular IM scheme.
459 This provides the connection to the IRC server, or represents the
460 buildbot's account with an IM service. Each Channel will have zero or
461 more Contacts associated with it.
464 class IrcStatusBot(irc
.IRCClient
):
465 """I represent the buildbot to an IRC server.
469 def __init__(self
, nickname
, password
, channels
, status
, categories
):
471 @type nickname: string
472 @param nickname: the nickname by which this bot should be known
473 @type password: string
474 @param password: the password to use for identifying with Nickserv
475 @type channels: list of strings
476 @param channels: the bot will maintain a presence in these channels
477 @type status: L{buildbot.status.builder.Status}
478 @param status: the build master's Status object, through which the
479 bot retrieves all status information
481 self
.nickname
= nickname
482 self
.channels
= channels
483 self
.password
= password
485 self
.categories
= categories
490 def addContact(self
, name
, contact
):
491 self
.contacts
[name
] = contact
493 def getContact(self
, name
):
494 if name
in self
.contacts
:
495 return self
.contacts
[name
]
496 new_contact
= IRCContact(self
, name
)
497 self
.contacts
[name
] = new_contact
500 def deleteContact(self
, contact
):
501 name
= contact
.getName()
502 if name
in self
.contacts
:
503 assert self
.contacts
[name
] == contact
504 del self
.contacts
[name
]
507 log
.msg("%s: %s" % (self
, msg
))
510 # the following irc.IRCClient methods are called when we have input
512 def privmsg(self
, user
, channel
, message
):
513 user
= user
.split('!', 1)[0] # rest is ~user@hostname
514 # channel is '#twisted' or 'buildbot' (for private messages)
515 channel
= channel
.lower()
516 #print "privmsg:", user, channel, message
517 if channel
== self
.nickname
:
519 contact
= self
.getContact(user
)
520 contact
.handleMessage(message
)
522 # else it's a broadcast message, maybe for us, maybe not. 'channel'
523 # is '#twisted' or the like.
524 contact
= self
.getContact(channel
)
525 if message
.startswith("%s:" % self
.nickname
):
526 message
= message
[len("%s:" % self
.nickname
):]
527 contact
.handleMessage(message
)
528 # to track users comings and goings, add code here
530 def action(self
, user
, channel
, data
):
531 #log.msg("action: %s,%s,%s" % (user, channel, data))
532 user
= user
.split('!', 1)[0] # rest is ~user@hostname
533 # somebody did an action (/me actions) in the broadcast channel
534 contact
= self
.getContact(channel
)
535 if "buildbot" in data
:
536 contact
.handleAction(data
, user
)
542 self
.msg("Nickserv", "IDENTIFY " + self
.password
)
543 for c
in self
.channels
:
546 def joined(self
, channel
):
547 self
.log("I have joined %s" % (channel
,))
548 def left(self
, channel
):
549 self
.log("I have left %s" % (channel
,))
550 def kickedFrom(self
, channel
, kicker
, message
):
551 self
.log("I have been kicked from %s by %s: %s" % (channel
,
555 # we can using the following irc.IRCClient methods to send output. Most
556 # of these are used by the IRCContact class.
558 # self.say(channel, message) # broadcast
559 # self.msg(user, message) # unicast
560 # self.me(channel, action) # send action
561 # self.away(message='')
562 # self.quit(message='')
564 class ThrottledClientFactory(protocol
.ClientFactory
):
567 def clientConnectionLost(self
, connector
, reason
):
568 reactor
.callLater(self
.lostDelay
, connector
.connect
)
569 def clientConnectionFailed(self
, connector
, reason
):
570 reactor
.callLater(self
.failedDelay
, connector
.connect
)
572 class IrcStatusFactory(ThrottledClientFactory
):
573 protocol
= IrcStatusBot
580 def __init__(self
, nickname
, password
, channels
, categories
):
581 #ThrottledClientFactory.__init__(self) # doesn't exist
583 self
.nickname
= nickname
584 self
.password
= password
585 self
.channels
= channels
586 self
.categories
= categories
588 def __getstate__(self
):
589 d
= self
.__dict
__.copy()
594 self
.shuttingDown
= True
596 self
.p
.quit("buildmaster reconfigured: bot disconnecting")
598 def buildProtocol(self
, address
):
599 p
= self
.protocol(self
.nickname
, self
.password
,
600 self
.channels
, self
.status
,
603 p
.status
= self
.status
604 p
.control
= self
.control
608 # TODO: I think a shutdown that occurs while the connection is being
609 # established will make this explode
611 def clientConnectionLost(self
, connector
, reason
):
612 if self
.shuttingDown
:
613 log
.msg("not scheduling reconnection attempt")
615 ThrottledClientFactory
.clientConnectionLost(self
, connector
, reason
)
617 def clientConnectionFailed(self
, connector
, reason
):
618 if self
.shuttingDown
:
619 log
.msg("not scheduling reconnection attempt")
621 ThrottledClientFactory
.clientConnectionFailed(self
, connector
, reason
)
624 class IRC(base
.StatusReceiverMultiService
):
625 """I am an IRC bot which can be queried for status information. I
626 connect to a single IRC server and am known by a single nickname on that
627 server, however I can join multiple channels."""
629 compare_attrs
= ["host", "port", "nick", "password",
630 "channels", "allowForce",
633 def __init__(self
, host
, nick
, channels
, port
=6667, allowForce
=True,
634 categories
=None, password
=None):
635 base
.StatusReceiverMultiService
.__init
__(self
)
637 assert allowForce
in (True, False) # TODO: implement others
639 # need to stash these so we can detect changes later
643 self
.channels
= channels
644 self
.password
= password
645 self
.allowForce
= allowForce
646 self
.categories
= categories
648 # need to stash the factory so we can give it the status object
649 self
.f
= IrcStatusFactory(self
.nick
, self
.password
,
650 self
.channels
, self
.categories
)
652 c
= internet
.TCPClient(host
, port
, self
.f
)
653 c
.setServiceParent(self
)
655 def setServiceParent(self
, parent
):
656 base
.StatusReceiverMultiService
.setServiceParent(self
, parent
)
657 self
.f
.status
= parent
.getStatus()
659 self
.f
.control
= interfaces
.IControl(parent
)
661 def stopService(self
):
662 # make sure the factory will stop reconnecting
664 return base
.StatusReceiverMultiService
.stopService(self
)
667 ## buildbot: list builders
668 # buildbot: watch quick
669 # print notification when current build in 'quick' finishes
671 ## buildbot: status full-2.3
672 ## building, not, % complete, ETA
673 ## buildbot: force build full-2.3 "reason"