changeset 112:a38eabd4b6e1

* Store build logs in a structured way, for example to highlight messages on the error stream. * Add basic infrastructure for database upgrades.
author cmlenz
date Thu, 04 Aug 2005 20:15:39 +0000
parents 8d76fd3918a5
children 142d95b9e8b0
files bitten/master.py bitten/model.py bitten/tests/model.py bitten/trac_ext/__init__.py bitten/trac_ext/htdocs/build.css bitten/trac_ext/main.py bitten/trac_ext/templates/build.cs bitten/trac_ext/web_ui.py bitten/upgrades.py
diffstat 9 files changed, 374 insertions(+), 56 deletions(-) [+]
line wrap: on
line diff
--- a/bitten/master.py
+++ b/bitten/master.py
@@ -30,7 +30,7 @@
 import time
 
 from trac.env import Environment
-from bitten.model import BuildConfig, TargetPlatform, Build, BuildStep
+from bitten.model import BuildConfig, TargetPlatform, Build, BuildStep, BuildLog
 from bitten.util import archive, beep, xmlio
 
 log = logging.getLogger('bitten.master')
@@ -70,17 +70,19 @@
         try:
             repos.sync()
 
-            for config in BuildConfig.select(self.env):
+            db = self.env.get_db_cnx()
+            for config in BuildConfig.select(self.env, db=db):
                 log.debug('Checking for changes to "%s" at %s', config.label,
                           config.path)
                 node = repos.get_node(config.path)
                 for path, rev, chg in node.get_history():
                     enqueued = False
-                    for platform in TargetPlatform.select(self.env, config.name):
+                    for platform in TargetPlatform.select(self.env,
+                                                          config.name, db=db):
                         # Check whether the latest revision of the configuration
                         # has already been built on this platform
                         builds = Build.select(self.env, config.name, rev,
-                                              platform.id)
+                                              platform.id, db=db)
                         if not list(builds):
                             log.info('Enqueuing build of configuration "%s" at '
                                      'revision [%s] on %s', config.name, rev,
@@ -90,9 +92,10 @@
                             build.rev = str(rev)
                             build.rev_time = repos.get_changeset(rev).date
                             build.platform = platform.id
-                            build.insert()
+                            build.insert(db)
                             enqueued = True
                     if enqueued:
+                        db.commit()
                         break
         finally:
             repos.close()
@@ -119,7 +122,13 @@
             build.status = Build.PENDING
             build.update(db=db)
         for build in Build.select(self.env, status=Build.PENDING, db=db):
+            for step in BuildStep.select(self.env, build=build.id, db=db):
+                for log in BuildLog.select(self.env, build=build.id,
+                                           step=step.name, db=db):
+                    log.delete(db=db)
+                step.delete(db=db)
             build.delete(db=db)
+        db.commit()
 
     def _cleanup_snapshots(self, when):
         log.debug('Checking for unused snapshot archives...')
@@ -332,16 +341,19 @@
             step.status = BuildStep.FAILURE
         else:
             step.status = BuildStep.SUCCESS
+        step.insert(db=db)
 
-        # TODO: Insert log messages into separate table, and also store reports
-        log_lines = []
+        # TODO: Store reports, too
+        level_map = {'debug': BuildLog.DEBUG, 'info': BuildLog.INFO,
+                     'warning': BuildLog.WARNING, 'error': BuildLog.ERROR}
         for log_elem in elem.children('log'):
+            build_log = BuildLog(self.env, build=build.id, step=step.name,
+                                 type=log_elem.attr.get('type'))
             for messages_elem in log_elem.children('messages'):
                 for message_elem in messages_elem.children('message'):
-                    log_lines.append(message_elem.gettext())
-        step.log = '\n'.join(log_lines)
-
-        step.insert(db=db)
+                    build_log.messages.append((message_elem.attr['level'],
+                                               message_elem.gettext()))
+            build_log.insert(db=db)
 
     def _build_completed(self, db, build, elem):
         log.info('Slave %s completed build %d ("%s" as of [%s])', self.name,
@@ -378,11 +390,11 @@
     except ValueError, e:
         raise ValueError, 'Invalid ISO date/time %s (%s)' % (string, e)
 
-
 def main():
     from bitten import __version__ as VERSION
     from optparse import OptionParser
 
+    # Parse command-line arguments
     parser = OptionParser(usage='usage: %prog [options] env-path',
                           version='%%prog %s' % VERSION)
     parser.add_option('-p', '--port', action='store', type='int', dest='port',
--- a/bitten/model.py
+++ b/bitten/model.py
@@ -359,7 +359,7 @@
                        "stopped=%s,status=%s WHERE id=%s",
                        (self.slave or '', self.started or 0,
                         self.stopped or 0, self.status, self.id))
-        cursor.execute("DELETE FROM bitten_slave WHERE build=%s", (self.id))
+        cursor.execute("DELETE FROM bitten_slave WHERE build=%s", (self.id,))
         cursor.executemany("INSERT INTO bitten_slave VALUES (%s,%s,%s)",
                            [(self.id, name, value) for name, value
                             in self.slave_info.items()])
@@ -425,8 +425,8 @@
     _schema = [
         Table('bitten_step', key=('build', 'name'))[
             Column('build', type='int'), Column('name'), Column('description'),
-            Column('status', size=1), Column('log'),
-            Column('started', type='int'), Column('stopped', type='int')
+            Column('status', size=1), Column('started', type='int'),
+            Column('stopped', type='int')
         ]
     ]
 
@@ -435,13 +435,12 @@
     FAILURE = 'F'
 
     def __init__(self, env, build=None, name=None, description=None,
-                 status=None, log=None, started=None, stopped=None):
+                 status=None, started=None, stopped=None):
         self.env = env
         self.build = build
         self.name = name
         self.description = description
         self.status = status
-        self.log = log
         self.started = started
         self.stopped = stopped
 
@@ -473,10 +472,9 @@
 
         cursor = db.cursor()
         cursor.execute("INSERT INTO bitten_step (build,name,description,status,"
-                       "log,started,stopped) VALUES (%s,%s,%s,%s,%s,%s,%s)",
+                       "started,stopped) VALUES (%s,%s,%s,%s,%s,%s)",
                        (self.build, self.name, self.description or '',
-                        self.status, self.log or '', self.started or 0,
-                        self.stopped or 0))
+                        self.status, self.started or 0, self.stopped or 0))
         if handle_ta:
             db.commit()
 
