(refs #402) sort changed files for easier display
[buildbot.git] / buildbot / changes / changes.py
blob4c2ae3b5914f617d5cdbb481126edfe21d321ba1
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):
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
68 # keep a sorted list of the files, for easier display
69 self.files = files[:]
70 self.files.sort()
72 def asText(self):
73 data = ""
74 data += self.getFileContents()
75 data += "At: %s\n" % self.getTime()
76 data += "Changed By: %s\n" % self.who
77 data += "Comments: %s\n\n" % self.comments
78 return data
80 def asHTML(self):
81 links = []
82 for file in self.files:
83 link = filter(lambda s: s.find(file) != -1, self.links)
84 if len(link) == 1:
85 # could get confused
86 links.append('<a href="%s"><b>%s</b></a>' % (link[0], file))
87 else:
88 links.append('<b>%s</b>' % file)
89 revision = ""
90 if self.revision:
91 revision = "Revision: <b>%s</b><br />\n" % self.revision
92 branch = ""
93 if self.branch:
94 branch = "Branch: <b>%s</b><br />\n" % self.branch
96 kwargs = { 'who' : html.escape(self.who),
97 'at' : self.getTime(),
98 'files' : html.UL(links) + '\n',
99 'revision': revision,
100 'branch' : branch,
101 'comments': html.PRE(self.comments) }
102 return html_tmpl % kwargs
104 def get_HTML_box(self, url):
105 """Return the contents of a TD cell for the waterfall display.
107 @param url: the URL that points to an HTML page that will render
108 using our asHTML method. The Change is free to use this or ignore it
109 as it pleases.
111 @return: the HTML that will be put inside the table cell. Typically
112 this is just a single href named after the author of the change and
113 pointing at the passed-in 'url'.
115 who = self.getShortAuthor()
116 return '<a href="%s" title="%s">%s</a>' % (url,
117 html.escape(self.comments),
118 html.escape(who))
120 def getShortAuthor(self):
121 return self.who
123 def getTime(self):
124 if not self.when:
125 return "?"
126 return time.strftime("%a %d %b %Y %H:%M:%S",
127 time.localtime(self.when))
129 def getTimes(self):
130 return (self.when, None)
132 def getText(self):
133 return [html.escape(self.who)]
134 def getColor(self):
135 return "white"
136 def getLogs(self):
137 return {}
139 def getFileContents(self):
140 data = ""
141 if len(self.files) == 1:
142 if self.isdir:
143 data += "Directory: %s\n" % self.files[0]
144 else:
145 data += "File: %s\n" % self.files[0]
146 else:
147 data += "Files:\n"
148 for f in self.files:
149 data += " %s\n" % f
150 return data
152 class ChangeMaster(service.MultiService):
154 """This is the master-side service which receives file change
155 notifications from CVS. It keeps a log of these changes, enough to
156 provide for the HTML waterfall display, and to tell
157 temporarily-disconnected bots what they missed while they were
158 offline.
160 Change notifications come from two different kinds of sources. The first
161 is a PB service (servicename='changemaster', perspectivename='change'),
162 which provides a remote method called 'addChange', which should be
163 called with a dict that has keys 'filename' and 'comments'.
165 The second is a list of objects derived from the ChangeSource class.
166 These are added with .addSource(), which also sets the .changemaster
167 attribute in the source to point at the ChangeMaster. When the
168 application begins, these will be started with .start() . At shutdown
169 time, they will be terminated with .stop() . They must be persistable.
170 They are expected to call self.changemaster.addChange() with Change
171 objects.
173 There are several different variants of the second type of source:
175 - L{buildbot.changes.mail.MaildirSource} watches a maildir for CVS
176 commit mail. It uses DNotify if available, or polls every 10
177 seconds if not. It parses incoming mail to determine what files
178 were changed.
180 - L{buildbot.changes.freshcvs.FreshCVSSource} makes a PB
181 connection to the CVSToys 'freshcvs' daemon and relays any
182 changes it announces.
186 implements(interfaces.IEventSource)
188 debug = False
189 # todo: use Maildir class to watch for changes arriving by mail
191 def __init__(self):
192 service.MultiService.__init__(self)
193 self.changes = []
194 # self.basedir must be filled in by the parent
195 self.nextNumber = 1
197 def addSource(self, source):
198 assert interfaces.IChangeSource.providedBy(source)
199 assert service.IService.providedBy(source)
200 if self.debug:
201 print "ChangeMaster.addSource", source
202 source.setServiceParent(self)
204 def removeSource(self, source):
205 assert source in self
206 if self.debug:
207 print "ChangeMaster.removeSource", source, source.parent
208 d = defer.maybeDeferred(source.disownServiceParent)
209 return d
211 def addChange(self, change):
212 """Deliver a file change event. The event should be a Change object.
213 This method will timestamp the object as it is received."""
214 log.msg("adding change, who %s, %d files, rev=%s, branch=%s, "
215 "comments %s" % (change.who, len(change.files),
216 change.revision, change.branch,
217 change.comments))
218 change.number = self.nextNumber
219 self.nextNumber += 1
220 self.changes.append(change)
221 self.parent.addChange(change)
222 # TODO: call pruneChanges after a while
224 def pruneChanges(self):
225 self.changes = self.changes[-100:] # or something
227 def eventGenerator(self, branches=[]):
228 for i in range(len(self.changes)-1, -1, -1):
229 c = self.changes[i]
230 if not branches or c.branch in branches:
231 yield c
233 def getChangeNumbered(self, num):
234 if not self.changes:
235 return None
236 first = self.changes[0].number
237 if first + len(self.changes)-1 != self.changes[-1].number:
238 log.msg(self,
239 "lost a change somewhere: [0] is %d, [%d] is %d" % \
240 (self.changes[0].number,
241 len(self.changes) - 1,
242 self.changes[-1].number))
243 for c in self.changes:
244 log.msg("c[%d]: " % c.number, c)
245 return None
246 offset = num - first
247 log.msg(self, "offset", offset)
248 return self.changes[offset]
250 def __getstate__(self):
251 d = service.MultiService.__getstate__(self)
252 del d['parent']
253 del d['services'] # lose all children
254 del d['namedServices']
255 return d
257 def __setstate__(self, d):
258 self.__dict__ = d
259 # self.basedir must be set by the parent
260 self.services = [] # they'll be repopulated by readConfig
261 self.namedServices = {}
264 def saveYourself(self):
265 filename = os.path.join(self.basedir, "changes.pck")
266 tmpfilename = filename + ".tmp"
267 try:
268 dump(self, open(tmpfilename, "wb"))
269 if sys.platform == 'win32':
270 # windows cannot rename a file on top of an existing one
271 if os.path.exists(filename):
272 os.unlink(filename)
273 os.rename(tmpfilename, filename)
274 except Exception, e:
275 log.msg("unable to save changes")
276 log.err()
278 def stopService(self):
279 self.saveYourself()
280 return service.MultiService.stopService(self)