| OLD | NEW |
| (Empty) |
| 1 import sys, os, time | |
| 2 from cPickle import dump | |
| 3 | |
| 4 from zope.interface import implements | |
| 5 from twisted.python import log | |
| 6 from twisted.internet import defer | |
| 7 from twisted.application import service | |
| 8 from twisted.web import html | |
| 9 | |
| 10 from buildbot import interfaces, util | |
| 11 from buildbot.process.properties import Properties | |
| 12 | |
| 13 html_tmpl = """ | |
| 14 <p>Changed by: <b>%(who)s</b><br /> | |
| 15 Changed at: <b>%(at)s</b><br /> | |
| 16 %(repository)s | |
| 17 %(branch)s | |
| 18 %(revision)s | |
| 19 <br /> | |
| 20 | |
| 21 Changed files: | |
| 22 %(files)s | |
| 23 | |
| 24 Comments: | |
| 25 %(comments)s | |
| 26 | |
| 27 Properties: | |
| 28 %(properties)s | |
| 29 </p> | |
| 30 """ | |
| 31 | |
| 32 class Change: | |
| 33 """I represent a single change to the source tree. This may involve | |
| 34 several files, but they are all changed by the same person, and there is | |
| 35 a change comment for the group as a whole. | |
| 36 | |
| 37 If the version control system supports sequential repository- (or | |
| 38 branch-) wide change numbers (like SVN, P4, and Arch), then revision= | |
| 39 should be set to that number. The highest such number will be used at | |
| 40 checkout time to get the correct set of files. | |
| 41 | |
| 42 If it does not (like CVS), when= should be set to the timestamp (seconds | |
| 43 since epoch, as returned by time.time()) when the change was made. when= | |
| 44 will be filled in for you (to the current time) if you omit it, which is | |
| 45 suitable for ChangeSources which have no way of getting more accurate | |
| 46 timestamps. | |
| 47 | |
| 48 Changes should be submitted to ChangeMaster.addChange() in | |
| 49 chronologically increasing order. Out-of-order changes will probably | |
| 50 cause the html.Waterfall display to be corrupted.""" | |
| 51 | |
| 52 implements(interfaces.IStatusEvent) | |
| 53 | |
| 54 number = None | |
| 55 | |
| 56 branch = None | |
| 57 category = None | |
| 58 revision = None # used to create a source-stamp | |
| 59 repository = None # optional repository | |
| 60 | |
| 61 def __init__(self, who, files, comments, isdir=0, links=None, | |
| 62 revision=None, when=None, branch=None, category=None, | |
| 63 repository='', revlink='', properties={}): | |
| 64 self.who = who | |
| 65 self.comments = comments | |
| 66 self.isdir = isdir | |
| 67 if links is None: | |
| 68 links = [] | |
| 69 self.links = links | |
| 70 self.revision = revision | |
| 71 if when is None: | |
| 72 when = util.now() | |
| 73 self.when = when | |
| 74 self.branch = branch | |
| 75 self.category = category | |
| 76 self.repository = repository | |
| 77 self.revlink = revlink | |
| 78 self.properties = Properties() | |
| 79 self.properties.update(properties, "Change") | |
| 80 | |
| 81 # keep a sorted list of the files, for easier display | |
| 82 self.files = files[:] | |
| 83 self.files.sort() | |
| 84 | |
| 85 def __setstate__(self, dict): | |
| 86 self.__dict__ = dict | |
| 87 # Older Changes won't have a 'properties' attribute in them | |
| 88 if not hasattr(self, 'properties'): | |
| 89 self.properties = Properties() | |
| 90 | |
| 91 def asText(self): | |
| 92 data = "" | |
| 93 data += self.getFileContents() | |
| 94 data += "At: %s\n" % self.getTime() | |
| 95 data += "Changed By: %s\n" % self.who | |
| 96 data += "Comments: %s" % self.comments | |
| 97 data += "Properties: \n%s\n\n" % self.getProperties() | |
| 98 return data | |
| 99 | |
| 100 def asHTML(self): | |
| 101 links = [] | |
| 102 for file in self.files: | |
| 103 link = filter(lambda s: s.find(file) != -1, self.links) | |
| 104 if len(link) == 1: | |
| 105 # could get confused | |
| 106 links.append('<a href="%s"><b>%s</b></a>' % (link[0], file)) | |
| 107 else: | |
| 108 links.append('<b>%s</b>' % file) | |
| 109 if self.revision: | |
| 110 if getattr(self, 'revlink', ""): | |
| 111 revision = 'Revision: <a href="%s"><b>%s</b></a>\n' % ( | |
| 112 self.revlink, self.revision) | |
| 113 else: | |
| 114 revision = "Revision: <b>%s</b><br />\n" % self.revision | |
| 115 else: | |
| 116 revision = '' | |
| 117 | |
| 118 if self.repository: | |
| 119 repository = "Repository: <b>%s</b><br />\n" % self.repository | |
| 120 else: | |
| 121 repository = '' | |
| 122 | |
| 123 branch = "" | |
| 124 if self.branch: | |
| 125 branch = "Branch: <b>%s</b><br />\n" % self.branch | |
| 126 | |
| 127 properties = [] | |
| 128 for prop in self.properties.asList(): | |
| 129 properties.append("%s: %s<br />" % (prop[0], prop[1])) | |
| 130 | |
| 131 kwargs = { 'who' : html.escape(self.who), | |
| 132 'at' : self.getTime(), | |
| 133 'files' : html.UL(links) + '\n', | |
| 134 'repository': repository, | |
| 135 'revision' : revision, | |
| 136 'branch' : branch, | |
| 137 'comments' : html.PRE(self.comments), | |
| 138 'properties': html.UL(properties) + '\n' } | |
| 139 return html_tmpl % kwargs | |
| 140 | |
| 141 def get_HTML_box(self, url): | |
| 142 """Return the contents of a TD cell for the waterfall display. | |
| 143 | |
| 144 @param url: the URL that points to an HTML page that will render | |
| 145 using our asHTML method. The Change is free to use this or ignore it | |
| 146 as it pleases. | |
| 147 | |
| 148 @return: the HTML that will be put inside the table cell. Typically | |
| 149 this is just a single href named after the author of the change and | |
| 150 pointing at the passed-in 'url'. | |
| 151 """ | |
| 152 who = self.getShortAuthor() | |
| 153 if self.comments is None: | |
| 154 title = "" | |
| 155 else: | |
| 156 title = html.escape(self.comments) | |
| 157 return '<a href="%s" title="%s">%s</a>' % (url, | |
| 158 title, | |
| 159 html.escape(who)) | |
| 160 | |
| 161 def getShortAuthor(self): | |
| 162 return self.who | |
| 163 | |
| 164 def getTime(self): | |
| 165 if not self.when: | |
| 166 return "?" | |
| 167 return time.strftime("%a %d %b %Y %H:%M:%S", | |
| 168 time.localtime(self.when)) | |
| 169 | |
| 170 def getTimes(self): | |
| 171 return (self.when, None) | |
| 172 | |
| 173 def getText(self): | |
| 174 return [html.escape(self.who)] | |
| 175 def getLogs(self): | |
| 176 return {} | |
| 177 | |
| 178 def getFileContents(self): | |
| 179 data = "" | |
| 180 if len(self.files) == 1: | |
| 181 if self.isdir: | |
| 182 data += "Directory: %s\n" % self.files[0] | |
| 183 else: | |
| 184 data += "File: %s\n" % self.files[0] | |
| 185 else: | |
| 186 data += "Files:\n" | |
| 187 for f in self.files: | |
| 188 data += " %s\n" % f | |
| 189 return data | |
| 190 | |
| 191 def getProperties(self): | |
| 192 data = "" | |
| 193 for prop in self.properties.asList(): | |
| 194 data += " %s: %s" % (prop[0], prop[1]) | |
| 195 return data | |
| 196 | |
| 197 def asDict(self): | |
| 198 result = {} | |
| 199 # Constant | |
| 200 result['number'] = self.number | |
| 201 result['branch'] = self.branch | |
| 202 result['category'] = self.category | |
| 203 result['who'] = self.getShortAuthor() | |
| 204 result['comments'] = self.comments | |
| 205 result['revision'] = self.revision | |
| 206 result['repository'] = self.repository | |
| 207 result['when'] = self.when | |
| 208 result['files'] = self.files | |
| 209 result['revlink'] = self.revlink | |
| 210 result['properties'] = self.properties.asList() | |
| 211 return result | |
| 212 | |
| 213 | |
| 214 class ChangeMaster(service.MultiService): | |
| 215 | |
| 216 """This is the master-side service which receives file change | |
| 217 notifications from CVS. It keeps a log of these changes, enough to | |
| 218 provide for the HTML waterfall display, and to tell | |
| 219 temporarily-disconnected bots what they missed while they were | |
| 220 offline. | |
| 221 | |
| 222 Change notifications come from two different kinds of sources. The first | |
| 223 is a PB service (servicename='changemaster', perspectivename='change'), | |
| 224 which provides a remote method called 'addChange', which should be | |
| 225 called with a dict that has keys 'filename' and 'comments'. | |
| 226 | |
| 227 The second is a list of objects derived from the ChangeSource class. | |
| 228 These are added with .addSource(), which also sets the .changemaster | |
| 229 attribute in the source to point at the ChangeMaster. When the | |
| 230 application begins, these will be started with .start() . At shutdown | |
| 231 time, they will be terminated with .stop() . They must be persistable. | |
| 232 They are expected to call self.changemaster.addChange() with Change | |
| 233 objects. | |
| 234 | |
| 235 There are several different variants of the second type of source: | |
| 236 | |
| 237 - L{buildbot.changes.mail.MaildirSource} watches a maildir for CVS | |
| 238 commit mail. It uses DNotify if available, or polls every 10 | |
| 239 seconds if not. It parses incoming mail to determine what files | |
| 240 were changed. | |
| 241 | |
| 242 - L{buildbot.changes.freshcvs.FreshCVSSource} makes a PB | |
| 243 connection to the CVSToys 'freshcvs' daemon and relays any | |
| 244 changes it announces. | |
| 245 | |
| 246 """ | |
| 247 | |
| 248 implements(interfaces.IEventSource) | |
| 249 | |
| 250 debug = False | |
| 251 # todo: use Maildir class to watch for changes arriving by mail | |
| 252 | |
| 253 changeHorizon = 0 | |
| 254 | |
| 255 def __init__(self): | |
| 256 service.MultiService.__init__(self) | |
| 257 self.changes = [] | |
| 258 # self.basedir must be filled in by the parent | |
| 259 self.nextNumber = 1 | |
| 260 | |
| 261 def addSource(self, source): | |
| 262 assert interfaces.IChangeSource.providedBy(source) | |
| 263 assert service.IService.providedBy(source) | |
| 264 if self.debug: | |
| 265 print "ChangeMaster.addSource", source | |
| 266 source.setServiceParent(self) | |
| 267 | |
| 268 def removeSource(self, source): | |
| 269 assert source in self | |
| 270 if self.debug: | |
| 271 print "ChangeMaster.removeSource", source, source.parent | |
| 272 d = defer.maybeDeferred(source.disownServiceParent) | |
| 273 return d | |
| 274 | |
| 275 def addChange(self, change): | |
| 276 """Deliver a file change event. The event should be a Change object. | |
| 277 This method will timestamp the object as it is received.""" | |
| 278 log.msg("adding change, who %s, %d files, rev=%s, branch=%s, " | |
| 279 "comments %s, category %s" % (change.who, len(change.files), | |
| 280 change.revision, change.branch, | |
| 281 change.comments, change.category)) | |
| 282 change.number = self.nextNumber | |
| 283 self.nextNumber += 1 | |
| 284 self.changes.append(change) | |
| 285 self.parent.addChange(change) | |
| 286 self.pruneChanges() | |
| 287 | |
| 288 def pruneChanges(self): | |
| 289 if self.changeHorizon and len(self.changes) > self.changeHorizon: | |
| 290 log.msg("pruning %i changes" % (len(self.changes) - self.changeHoriz
on)) | |
| 291 self.changes = self.changes[-self.changeHorizon:] | |
| 292 | |
| 293 def eventGenerator(self, branches=[], categories=[], committers=[], minTime=
0): | |
| 294 for i in range(len(self.changes)-1, -1, -1): | |
| 295 c = self.changes[i] | |
| 296 if (c.when < minTime): | |
| 297 break | |
| 298 if (not branches or c.branch in branches) and ( | |
| 299 not categories or c.category in categories) and ( | |
| 300 not committers or c.who in committers): | |
| 301 yield c | |
| 302 | |
| 303 def getChangeNumbered(self, num): | |
| 304 if not self.changes: | |
| 305 return None | |
| 306 first = self.changes[0].number | |
| 307 if first + len(self.changes)-1 != self.changes[-1].number: | |
| 308 log.msg(self, | |
| 309 "lost a change somewhere: [0] is %d, [%d] is %d" % \ | |
| 310 (self.changes[0].number, | |
| 311 len(self.changes) - 1, | |
| 312 self.changes[-1].number)) | |
| 313 for c in self.changes: | |
| 314 log.msg("c[%d]: " % c.number, c) | |
| 315 return None | |
| 316 offset = num - first | |
| 317 log.msg(self, "offset", offset) | |
| 318 if 0 <= offset <= len(self.changes): | |
| 319 return self.changes[offset] | |
| 320 else: | |
| 321 return None | |
| 322 | |
| 323 def __getstate__(self): | |
| 324 d = service.MultiService.__getstate__(self) | |
| 325 del d['parent'] | |
| 326 del d['services'] # lose all children | |
| 327 del d['namedServices'] | |
| 328 return d | |
| 329 | |
| 330 def __setstate__(self, d): | |
| 331 self.__dict__ = d | |
| 332 # self.basedir must be set by the parent | |
| 333 self.services = [] # they'll be repopulated by readConfig | |
| 334 self.namedServices = {} | |
| 335 | |
| 336 | |
| 337 def saveYourself(self): | |
| 338 filename = os.path.join(self.basedir, "changes.pck") | |
| 339 tmpfilename = filename + ".tmp" | |
| 340 try: | |
| 341 dump(self, open(tmpfilename, "wb")) | |
| 342 if sys.platform == 'win32': | |
| 343 # windows cannot rename a file on top of an existing one | |
| 344 if os.path.exists(filename): | |
| 345 os.unlink(filename) | |
| 346 os.rename(tmpfilename, filename) | |
| 347 except Exception, e: | |
| 348 log.msg("unable to save changes") | |
| 349 log.err() | |
| 350 | |
| 351 def stopService(self): | |
| 352 self.saveYourself() | |
| 353 return service.MultiService.stopService(self) | |
| 354 | |
| 355 class TestChangeMaster(ChangeMaster): | |
| 356 """A ChangeMaster for use in tests that does not save itself""" | |
| 357 def stopService(self): | |
| 358 return service.MultiService.stopService(self) | |
| 359 | |
| 360 # vim: set ts=4 sts=4 sw=4 et: | |
| OLD | NEW |