@@ -485,15 +483,15 @@
             db = env.get_db_cnx()
 
         cursor = db.cursor()
-        cursor.execute("SELECT description,status,log,started,stopped "
+        cursor.execute("SELECT description,status,started,stopped "
                        "FROM bitten_step WHERE build=%s AND name=%s",
                        (build, name))
         row = cursor.fetchone()
         if not row:
             return None
 
-        return BuildStep(env, build, name, row[0] or '', row[1], row[2] or '',
-                         row[3] and int(row[3]), row[4] and int(row[4]))
+        return BuildStep(env, build, name, row[0] or '', row[1],
+                         row[2] and int(row[2]), row[3] and int(row[3]))
     fetch = classmethod(fetch)
 
     def select(cls, env, build=None, name=None, db=None):
@@ -511,16 +509,129 @@
             where = ""
 
         cursor = db.cursor()
-        cursor.execute("SELECT build,name,description,status,log,started,"
-                       "stopped FROM bitten_step %s ORDER BY stopped"
+        cursor.execute("SELECT build,name,description,status,started,stopped "
+                       "FROM bitten_step %s ORDER BY stopped"
                        % where, [wc[1] for wc in where_clauses])
-        for build, name, description, status, log, started, stopped in cursor:
+        for build, name, description, status, started, stopped in cursor:
             yield BuildStep(env, build, name, description or '', status,
-                            log or '', started and int(started),
-                            stopped and int(stopped))
+                            started and int(started), stopped and int(stopped))
+    select = classmethod(select)
+
+
+class BuildLog(object):
+    """Represents a build log."""
+
+    _schema = [
+        Table('bitten_log', key='id')[
+            Column('id', auto_increment=True), Column('build', type='int'),
+            Column('step'), Column('type')
+        ],
+        Table('bitten_log_message', key=('log', 'line'))[
+            Column('log', type='int'), Column('line', type='int'),
+            Column('level', size=1), Column('message')
+        ]
+    ]
+
+    # Message levels
+    DEBUG = 'D'
+    INFO = 'I'
+    WARNING = 'W'
+    ERROR = 'E'
+
+    def __init__(self, env, build=None, step=None, type=None):
+        self.env = env
+        self.id = None
+        self.build = build
+        self.step = step
+        self.type = type
+        self.messages = []
+
+    exists = property(fget=lambda self: self.id is not None)
+
+    def delete(self, db=None):
+        assert self.exists, 'Cannot delete a non-existing build log'
+        if not db:
+            db = self.env.get_db_cnx()
+            handle_ta = True
+        else:
+            handle_ta = False
+
+        cursor = db.cursor()
+        cursor.execute("DELETE FROM bitten_log WHERE id=%s", (self.id,))
+        cursor.execute("DELETE FROM bitten_log_message WHERE log=%s",
+                       (self.id,))
+
+        if handle_ta:
+            db.commit()
+        self.id = None
+
+    def insert(self, db=None):
+        if not db:
+            db = self.env.get_db_cnx()
+            handle_ta = True
+        else:
+            handle_ta = False
+
+        assert self.build and self.step
+
+        cursor = db.cursor()
+        cursor.execute("INSERT INTO bitten_log (build,step,type) "
+                       "VALUES (%s,%s,%s)", (self.build, self.step, self.type))
+        id = db.get_last_id(cursor, 'bitten_log')
+        cursor.executemany("INSERT INTO bitten_log_message "
+                           "(log,line,level,message) VALUES (%s,%s,%s,%s)",
+                           [(id, idx, message[0], message[1]) for idx, message
+                            in enumerate(self.messages)])
+
+        if handle_ta:
+            db.commit()
+        self.id = id
+
+    def fetch(cls, env, id, db=None):
+        if not db:
+            db = env.get_db_cnx()
+
+        cursor = db.cursor()
+        cursor.execute("SELECT build,step,type FROM bitten_log "
+                       "WHERE id=%s", (id,))
+        row = cursor.fetchone()
+        if not row:
+            return None
+        log = BuildLog(env, int(row[0]), row[1], row[2] or '')
+        log.id = id
+        cursor.execute("SELECT level,message FROM bitten_log_message "
+                       "WHERE log=%s ORDER BY line", (id,))
+        log.messages = cursor.fetchall()
+
+        return log
+
+    fetch = classmethod(fetch)
+
+    def select(cls, env, build=None, step=None, type=None, db=None):
+        if not db:
+            db = env.get_db_cnx()
+
+        where_clauses = []
+        if build is not None:
+            where_clauses.append(("build=%s", build))
+        if step is not None:
+            where_clauses.append(("step=%s", step))
+        if type is not None:
+            where_clauses.append(("type=%s", type))
+        if where_clauses:
+            where = "WHERE " + " AND ".join([wc[0] for wc in where_clauses])
+        else:
+            where = ""
+
+        cursor = db.cursor()
+        cursor.execute("SELECT id FROM bitten_log %s ORDER BY type"
+                       % where, [wc[1] for wc in where_clauses])
+        for (id, ) in cursor:
+            yield BuildLog.fetch(env, id, db=db)
+
     select = classmethod(select)
 
 
 schema = BuildConfig._schema + TargetPlatform._schema + Build._schema + \
-         BuildStep._schema
-schema_version = 1
+         BuildStep._schema + BuildLog._schema
+schema_version = 2
--- a/bitten/tests/model.py
+++ b/bitten/tests/model.py
@@ -21,7 +21,7 @@
 import unittest
 
 from trac.test import EnvironmentStub
-from bitten.model import BuildConfig, TargetPlatform, Build, BuildStep
+from bitten.model import BuildConfig, TargetPlatform, Build, BuildStep, BuildLog
 
 
 class BuildConfigTestCase(unittest.TestCase):
@@ -268,6 +268,23 @@
         self.assertEquals('127.0.0.1', build.slave_info[Build.IP_ADDRESS])
         self.assertEquals('joe@example.org', build.slave_info[Build.MAINTAINER])
 
+    def test_update(self):
+        db = self.env.get_db_cnx()
+        cursor = db.cursor()
+        cursor.execute("INSERT INTO bitten_build (config,rev,rev_time,platform,"
+                       "slave,started,stopped,status) "
+                       "VALUES (%s,%s,%s,%s,%s,%s,%s,%s)",
+                       ('test', '42', 12039, 1, 'tehbox', 15006, 16007,
+                        Build.SUCCESS))
+        build_id = db.get_last_id(cursor, 'bitten_build')
+        cursor.executemany("INSERT INTO bitten_slave VALUES (%s,%s,%s)",
+                           [(build_id, Build.IP_ADDRESS, '127.0.0.1'),
+                            (build_id, Build.MAINTAINER, 'joe@example.org')])
+
+        build = Build.fetch(self.env, build_id)
+        build.status = Build.FAILURE
+        build.update()
+
 
 class BuildStepTestCase(unittest.TestCase):
 
@@ -292,9 +309,9 @@
 
         db = self.env.get_db_cnx()
         cursor = db.cursor()
-        cursor.execute("SELECT build,name,description,status,log,started"
-                       ",stopped FROM bitten_step")
-        self.assertEqual((1, 'test', 'Foo bar', BuildStep.SUCCESS, '', 0, 0),
+        cursor.execute("SELECT build,name,description,status,started,stopped "
+                       "FROM bitten_step")
+        self.assertEqual((1, 'test', 'Foo bar', BuildStep.SUCCESS, 0, 0),
                          cursor.fetchone())
 
     def test_insert_no_build_or_name(self):
@@ -307,8 +324,8 @@
     def test_fetch(self):
         db = self.env.get_db_cnx()
         cursor = db.cursor()
-        cursor.execute("INSERT INTO bitten_step VALUES (%s,%s,%s,%s,%s,%s,%s)",
-                       (1, 'test', 'Foo bar', BuildStep.SUCCESS, '', 0, 0))
+        cursor.execute("INSERT INTO bitten_step VALUES (%s,%s,%s,%s,%s,%s)",
+                       (1, 'test', 'Foo bar', BuildStep.SUCCESS, 0, 0))
 
         step = BuildStep.fetch(self.env, build=1, name='test')
         self.assertEqual(1, step.build)
@@ -317,12 +334,127 @@
         self.assertEqual(BuildStep.SUCCESS, step.status)
 
 
+class BuildLogTestCase(unittest.TestCase):
+
+    def setUp(self):
+        self.env = EnvironmentStub()
+        db = self.env.get_db_cnx()
+        cursor = db.cursor()
+        for table in BuildLog._schema:
+            for stmt in db.to_sql(table):
+                cursor.execute(stmt)
+        db.commit()
+
+    def test_new(self):
+        log = BuildLog(self.env)
+        self.assertEqual(False, log.exists)
+        self.assertEqual(None, log.id)
+        self.assertEqual(None, log.build)
+        self.assertEqual(None, log.step)
+        self.assertEqual(None, log.type)
+        self.assertEqual([], log.messages)
+
+    def test_insert(self):
+        log = BuildLog(self.env, build=1, step='test', type='distutils')
+        log.messages = [
+            (BuildLog.INFO, 'running tests'),
+            (BuildLog.ERROR, 'tests failed')
+        ]
+        log.insert()
+        self.assertNotEqual(None, log.id)
+
+        db = self.env.get_db_cnx()
+        cursor = db.cursor()
+        cursor.execute("SELECT build,step,type FROM bitten_log "
+                       "WHERE id=%s", (log.id,))
+        self.assertEqual((1, 'test', 'distutils'), cursor.fetchone())
+        cursor.execute("SELECT level,message FROM bitten_log_message "
+                       "WHERE log=%s ORDER BY line", (log.id,))
+        self.assertEqual((BuildLog.INFO, 'running tests'), cursor.fetchone())
+        self.assertEqual((BuildLog.ERROR, 'tests failed'), cursor.fetchone())
+
+    def test_insert_no_build_or_step(self):
+        log = BuildLog(self.env, step='test')
+        self.assertRaises(AssertionError, log.insert) # No build
+
+        step = BuildStep(self.env, build=1)
+        self.assertRaises(AssertionError, log.insert) # No step
+
+    def test_delete(self):
+        db = self.env.get_db_cnx()
+        cursor = db.cursor()
+        cursor.execute("INSERT INTO bitten_log (build,step,type) "
+                       "VALUES (%s,%s,%s)", (1, 'test', 'distutils'))
+        id = db.get_last_id(cursor, 'bitten_log')
+        cursor.executemany("INSERT INTO bitten_log_message "
+                           "VALUES (%s,%s,%s,%s)",
+                           [(id, 1, BuildLog.INFO, 'running tests'),
+                            (id, 2, BuildLog.ERROR, 'tests failed')])
+
+        log = BuildLog.fetch(self.env, id=id, db=db)
+        self.assertEqual(True, log.exists)
+        log.delete()
+        self.assertEqual(False, log.exists)
+
+        cursor.execute("SELECT * FROM bitten_log WHERE id=%s", (id,))
+        self.assertEqual(True, not cursor.fetchall())
+        cursor.execute("SELECT * FROM bitten_log_message WHERE log=%s", (id,))
+        self.assertEqual(True, not cursor.fetchall())
+
+    def test_delete_new(self):
+        log = BuildLog(self.env, build=1, step='test', type='foo')
+        self.assertRaises(AssertionError, log.delete)
+
+    def test_fetch(self):
+        db = self.env.get_db_cnx()
+        cursor = db.cursor()
+        cursor.execute("INSERT INTO bitten_log (build,step,type) "
+                       "VALUES (%s,%s,%s)", (1, 'test', 'distutils'))
+        id = db.get_last_id(cursor, 'bitten_log')
+        cursor.executemany("INSERT INTO bitten_log_message "
+                           "VALUES (%s,%s,%s,%s)",
+                           [(id, 1, BuildLog.INFO, 'running tests'),
+                            (id, 2, BuildLog.ERROR, 'tests failed')])
+
+        log = BuildLog.fetch(self.env, id=id, db=db)
+        self.assertEqual(True, log.exists)
+        self.assertEqual(id, log.id)
+        self.assertEqual(1, log.build)
+        self.assertEqual('test', log.step)
+        self.assertEqual('distutils', log.type)
+        self.assertEqual((BuildLog.INFO, 'running tests'), log.messages[0])
+        self.assertEqual((BuildLog.ERROR, 'tests failed'), log.messages[1])
+
+    def test_select(self):
+        db = self.env.get_db_cnx()
+        cursor = db.cursor()
+        cursor.execute("INSERT INTO bitten_log (build,step,type) "
+                       "VALUES (%s,%s,%s)", (1, 'test', 'distutils'))
+        id = db.get_last_id(cursor, 'bitten_log')
+        cursor.executemany("INSERT INTO bitten_log_message "
+                           "VALUES (%s,%s,%s,%s)",
+                           [(id, 1, BuildLog.INFO, 'running tests'),
+                            (id, 2, BuildLog.ERROR, 'tests failed')])
+
+        logs = BuildLog.select(self.env, build=1, step='test', db=db)
+        log = logs.next()
+        self.assertEqual(True, log.exists)
+        self.assertEqual(id, log.id)
+        self.assertEqual(1, log.build)
+        self.assertEqual('test', log.step)
+        self.assertEqual('distutils', log.type)
+        self.assertEqual((BuildLog.INFO, 'running tests'), log.messages[0])
+        self.assertEqual((BuildLog.ERROR, 'tests failed'), log.messages[1])
+        self.assertRaises(StopIteration, logs.next)
+
+
 def suite():
     suite = unittest.TestSuite()
     suite.addTest(unittest.makeSuite(BuildConfigTestCase, 'test'))
     suite.addTest(unittest.makeSuite(TargetPlatformTestCase, 'test'))
     suite.addTest(unittest.makeSuite(BuildTestCase, 'test'))
     suite.addTest(unittest.makeSuite(BuildStepTestCase, 'test'))
+    suite.addTest(unittest.makeSuite(BuildLogTestCase, 'test'))
     return suite
 
 if __name__ == '__main__':
--- a/bitten/trac_ext/__init__.py
+++ b/bitten/trac_ext/__init__.py
@@ -17,3 +17,6 @@
 # Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA.
 #
 # Author: Christopher Lenz <cmlenz@gmx.de>
+
+import bitten.trac_ext.main
+import bitten.trac_ext.web_ui
--- a/bitten/trac_ext/htdocs/build.css
+++ b/bitten/trac_ext/htdocs/build.css
@@ -16,4 +16,6 @@
 #content.build #builds td.failed { background: #d99; }
 #content.build #builds td.in-progress { background: #ff9; }
 
-#content.build pre.log { overflow: auto; white-space: pre; }
+#content.build .log { overflow: auto; white-space: pre; }
+#content.build .log .warning { color: #660; font-weight: bold; }
+#content.build .log .error { color: #900; font-weight: bold; }
--- a/bitten/trac_ext/main.py
+++ b/bitten/trac_ext/main.py
@@ -55,6 +55,8 @@
         cursor = db.cursor()
         cursor.execute("SELECT value FROM system WHERE name='bitten_version'")
         row = cursor.fetchone()
+        self.log.debug("Current DB version is %s, we need %s",
+                       row and int(row[0]) or None, schema_version)
         if not row or int(row[0]) < schema_version:
             return True
 
@@ -65,19 +67,15 @@
         if not row:
             self.environment_created()
         else:
-            current_version = int(row.fetchone()[0])
-            for i in range(current_version + 1, schema_version + 1):
-                name  = 'db%i' % i
-                try:
-                    upgrades = __import__('upgrades', globals(), locals(),
-                                          [name])
-                    script = getattr(upgrades, name)
-                except AttributeError:
-                    err = 'No upgrade module for version %i (%s.py)' % (i, name)
-                    raise TracError, err
-                script.do_upgrade(self.env, i, cursor)
+            current_version = int(row[0])
+            from bitten import upgrades
+            for version in range(current_version + 1, schema_version + 1):
+                self.log.debug('Updating to schema version %s', version)
+                for function in upgrades.map.get(version):
+                    self.log.debug('Executing upgrade function %s', function)
+                    function(self.env, db)
             cursor.execute("UPDATE system SET value=%s WHERE "
-                           "name='bitten_version'", (schema_version))
+                           "name='bitten_version'", (schema_version,))
             self.log.info('Upgraded Bitten tables from version %d to %d',
                           current_version, schema_version)
 
--- a/bitten/trac_ext/templates/build.cs
+++ b/bitten/trac_ext/templates/build.cs
@@ -160,7 +160,10 @@
   each:step = build.steps ?>
    <h2><?cs var:step.name ?> (<?cs var:step.duration ?>)</h2>
    <p><?cs var:step.description ?></p>
-   <pre class="log"><?cs var:step.log ?></pre><?cs
+   <div class="log"><?cs
+    each:item = step.log ?><code class="<?cs var:item.level ?>"><?cs
+     var:item.message ?></code><br /><?cs
+    /each ?></div><?cs
   /each ?><?cs
   /if ?>
 
--- a/bitten/trac_ext/web_ui.py
+++ b/bitten/trac_ext/web_ui.py
@@ -31,7 +31,7 @@
                             add_link, add_stylesheet
 from trac.web.main import IRequestHandler
 from trac.wiki import wiki_to_html
-from bitten.model import BuildConfig, TargetPlatform, Build, BuildStep
+from bitten.model import BuildConfig, TargetPlatform, Build, BuildStep, BuildLog
 
 
 class BuildModule(Component):
@@ -43,6 +43,10 @@
     _status_label = {Build.IN_PROGRESS: 'in progress',
                      Build.SUCCESS: 'completed',
                      Build.FAILURE: 'failed'}
+    _level_label = {BuildLog.DEBUG: 'debug',
+                    BuildLog.INFO: 'info',
+                    BuildLog.WARNING: 'warning',
+                    BuildLog.ERROR: 'error'}
 
     # INavigationContributor methods
 
@@ -380,7 +384,7 @@
                         Build.IN_PROGRESS: 'In Progress'}
         req.hdf['title'] = 'Build %s - %s' % (build_id,
                                               status2title[build.status])
