(closes #182) Add category support to Schedulers and Changes.
[buildbot.git] / buildbot / changes / changes.py
blobf58b8e026b3841846f01ecf9464f6eb4614d9ad9
2 import sys, os, time
3 from cPickle import dump
5 from zope.interface import implements
6 from twisted.python import log
7 from twisted.internet import defer
8 from twisted.application import service
9 from twisted.web import html
11 from buildbot import interfaces, util
13 html_tmpl = """
14 <p>Changed by: <b>%(who)s</b><br />
15 Changed at: <b>%(at)s</b><br />
16 %(branch)s
17 %(revision)s
18 <br />
20 Changed files:
21 %(files)s
23 Comments:
24 %(comments)s
25 </p>
26 """
28 class Change:
29 """I represent a single change to the source tree. This may involve
30 several files, but they are all changed by the same person, and there is
31 a change comment for the group as a whole.
33 If the version control system supports sequential repository- (or
34 branch-) wide change numbers (like SVN, P4, and Arch), then revision=
35 should be set to that number. The highest such number will be used at
36 checkout time to get the correct set of files.
38 If it does not (like CVS), when= should be set to the timestamp (seconds
39 since epoch, as returned by time.time()) when the change was made. when=
40 will be filled in for you (to the current time) if you omit it, which is
41 suitable for ChangeSources which have no way of getting more accurate
42 timestamps.
44 Changes should be submitted to ChangeMaster.addChange() in
45 chronologically increasing order. Out-of-order changes will probably
46 cause the html.Waterfall display to be corrupted."""
48 implements(interfaces.IStatusEvent)
50 number = None
52 links = []
53 branch = None
54 revision = None # used to create a source-stamp
56 def __init__(self, who, files, comments, isdir=0, links=[],
57 revision=None, when=None, branch=None, category=None):
58 self.who = who
59 self.comments = comments
60 self.isdir = isdir
61 self.links = links
62 self.revision = revision
63 if when is None:
64 when = util.now()
65 self.when = when
66 self.branch = branch
67 self.category = category
69 # keep a sorted list of the files, for easier display
70 self.files = files[:]
71 self.files.sort()
73 def asText(self):
74 data = ""
75 data += self.getFileContents()
76 data += "At: %s\n" % self.getTime()
77 data += "Changed By: %s\n" % self.who
78 data += "Comments: %s\n\n" % self.comments
79 return data
81 def asHTML(self):
82 links = []
83 for file in self.files:
84 link = filter(lambda s: s.find(file) != -1, self.links)
85 if len(link) == 1:
86 # could get confused
87 links.append('<a href="%s"><b>%s</b></a>' % (link[0], file))
88 else:
89 links.append('<b>%s</b>' % file)
90 revision = ""
91 if self.revision:
92 revision = "Revision: <b>%s</b><br />\n" % self.revision
93 branch = ""
94 if self.branch:
95 branch = "Branch: <b>%s</b><br />\n" % self.branch
97 kwargs = { 'who' : html.escape(self.who),
98 'at' : self.getTime(),
99 'files' : html.UL(links) + '\n',
100 'revision': revision,
101 'branch' : branch,
102 'comments': html.PRE(self.comments) }
103 return html_tmpl % kwargs
105 def get_HTML_box(self, url):
106 """Return the contents of a TD cell for the waterfall display.
108 @param url: the URL that points to an HTML page that will render
109 using our asHTML method. The Change is free to use this or ignore it
110 as it pleases.
112 @return: the HTML that will be put inside the table cell. Typically
113 this is just a single href named after the author of the change and
114 pointing at the passed-in 'url'.
116 who = self.getShortAuthor()
117 return '<a href="%s" title="%s">%s</a>' % (url,
118 html.escape(self.comments),
119 html.escape(who))
121 def getShortAuthor(self):
122 return self.who
124 def getTime(self):
125 if not self.when:
126 return "?"
127 return time.strftime("%a %d %b %Y %H:%M:%S",
128 time.localtime(self.when))
130 def getTimes(self):
131 return (self.when, None)
133 def getText(self):
134 return [html.escape(self.who)]
135 def getColor(self):
136 return "white"
137 def getLogs(self):
138 return {}
140 def getFileContents(self):
141 data = ""
142 if len(self.files) == 1:
143 if self.isdir:
144 data += "Directory: %s\n" % self.files[0]
145 else:
146 data += "File: %s\n" % self.files[0]
147 else:
148 data += "Files:\n"
149 for f in self.files:
150 data += " %s\n" % f
151 return data
153 class ChangeMaster(service.MultiService):
155 """This is the master-side service which receives file change
156 notifications from CVS. It keeps a log of these changes, enough to
157 provide for the HTML waterfall display, and to tell
158 temporarily-disconnected bots what they missed while they were
159 offline.
161 Change notifications come from two different kinds of sources. The first
162 is a PB service (servicename='changemaster', perspectivename='change'),
163 which provides a remote method called 'addChange', which should be
164 called with a dict that has keys 'filename' and 'comments'.
166 The second is a list of objects derived from the ChangeSource class.
167 These are added with .addSource(), which also sets the .changemaster
168 attribute in the source to point at the ChangeMaster. When the
169 application begins, these will be started with .start() . At shutdown
170 time, they will be terminated with .stop() . They must be persistable.
171 They are expected to call self.changemaster.addChange() with Change
172 objects.
174 There are several different variants of the second type of source:
176 - L{buildbot.changes.mail.MaildirSource} watches a maildir for CVS
177 commit mail. It uses DNotify if available, or polls every 10
178 seconds if not. It parses incoming mail to determine what files
179 were changed.
181 - L{buildbot.changes.freshcvs.FreshCVSSource} makes a PB
182 connection to the CVSToys 'freshcvs' daemon and relays any
183 changes it announces.
187 implements(interfaces.IEventSource)
189 debug = False
190 # todo: use Maildir class to watch for changes arriving by mail
192 def __init__(self):
193 service.MultiService.__init__(self)
194 self.changes = []
195 # self.basedir must be filled in by the parent
196 self.nextNumber = 1
198 def addSource(self, source):
199 assert interfaces.IChangeSource.providedBy(source)
200 assert service.IService.providedBy(source)
201 if self.debug:
202 print "ChangeMaster.addSource", source
203 source.setServiceParent(self)
205 def removeSource(self, source):
206 assert source in self
207 if self.debug:
208 print "ChangeMaster.removeSource", source, source.parent
209 d = defer.maybeDeferred(source.disownServiceParent)
210 return d
212 def addChange(self, change):
213 """Deliver a file change event. The event should be a Change object.
214 This method will timestamp the object as it is received."""
215 log.msg("adding change, who %s, %d files, rev=%s, branch=%s, "
216 "comments %s, category %s" % (change.who, len(change.files),
217 change.revision, change.branch,
218 change.comments, change.category))
219 change.number = self.nextNumber
220 self.nextNumber += 1
221 self.changes.append(change)
222 self.parent.addChange(change)
223 # TODO: call pruneChanges after a while
225 def pruneChanges(self):
226 self.changes = self.changes[-100:] # or something
228 def eventGenerator(self, branches=[]):
229 for i in range(len(self.changes)-1, -1, -1):
230 c = self.changes[i]
231 if not branches or c.branch in branches:
232 yield c
234 def getChangeNumbered(self, num):
235 if not self.changes:
236 return None
237 first = self.changes[0].number
238 if first + len(self.changes)-1 != self.changes[-1].number:
239 log.msg(self,
240 "lost a change somewhere: [0] is %d, [%d] is %d" % \
241 (self.changes[0].number,
242 len(self.changes) - 1,
243 self.changes[-1].number))
244 for c in self.changes:
245 log.msg("c[%d]: " % c.number, c)
246 return None
247 offset = num - first
248 log.msg(self, "offset", offset)
249 return self.changes[offset]
251 def __getstate__(self):
252 d = service.MultiService.__getstate__(self)
253 del d['parent']
254 del d['services'] # lose all children
255 del d['namedServices']
256 return d
258 def __setstate__(self, d):
259 self.__dict__ = d
260 # self.basedir must be set by the parent
261 self.services = [] # they'll be repopulated by readConfig
262 self.namedServices = {}
265 def saveYourself(self):
266 filename = os.path.join(self.basedir, "changes.pck")
267 tmpfilename = filename + ".tmp"
268 try:
269 dump(self, open(tmpfilename, "wb"))
270 if sys.platform == 'win32':
271 # windows cannot rename a file on top of an existing one
272 if os.path.exists(filename):
273 os.unlink(filename)
274 os.rename(tmpfilename, filename)
275 except Exception, e:
276 log.msg("unable to save changes")
277 log.err()
279 def stopService(self):
280 self.saveYourself()
281 return service.MultiService.stopService(self)