diff options
Diffstat (limited to 'buildbot/buildbot/changes/changes.py')
-rw-r--r-- | buildbot/buildbot/changes/changes.py | 288 |
1 files changed, 288 insertions, 0 deletions
diff --git a/buildbot/buildbot/changes/changes.py b/buildbot/buildbot/changes/changes.py new file mode 100644 index 0000000..7d399e0 --- /dev/null +++ b/buildbot/buildbot/changes/changes.py @@ -0,0 +1,288 @@ + +import sys, os, time +from cPickle import dump + +from zope.interface import implements +from twisted.python import log +from twisted.internet import defer +from twisted.application import service +from twisted.web import html + +from buildbot import interfaces, util + +html_tmpl = """ +<p>Changed by: <b>%(who)s</b><br /> +Changed at: <b>%(at)s</b><br /> +%(branch)s +%(revision)s +<br /> + +Changed files: +%(files)s + +Comments: +%(comments)s +</p> +""" + +class Change: + """I represent a single change to the source tree. This may involve + several files, but they are all changed by the same person, and there is + a change comment for the group as a whole. + + If the version control system supports sequential repository- (or + branch-) wide change numbers (like SVN, P4, and Arch), then revision= + should be set to that number. The highest such number will be used at + checkout time to get the correct set of files. + + If it does not (like CVS), when= should be set to the timestamp (seconds + since epoch, as returned by time.time()) when the change was made. when= + will be filled in for you (to the current time) if you omit it, which is + suitable for ChangeSources which have no way of getting more accurate + timestamps. + + Changes should be submitted to ChangeMaster.addChange() in + chronologically increasing order. Out-of-order changes will probably + cause the html.Waterfall display to be corrupted.""" + + implements(interfaces.IStatusEvent) + + number = None + + links = [] + branch = None + revision = None # used to create a source-stamp + + def __init__(self, who, files, comments, isdir=0, links=[], + revision=None, when=None, branch=None, category=None): + self.who = who + self.comments = comments + self.isdir = isdir + self.links = links + self.revision = revision + if when is None: + when = util.now() + self.when = when + self.branch = branch + self.category = category + + # keep a sorted list of the files, for easier display + self.files = files[:] + self.files.sort() + + def asText(self): + data = "" + data += self.getFileContents() + data += "At: %s\n" % self.getTime() + data += "Changed By: %s\n" % self.who + data += "Comments: %s\n\n" % self.comments + return data + + def asHTML(self): + links = [] + for file in self.files: + link = filter(lambda s: s.find(file) != -1, self.links) + if len(link) == 1: + # could get confused + links.append('<a href="%s"><b>%s</b></a>' % (link[0], file)) + else: + links.append('<b>%s</b>' % file) + revision = "" + if self.revision: + revision = "Revision: <b>%s</b><br />\n" % self.revision + branch = "" + if self.branch: + branch = "Branch: <b>%s</b><br />\n" % self.branch + + kwargs = { 'who' : html.escape(self.who), + 'at' : self.getTime(), + 'files' : html.UL(links) + '\n', + 'revision': revision, + 'branch' : branch, + 'comments': html.PRE(self.comments) } + return html_tmpl % kwargs + + def get_HTML_box(self, url): + """Return the contents of a TD cell for the waterfall display. + + @param url: the URL that points to an HTML page that will render + using our asHTML method. The Change is free to use this or ignore it + as it pleases. + + @return: the HTML that will be put inside the table cell. Typically + this is just a single href named after the author of the change and + pointing at the passed-in 'url'. + """ + who = self.getShortAuthor() + if self.comments is None: + title = "" + else: + title = html.escape(self.comments) + return '<a href="%s" title="%s">%s</a>' % (url, + title, + html.escape(who)) + + def getShortAuthor(self): + return self.who + + def getTime(self): + if not self.when: + return "?" + return time.strftime("%a %d %b %Y %H:%M:%S", + time.localtime(self.when)) + + def getTimes(self): + return (self.when, None) + + def getText(self): + return [html.escape(self.who)] + def getLogs(self): + return {} + + def getFileContents(self): + data = "" + if len(self.files) == 1: + if self.isdir: + data += "Directory: %s\n" % self.files[0] + else: + data += "File: %s\n" % self.files[0] + else: + data += "Files:\n" + for f in self.files: + data += " %s\n" % f + return data + +class ChangeMaster(service.MultiService): + + """This is the master-side service which receives file change + notifications from CVS. It keeps a log of these changes, enough to + provide for the HTML waterfall display, and to tell + temporarily-disconnected bots what they missed while they were + offline. + + Change notifications come from two different kinds of sources. The first + is a PB service (servicename='changemaster', perspectivename='change'), + which provides a remote method called 'addChange', which should be + called with a dict that has keys 'filename' and 'comments'. + + The second is a list of objects derived from the ChangeSource class. + These are added with .addSource(), which also sets the .changemaster + attribute in the source to point at the ChangeMaster. When the + application begins, these will be started with .start() . At shutdown + time, they will be terminated with .stop() . They must be persistable. + They are expected to call self.changemaster.addChange() with Change + objects. + + There are several different variants of the second type of source: + + - L{buildbot.changes.mail.MaildirSource} watches a maildir for CVS + commit mail. It uses DNotify if available, or polls every 10 + seconds if not. It parses incoming mail to determine what files + were changed. + + - L{buildbot.changes.freshcvs.FreshCVSSource} makes a PB + connection to the CVSToys 'freshcvs' daemon and relays any + changes it announces. + + """ + + implements(interfaces.IEventSource) + + debug = False + # todo: use Maildir class to watch for changes arriving by mail + + def __init__(self): + service.MultiService.__init__(self) + self.changes = [] + # self.basedir must be filled in by the parent + self.nextNumber = 1 + + def addSource(self, source): + assert interfaces.IChangeSource.providedBy(source) + assert service.IService.providedBy(source) + if self.debug: + print "ChangeMaster.addSource", source + source.setServiceParent(self) + + def removeSource(self, source): + assert source in self + if self.debug: + print "ChangeMaster.removeSource", source, source.parent + d = defer.maybeDeferred(source.disownServiceParent) + return d + + def addChange(self, change): + """Deliver a file change event. The event should be a Change object. + This method will timestamp the object as it is received.""" + log.msg("adding change, who %s, %d files, rev=%s, branch=%s, " + "comments %s, category %s" % (change.who, len(change.files), + change.revision, change.branch, + change.comments, change.category)) + change.number = self.nextNumber + self.nextNumber += 1 + self.changes.append(change) + self.parent.addChange(change) + # TODO: call pruneChanges after a while + + def pruneChanges(self): + self.changes = self.changes[-100:] # or something + + def eventGenerator(self, branches=[]): + for i in range(len(self.changes)-1, -1, -1): + c = self.changes[i] + if not branches or c.branch in branches: + yield c + + def getChangeNumbered(self, num): + if not self.changes: + return None + first = self.changes[0].number + if first + len(self.changes)-1 != self.changes[-1].number: + log.msg(self, + "lost a change somewhere: [0] is %d, [%d] is %d" % \ + (self.changes[0].number, + len(self.changes) - 1, + self.changes[-1].number)) + for c in self.changes: + log.msg("c[%d]: " % c.number, c) + return None + offset = num - first + log.msg(self, "offset", offset) + return self.changes[offset] + + def __getstate__(self): + d = service.MultiService.__getstate__(self) + del d['parent'] + del d['services'] # lose all children + del d['namedServices'] + return d + + def __setstate__(self, d): + self.__dict__ = d + # self.basedir must be set by the parent + self.services = [] # they'll be repopulated by readConfig + self.namedServices = {} + + + def saveYourself(self): + filename = os.path.join(self.basedir, "changes.pck") + tmpfilename = filename + ".tmp" + try: + dump(self, open(tmpfilename, "wb")) + if sys.platform == 'win32': + # windows cannot rename a file on top of an existing one + if os.path.exists(filename): + os.unlink(filename) + os.rename(tmpfilename, filename) + except Exception, e: + log.msg("unable to save changes") + log.err() + + def stopService(self): + self.saveYourself() + return service.MultiService.stopService(self) + +class TestChangeMaster(ChangeMaster): + """A ChangeMaster for use in tests that does not save itself""" + def stopService(self): + return service.MultiService.stopService(self) |