-        req.hdf['build'] = self._build_to_hdf(build)
+        req.hdf['build'] = self._build_to_hdf(build, include_output=True)
         req.hdf['build.mode'] = 'view_build'
 
         config = BuildConfig.fetch(self.env, build.config)
@@ -389,7 +393,7 @@
             'href': self.env.href.build(config.name)
         }
 
-    def _build_to_hdf(self, build):
+    def _build_to_hdf(self, build, include_output=False):
         hdf = {'id': build.id, 'name': build.slave, 'rev': build.rev,
                'status': self._status_label[build.status],
                'cls': self._status_label[build.status].replace(' ', '-'),
@@ -411,13 +415,19 @@
             'machine': build.slave_info.get(Build.MACHINE),
             'processor': build.slave_info.get(Build.PROCESSOR)
         }
+        db = self.env.get_db_cnx()
         steps = []
-        for step in BuildStep.select(self.env, build=build.id):
+        for step in BuildStep.select(self.env, build=build.id, db=db):
             steps.append({
                 'name': step.name, 'description': step.description,
-                'duration': pretty_timedelta(step.started, step.stopped),
-                'log': step.log
+                'duration': pretty_timedelta(step.started, step.stopped)
             })
+            if include_output:
+                for log in BuildLog.select(self.env, build=build.id,
+                                           step=step.name, db=db):
+                    steps[-1]['log'] = [{'level': level,
+                                         'message': message}
+                                        for level, message in log.messages]
         hdf['steps'] = steps
 
         return hdf
new file mode 100644
--- /dev/null
+++ b/bitten/upgrades.py
@@ -0,0 +1,47 @@
+# -*- coding: iso8859-1 -*-
+#
+# Copyright (C) 2005 Christopher Lenz <cmlenz@gmx.de>
+#
+# Bitten is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License as
+# published by the Free Software Foundation; either version 2 of the
+# License, or (at your option) any later version.
+#
+# Trac is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA.
+#
+# Author: Christopher Lenz <cmlenz@gmx.de>
+
+def add_log_table(env, db):
+    from bitten.model import BuildLog, BuildStep
+    cursor = db.cursor()
+
+    for table in BuildLog._schema:
+        for stmt in db.to_sql(table):
+            cursor.execute(stmt)
+
+    cursor.execute("SELECT build,name,log FROM bitten_step "
+                   "WHERE log IS NOT NULL")
+    for build, step, log in cursor:
+        build_log = BuildLog(env, build, step)
+        build_log.messages = [(BuildLog.INFO, msg) for msg in log.splitlines()]
+        build_log.insert(db)
+
+    cursor.execute("CREATE TEMP TABLE old_step AS SELECT * FROM bitten_step")
+    cursor.execute("DROP TABLE bitten_step")
+    for table in BuildStep._schema:
+        for stmt in db.to_sql(table):
+            cursor.execute(stmt)
+    cursor.execute("INSERT INTO bitten_step (build,name,description,status,"
+                   "started,stopped) SELECT build,name,description,status,"
+                   "started,stopped FROM old_step")
+
+map = {
+    2: [add_log_table]
+}
Copyright (C) 2012-2017 Edgewall Software